今回のチュートリアルでは、進化形マッチ3パズルゲームと銘打って、盤面上で1つのピースを一定時間(数秒間)自由に動かして同じ色のピースを3つ以上並べて消すタイプのパズルゲームをを作っていく。

これはモバイルゲームで人気を博した「パズル&ドラゴンズ(Puzzle & Dragons)/ 通称パズドラ」のようなパズルをイメージしていただくとわかりやすいだろう。

ただし、チュートリアルに味方のデッキやガチャ、敵キャラクターとのバトルなど全てのゲーム要素を盛り込むとボリュームが大きすぎるので、今回はパズル部分にフォーカスして手順を説明させていただく。

なお「キャンディークラッシュ」のようなオーソドックスなマッチ3(スリー)パズルゲームや「LINEツムツム」のような指でピースをなぞって繋げて消すタイプのパズルゲームの作り方については、以下のチュートリアルを参照いただきたい。

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だ。もし思いつかなければ「Advanced 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. 「ルートノードを生成」にて「その他のノード」を選択する。
  3. 「Area2D」クラスのルートノードが生成されたら、その名前を「Grid」に変更する。
  4. シーンを保存する。フォルダを作成して、ファイルパスを「res://Grid/Grid.tscn」としてシーンを保存する。

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

「Grid」ルートノードに、さらにノードを追加していこう。

  1. 「Grid」ルートノードに「CollisionShape2D」ノードを追加する。
  2. 「Grid」ルートノードに「Node2D」ノードを追加し、名前を「PiecesContainer」に変更する。
  3. 「Grid」ルートノードに「Timer」ノードを2つ追加し、それぞれ名前を「TouchTimer」、「WaitTimer」に変更する。

ひとまず現時点でのシーンツリーは以下のようになったはずだ。
Scene Dock

続けて追加したノードを編集していこう。


Grid シーンのノードを編集する

Grid (Area2D) ルートノード

このパズドラ風のパズルでは、盤面上でピースを自由に動かせるわけだが、盤面から動かしているピースがはみ出してはいけない。そうしないと、盤面の外側を移動させて離れたピースと入れ替えることができてしまう。このパズルゲームは、あくまで動かしているピースと隣り合ったドロップが次々に交換されていく仕様なのだ。

ルートノードを Area2D クラスにしたのは、そのシグナルを使って盤面から指(動かしているピース)がはみ出したことを検知させるためだ。検知さえできれば、あとはスクリプトで制限できる。このシーンへのスクリプトのアタッチは最後に行うので、その時にあらためてシグナルの接続を行うことにしよう。

このノードそのもののプロパティ編集は不要だ。


CollisionShape2D ノード

このノードは、盤面の外に指(動かしているピース)がはみ出した時にそれを検知するために利用する。インスペクターで以下の通りに各プロパティを編集しよう。

  • Shape: 新規 RectangleShape2D リソースを適用する。
    • RectangleShape2D >
      • Extents: (x: 225, y: 190)
        CollisionShape2D Properties - Shape
    • Transform >
      • Position: (x: 315, y: 840)
        CollisionShape2D Properties - Position

2D ワークスペース上では以下のようになったはずだ。
CollisionShape2D in 2D workspace


PiecesContainer (Node2D) ノード

このノードは盤面に配置されるピースをまとめるためのノードだ。ゲーム中、スクリプトでピースのインスタンスが生成されたら全てこの「PiecesContainer」ノードの子として追加することになる。そうすることで、ピースのインスタンスがどれだけ生成されても、シーンツリー上のノードの順序を維持することができる。

特に編集が必要なプロパティはないので次へ行こう。


TouchTimer ノード

この Timer クラスのノードは、指でピースを動かし始めてからカウントダウンを始め、タイムアウトしたら、動かしていたピースが指から自動的に離れるようにするためのものだ。これにより、制限時間内でピースを並べなければならないというある種の緊張感をプレイヤーに与えることができる。今回は 5 秒間ピースを移動できる設定にする。

インスペクターで以下のように編集しよう。

  • Wait Time: 5
  • One Shot: オン
    TouchTimer properties

