今回のチュートリアルではマッチ3パズルゲームを作っていく。マッチ3(スリー)パズルゲームとは、盤面のグリッドに沿って均一に並べられた複数のカラフルなピースのうちの1つを、1マス動かして同じ色のピースを3つ以上並べて消すタイプのパズルゲームの総称だ。うまく動かすと、一回の操作で連続的に複数のピースを消すことができ、なんとも気持ちの良いプレイ感覚を味わうことができる。簡単な操作で気軽に楽しめるため、モバイルゲームで特に人気のあるジャンルだ。

人気どころをいくつか例に挙げると、キャンディークラッシュ、トゥーンブラスト、ロイヤルマッチなどがそれにあたる。少し操作感は異なるが、他にも「パズル&ドラゴンズ」や「LINEツムツム」もベースはマッチ3だ。今回はキャンディークラッシュのような、一回の操作でピースを1マスだけ動かして色を揃えるタイプのパズルを作っていく。

Other Tutorials
「パズル&ドラゴンズ」のようなゲームを作ってみたい場合:
Godot で作る進化形マッチ 3 パズルゲーム
「LINE:ディズニーツムツム」のようなゲームを作って見たい場合:
Godot で作る同じ色をつなげて消すパズルゲーム


このチュートリアルで最後にできあがるプロジェクトのファイルは GitHubリポジトリ に置いている。.zipファイルをダウンロードしていただき、「End」フォルダ内の「project.godot」ファイルを Godot Engine でインポートしていただければ、直接プロジェクトを確認していただくことも可能だ。

Environment
このチュートリアルは以下の環境で作成しました。

Godot のバージョン: 3.4.4
コンピュータのOS: macOS 11.6.5

Memo:
ゲームを作り始めるのに以下の記事もお役立てください。
Godot をダウンロードする
Godot のプロジェクトマネージャー
Godot の言語設定



新規プロジェクトを作成する

それでは Godot Engine を立ち上げて、新規プロジェクトを作成しよう。プロジェクトの名前はあなたのお好みで決めていただいてOKだ。もし思いつかなければ「Match3 Start」としておこう。


プロジェクト設定を更新する

エディタが表示されたら、先にプロジェクト全体に関わる設定を更新しておこう。

まずはゲームのディスプレイサイズを設定する。今回はスマホの縦向きの画面を想定して、縦横の比率を 16 : 9 とする。

  1. 「プロジェクト」メニュー>「プロジェクト設定」を開く。
  2. 「一般」タブで「window」で検索して、サイドバーの「Display」>「Window」を選択する。
  3. 「Size」セクションで以下の項目の値を変更する。
    • Width: 630
    • Height: 1120
    • Test Width: 315
    • Test Height: 560
      project settings - Display - Window - Size
  4. 「Stretch」セクションで以下の項目の値を変更する。
    • Mode: 2d
    • Aspect: keep
      project settings - Display - Window - Stretch

そのまま「プロジェクト設定」ウインドウを開いた状態で、デバッグパネルでスマホのタッチ操作をマウスで代用するための設定をする。

  1. 「一般」タブで「mouse」と検索し、サイドバーの「Input Devices」>「Pointing」を選択する。
  2. 「Emulate Touch From Mouse」の On のチェックを入れる。
    Input Devices - Pointing - Emulate Touch From Mouse

さらに「プロジェクト設定」ウインドウを開いた状態で、インプットマップにスマホのタッチ操作の相当するアクションを追加しよう。

  1. 「インプットマップ」タブに切り替え、アクションに「touch」を追加する。
  2. 「touch」の操作にマウスの左クリックを追加する。
    Inputmap - action - tap

アセットをダウンロードしてインポートする

次に、KENNEYのサイトからアセットをダウンロードして利用させてもらおう。今回利用するのは「Physics Assets 」 というアセットパックだ。このアセットに含まれるなんともかわいいエイリアンの顔の画像を、ゲームの盤面に並べるピースのテクスチャとして使用する。この素晴らしすぎる無料の素材に感謝せずにはいられない。

ダウンロードしたら「/physicspack/PNG/Aliens」フォルダの中のファイル名が「~_round.png」の画像だけを残して他は削除し、「Aliens」フォルダごとエディタのファイルシステムドックへドラッグしてプロジェクトにインポートしよう。



Grid シーンを作る

まずはマッチ3パズルゲームでピースが配置される盤面として、「Grid」シーンを作成しよう。

  1. 「シーン」メニュー>「新規シーン」を選択する。
  2. 「ルートノードを生成」にて「2D シーン」を選択する。
    1.「Node2D」クラスのルートノードが生成されたら、その名前を「Grid」に変更する。
  3. シーンを保存する。フォルダを作成して、ファイルパスを「res://Grid/Grid.tscn」としてシーンを保存する。

Grid シーンにノードを追加する

