【Godot】15パズルの実装サンプル

Godot Engine で15パズルの実装サンプルを作ったので、プロジェクトファイルと簡単な解説を書きます。

15パズルの実装サンプル

プロジェクトファイル

プロジェクトファイルは GitHub にアップロードしているので、以下のURLからダウンロードできます。

プロジェクト概要
gd_16puzzle
 +-- assets
 |    +-- fonts: フォントデータ
 |    |    +-- font_big.tres: 大きめのフォント
 |    |    +-- font_ui.tres: ボタン用のフォント
 |    |    +-- plus-1c-regular.ttf: M+フォント
 |    |    
 |    +-- puzzle
 |    |    +-- puzzle001.png: パズル画像001 (512x512)
 |    |    +-- puzzle002.png: パズル画像002 (512x512)
 |    |    +-- puzzle003.png: パズル画像003 (512x512)
 |    |    
 |    +-- frame.png: パネルの枠
 |    +-- radio_buttongroup.tres: ラジオボタン用のリソース
 |    
 +-- Common.gd: 共通スクリプト
 +-- default_env.tres: デフォルト環境リソース
 +-- icon.png: Godotくん
 +-- Main.gd: メインシーンスクリプト
 +-- Main.tscn: メインシーン
 +-- Tile.gd: パネルシーンスクリプト
 +-- Tile.tscn: パネルシーン

座標系の扱い

座標系として、「ワールド座標系」「グリッド座標系」「インデックス座標系」という3つの座標系を使っています。この考え方については以下の記事にまとめています。

グリッド制のゲームでよく使う座標系について

タイル画像について

タイル画像はもととなる画像を Spriteノードで分割する方法を使っています。

例えば 4×4 にする場合は 16分割します。

hframes / vframes の値をそれぞれ 4にすると縦横4分割の画像となります。

なお、パネル番号の内部的な値としては「0」を何もない位置としたいので「1」始まりの値となっていることに注意します。

タイルの移動判定について

16パズルでは、空のタイルに向けてスライドできます。

例えば以下の状況で「9」のタイルをスライドすると、同時に「3」「2」も右方向にスライドできます。

932をまとめてスライドできる

スライドできるパネルの判定と移動処理は、Main.gd の _panel_slide() で実装しています。

## パネルをスライドする.
func _panel_slide(i:int, j:int) -> void:
	
	# 上下左右の方向を調べる.
	for v in [[-1, 0], [0, -1], [1, 0], [0, 1]]:
		var dx = v[0]
		var dy = v[1]

		var idx_list = [] # 移動可能パネルのリスト (インデックス座標系)
		# 開始パネルをリストに追加.
		idx_list.append(Common.grid_to_idx(i, j))

		# 移動可能かどうかチェックする.
		if _panel_slide_sub(idx_list, i, j, dx, dy):
			# 移動可能.
			idx_list.invert() # 後ろから動かしたいので逆順にする.
			#print(idx_list)
			for idx in idx_list:
				var i2 = Common.idx_to_grid_x(idx)
				var j2 = Common.idx_to_grid_y(idx)
				var tile = _get_tile_from_grid(i2, j2)
				var i2next = i2 + dx
				var j2next = j2 + dy
				tile.move_to(i2next, j2next)
				_arr.set_v(i2next, j2next, tile.get_number())
			# 開始地点を空にしておく.
			_arr.set_v(i, j, Common.Array2D.EMPTY)

## PoolIntArrayにすると実体のコピー渡しになるのであえてArrayで渡す.
## @param idx_list 移動可能なパネルの位置を表すリスト
## @param i 基準座標(X)
## @param j 基準座標(Y)
## @param dx 移動方向(X)
## @param dy 移動方向(Y)
func _panel_slide_sub(idx_list:Array, i:int, j:int, dx:int, dy:int) -> bool:
	var inext = i + dx
	var jnext = j + dy
	var n = _arr.get_v(inext, jnext)
	if n == Common.Array2D.OUT_OF_RANGE:
		return false # フィールド外になったので移動できない方向.
	
	if n == Common.Array2D.EMPTY:
		return true # 移動可能.
	
	# リストに追加.
	idx_list.append(Common.grid_to_idx(inext, jnext))
	
	# 次を調べる.
	return _panel_slide_sub(idx_list, inext, jnext, dx, dy)

idx_list が移動可能となるパネルの位置を表すリストです。整数値の配列を使いたかったので中身は「インデックス座標」となっています。

dx, dy に移動方向が入っているので、その方向を連続して調べるために _panel_slide_sub() は再帰関数となっています。

Common.Array2D.OUT_OF_RANGE はフィールドの領域外を示す値なので、この値が見つかったらスライド移動失敗です。もし Common.Array2D.EMPTY が見つかれば空の場所なので移動可能となります。

問題作成ルーチン

16パズルは、完全にランダムで配置するとクリアできない問題が生成される可能性があります。そのため「答えから逆算して問題を作る」ことが必要となります。

Main.gd の _create_random() が問題生成関数となります。

## 完全なランダムだと解法がなくなる可能性があるので答えからランダムで動かします.
func _create_random() -> Common.Array2D:
	var tmp = Common.Array2D.new(Common.width(), Common.height())
	
	# 完成の状態を作る
	var num = 1 # 1始まり.
	for j in range(tmp.height):
		for i in range(tmp.width):
			tmp.set_v(i, j, num)
			num += 1

	# 空とする番号.
	var empty = Common.width() * Common.height()
	
	while true:
		# ここからシャッフル処理.
		var cnt = SHUFFLE_CNT
		if Common.get_mode() == Common.eMode.TILE_4x4:
			cnt *= 5 # 4x4のときはシャッフル回数を増やします
		for i in range(cnt):
			var idx = tmp.search(empty) # 空のパネルを探す
			var tbl = [[-1, 0], [0, -1], [1, 0], [0, 1]] # 上下左右を調べる.
			tbl.shuffle() # シャッフルする.
			for v in tbl:
				var i1 = Common.idx_to_grid_x(idx)
				var j1 = Common.idx_to_grid_y(idx)
				var dx = v[0]
				var dy = v[1]
				var i2 = i1 + dx
				var j2 = j1 + dy
				if tmp.swap(i1, j1, i2, j2):
					break # 交換成功.
		
		# 完成しているかどうか.
		var is_completed = _check_completed(tmp)		
		if is_completed == false:
			break # 完成していないのでOK
	return tmp

処理の手順としては以下のとおりです。

  1. 完成の状態を作る
  2. 空の位置を探して、ランダムな方向のパネルと交換する
  3. 交換を何回か繰り返す
  4. 完成状態となっていなければ問題の作成完了

まれにランダムな交換の結果が完成状態となってしまう可能性がある(ゲーム開始直後にクリアとなってしまう)ので、完成状態でなくなるまでシャッフルを繰り返します。