WaitTimer ノード

こちらの Timer クラスのノードは、指からピースが離れた後の、ピースのマッチング処理、マッチしたピースの削除、削除されて空いたグリッドへピースを詰めて、不足している分を追加する、という一連の流れを自動処理する際に、それぞれの処理の間に一瞬だけ間をあけるためのノードだ。

インスペクターで以下のように編集しよう。

  • Wait Time: 0.3
  • One Shot: オン
    WaitTimer properties

以上で、「Grid」シーンの編集は完了だ。



Piece シーンを作る

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

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

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

続けて、「Piece」シーンに必要なノードを追加していこう。

  1. 「Piece」ルートノードに「Sprite」ノードを追加する。
  2. 「Piece」ルートノードに「CollisionShape2D」ノードを追加する。
  3. 「Piece」ルートノードに「Tween」ノードを追加する。

Piece シーンのシーンツリーは以下のようになったはずだ。
Piece scene tree


Piece シーンのノードを編集する

Piece (Area2D) ルートノード

ルートノードを Area2D クラスにしたのは、動かしたいピースに指が当たった時や離れた時、ピース同士が衝突した時に、それを検知できるようにするためだ。

このノードのプロパティは編集不要だが、1つだけグループへの追加が必要だ。シーンドックで「Piece」ルートノードを選択したら、ノードドック>グループタブを選択して、「Pieces」という名前のグループを作成して追加しよう。
Group Pieces
「Grid」シーンの方で「Grid」ルートノードには盤面からのはみ出し検知用のコリジョン形状が設定されており、これは常に全ピースと接触しているので、ピース同士との衝突を区別するために必要なグループなのだ。


Sprite ノード

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

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

  • Offset:
    Offset: (x: 35, y: -35)
    Sprite - offset

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


CollisionShape2D ノード

このノードはルートノードにコリジョン形状を付与する役目だ。ピースに指を触れたり、指を離したり、隣のピースと衝突したりするのを検知させるのに必要なノードである。コリジョン形状を縦横 70 px の Sprite の Texture にピッタリ合わせてしまうと、少しピースを動かしただけで、隣のピースとの衝突を検知してしまう。かといって、コリジョン形状が小さすぎると、指でピースに触れているつもりなのに検知されなかったり、ピースを移動させるときに、ピース同士の衝突をうまく検知できずにピースとピースの間を通り抜けてしまったりする。そこでコリジョン形状は Sprite の Texture の半分のサイズにするとちょうど良い。位置も Sprite の Texture にきれいに重なるように調整しよう。

  • Shape: 新規 CircleShape2D リソースを適用する
    • CircleShape2D:
      • Radius: 17.5
        CollisionShape2D - Shape, Radius
  • Transform:
    • Position: x: 35, y: -35
      CollisionShape2D - Position

2D ワークスペース上では以下のようになったはずだ。
CollisionShape2D - 2D workspace


Tween ノード

このノードは、指定したノードの単一のプロパティのみをアニメーションさせることができる。今回このノードを使用するのは、ピースの位置が交換される時や、マッチして消えたピースの位置にピースを詰める時のピースの移動をアニメーションさせるためだ。

ただし、アニメーションはスクリプトで実装するので、このタイミングでのプロパティの編集は不要だ。

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

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

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

###Piece.gd###
extends Area2D

# プレイヤーが動かしているピースに衝突したら発信するシグナル(引数にピース自身を渡す)
signal collided(self_piece)
# ピースの色を設定するプロパティ
export (String) var color
# マッチした場合のフラグとなるプロパティ
var matched = false
# マッチしたピースのグループに割り振られるインデックス
var matched_index = 0
# プレイヤーが動かしている場合のフラグとなるプロパティ
var held = false
# ピースのテクスチャと同じオフセット
var offset = Vector2(35, -35)
# Spriteノードへの参照
onready var sprite = $Sprite
# Tweenノードへの参照
onready var tween = $Tween