「Grid」ルートノードに、さらに「Node2D」クラスの子ノードを追加し、名前を「PiecesContainer」に変更しよう。このノードは盤面に配置されるピースをまとめるためのノードだ。ゲーム中は、スクリプトでピースのインスタンスが生成されたら全てこの「PiecesContainer」ノードの子として追加することになる。

シーンツリードックの表示は以下のようになったはずだ。
scene tree dock

なお、「Grid」シーンのノードのプロパティ編集は特に必要ない。



Piece シーンを作る

次に、盤面に並べるピースとして「Piece」シーンを作成する。ただし、この「Piece」シーンはあくまで雛形で、実際にゲーム中で利用する各色のピースは、この「Piece」シーンを継承する形でのちほど用意する。

  1. 「シーン」メニュー>「新規シーン」を選択する。
  2. 「ルートノードを生成」にて「2D シーン」を選択する。
  3. 「Node2D」クラスのルートノードが生成されたら、その名前を「Piece」に変更する。
  4. シーンを保存しておこう。フォルダを作成して、ファイルパスを「res://Pieces/Piece.tscn」としてシーンを保存する。

Piece ノードに子ノードを追加する

「Piece」ルートノードに「Sprite」クラスの子ノードを追加しよう。シーンツリードックの表示は以下のようになったはずだ。
Piece scene tree


Sprite ノードのプロパティを編集する

「Sprite」ノードのプロパティを少し編集しておこう。先述の通り、「Piece」はあくまで継承元(雛形)なので、このシーンでは「Sprite」ノードの「Texture」プロパティにはリソースを敢えて適用せずそのままにしておく。継承先のシーンでそれぞれのピースの色にあった画像を適用する予定だ。

「Offset」>「Offset」プロパティの値を「(x: 35, y: -35)」に変更しておこう。このシーンを継承する各色のピースのシーンでは「Texture」プロパティに先にインポートした KENNEY の画像を適用するが、その画像の縦横のサイズが 70 px なので、その画像の中心を右上にずらして画像の左下の角を(x: 0, y: 0)に合わせた。
Piece scene - Sprite - offset

ピースを配置する盤面のグリッドは x 軸は左から右へ、y 軸は下から上へカウントする仕様で、かつ盤面の 1 グリッドのサイズもテクスチャのサイズに合わせて 70 px とする。ピースのテクスチャ画像の左下の角を (x: 0, y: 0) に合わせれば、「Piece」ルートノードの位置 (x: 0, y: 0) をグリッドに合わせて配置したときに、ちょうど「Sprite」の画像がグリッドに沿って配置されるというわけだ。
Diagram - Sprite offset ajusted to grid


Piece ノードにスクリプトをアタッチして編集する

「Piece」ルートノードに新規スクリプトをアタッチしよう。ファイルパスを「res://Pieces/Piece.gd」としてスクリプトファイルを作成する。

ひとまずスクリプトを以下のように編集してほしい。

###Piece.gd###
extends Node2D

# ピースの色を文字列データとして設定するためのプロパティ
export (String) var color
# マッチした(3つ以上同じ色が並んだ)状態を示すプロパティ
var matched = false
# Sprite ノードの参照
onready var sprite = $Sprite

# ピースを移動させるメソッド
# 引数 target に渡した位置に Piece インスタンスを移動する
func move(target):
	position = target

# マッチした(3つ以上同じ色が並んだ)ときに呼ばれるメソッド
# matched プロパティを true にし、色を半透明にする
func make_matched():
	matched = true
	sprite.modulate = Color(1,1,1,.5)

これで「Piece.gd」スクリプトの編集は完了だ。



Piece シーンを継承した各色のシーンを作る

雛形となる「Piece」シーンは完成したので、それを継承したシーンをピースの色の数だけ作成していこう。ピースの色は、ベージュ、青、緑、ピンク、黄の 5 色だ。まずは「ベージュ」のドロップを例に手順を進めてみよう。

  1. 「シーン」メニュー>「新しい継承シーン」を選択する。
  2. 継承元のシーンとして「Piece.tscn」を選択する。
  3. 継承シーンが生成されたら、ルートノードの名前を「PieceBeige」に変更する。
    *このルートノードの名前はそれぞれのドロップの色に合わせること。
  4. シーンを一旦保存しておく。ファイルパスを「res://Pieces/PieceBeige.tscn」として保存する。
  5. シーンツリードックでルートノード「PieceBeige」を選択した状態で、インスペクターで「Script Variables」の「Color」プロパティの値を「beige」とする。
    BlueDrop - Color property
  6. シーンツリードックで「Sprite」ノードを選択し、「Texture」プロパティに先にインポートしておいたリソース「res://Aliens/alienBeige_round.png」を適用する(ファイルシステムドックからドラッグすればOK)。
    Sprite - Texture Region
    2D ワークスペース上では以下のスクリーンショットのようになったはずだ。
    Sprite - Texture Region
    以上で、「PieceBeige」シーンは完成だ。残りの 4 色のピースについても、同様の手順でシーンを作成してほしい。なお、シーンごとに異なる部分については、以下を参考にしてほしい。
  • 青のピース
    • ルートノード名: PieceBlue
    • Color プロパティ: blue
    • Sprite > Texture プロパティ: res://Aliens/alienBlue_round.png
    • シーン保存時のファイルパス: res://Pieces/PieceBlue.tscn
  • 緑のピース
    • ルートノード名: PieceGreen
    • Color プロパティ: green
    • Sprite > Texture プロパティ: res://Aliens/alienGreen_round.png
    • シーン保存時のファイルパス: res://Pieces/PieceGreen.tscn
  • ピンクのピース
    • ルートノード名: PiecePink
    • Color プロパティ: green
    • Sprite > Texture プロパティ: res://Aliens/alienPink_round.png
    • シーン保存時のファイルパス: res://Pieces/PiecePink.tscn
  • 黄のピース
    • ルートノード名: PieceYellow
    • Color プロパティ: yellow
    • Sprite > Texture プロパティ: res://Aliens/alienYellow_round.png
    • シーン保存時のファイルパス: res://Pieces/PieceYellow.tscn

