このチュートリアルでは、2Dトップダウンシューティングにおける「ホーミングミサイル」を作っていく。ホーミングミサイルというのは、ターゲットを追跡するミサイルのことだ。
Environment
このチュートリアルは以下の環境で作成しました。
・Godot のバージョン: 3.4.2
・コンピュータのOS: macOS 11.6.5
このチュートリアルでは、ホーミングミサイルの作成にフォーカスするため、それ以外の部分は事前に下準備として作成済みだ。
このチュートリアルのプロジェクトファイルは GitHubリポジトリ に置いている。.zipファイルをダウンロードしていただき、「Start」フォルダの中の「project.godot」ファイルを Godot Engine にインポートしていただければ、下準備だけ完了したプロジェクトを開始できる。また、取り急ぎ完成形を確認されたい場合は「End」フォルダの方の「project.godot」ファイルをインポートしていただければOKだ。
なお、今回プロジェクトにインポートしてあるアセットは全て KENNEY(ケニー) のサイトからダウンロードさせていただいた。今回利用したのは Tower Defense (top-down) というアセットパックだ。このような素晴らしいアセットパックを公開いただいていることに、ただただ感謝である。
下準備
以下のゲームの仕様のうち、(予定)と記載しているホーミングミサイルの部分以外は、すでに下準備として作成済みだ。
- プレイヤーキャラクター(飛行機):
- 以下のキーで操作可能。
- move_up: W キー・・・プレイヤーキャラクターが上に移動する
- move_down: S キー・・・プレイヤーキャラクターが下に移動する
- move_right: D キー・・・プレイヤーキャラクターが右に移動する
- move_left: A キー・・・プレイヤーキャラクターが左に移動する
- magic: スペースキー、またはマウス左ボタン・・・マシンガンを撃つ
- マシンガンの弾は敵キャラクターの戦車とそれが発射するホーミングミサイルに当たると消える。
- マシンガンの弾は画面外に出たら消える。
- 以下のキーで操作可能。
- 敵キャラクター(戦車):
- ゲーム画面上に最大5機現れる。残りが 0 機になると新たに 5 機生成される。
- 常にプレイヤーキャラクターの方に向かって移動してくる。
- プレイヤーが一定距離まで近づくと、ホーミングミサイル を発射する(予定)。
- ホーミングミサイルは、プレイヤーキャラクターかマシンガンの弾が当たると消える(予定)。
- HUD:
- 左上に表示されている Life と Score のみのシンプルな HUD(ヘッズアップディスプレイ)。
- Life: プレイヤーキャラクターの残機数(最大 5)。プレイヤーの飛行機に戦車のホーミングミサイルが命中すると 1 減り、0になるとゲームオーバー(デバッグパネルが閉じる)。
- Score: 戦車にマシンガンの弾が命中すると敵キャラクターのライフ(最大 3)が 1 減り、0 になって戦車を破壊すると 1 ポイント加算される。
それでは、Godot Engine で「Start」フォルダの方のプロジェクトを開いて、ホーミングミサイルを実装していこう。
ホーミングミサイルを作る
シーンを作る
まずは以下の手順でホーミングミサイルのシーンを作成する。
- 「シーン」メニュー>「新規シーン」を選択する。
- 「ルートノードを生成」にて「その他のノード」を選択する。
- 「Area2D」クラスのノードをルートノードとして選択。
- ルートノード「Area2D」の名前を「Missile」に変更する。
- 一旦ここでシーンを保存しておく。ファイルパスを「res://Enemy/Missile.tscn」とする。
次にルートノード「Missile」に子ノードを追加していく。
- ルートノード「Missile」に「Sprite」クラスのノードを追加する。これはミサイルの見た目だ。
- ルートノード「Missile」に「CollisionShape2D」クラスのノードを追加する。これはミサイルがプレイヤーの飛行機やそれが放ったマシンガンの弾との衝突を検知するために利用する。
- ルートノード「Missile」に「Timer」クラスのノードを追加する。これはミサイルのインスタンスを一定時間経過後に解放するためだ。
シーンツリーは以下のようになったはずだ。
ノードのプロパティを編集する
インスペクターで、シーンツリーの各ノードのプロパティを編集していこう。
Missile (Area2D) ルートノード
「CollisionObject2D」クラスの「Collision」>「Layer」プロパティと「Collision」>「Mask」プロパティを編集する。
「Layer」プロパティは、そのオブジェクト(ここでは「Missile」ノード)をどのコリジョンレイヤーに割り当てるかを設定できる。
「Mask」プロパティは、そのオブジェクトがどのコリジョンレイヤーのオブジェクトとの衝突を有効にするかを設定できる。つまり、「Mask」プロパティで選択しなかったレイヤーのオブジェクトとは位置が重なっても衝突せずに互いに通り抜ける。例えば、ホーミングミサイルは敵キャラクターの戦車との衝突は無視されて通過するが、プレイヤーキャラクターやマシンガンの弾との衝突は検出される必要がある。プレイヤーキャラクターの飛行機は戦車の上を飛んでいるため、戦車との衝突は無視され、ホーミングミサイルとの衝突は検出される必要がある。
どのコリジョンレイヤーにどのオブジェクトを割り当てるのかわかりやすくするため、以下の手順で、すでに使用するコリジョンレイヤーに名前をつけておいた。
- 「プロジェクト」メニュー>「プロジェクト設定」を開く。
- サイドバー「Layer Names」>「2d Physics」を選択する。
- Layer 1 ~ 4 まで、以下の通りに名前を設定する。
- Layer 1: Player
- Layer 2: Enemies
- Layer 3: PlayerBullets
- Layer 4: EnemyMissiles
上記のコリジョンレイヤーの名前に合わせて、プレイヤーキャラクター、マシンガンの弾、敵キャラクターの「Layer」プロパティと「Mask」プロパティは以下の通りに設定済みである。
- プレイヤーキャラクター: Player.tscn シーン > Player(KinematicBody2D)ルートノード
- Layer プロパティ: Layer 1
- Mask プロパティ: Layer 4(ミサイルとの衝突を検知)
- 敵キャラクター: Enemy.tscn シーン > Enemy(KinematicBody2D)ルートノード
- Layer プロパティ: Layer 2
- Mask プロパティ: Layer 2, 3(他の戦車とマシンガンの弾との衝突を検知)
- マシンガンの弾: Bullet.tscn シーン > Bullet(Area2D)ルートノード
- Layer プロパティ: Layer 3
- Mask プロパティ: Layer 2, 4(戦車とホーミングミサイルとの衝突を検知)
そして、今編集中のホーミングミサイルも同様に以下のように設定してほしい。
- ホーミングミサイル: Missile.tscn > Missile(Area2D)ルートノード
- Layer プロパティ: Layer 4
- Mask プロパティ: Layer 1, 3(飛行機とマシンガンの弾との衝突を検知)
ルートノード「Missile」のプロパティの編集ができたら、ついでにこのノードを「Missiles」というグループを作って追加しておこう。これはマシンガンの弾がミサイルにヒットした時に、ミサイルかどうかを判別するのに用いている。
- シーンツリードックで「Missile」を選択する
- ノードドック>グループタブを開いて「Missiles」のグループを追加する。
Sprite ノード
たくさんのテクスチャをまとめた1枚のスプライトシートから使いたいテクスチャの範囲を指定してスプライトのテクスチャを設定する方法を採用する。
- インスペクターにて、「Texture」プロパティにリソースファイル「res://towerDefense_tilesheet.png」を適用する。
- 「Region」>「Enabled」をオンにする。
- エディタ下部の「テクスチャ領域」パネルを開き、スプライトシートの中の利用したいテクスチャの領域を指定する。
- 作業しやすいように、展開アイコンをクリックしてパネルを広げる。
- 「テクスチャ領域」パネル上部の「snapモード」で「グリッドスナップ」を選択する。
- 同じくパネル上部の「ステップ」を 64px 64px にする。これでグリッドのサイズがスプライトシートのスプライトと同じサイズになる。
- スプライトシート上で大きい方のミサイルのテクスチャを選択する。
- 作業しやすいように、展開アイコンをクリックしてパネルを広げる。
- インスペクターに戻り、「Transform」>「Rotation」プロパティを 90 にする。
これで 90° テクスチャが回転した状態になる。通常オブジェクトの向きは正のx軸方向が 0° であり、ミサイルの進行方向もこれを基準にするため、それに合わせてスプライトの向きも調整した。
CollisionShape2D ノード
このノードでホーミングミサイルのコリジョン形状を設定する。ミサイルとプレイヤーキャラクターとの衝突判定のために必須であり、システム上「Area2D」クラスのノードにコリジョン形状を設定する子ノードが追加されていないとアラートが表示されるようになっている。
- インスペクターにて、「Shape」プロパティに「新規 CapsuleShape2D」リソースを適用する。
- 2Dワークスペースにて、コリジョン形状を「Sprite」ノードのテクスチャのサイズに合わせる。
インスペクターで直接入力する場合は、「CapsuleShape2D」リソースのプロパティを以下のようにする。- 「Radius」プロパティを 8 にする。
- 「Height」プロパティを 24 にする。
Timer ノード
このノードは、ホーミングミサイルが一定時間経過後に自爆するためのタイマーだ。いつまでもゲームの中にミサイルが存在し続けると、ミサイルの数が膨れ上がって、コンピュータのメモリが不足して処理しきれなくなる。それを回避するため、時間切れによりインスタンスを解放する仕様にする。直線的に飛んでいくミサイルなら画面外に出た時に「VisibilityNotifier2D」ノードのシグナルを使ってインスタンスを解放することも可能だが、プレイヤーキャラクターを追尾するホーミングミサイルではタイマーの方が適切だ。
- インスペクターにて、「Wait Time」プロパティを 3 にする。これはミサイルの有効時間だ。お好みで微調整いただいて良いのだが、長すぎるとミサイルが同時に複数存在しすぎてメモリ消費が多くなるので注意してほしい。
- 「One Shot」プロパティをオンにする。
- 「Auto Start」プロパティをオンにする。
以上で、インスペクターでの各ノードのプロパティ編集は完了だ。
スクリプトで制御する
次はスクリプトを作成して、ミサイルを制御していく。ルートノード「Missile」に新しいスクリプトをアタッチしてほしい。ファイルパスは「res://Enemy/Missile.gd」として作成する。
まずは以下のように「Missile.gd」スクリプトを編集しよう。
###Missile.gd###
extends Node2D
# ミサイルのスピード
export var speed = 400
# ミサイルのベロシティ(方向の要素を持った速度)
var velocity = Vector2()
# 物理プロセス(60FPS: 60回/秒呼ばれる組み込みメソッド)
func _physics_process(delta):
# ベロシティ(方向性をもつ速度) = 現在の向き x スピード
velocity = transform.x * speed
# 位置を更新: 現在の位置 + ベロシティ x delta(1フレームの秒数)
position += velocity * delta
# 向きを更新:現在のベロシティに合わせる
rotation = velocity.angle()
さて、これでひとまずミサイルが真っ直ぐ飛んでいくようにはなったはずだ。シーンを実行して確認してみよう。
GIF画像の上端部分をミサイルが左から右に飛んでいくのがわかるだろうか。
続いて、プレイヤーキャラクターを追尾する動きをスクリプトに追加していく。まずは必要なプロパティをいくつか定義しておこう。以下のコードの「# 追加」とコメントしている箇所を追加してほしい。
###Missile.gd###
extends Node2D
export var speed = 400
# 追加:プレイヤーのいる方向へ舵を切る力
# 値が大きいほど素早く方向を修正できる
export var steering_force = 20.0
var velocity = Vector2()
# 追加:加速度
var acceleration = Vector2()
# ターゲットである Player オブジェクトの参照(検出され次第)
var target = null
次にミサイルがプレイヤーキャラクターの方へ舵を切る(軌道修正する)メソッドを定義する。
###Missile.gd###
# ミサイルがプレイヤーキャラクターの方へ舵を切るメソッド
func steer():
# 舵を切るベロシティとして定義
var steering = Vector2()
# 理想のベロシティ(現在の位置から見たプレイヤーキャラクターへの方向 x スピード)を定義
var ideal_velocity = (target.position - position).normalized() * speed
# 舵を切る速度 = (理想のベロシティ - 現在のベロシティ)で得た方向ベクトル x 舵を切る力
steering = (ideal_velocity - velocity).normalized() * steering_force
# 舵を切るベロシティを出力
return steering
コード上のコメントだけではイメージしにくいと思われるため、図で補足しておく。
ホーミングミサイルはプレイヤーキャラクターの飛行機を検知したら、それをtarget
として追尾する。このとき、ミサイルがtarget
の方向へ直線で移動できたと仮定した場合の理想のベロシティが変数ideal_velocity
だ。しかし実際には、ミサイルはプロパティvelocity
の持つ方向で飛んでいるため、ideal_velocity
の方へ舵を切って軌道修正したい。そこでベクトルの計算になる。ideal_velocity
からvelocity
を引いたベクトルをメソッドnormalized
で方向ベクトル(長さが 1 のベクトル)にして、それに舵を切る力であるプロパティsteering_force
の値を乗算すれば、舵を切るベロシティである変数steering
の値が定まる。
ではこのメソッドsteer
で出力される変数steering
の値を利用して、ミサイルが軌道修正しながら飛行するように修正しよう。「# 追加」とコメントした箇所をメソッド_physics_process
に追加してほしい。
###Missile.gd###
func _physics_process(delta):
velocity = transform.x * speed
# 追加:加速度に舵を切るベロシティを加算していく
acceleration += steer()
# 追加:ベロシティに 加速度 x delta の値を加算していく
velocity += acceleration * delta
# 追加:ベロシティのベクトルの長さがスピードを超えないように制限する
velocity = velocity.clamped(speed)
position += velocity * delta
rotation = velocity.angle()
これでホーミングミサイルがプレイヤーキャラクターを追尾する動きが実装できたはずだ。
続けて、プレイヤーキャラクターやマシンガンの弾に衝突した時のプログラムを追加しよう。これにはルートノード「Missile」の Area2D クラスのシグナルを利用する。
ではシーンツリードックで「Missile」を選択したら、ノードドック>シグナルタブでシグナル「body_entered(body: Node)」をこの「Missile.gd」スクリプトに接続しよう。
接続後、自動的に生成されたメソッド_on_Missile_body_entered
は以下のように編集する。
###Missile.gd###
# ミサイルに物理ボディが衝突したら発信するシグナルで呼ばれるメソッド
func _on_Missile_body_entered(body):
# 物理ボディがプレイヤーキャラクターだったら
if body.name == "Player":
print("Missile hit ", body.name)
# Player シーンの AnimationPlayer のアニメーション hit を再生する
body.anim_player.play("hit")
# Player ノードのシグナル player_hit を発信する
body.emit_signal("player_hit")
# Player の Life が 1 より大きかったら -1 する
if body.life > 1:
body.life -= 1
# 1 以下の場合
else:
# アニメーション hit の再生終了を待つ
yield(body.anim_player, "animation_finished")
# Player オブジェクトを解放する
body.queue_free()
print("Game Over!")
# デバッグパネルを閉じて終了
get_tree().quit()
# ミサイル自身も解放する
queue_free()
これでミサイルがプレイヤーキャラクターの飛行機に当たったらアニメーション(赤く点滅)して Life が一つ減り、5 機なくなったらゲームオーバーになる仕組みが実装できた。
続いて「Timer」ノードのタイマーを利用して時間切れになったらミサイルを解放する仕組みを作る。これも「Timer」ノードのシグナルを利用する。ではシーンツリードックで「Timer」を選択したら、シグナル「timeout()」をスクリプトに接続しよう。
接続して生成されたメソッド_on_Timer_timeout
はシンプルにメソッドqueue_free
を実行するだけでOKだ。
###Missile.gd###
func _on_Timer_timeout():
queue_free()
ひとまずここまででミサイルの基本は完成だ。
敵キャラクターにホーミングミサイルを発射させる
次は「Enemy.tscn」シーンの方で、作成したばかりの「Missile.tscn」シーンのインスタンスを生成して、敵キャラクターの戦車がホーミングミサイルを発射するようにしていく。
スクリプトを編集して制御する
それでは「Enemy.tscn」シーンのルートノード「Enemy」にアタッチしている「Enemy.gd」スクリプトを開いて編集していこう。
まず先にミサイルのシーンファイルをプリロードしておこう。スクリプトの冒頭で参照用の定数を定義しておく。「# 追加」のコメントが目印だ。
###Enemy.gd###
extends KinematicBody2D
signal enemy_killed
# 追加:プリロードした Missile.tscn シーンファイルの参照
const missile_scn = preload("res://Enemy/Missile.tscn")
次に、メソッド_on_Timer_timeout
のブロックにはpass
しか記述されていない状態なので、これを以下のように更新してほしい。ちなみに、このメソッドは「LaunchTimer」ノード(Timer クラス)のシグナル「timeout()」を接続して生成されたものだ。つまり、タイマーがタイムアウトするたびにこのメソッドが呼ばれる。
###Enemy.gd###
# LaunchTimer ノードがタイムアウトしたら発信されるシグナルで呼ばれるメソッド
func _on_LaunchTimer_timeout():
# 親ノード(World)に Player という名前のノードがある場合
if get_parent().has_node("Player"):
# ミサイル発射のメソッドを呼ぶ
launch_missile()
最後のメソッドlaunch_missile
はこれから定義するところだ。
それではメソッドlaunch_missile
を定義した以下のコードを、上記_on_LaunchTimer_timeout
の後に挿入してほしい。
###Enemy.gd###
# ミサイルを発射するメソッド
func launch_missile():
# Missile.tscn シーンをインスタンス化
var missile = missile_scn.instance()
# Missile の target プロパティに Player オブジェクトを代入
missile.target = get_parent().get_node("Player")
# 親ノード(World)にミサイルのインスタンスを追加
get_parent().add_child(missile)
# ミサイルの位置を戦車の大砲の先端に合わせる
missile.position = muzzle.global_position
# ミサイルの向きを戦車の大砲の先端の向きに合わせる
missile.rotation = muzzle.global_rotation
これでミサイルが発射されるはずだ。ここまでの作業がうまくいったか、プロジェクトを実行して確認してみよう。
おまけ:演出を追加する
演出用に、下準備の段階でミサイルが当たった時の爆発のパーティクルと、ミサイルの後ろから出る煙のパーティクルを用意している。これらを「Missile.gd」スクリプトにコードを追加して利用し、見た目上の演出を咥えよう。
「Missile.gd」スクリプトを開いたら、「# 追加」の行をコードに追加してほしい。
###Missile.gd###
# 追加:Smoke.tscn(煙のパーティクルだけのシーン)のプリロード参照
const smoke_scn = preload("res://Effect/Smoke.tscn")
# 追加:Explosion.tscn(爆発のパーティクルだけのシーン)のプリロード参照
const explosion_scn = preload("res://Effect/Explosion.tscn")
export var speed = 400
export var steering_force = 20.0
var velocity = Vector2()
var acceleration = Vector2()
var target = null
# 追加:煙が出る間隔を空けるためのカウント
var smoke_count = 0
次は_physics_process
メソッド内で煙を出すメソッドを呼ぶ。
###Missile.gd###
func _physics_process(delta):
velocity = transform.x * speed
acceleration += steer()
velocity += acceleration * delta
velocity = velocity.clamped(speed)
position += velocity * delta
rotation = velocity.angle()
# smoke_count に delta の値を加算する
smoke_count += delta
# smoke_count が 0.05 より大きければ
if smoke_count > 0.05:
# smoke_count を 0 に戻して
smoke_count = 0
# 煙を出すメソッドを呼ぶ
spawn_smoke()
そして、最後の煙を出すメソッドspawn_smoke
は以下のように定義しよう。このコードはsteer
メソッドの下あたりに挿入しておけば良いだろう。
###Missile.gd###
# 煙を出すメソッド
func spawn_smoke():
# 煙のパーティクルのシーンをインスタンス化
var smoke = smoke_scn.instance()
# 親ノード(World)にインスタンスノードを追加
get_parent().add_child(smoke)
# 煙を現在のミサイルの位置に置く
smoke.position = global_position
# 煙の向きを現在のミサイルの向きに合わせる(あまり意味はないが)
smoke.rotation = global_rotation
続いて、今度は爆発のパーティクルを発生させるメソッドを定義する。これは上記メソッドspawn_smoke
の下に追加すればOKだ。
###Missile.gd###
# 爆発させるメソッド
func explode():
# 爆発のパーティクルのシーンをインスタンス化
var explosion = explosion_scn.instance()
# 親ノード(World)にインスタンスノードを追加
get_parent().add_child(explosion)
# 爆発パーティクルを現在のミサイルの位置に置く
explosion.position = global_position
# 爆発パーティクルの向きを現在のミサイルの向きに合わせる(あまり意味はないが)
explosion.rotation = global_rotation
以下の「# 追加」コメントがある3箇所でメソッドexplode
を呼び出すようにコードを更新しよう。
###Missile.gd###
func _on_Missile_body_entered(body):
if body.name == "Player":
print("Missile hit ", body.name)
body.anim_player.play("hit")
body.emit_signal("player_hit")
if body.life > 1:
body.life -= 1
else:
explode() # 追加
yield(body.anim_player, "animation_finished")
body.queue_free()
print("Game Over!")
get_tree().quit()
explode() # 追加
queue_free()
func _on_Timer_timeout():
explode() # 追加
queue_free()
更新は以上だ。最後にもう一度プロジェクトを実行して、追加した演出を確認してみよう。
ところで、煙と爆発いずれのパーティクルも「Particles2D」クラスのノードを使用している。macOS ではこのノードを使うとパフォーマンスが低下する問題があるようだ。私は macOS 利用者だが、確かに動作遅延が生じた。上のGIF画像でもわかるレベルだ。
もし macOS を利用されている場合は、シーンツリードックでルートノード(Particles2D クラス)を選択した状態で、ツールバーの「Paricles」から「Convert to CPUParticles2D」を利用して「CPUParticles2D」クラスに変換できるようだ。
おわりに
今回のチュートリアルでは、トップダウンシューティングにおけるホーミングミサイルを作った。作成する際のポイントをまとめておこう。
- ミサイルシーンはインスタンスが追加されたらすぐに自分で飛んでいくようにプログラムしておく。
- ミサイルの追尾の動きを再現するには、物理プロセスで毎フレーム以下を順次実行する。
- ターゲット(このチュートリアルの「Player」)に対する方向ベクトルにスピードを乗算して理想のベロシティを求める
- 理想のベロシティと実際のベロシティの差のベクトルをノーマライズして舵を切るべき方向を求める
- 舵を切るべき方向に予めプロパティで定義済みの舵を切る力を乗算して舵を切る速度を求める。
- 現在の加速度に舵を切る速度を加算する。
- 現在のベロシティに更新した加速度を加算する。
- ミサイルのシーンのインスタンスを作成する時は、回転したり移動したりするオブジェクト(このチュートリアルの「Enemy」)の子ではなく、例えば、そのオブジェクトの親ノード(このチュートリアルの「World」)などで動かないオブジェクトの子にすると正しく飛んでいく。
- メモリ消費を抑制するため、Timerのシグナルを利用し、タイムアウトでミサイルのインスタンスが解放されるようにする。
リンク
このチュートリアルの作成にあたって、KidsCanCodeのYouTube動画や記事が非常に参考になった。この場を借りて感謝申し上げたい。より理解していただくのに、それらのコンテンツも併せてご覧いただくことをお勧めする。
- Godot Docs: RayCast2D
- YouTube: Make your first 2D grid-based game from scratch in Godot
- YouTube: Grid-based movement Godot 3 demo overview
- KidsCanCode: Grid-based movement
UPDATE
2022/05/28 「リンク」のリンク修正、「リンク」にコメント追加。