# メインループで毎フレーム呼ばれる組み込みメソッド
func _process(_delta):
    # もしプレイヤーがピースを移動中だったら
	if held:
        # ピースの位置を(35, -35)ずらしてマウスに追随させる
		position = get_global_mouse_position() - offset

# ピースを移動させるメソッド
func move(destination):
    # Tweenノードのアニメーションを設定する
    # ピースを0.1秒かけて現在の位置から引数destinationの位置まで移動させる
	tween.interpolate_property(self, "position", position, destination, .1, Tween.TRANS_QUINT, Tween.EASE_IN)
    # Tweenノードのアニメーションを開始する
	tween.start()

# ピースがマッチした時に呼ばれるメソッド
func make_matched(index):
    # マッチのフラグを立てる
	matched = true
    # マッチしたグループごとのインデックス(引数index)を割り当てる
	matched_index = index
    # ピースの色を半透明にする
	modulate = Color(1,1,1,.5)

# プレイヤーの指がピースに触れたら呼ばれるメソッド
func enable_held():
    # プレイヤーが動かしているフラグを立てる
	held = true
    # ピースの色を20%透明にする
	modulate = Color(1, 1, 1, 0.8)

# プレイヤーが指をピースから離したら呼ばれるメソッド
func disable_held():
    # プレイヤーが動かしているフラグを解除する
	held = false
    # ピースの色をデフォルトに戻す
	modulate = Color(1, 1, 1, 1)

続けて、ピースに別のピースが当たった時に発信されるシグナルを利用する。シーンドックで「Piece」ルートノードを選択したらノードドック>シグナルタブでarea_entered(area: Area2D)を、現在編集中の「Piece.gd」スクリプトに接続しよう。

プレイヤーが動かしているピースが当たったらシグナルcollided(self_piece)を発信させたいので、自動生成されたメソッド_on_Piece_area_entered(area)内にそのためのコードを記述しよう。

###Piece.gd###

# Area2Dが当たったらシグナルが発信されて呼ばれるメソッド
func _on_Piece_area_entered(area):
    # もし当たったArea2Dが「Pieces」グループ(つまりPieceのインスタンス)で...
    # かつ、プレイヤーが動かしているピースだったら
	if area.is_in_group("Pieces") and area.held:
        # 引数に自分自身のピースを渡してシグナル collided を発信する
		emit_signal("collided", self)

これで「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 Area2D

# ピースを動かして指を離した後の自動処理開始時に発信するシグナル
signal waiting_started

# 各色のピースのシーンファイルを要素とした配列
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: = 6
# x軸方向のグリッド開始位置(pixel)
var x_start: = 70
# y軸方向のグリッド開始位置(pixel)
var y_start: = 1050
# 1グリッドのサイズ(PieceのSpriteのTextureと同じにする)
var grid_size: = 70
# ピースが生成される時に何グリッドy軸方向にズラして落とすか
var y_offset: = 3

# 盤面のピースの配置を表す配列(二次元配列)
var board = []
# プレイヤーが動かしているピースの参照
var moving_piece
# プレイヤーが動かしているピースの最後の位置(グリッド)
var last_pos = Vector2()

# ゲーム開始時の準備中のフラグ
var is_initializing = true
# プレイヤーがピースを動かしている場合のフラグ
var is_touching = false
# ピース入れ替え中のフラグ
var is_swapping = false
# プレイヤーがピースを動かした後の自動処理中のフラグ
var is_waiting = false

# マッチしたピースのグループの数(カウントアップする)
var matched_groups = 0

# PiecesContainerノードの参照
onready var pieces_container = $PiecesContainer
# TouchTimerノードの参照
onready var touch_timer = $TouchTimer
# WaitTimerノードの参照
onready var wait_timer = $WaitTimer

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

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

###Grid.gd###

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