全部で 5 色のピースの継承シーンができたら作業完了だ。



Grid シーンをスクリプトで制御する

各色のピースのシーンができあがったので、ここからはプログラミングしてゲームを制御していく。今回はコード量がやや多めなので頑張ろう。

Godot エディタで「Grid.tscn」シーンに切り替えたら、「Grid」ルートノードに新規スクリプトをアタッチしよう。ファイルパスは「res://Grid/Grid.gd」として作成する。

なお、スクリプト内のコメントには「指が触れた」または「指が離れた」と記載しているが、Godot デバッグパネル上では「マウス左ボタンを押した」または「マウス左ボタンを離した」と置き換えてほしい。

また、同じ色が3つ以上揃った状態のことを「マッチ」と表現しているので、こちらもご留意いただきたい。

では、スクリプトエディタが開いたら、まずは必要なプロパティを定義しておこう。

###Grid.gd###

extends Node2D

# 各色のピースのシーンファイルを要素とした配列
const pieces_scn = [
	preload("res://Pieces/PieceBeige.tscn"),
	preload("res://Pieces/PieceBlue.tscn"),
	preload("res://Pieces/PieceGreen.tscn"),
	preload("res://Pieces/PiecePink.tscn"),
	preload("res://Pieces/PieceYellow.tscn")
]

# x軸方向のグリッド数
var width: = 7
# y軸方向のグリッド数
var height: = 10
# x軸方向のグリッド開始位置(pixel)
var x_start: = 70
# y軸方向のグリッド開始位置(pixel)
var y_start: = 910
# 1グリッドのサイズ(PieceのSpriteのTextureと同じにする)
var grid_size: = 70

# 盤面のピースの配置を表す配列(二次元配列)
var all_pieces = []

# 画面に指を触れた位置
var touched_pos = Vector2()
# 画面から指を離れた位置
var released_pos = Vector2()

# 画面に指が触れているステート、触れている: true / 離れている: false
var is_touching = false
# マッチしたピースの自動処理ステート、処理中: true / 停止中: false
var is_waiting = false

# PiecesContainer ノードの参照
onready var pieces_container = $PiecesContainer

続いて以下のメソッドを追加しよう。なお、以下のコード内に出てくる 二次元配列 とは、要素として配列を格納する配列、つまり配列の配列のことだ。

今回のスクリプトで利用している二次元配列の場合、一階層目の配列では、盤面の x 軸方向のグリッドの数だけ空の配列を要素とし、二階層目としてそれぞれの配列内に縦方向のグリッド数だけ要素を格納する。その要素として、ピースオブジェクトを格納することで、それぞれのピースが盤面のどこに位置しているか(x 軸方向に何番目のグリッドで、y 軸方向に何番目のグリッドか)を管理することができるというわけだ。

###Grid.gd###

# シーンが読み込まれたら呼ばれる関数
func _ready():
    # ランダムな数を生成する関数の出力結果を毎回ランダムにするための組み込み関数を呼ぶ
	randomize()
    # all_pieces を盤面のグリッドを構成する二次元配列にする
	all_pieces = make_2d_array() # このあと定義
    # ピースを生成して盤面に配置して盤面情報を all_pieces に反映するメソッドを呼ぶ
	spawn_pieces() # このあと定義

# 盤面のグリッドを構成する二次元配列を生成するメソッド
func make_2d_array():
	# array という名前の配列を用意
    var array = []
    # array に x 軸方向のグリッド数だけ空の配列を入れる
	for i in width:
		array.append([])
        # さらにそれぞれの配列に y 軸方向のグリッド数だけ暫定的に null を入れる
		for j in height:
			array[i].append(null)
    # できあがった二次元配列を返す
	return array

# ピースを生成して盤面に配置して盤面情報を board に反映するメソッド
func spawn_pieces():
    # x軸方向のグリッド数だけループ
	for i in width:
        # y軸方向のグリッド数だけループ
		for j in height:
			# 全ピースの二次元配列上で該当グリッドにピースが存在しない場合
            #(ゲーム開始時は全部 null)
            if all_pieces[i][j] == null:
                # 各色のピースのシーンからランダムで1つ選択してインスタンス化
				var index = floor(rand_range(0, pieces_scn.size()))
				var piece = pieces_scn[index].instance()
                # マッチしてしまった場合は、ピースのインスタンスを削除してやり直し
				while match_at(i, j, piece.color): # このあと定義
					piece.queue_free()
					index = floor(rand_range(0, pieces_scn.size()))
					piece = pieces_scn[index].instance()
                # ピースのインスタンスをPiecesContainerノードの子にする
				pieces_container.add_child(piece)
                # グリッドからピクセルに変換した位置にピースのインスタンスを配置
				piece.position = grid_to_pixel(i, j) # このあと定義
                # 全ピースの二次元配列を更新
				all_pieces[i][j] = piece

上記コードの中で未定義のmatch_atメソッドとgrid_to_pixelメソッドを定義しておこう。

###Grid.gd###

# 指定したグリッド位置で同じ色ピースが3つ以上並んでいるか確認するメソッド
# 引数columnはx軸のグリッド位置、rowはy軸のグリッド位置、colorはピースの色
func match_at(column, row, color):
    # 指定したグリッドの x 軸方向の位置が3以上の場合
	if column >= 2:
        # 指定したグリッド位置の左隣ともう一つ左隣にピースがある場合
		if all_pieces[column-1][row] != null \
		and all_pieces[column-2][row] != null:
            # 左隣ともう一つ左隣のピースの色が指定したピースの色と同じ場合
			if all_pieces[column-1][row].color == color \
			and all_pieces[column-2][row].color == color:
                # true を返す
				return true
    # 指定したグリッドの y 軸方向の位置が3以上の場合
	if row >= 2:
        # 指定したグリッド位置の下ともう一つ下にピースがある場合
		if all_pieces[column][row-1] != null \
		and all_pieces[column][row-2] != null:
            # 下ともう一つ下のピースの色が指定したピースの色と同じ場合
			if all_pieces[column][row-1].color == color \
			and all_pieces[column][row-2].color == color:
                # true を返す
				return true

# グリッドの位置をピクセルの位置に変換するメソッド
func grid_to_pixel(column, row):
    # 先にピクセル位置出力用に Vector2 型の変数 pixel_pos を定義
	var pixel_pos = Vector2()
    # ピクセル x 座標 = x 軸方向のグリッド開始位置 + グリッドサイズ x グリッド x 座標
	pixel_pos.x = x_start + grid_size * column
    # ピクセル y 座標 = y 軸方向のグリッド開始位置 - グリッドサイズ x グリッド y 座標
	pixel_pos.y = y_start - grid_size * row
    # ピクセル座標を返す
	return pixel_pos

これで、ゲーム開始時に各色のピースが盤面にランダムで並べられるはずだ。一度プロジェクトを実行して確認してみよう。なお、初めてプロジェクトを実行する場合は、メインシーン選択のダイアログが表示されるので、「Grid.tscn」をメインシーンとして選択しよう。
run project - distribute pieces on the grid board


ちょうどgrid_to_pixelメソッドを定義したので、ついでにこのあと使用するpixel_to_gridメソッドも定義しておこう。名前の通り、先に定義したgrid_to_pixelとは逆で、ピクセルの位置をグリッドの位置に変換するメソッドだ。

###Grid.gd###

# ピクセルの位置をグリッドの位置に変換するメソッド
func pixel_to_grid(pixel_x, pixel_y) -> Vector2:
	var grid_pos = Vector2()
	grid_pos.x = floor((pixel_x - x_start) / grid_size)
	grid_pos.y = floor((pixel_y - y_start) / -grid_size)
	return grid_pos

さらに、もう一つこのあと使用するis_in_gridメソッドを定義しておく。これは引数に渡した位置が盤面グリッドの範囲内かどうかを判定してその結果を返すメソッドだ。

###Grid.gd###

# 指定した位置が盤面グリッドの範囲内かどうかを返すメソッド
func is_in_grid(grid_position: Vector2):
	if grid_position.x >= 0 and grid_position.x < width \
	and grid_position.y >= 0 and grid_position.y < height:
        # 盤面グリッドの範囲内だったら true を返す
		return true
	else:
        # 盤面グリッドの範囲外だったら false を返す
		return false

ここで、ゲームのプレイヤーの入力(画面のタッチ操作)を処理するプログラムを記述していく。

###Grid.gd###

# ゲームのメインループで毎フレーム呼ばれる関数
func _process(_delta):
    # もしマッチングの処理中でなければ
	if not is_waiting:
        # プレイヤーの入力を処理する
		touch_input() # このあと定義