# 盤面のグリッドを構成する二次元配列を生成するメソッド
func make_2d_array() -> 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 board[i][j] == null:
                # 各色のピースのシーンからランダムで1つ選択してインスタンス化
				var index = floor(rand_range(0, pieces_scn.size()))
				var piece = pieces_scn[index].instance()
                # もしゲーム開始時の準備中だったら
				if is_initializing:
                    # マッチしてしまった場合は、ピースのインスタンスを削除してやり直し
					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)
                # ピースのインスタンスの collided シグナルを...
                # _on_Piece_collided メソッド(あとで定義)に接続する
				piece.connect("collided", self, "_on_Piece_collided")
                # ピースのインスタンスを配置位置より y_offset 分ずらして置き...
                # そこから配置位置へ移動させる(落とす)
				piece.position = grid_to_pixel(i, j + y_offset) # このあと定義
				piece.move(grid_to_pixel(i, j)) # このあと定義
                # 盤面情報として board の適切なインデックスの要素に生成したピースを追加
				board[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 board[column-1][row] != null \
        and board[column-2][row] != null:
            # 左隣ともう一つ左隣のピースの色が指定したピースの色と同じ場合
			if board[column-1][row].color == color \
            and board[column-2][row].color == color:
                # true を返す
				return true
    # 指定したグリッドの y 軸方向の位置が3以上の場合
	if row >= 2:
        # 指定したグリッド位置の下ともう一つ下にピースがある場合
		if board[column][row-1] != null \
        and board[column][row-2] != null:
            # 下ともう一つ下のピースの色が指定したピースの色と同じ場合
			if board[column][row-1].color == color \
            and board[column][row-2].color == color:
                # true を返す
				return true

# グリッドの位置をピクセルの位置に変換するメソッド
func grid_to_pixel(column, row) -> Vector2:
    # 先にピクセル位置出力用に 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) -> bool:
	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"):
        # ピースに触れた時の処理を実行するメソッドを呼ぶ
		touch_piece() # このあと定義
    # もし画面から指が離れたら
	if Input.is_action_just_released("touch") and is_touching:
        # ピースから指が離れた時の処理を実行するメソッドを呼ぶ
		release_piece() # このあと定義

# ピースに触れた時の処理を実行するメソッド
func touch_piece():
    # 指が触れた時のピクセル座標を取得する
	var pos = get_global_mouse_position()
    # ピクセル座標からグリッド座標に変換する
	var grid_pos = pixel_to_grid(pos.x, pos.y)
    # もしグリッド座標が盤面の範囲内だったら
	if is_in_grid(grid_pos):
        # 動かしているピースの最後の位置としてグリッド座標を登録
		last_pos = grid_pos
        # 動かしているピースとしてグリッド座標に位置するピースを登録
		moving_piece = board[last_pos.x][last_pos.y]
        # ピースを動かしているフラグを立てる
		is_touching = true
        # 動かしているピースインスタンス自体の動かしているフラグも立てる
		moving_piece.enable_held()
        # ピースを動かせる制限時間のタイマースタート
		touch_timer.start()

# ピースから指が離れた時の処理を実行するメソッド
func release_piece():
    # 二次元配列 board の要素から動かしていたピースを見つけたら...
    # 動かしていたピースを盤面グリッドにきっちり収める
	for i in width:
		for j in height:
			if board[i][j] == moving_piece:
				moving_piece.move(grid_to_pixel(i, j))
				break
    # 動かしていたピースインスタンス自体の動かしているフラグを解除する
	moving_piece.disable_held()
    # ピースを動かしているフラグを解除する
	is_touching = false
    # ピースを動かせる制限時間のタイマーストップ
	touch_timer.stop()
    # このあとのマッチング自動処理開始のシグナルを発信
	emit_signal("waiting_started")

まだピースの交換は実装していないが、ここまでのコーディングで、指で触ったピースを移動させ、指を離したら移動しているピースが最後にいた場所にきっちり収まる動きが実装できたはずだ。一度プロジェクトを実行して確認してみよう。
run project - touch and release a piece


次に定義する_on_Piece_collidedメソッドには、すでに Piece インスタンス生成時にcollidedシグナルを接続するようコーディング済みだ。このシグナルはピースにプレイヤーが動かしているピースが当たった時に発信されるように「Piece.gd」スクリプトの方でコーディングしたことを思い出してほしい。

動かしているピースとそれに当たったピースの場所を入れ替えるメソッドを定義して、_on_Piece_collidedの中で呼び出してみよう。

###Grid.gd###

# Piece インスタンスの collided シグナルで呼ばれるメソッド
func _on_Piece_collided(self_piece):
    # ピースを動かしていて、かつピース交換中でなければ
	if is_touching and not is_swapping:
        # ピース交換中のフラグを立てる
		is_swapping = true
        # ピースを交換するメソッドを呼ぶ
		swap_pieces(self_piece) # あとで定義
        # ピース交換中のフラグを解除
		is_swapping = false

# 動かしているピースとそれに当たったピースの場所を入れ替えるメソッド
# 引数 collided_piece には動かしているピースに当たったピースが渡される
func swap_pieces(collided_piece):
    # 当たったピースのグリッド座標を取得する。
	var collided_pos = pixel_to_grid(collided_piece.position.x, collided_piece.position.y)
    # 二次元配列 board 上の動かしているピースと動かしているピースが一致していれば
	if board[last_pos.x][last_pos.y] == moving_piece:
        # board の動かしているピースの位置に当たったピースを入れる
		board[last_pos.x][last_pos.y] = collided_piece
        # 当たったピースを動かしているピースの最後のグリッド座標へ移動させる
		collided_piece.move(grid_to_pixel(last_pos.x, last_pos.y))
        # board の当たったピースの位置に動かしているピースを入れる
		board[collided_pos.x][collided_pos.y] = moving_piece
        # 動かしているピースの最後の位置として当たったピースのグリッド座標を登録する
		last_pos = collided_pos

これでピースの交換も実装できたはずだ。では実際にプロジェクトを実行してピースを動かしてみよう。通ったルートのピースが次々と交換される動作を確認しよう。
run project - swapping piece

さて、この時点で問題になるのは以下の2点だ。

  • 盤面の外側を自由に移動できてしまうこと
  • いつまでもピースをつかんでいられること

これらの問題はシグナルを使って解決することができる。

Area2D クラスである「Grid」ルートノードのシグナルをスクリプトに接続しよう。コリジョン形状を盤面のサイズに調整したことを覚えているだろうか。その範囲から指(動かしているピース)が外にはみ出た場合に発信するシグナルによってrelease_pieceメソッドを呼び出して、動かしていたピースが指からも離れて盤面上の最後の位置に戻るようにする。

シーンドックで「Grid」ルートノードを選択し、ノードドック>シグナルタブを選択し、erea_exitedシグナルをこのスクリプトに接続する。
Grid - Connect signal - area_exited

もう一つ、「TouchTimer」ノードのピースを動かせる制限時間がタイムアウトした時に発信されるシグナルもスクリプトに接続する。この場合も同様に、シグナルによってにrelease_pieceメソッドを呼び出すようにする。timeoutシグナルを接続しよう。
TouchTimer - Connect signal - timeout

続けて、それぞれのシグナルの接続によって生成されたメソッドを以下のように編集してほしい。

###Grid.gd###

# 盤面から Area2D がはみ出たときにシグナルで呼ばれるメソッド
func _on_Grid_area_exited(area):
    # はみ出た Area2D が動かしているピースの場合
	if area.is_in_group("Pieces") and area.held:
        # ピースから指が離れた時の処理を実行するメソッドを呼ぶ
		release_piece()

# ピースを動かせる制限時間が切れたときにシグナルで呼ばれるメソッド
func _on_TouchTimer_timeout():
    # 移動中の場合
	if is_touching:
        # ピースから指が離れた時の処理を実行するメソッドを呼ぶ
		release_piece()

これで盤面からはみ出したら、ピースが指から離れて盤面上の最後の位置に戻るようになったはずだ。また、5秒以上ピースをつかんでいた場合も、タイマーにより、指からピースが離れて盤面上の最後のグリッド座標に収まるようになったはずだ。

それでは実際にプロジェクトを実行して動作を確認してみよう。
run project - signal effects


ここからはマッチしたピースを自動的に処理するプログラムを作っていく。release_pieceメソッドの最後にemit_signal("waiting_started")というコードでwaiting_startedシグナルを発信するようにしていたのだが、このシグナルをスクリプトに接続するところから始める。

シーンドックで「Grid」ルートノードを選択し、ノードドック>シグナルタブでwaiting_startedシグナルをスクリプトに接続しよう。_on_Grid_waiting_startedメソッドがスクリプトに追加されたら、そのメソッド内にマッチしたピースの自動処理を記述していく。大まかに自動処理は以下の流れになる。

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

上記の流れを段階的に実装していこう。まずは「全てのピースをチェックしてマッチしたピースにフラグを立てる」ところまで進めてみよう。

###Grid.gd###

# releaseメソッドの最後に発信されるシグナルで呼ばれるメソッド
func _on_Grid_waiting_started():
    
    # 自動処理中のフラグを立てる
	is_waiting = true

    # 人組でマッチしているピースが1組でもあればループし続ける
	while check_matches(): # このあと定義
        # マッチしているピースにフラグを立てるメソッドを呼ぶ
		find_matches() # このあと定義
        # WaitTimerのタイマースタート(0.3秒)
		wait_timer.start()
        # WaitTimerがタイムアウトするまで待機
		yield(wait_timer, "timeout")
    # 自動処理中のフラグを解除する
	is_waiting = false


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


# マッチしているピースにフラグを立てるメソッド
func find_matches():
    # 盤面の x 軸方向のグリッド数だけループ
	for i in width:
        # 盤面の y 軸方向のグリッド数だけループ
		for j in height:
            # そのグリッド座標にピースが存在していれば
			if board[i][j] != null:
                # 現在の色をそのグリッド座標のピースの色と定義する
				var current_color = board[i][j].color
                # もしその x 軸座標が x 軸方向のグリッド数 - 2 より小さければ
				if i < width - 2:
                    # そのピースの右隣とさらにその右隣にピースが存在する場合
					if board[i+1][j] != null \
					and board[i+2][j] != null:
                        # それらのピースの色が現在の色と同じ場合
						if board[i+1][j].color == current_color \
						and board[i+2][j].color == current_color:
                            # マッチした組に割り振る番号の変数を定義
							var matched_index: int
                            # そのピースにすでにマッチしているフラグが立っていれば
							if board[i][j].matched:
                                # マッチした組の番号はそのピースの組の番号とする
								matched_index = board[i][j].matched_index
                            # そのピースにマッチしているフラグが立ってなければ
							else:
                                # マッチした組のグループ数を1増やす
								matched_groups += 1
                                # マッチした組の番号をマッチした組のグループ数とする
								matched_index = matched_groups
                            # その座標のピースにマッチしたフラグを立て、組の番号を割り当て、半透明にする
							board[i][j].make_matched(matched_index)
                            # その座標の右隣のピースも同様にする
							board[i+1][j].make_matched(matched_index)
                            # さらにもう一つ右隣のピースも同様にする
							board[i+2][j].make_matched(matched_index)
                # y軸方向に対しても同様にする
				if j < height - 2:
					if board[i][j+1] != null \
					and board[i][j+2] != null:
						if board[i][j+1].color == current_color \
						and board[i][j+2].color == current_color:
							var matched_index: int
							if board[i][j].matched:
								matched_index = board[i][j].matched_index
							else:
								matched_groups += 1
								matched_index = matched_groups
							board[i][j].make_matched(matched_index)
							board[i][j+1].make_matched(matched_index)
							board[i][j+2].make_matched(matched_index)



マッチしたピースの処理のうち、マッチしているピースにフラグを立てるところまで実装した。プロジェクトを実行して、マッチしたピースが半透明になるか確認してみよう。

run project - add flag on matched pieces


次はマッチ状態のフラグが立っているピースを削除するメソッドを定義する。

###Grid.gd###

# マッチのフラグが立っているピースを削除するメソッド
# 引数 index には削除対象のマッチの組番号を渡す
func delete_matches(index):
    # 盤面の x 軸方向のグリッド数だけループ
	for i in width:
        # 盤面の y 軸方向のグリッド数だけループ
		for j in height:
            # もしそのグリッド座標にピースが存在していれば
			if board[i][j] != null:
                # もしそのグリッド座標のピースにマッチのフラグが立っていたら
				if board[i][j].matched:
                    # もしそのグリッド座標のピースの組番号は削除対象の組番号と一致したら
					if board[i][j].matched_index == index:
                        # そのグリッド座標のピースを解放する
						board[i][j].queue_free()
                        # 二次元配列 board の該当の要素を null にする
						board[i][j] = null

delete_matchesメソッドが定義できたので、これを_on_Grid_waiting_startedメソッドのループの中に入れよう。

###Grid.gd###

func _on_Grid_waiting_started():
	is_waiting = true

	while check_matches():
		find_matches()
		wait_timer.start()
		yield(wait_timer, "timeout")
		
        # ここを追加
        # マッチしたグループの数が0より大きければ
		if matched_groups > 0:
            # マッチしたグループの数だけループさせる
            # 全て同時に削除せず、マッチした組ごとに削除する
			for index in range(1, matched_groups + 1):
                # マッチしたピースを削除するメソッドを呼ぶ
				delete_matches(index)
                # WaitTimerのタイマースタート(0.3秒)
				wait_timer.start()
                # WaitTimerがタイムアウトするまで待機
				yield(wait_timer, "timeout")
            # マッチしたピースをすべて削除したらマッチしたグループの数を 0 に戻す
			matched_groups = 0

	is_waiting = false



これで、マッチしたピースが半透明になったあと、削除されるところまで実装できたはずだ。プロジェクトを実行して確認してみよう。

run project - delete matched pieces


続いて、ピースが削除されて空いたグリッドスペースに上のピースを詰める(落とす)処理を実装する。まずはメソッドから定義しよう。

###Grid.gd###

# 空いたグリッドスペースに上のピースを詰める(落とす)メソッド
func collapse_columns():
    # 盤面の x 軸方向のグリッド数だけループ
	for i in width:
        # 盤面の y 軸方向のグリッド数だけループ
		for j in height:
            # もしそのグリッド座標にピースがなければ
			if board[i][j] == null:
                # その y座標より1つ上のグリッドから残りの y軸方向のグリッド数だけループ
				for k in range(j + 1, height):
                    # もしそのグリッド座標にピースが存在していれば
					if board[i][k] != null:
                        # そのグリッド座標のピースを空いたスペースへ移動する
						board[i][k].move(grid_to_pixel(i, j))
                        # 二次元配列 board の要素を入れ替える
						board[i][j] = board[i][k]
						board[i][k] = null
                        # ループ終了
						break

collapse_columnsメソッドが定義できたので、これを_on_Grid_waiting_startedメソッドのループの中に追加しよう。

###Grid.gd###

func _on_Grid_waiting_started():
	is_waiting = true

	while check_matches():
		find_matches()
		wait_timer.start()
		yield(wait_timer, "timeout")
		
		if matched_groups > 0:
			for index in range(1, matched_groups + 1):
				delete_matches(index)
				wait_timer.start()
				yield(wait_timer, "timeout")
			matched_groups = 0

        # ここを追加
        # 空いたグリッドスペースに上のピースを詰める(落とす)メソッドを呼ぶ
		collapse_columns()
        # WaitTimerのタイマースタート(0.3秒)
		wait_timer.start()
        # WaitTimerがタイムアウトするまで待機
		yield(wait_timer, "timeout")

	is_waiting = false

これで、マッチして半透明になったピースが削除されたあと、その空いたスペースには上のピースが詰められる(落とされる)ようになったはずだ。プロジェクトを実行して確認してみよう。

run project - delete matched pieces


最後に、ピースを下に詰めた後の上の空いたスペースには新しいピースを補充する必要がある。そのためのメソッドは、_ready関数の中でも実行している、すでに定義済みのspawn_piecesメソッドだ。ゲームのプレイが開始した時点ではis_initializingプロパティはfalseになっているので、このメソッドを実行してピースが生成された時点ですでに新たなマッチが発生するかもしれない。これが期待以上のコンボを発生させ、ゲーム体験をより気持ち良く、楽しいものにしてくれるはずだ。

spawn_piecesメソッドを_on_Grid_waiting_startedメソッドのループの中に追加しよう。

###Grid.gd###

func _on_Grid_waiting_started():
	is_waiting = true

	while check_matches():
		find_matches()
		wait_timer.start()
		yield(wait_timer, "timeout")
		
		if matched_groups > 0:
			for index in range(1, matched_groups + 1):
				delete_matches(index)
				wait_timer.start()
				yield(wait_timer, "timeout")
			matched_groups = 0

		collapse_columns()
		wait_timer.start()
		yield(wait_timer, "timeout")

        # ここを追加
        # 空いたスペースにピースを生成するメソッドを呼ぶ
		spawn_pieces()
        # WaitTimerのタイマースタート(0.3秒)
		wait_timer.start()
        # WaitTimerがタイムアウトするまで待機
		yield(wait_timer, "timeout")

	is_waiting = false

これでマッチ処理のコーディングが完了だ。このチュートリアルとしても作業はここまでとなる。最後にプロジェクトを実行して、このパズルゲームの最終動作確認をして終わりにしよう。

run project - delete matched pieces



サンプルゲーム

今回のチュートリアルで作成したプロジェクトをさらにブラッシュアップしたサンプルゲームを用意した。



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

ゲームのルール:

  • プレイヤーがピースを動かせるのは1回につき5秒まで。5秒経過すると指から離れる。
  • プレイヤーが動かしているピースが盤面からはみ出すと、ピースが指から離れて、移動もそこまでとなる。
  • マッチしたピースの組の数だけコンボ数が上がる。プレイヤーはコンボの数だけエイリアン(敵キャラクター)を攻撃でき、パワーも上がる。
  • エイリアンを攻撃するとパワーの分だけHPを減らすことができ、エイリアンのHPを0にすると倒すことができる。
  • エイリアンは一定の間隔でプレイヤーを攻撃してくる。
  • プレイヤーのライフは最大10。エイリアンに攻撃されると1つ減り、10回攻撃されるとゲームオーバー。
  • 敵を倒すごとにレベルが1上がる。レベルが上がるとプレイヤーのパワーが少しアップする一方、敵キャラクターもHPが上がり、攻撃してくる間隔も少し短くなる。
  • マッチしたピースの自動処理中(コンボカウント中と敵への攻撃中)は敵のタイムゲージは一時停止する。
  • 最終的に、倒したエイリアンの数がこのゲームのスコアとなる。


おわりに

今回のチュートリアルでは進化系マッチ3パズルゲームと銘打って、パズドラ風のパズルゲームを作った。オーソドックスなマッチ3とは違い、盤面上で一定時間ピースを自由に動かせるようにしたり、マッチしたピースが消える時も、パズドラのようにマッチした組ごとに順番に消えるようにしたりと、スクリプトのコードはやや複雑になったかもしれないが、最後まで作り切ることができただろうか。

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

  • 最低限必要なシーンは盤面とピースの2つだけ。
  • 雛形のピースシーンを作ってから、それを継承して各色のピースシーンを作る。
  • 二次元配列を利用して盤面グリッドに配置するピースを管理する。
  • Area2Dのシグナルを利用して、以下について検知させる。
    • 指がピースに触れた時
    • 指がピースから離れた時
    • 動かしているピースと静止しているピースが当たった時
    • 動かしているピースが盤面からはみ出した時
  • フラグ用のプロパティを用意して、状態管理をする。例えば以下。
    • ピースを動かしているかどうか
    • ピースが入れ替え中かどうか
    • ピースがマッチしているかどうか
    • マッチの自動処理中かどうか
  • ピースを入れ替えるときは、画面上のピースの位置と二次元配列の要素をそれぞれ更新する必要がある。


参照