# プレイヤーの入力を処理するメソッド
func touch_input():
    # もし画面に指が触れたら
	if Input.is_action_just_pressed("touch"):
        # 指の位置をピクセルからグリッドに変換
		var start_pos = get_global_mouse_position()
		var start_grid = pixel_to_grid(start_pos.x, start_pos.y)
        # 指の位置が盤面グリッドの範囲内だったら
		if is_in_grid(start_grid):
            # 画面に指を触れた位置を保存
			touched_pos = start_grid
            # ステートを画面に指が触れている状態にする
			is_touching = true
	# もし画面から指が離れたら
    if Input.is_action_just_released("touch"):
        # 指の位置をピクセルからグリッドに変換
		var end_pos = get_global_mouse_position()
		var end_grid = pixel_to_grid(end_pos.x, end_pos.y)
        # もし指の位置が盤面グリッドの範囲内...
        # かつステートが画面に指が触れている状態だったら
		if is_in_grid(end_grid) and is_touching:
            # 指を離した位置情報として保存
			released_pos = end_grid
            # 指が触れた位置と指を離した位置でピースの移動を処理するメソッドを呼ぶ
			touch_and_release() # このあと定義
        # ステートを画面から指が離れている状態にする
		is_touching = false

上記コードで、盤面グリッド内で画面に指を触れた位置と画面から指を離した位置を取得し、それらの情報を利用してピースの移動を処理するtouch_and_releaseメソッドを呼び出している。このメソッドとその中でさらに呼び出すswap_piecesというヘルパーメソッドを定義していこう。ヘルパーメソッドというのは簡単に言うと、メソッドの中で呼び出されるメソッドで、親のメソッドをシンプルに保つ役割をする。

###Grid.gd###

# 指が触れた位置と指を離した位置を利用してピースの移動を処理するメソッド
func touch_and_release():
    # 指が触れた位置と指が離れた位置との差を計算
	var difference = released_pos - touched_pos
    # x 軸方向の差の絶対値が y 軸方向の差の絶対値より大きい場合
	if abs(difference.x) > abs(difference.y):
        # x 軸方向の差が 0 より大きい場合
		if difference.x > 0:
            # ヘルパーメソッドを呼び、触れた位置のピースと右に隣接するピースを入れ替える
			swap_pieces(touched_pos, Vector2.RIGHT) # このあと定義
        # x 軸方向の差が 0 より小さい場合
		elif difference.x < 0:
            # ヘルパーメソッドを呼び、触れた位置のピースと左に隣接するピースを入れ替える
			swap_pieces(touched_pos, Vector2.LEFT) # このあと定義
    # x 軸方向の差の絶対値が y 軸方向の差の絶対値より小さい場合
	elif abs(difference.x) < abs(difference.y):
        #  y 軸方向の差が 0 より大きい場合
		if difference.y > 0:
            # ヘルパーメソッドを呼び、触れた位置のピースと下に隣接するピースを入れ替える
			swap_pieces(touched_pos, Vector2.DOWN) # このあと定義
        # y 軸方向の差が 0 より小さい場合
		elif difference.y < 0:
            # ヘルパーメソッドを呼び、触れた位置のピースと上に隣接するピースを入れ替える
			swap_pieces(touched_pos, Vector2.UP) # このあと定義

# ピースを入れ替えるヘルパーメソッド
func swap_pieces(pos, dir):
    # 指を触れた位置のピースを全ピースの二次元配列から取得
	var touched_piece = all_pieces[pos.x][pos.y]
    # 指を離した方向に隣接するピースを全ピースの二次元配列から取得
	var target_piece = all_pieces[pos.x + dir.x][pos.y + dir.y]
    # 全ピースの二次元配列上、どちらのピースも存在している場合
	if touched_piece != null and target_piece != null:
        # 全ピースの二次元配列の中から指を触れた位置のピースを指を離した方に隣接するピースで上書きする
		all_pieces[pos.x][pos.y] = target_piece
        # 全ピースの二次元配列上、指を離した方に隣接するピースを指を触れた位置のピースで上書きする
		all_pieces[pos.x + dir.x][pos.y + dir.y] = touched_piece
        # 盤面上、指を触れた位置のピースインスタンスを指を離した方にに1グリッド移動する
		touched_piece.move(grid_to_pixel(pos.x + dir.x, pos.y + dir.y))
        # 盤面上、指を離した方に隣接するピースインスタンスを指を触れた位置へ移動する
		target_piece.move(grid_to_pixel(pos.x, pos.y))
        # ここからマッチしたピースの処理が始まるので、自動処理ステートを処理中にする
		is_waiting = true

上記コードで、プレイヤーがドラッグしたピースが隣接するピースと入れ替わる処理が実装できた。入力操作が正しく機能するか、プロジェクトを実行して確認しておこう。
run project - swap pieces


ここからはピースを入れ替えたあとに自動的に実行されるべき処理を実装していく。大まかには以下の流れだ。

  1. 自動処理ステートを処理中に変更する。
  2. マッチしたピースが1組でもあるかチェックする。1組でもある場合は以下の処理をループする。
    1. 全てのピースをチェックしてマッチしたピースにフラグを立てる。
    2. フラグの立っているピースを削除する。
    3. 削除して空になったスペースへ同じ列の上のグリッドからピースを移動させて詰める。
    4. ピースを下へ詰めたら、最後に空のスペースに新しいピースを生成する。
  3. マッチしたピースが1組もなくなったら自動処理ステートを停止中にする。

上記の大まかな流れをコーディングしよう。

まず先に定義したピースを入れ替えるswap_piecesメソッドの最後にis_waiting = trueの1行を追加しよう。つまりこれは、自動処理ステートを処理中に変更している。

###Grid.gd###

func swap_pieces(pos, dir):
	var touched_piece = all_pieces[pos.x][pos.y]
	var target_piece = all_pieces[pos.x + dir.x][pos.y + dir.y]
	if touched_piece != null and target_piece != null:
		all_pieces[pos.x][pos.y] = target_piece
		all_pieces[pos.x + dir.x][pos.y + dir.y] = touched_piece
		touched_piece.move(grid_to_pixel(pos.x + dir.x, pos.y + dir.y))
		target_piece.move(grid_to_pixel(pos.x, pos.y))

        # 以下を追加
        # ここからマッチしたピースの自動処理が開始するので自動処理ステートを処理中にする
		is_waiting = true

touch_inputメソッドに「もし自動処理実行中のステートだったら」というif構文を記述し、そのブロックの中に自動的に実行させたい処理を追加していこう。追加する位置はちょうど指での操作が終わったあとだ。処理が完了した後にはis_waiting = falseの1行追加して、自動処理ステートを停止中にしておこう。

###Grid.gd###

func touch_input():
	if Input.is_action_just_pressed("touch"):
		# 省略
	if Input.is_action_just_released("touch"):
		# 省略

        # ここから追加
        # もし処理中だったら
		if is_waiting:
            # マッチしたピースが1組でもあるかチェック > ある限りループ
			while check_matches(): # このあと定義
                pass
            # マッチしたピースが1組もなく処理が完了したら、自動処理ステートを停止中にする
			is_waiting = false

whileループで、マッチしたピースが1組でもあれば必要な処理を繰り返すようにしている。


まずはwhileループのループ条件ともなっているcheck_matches()を以下の通りに定義する。

###Grid.gd###

# マッチしたピースが1組でもあるかチェックして結果を返すメソッド
func check_matches() -> bool:
    # 盤面の x軸方向のグリッドでループ
	for i in width:
        # 盤面の y軸方向のグリッドでループ
		for j in height:
            # その グリッドの座標にピースが存在する場合
			if all_pieces[i][j] != null:
                # そのグリッド座標のピースがマッチしていれば true を返してメソッドも終了
				if match_at(i, j, all_pieces[i][j].color):
					return true
    # 全ピースをチェックしてマッチしているピースが一つもなければ false を返す
	return false

続いて、whileループ内の最初の処理」全てのピースをチェックしてマッチしたピースにはフラグを立てる」を行うためのfind_matchesメソッドを以下のように定義しよう。ここでいう「フラグを立てる」と言うのは、つまり、ピースのインスタンスのmatchedプロパティをtrueに変更する、ということだ。

###Grid.gd###

# マッチしているピースを見つけてフラグを立てるメソッド
func find_matches():
    # 盤面の x 軸方向のグリッド数だけループ
	for i in width:
        # 盤面の y 軸方向のグリッド数だけループ
		for j in height:
            # そのグリッドの座標にピースが存在する場合
			if all_pieces[i][j] != null:
                # 現在の色をそのグリッド座標のピースの色と定義する
				var current_color = all_pieces[i][j].color
                # もしその x 軸座標が x 軸方向のグリッド数 - 2 より小さければ
				if i < width - 2:
                    # そのピースの右隣とさらにその右隣にピースが存在する場合
					if all_pieces[i+1][j] != null \
					and all_pieces[i+2][j] != null:
                        # それらのピースの色が現在の色と同じ場合
						if all_pieces[i+1][j].color == current_color \
						and all_pieces[i+2][j].color == current_color:
                            # そのピースにフラグが立ってなければ
							if not all_pieces[i][j].matched:
                                # そのピースの matched プロパティを true にしてフラグを立てる
                                # 同時にそのピースのテクスチャの色を半透明にする
								all_pieces[i][j].make_matched()
                            # そのピースの右隣のピースにフラグが立ってなければ
							if not all_pieces[i+1][j].matched:
                                # そのピースの matched プロパティを true にしてフラグを立てる
                                # 同時にそのピースのテクスチャの色を半透明にする
								all_pieces[i+1][j].make_matched()
                            # そのピースの2つ右隣のピースにフラグが立ってなければ
							if not all_pieces[i+2][j].matched:
                                # そのピースの matched プロパティを true にしてフラグを立てる
                                # 同時にそのピースのテクスチャの色を半透明にする
								all_pieces[i+2][j].make_matched()
				# もしそのピースの y 座標が y 軸方向のグリッド数 - 2 より小さければ
                if j < height - 2:
                    # そのピースの上とさらにその上にピースが存在する場合
					if all_pieces[i][j+1] != null \
					and all_pieces[i][j+2] != null:
                        # それらのピースの色が現在の色と同じ場合
						if all_pieces[i][j+1].color == current_color \
						and all_pieces[i][j+2].color == current_color:
                            # そのピースにフラグが立ってなければ
							if not all_pieces[i][j].matched:
                                # そのピースの matched プロパティを true にしてフラグを立てる
                                # 同時にそのピースのテクスチャの色を半透明にする
								all_pieces[i][j].make_matched()
                            # そのピースの上のピースにフラグが立ってなければ
							if not all_pieces[i][j+1].matched:
                                # そのピースの matched プロパティを true にしてフラグを立てる
                                # 同時にそのピースのテクスチャの色を半透明にする
								all_pieces[i][j+1].make_matched()
                            # そのピースの2つ上のピースにフラグが立ってなければ
							if not all_pieces[i][j+2].matched:
                                # そのピースの matched プロパティを true にしてフラグを立てる
                                # 同時にそのピースのテクスチャの色を半透明にする
								all_pieces[i][j+2].make_matched()

このfind_matchesメソッドをtouch_inputメソッド内のwhileループの中で呼び出す必要があるので、以下のように更新しよう。

###Grid.gd###

func touch_input():
	if Input.is_action_just_pressed("touch"):
		# 省略
	if Input.is_action_just_released("touch"):
		# 省略

		if is_waiting:
            # マッチしたピースが1組でもあるかチェック > ある限りループ
			while check_matches():
                # マッチしたピースを見つけてフラグを立てる
				find_matches()
                # 処理を視覚的にわかりやすくするため0.3秒待機
				yield(get_tree().create_timer(0.3), "timeout")
			is_waiting = false

これで、マッチした各ピースのインスタンスのmatchedプロパティはtrueになり、ピースの色も半透明になるはずだ。プロジェクトを実行して確認してみよう。
run project - flag on matched pieces


続いて、whileループ内の2番目の処理「フラグの立っているピースを削除する」を実行するdelete_matchesメソッドを以下のように定義してほしい。

###Grid.gd###

# マッチしているピースを削除するメソッド
func delete_matches():
    # 盤面の x 軸方向のグリッド数だけループ
	for i in width:
        # 盤面の y 軸方向のグリッド数だけループ
		for j in height:
            # そのグリッド座標にピースが存在する場合
			if all_pieces[i][j] != null:
                 # そのグリッド座標のピースにフラグが立っていたら
				if all_pieces[i][j].matched:
                    # そのグリッド座標のピースを削除する
					all_pieces[i][j].queue_free()
                    # 全ピースの二次元配列からそのグリッド座標の要素を空にする
					all_pieces[i][j] = null

このdelete_matchesメソッドをtouch_inputメソッドのwhileループ内に追加しよう。

###Grid.gd###

func touch_input():
	if Input.is_action_just_pressed("touch"):
		# 省略
	if Input.is_action_just_released("touch"):
		# 省略

		if is_waiting:
            # マッチしたピースが1組でもあるかチェック > ある限りループ
			while check_matches():
                # マッチしたピースを見つけてフラグを立てる
				find_matches()
                # 0.3秒待機
				yield(get_tree().create_timer(0.3), "timeout")
                # フラグを立てたピースを削除する
				delete_matches()
                # 0.3秒待機
				yield(get_tree().create_timer(0.3), "timeout")
            # マッチしたピースが1組もなく処理が完了したら、処理中状態を無効にする
			is_waiting = false

これでマッチしたピースが半透明になった後、削除されるはずだ。プロジェクトを実行して確認してみよう。
run project - delete matched pieces


次は、whileループ内の3番目の処理「削除して空になったスペースへ同じ列の上のグリッドからピースを移動させて詰める」を実行するcollapse_columnsメソッドを以下のように更新してほしい。

###Grid.gd###

# 列ごとにピースが存在しないスペースには上にあるピースを移動して詰めるメソッド
func collapse_columns():
    # 盤面の x 軸方向のグリッド数だけループ
	for i in width:
        # 盤面の y 軸方向のグリッド数だけループ
		for j in height:
            # そのグリッド座標にピースが存在しない(null)場合
			if all_pieces[i][j] == null:
                # そのグリッドの y 座標より1つ上の行から一番上の行までのループ
				for k in range(j + 1, height):
                    # 1つ上のグリッドにピースが存在している場合
					if all_pieces[i][k] != null:
                        # 1つ上のグリッドのピースを下の空いているグリッドに移動する
						all_pieces[i][k].move(grid_to_pixel(i, j))
                        # 全ピースの二次元配列の現在のグリッド座標に1つ上のピースを入れる
						all_pieces[i][j] = all_pieces[i][k]
                        # 全ピースの二次元配列の1つ上のグリッド座標を空にする
						all_pieces[i][k] = null
                        # ループを抜ける
						break

このcollapse_columnsメソッドをtouch_inputメソッドのwhileループ内で呼び出そう。

###Grid.gd###

func touch_input():
	if Input.is_action_just_pressed("touch"):
		# 省略
	if Input.is_action_just_released("touch"):
		# 省略

		if is_waiting:
            # マッチしたピースが1組でもあるかチェック > ある限りループ
			while check_matches():
                # マッチしたピースを見つけてフラグを立てる
				find_matches()
                # 0.3秒待機
				yield(get_tree().create_timer(0.3), "timeout")
                # フラグを立てたピースを削除する
				delete_matches()
                # 0.3秒待機
				yield(get_tree().create_timer(0.3), "timeout")
                # 空のスペースに同じ列の上にあるピースを移動して詰める
				collapse_columns()
                # 0.3秒待機
				yield(get_tree().create_timer(0.3), "timeout")
            # マッチしたピースが1組もなく処理が完了したら、処理中状態を無効にする
			is_waiting = false

これでピースが削除されて空いたスペースに上にあるピースを詰める仕組みができた。さらにwhileループにより、空いたスペースにピースを詰めた あとに マッチしたピースも連続的に削除されるはずだ。プロジェクトを実行して確認してみよう。
run project - collapse columns


最後に、すでに定義済みのspawn_piecesメソッドをピースを下に詰めたあとに呼び出すようにすれば、空いた空間に新しいピースが配置され盤面が埋まるはずだ。touch_inputメソッドを以下のように更新しよう。

###Grid.gd###

func touch_input():
	if Input.is_action_just_pressed("touch"):
		# 省略
	if Input.is_action_just_released("touch"):
		# 省略

		if is_waiting:
            # マッチしたピースが1組でもあるかチェック > ある限りループ
			while check_matches():
                # マッチしたピースを見つけてフラグを立てる
				find_matches()
                # 0.3秒待機
				yield(get_tree().create_timer(0.3), "timeout")
                # フラグを立てたピースを削除する
				delete_matches()
                # 0.3秒待機
				yield(get_tree().create_timer(0.3), "timeout")
                # 空のスペースに同じ列の上にあるピースを移動して詰める
				collapse_columns()
                # 0.3秒待機
				yield(get_tree().create_timer(0.3), "timeout")
                # 空のスペースに新しいピースを生成して配置する
				spawn_pieces()
                # 0.3秒待機
				yield(get_tree().create_timer(0.3), "timeout")
            # マッチしたピースが1組もなく処理が完了したら、処理中状態を無効にする
			is_waiting = false

以上で自動処理部分のコーディングは完了だ。このチュートリアルの手順もここまでとなる。最後にプロジェクトを実行して動作を確認しておこう。
run project - whole check



サンプルゲーム

今回のチュートリアルで作成したプロジェクトをさらにブラッシュアップしたサンプルゲームを用意した。ちなみに以下のGIF画像は3倍速で再生しているので実際はもう少し穏やかだ。

sample game


サンプルゲームのプロジェクトファイルは、GitHubリポジトリ に置いているので、そこから .zip ファイルをダウンロードしていただき、「Sample」フォルダ内の「project.godot」ファイルを Godot Engine でインポートすれば確認していただけるはずだ。



おわりに

今回はマッチ3パズルゲームを作った。シンプルな操作で何回でも楽しめるモバイルゲームにはうってつけのゲームジャンルだ。

今回のようなシンプルなマッチ3パズルゲームを作るときのポイントをまとめておこう。

  • 最低限必要なシーンは盤面とピースの2つだけ。
  • 雛形のピースシーンを作ってから、それを継承して各色のピースシーンを作る。
  • 二次元配列を利用して盤面グリッドに配置するピースを管理する。
  • ピースを入れ替えるときは、画面上のピースの位置の入れ替えと二次元配列の要素の入れ替えの両方が必要。
  • スクリプトは以下がポイント。
    • プレイヤーがピースを動かす処理
      1. 画面に指が触れた位置と画面から指が離れた位置を取得する。
      2. 2つの位置がグリッド内か(有効な操作か)判定する。
      3. 2つの位置の差からピースを入れ替える方向を判定する。
    • マッチした時の自動処理(ループ)。
      1. マッチしているピースが1組でもあるかチェック(ループの条件)。
      2. マッチしているピースにフラグを立てる。
      3. フラグの立っているピースを削除する。
      4. 削除されて空いたスペースに上のピースを詰める。
      5. 詰めて空いたスペースに新しいピースを生成する。


参照