Godotで初めて2Dシューティングゲームを作ってみたので、少し悩んだポイントをまとめてみました
プロジェクトファイルは以下からダウンロードできます
https://github.com/syun77/godot_stg_test
ゲームオブジェクトとシーンの関係
Godotでは「ノード」という単位ですべて管理されているようです。
そしてノードは「シーン」という単位に配置されます。
さらに「シーンにシーンを配置できる」……という構造になっていて、プレイヤーや敵などは1つのシーンで作っておいて、それをメインゲームとなるシーンに配置する、という開発フローになっているようです。
Unityに例えるとオブジェクトを複製する場合は通常 Prefab を使いますが、Godotの場合はシーンが Prefab であり、シーンとしてそのまま使うこともできる、ということになります。
ゲームオブジェクトの構成
今回作ったゲームオブジェクトのノード構成は以下のようになりました。(例として プレイヤー[自機])
Player (Area2D)
+-- Sprite
+-- CollisionShape2D
ゲームオブジェクトのベースとなるノードでGodotのサンプルとしてよく使われるのが KinematicBody2D
なのですが、今回は Area2D
を使用しました。
理由としては、KinematicBody2D
の場合、オブジェクト同士にめり込みが発生した場合、自動で衝突応答(重ならないように押し出し)が行われてしまうためです。
(※ひょっとしたら何か回避方法があるかもしれませんが……)
このように敵の中心から敵が出現したり、弾が発射されたりします。
そのため、今回のシューティングでは、衝突応答をせず衝突検知のみが必要なので Area2D
を使うことにしました。
ちなみに KinematicBody2D
を使用する場合は move_and_slide()
を使うと、重なりが発生せずいい感じに移動させることができるようです。
KinematicBody2D
については、以下の記事に詳しくまとめられていますね。
衝突検知には area_entered
シグナルを使用する
シグナルをまだ完全に理解してはいないのですが、他のゲームエンジンでのイベントやコールバックに該当するのがシグナルのようです。
Area2D
の場合、area_entered()
というシグナルがあり、これを利用すると衝突検知が実装できます。
このシグナルを作成して、スクリプト側には以下のように記述します。
func _on_Bullet_area_entered(area):
if area.name == "Player":
area.hit(1) # プレイヤーに1ダメージ
destroy() # 自分も消える
このシグナルの引数には Area2D
が渡されるので、name
プロパティ(ノード名)が "Player" の場合に衝突処理を行う……、という実装にしました。
おそらくArea2D
の「Layer」や「Mask」を使用することでより最適な書き方になる気がしますが、このあたり調査ができておらず今後の課題です。
なお、ノード名で判定する場合、敵キャラのように同時に複数のインスタンスが登場すると問題が発生します。理由は「同じシーンに同じノード名は存在できないため」です。
具体的には "Enemy" が複数生成されると、"@Enemy@2"、"@Enemy@3"、……というような名前となります。
これにより発生するコリジョン抜けを対処するには、以下のように記述します。
func _on_Shot_area_entered(area):
if "Enemy" in area.name or "Boss" in area.name:
area.hit(1) # 敵に1ダメージ
queue_free() # 自分も消える
"Enemy" in area.name
という記述にすることで、文字列 "area.name" に "Enemy" という文字が含まれるかどうか、という判定をすることができます。
2021.2.22 追記:コリジョンレイヤーとマスクについて
コリジョンレイヤーとマスクについて調べました
→ 【Godot】コリジョンレイヤーとマスクについて
この仕組みを使えば、ノード名で判定せずに衝突検知ができます。
インスタンスの生成
ゲームオブジェクトを生成するには、対象のシーンをpreload()
で読み込み instance()
を呼び出す、という記述となります。
extends Area2D
# ショットシーンを読み込み
const Shot = preload("res://Scenes/Shot.tscn")
……
func _process(delta):
……
# Spaceキーを押したらショットを発射
if Input.is_action_pressed(("ui_select")):
# ショット生成
var shot = Shot.instance()
# 位置と移動量(90度方向に1000の速さ)を設定
shot.start(position.x, position.y, 90, 1000)
# ルートノードを取得
var main_node = get_owner()
main_node.add_child(shot)
instance()
を呼び出すだけではメモリ上に配置されるだけなので、どこかのノードに add_child()
でぶら下げることで表示されるようになります。ここでは get_owner()
でノードの所有者を取得してそれにぶら下げていますが、しっかりと作る場合はオブジェクトプールを管理するノードにぶら下げるのが良いのかもしれません。
インスタンスの破棄
インスタンスの破棄は queue_free()
を呼び出します。
func destroy():
# 消滅
queue_free()
これによりノードツリーから削除され、メモリ上からも消えるようです
Mainノードの構成
今回はメインとなるシーンを以下のようなノード構成としました。
Main (Node2D): メインシーン
+-- Player (Area2D): プレイヤー
+-- Boss (Area2D): ボス
+-- HPBar (TextureProgress): ボスのHP
+-- Label (Label): キャプション
Playerは Shot(自機が発射する弾) を生成し、BossはEnemyを生成します。
BossのHPに連動して、HPBarのゲージの幅が変化し、ゲームオーバーやゲームクリア時に、Labelのテキストが変化する……という実装となっています。
すべてのノードは Mainノードに直接ぶら下がるようにしました。
プレイヤーの移動
Area2Dを使用する場合、物理エンジンを使わず移動するので、直接 position
の値を変化させて移動させるようにしました。
func _process(delta):
# 移動速度
var spd = 500 * delta
if Input.is_action_pressed("ui_select"):
# Spaceキーを押しているとスロー
spd = 200 * delta
var v = Vector2()
if Input.is_action_pressed("ui_up"):
v.y -= 1 # 上に移動
if Input.is_action_pressed("ui_down"):
v.y += 1 # 下に移動
if Input.is_action_pressed("ui_left"):
v.x -= 1 # 左に移動
if Input.is_action_pressed("ui_right"):
v.x += 1 # 右に移動
position += v.normalized() * spd
KinematicBody2D
を使用する場合には、移動処理は move_and_collide()
などを使用することになりそうですが、Area2D
を使用する場合には position
を直接変更して問題なさそうです。
入力ボタン設定
デバイスからの入力を判定するボタン(キー)は、メニューの「プロジェクト > プロジェクト設定」から、インプットマップ
のタブを選ぶことで確認することができます。
スクリプトとシーンの切り替え
エディタの上にある「2D」と「Script」をクリックすることで切り替えられます。
「出力」ウィンドウを非表示にする
スクリプトを編集するときに非表示にしておきたい「出力」ウィンドウ。これを非表示にするには、左下の「出力」の文字をクリックします。
HPバーの作り方
HPバーは、TextureProgress
を使うと簡単に実装できます。
Textureの Over と Under に画像を登録して……
※画像サイズは実際に表示するサイズに合わせます。例えば横幅 400px にしたい場合は、幅を 400px にした画像を用意します。
そうすると、valueの値を書き換えることで幅が変化します。
以下はボスからHPバーの値を書き換えるコードです
func _physics_process(delta):
……
# HPバー更新
var hpbar:TextureProgress = $"../HPBar"
var hpratio = _hpratio() # 残りHPの割合を 0.0〜1.0 で取得
# valueに 0〜100 を設定することで幅が変化する
hpbar.value = 100 * hpratio
# HPバーの色を更新
hpbar.tint_progress = lerp(Color.red, Color.limegreen, hpratio)
シーンを単体で確認する方法
Godotの便利な機能として、シーン単体を簡単に確認することができます。
まずは確認したいシーンを開きます。ここでは "Enemy.tscn" シーンを開きます。
そして、右上の「編集したシーンを実行」のボタンをクリックします
エディタで設定した値を反映することもできるので、敵の弾幕パターンを確認するなどのときにはとても便利なのではないかと思います。
なお、実行中のアプリケーションに対してエディタの値をリアルタイムで反映するには、変数の前に export
をつける必要があります
# 変数 "id" を実行時に編集できるようにする
export var id = 0
常にロードされるシーンを設定する
Godotでは preload()
で外部のシーンを読み込むことができますが、C++のような staticクラス・static変数・シングルトンを利用するには、常にロードする設定にしておくと便利です。
設定方法としては、メニューから「プロジェクト > プロジェクト設定」を選択して……
「自動読み込み」タブを選び、対象のパスとノード名を指定して、「追加」ボタンをクリックします。
シーンの遷移
シーンの遷移は get_tree().change_scene()
で対象のシーンを指定することで行えるようです。
func _process(delta):
……
if is_instance_valid(_player) == false:
# プレイヤーが消滅したら ”GAME OVER” のキャプションテキストを設定
_caption.text = "GAME OVER\n\nPRESS SPACE KEY"
if Input.is_action_just_pressed("ui_select"):
# リスタート (Mainシーンの読み込み)
get_tree().change_scene("res://Scenes/Main.tscn")
コルーチンの利用方法
指定秒数後に弾を発射する、という処理をしたい場合は以下の記述で実装できます
# 弾を撃つ
func bullet(deg, speed, delay=0):
if delay > 0:
# 遅延して発射する
yield(get_tree().create_timer(delay), "timeout")
var bullet = Bullet.instance()
bullet.start(position.x, position.y, deg, speed)
# ルートノードを取得
var main_node = get_parent()
main_node.add_child(bullet)
yield(get_tree().create_timer([遅延秒数]), "timeout")
ただこれを使うと実行しているコルーチンを停止して、新しくコルーチンを作る方法がわからなかったので、敵のAI処理は以下のように記述しました。
export var id = 0
var id_previous = 0
var _wait = 0
# 初期化フラグ
var is_init = false
var func_ai = null
func wait(t):
_wait += t
func ai_1():
# 高速狙い撃ち弾を撃つコルーチン
while true:
wait(1)
yield()
while true:
for i in range(5):
aim(700, i * 0.05)
wait(0.8)
yield()
# その他様々な AIのコルーチン
func ai_2():
func ai_3():
func ai_4():
……
func _physics_process(delta):
if id != id_previous:
# idが切り替わった
id_previous = id
is_init = false # 初期化し直す
_wait = 0
if is_init == false:
# 初期化
is_init = true
# コルーチンを生成
func_ai = ai_1() # ここを "id" の値で呼び出しを切り替える
_wait -= delta
if _wait <= 0:
if func_ai:
func_ai = func_ai.resume()
コルーチンを変数 func_ai
に格納して resume()
を呼び出すことでコルーチンの切り替えをできるようにしました。ただこの方法だと _process()
の呼び出しに遅延が出てしまうので、弾幕が不揃いになってあまりキレイにならないという問題があります。
見た目の問題なので今回はそのままとしましたが、ひょっとしたら何かもっと良い方法があるのかもしれません。
プロジェクトの構成
今回のプロジェクトは以下のような構成としました。
godot_stg_test
+-- *.png: 各種画像リソース
+-- Scene
| +-- *.tscn: 各種シーンファイル
|
+-- Scripts
+-- *.gd: 各種スクリプトファイル
Unityにならって、"Scenes"、"Scripts" フォルダを作成しましたが、Godotの場合は シーンとスクリプトは同一フォルダにした方が良さそうです。
ウィンドウサイズの変更
メニューから プロジェクト > プロジェクト設定 を選択します。
一般 > Display > Window
から WidthとHeightの値を変更すると画面サイズが変更できます。
フォントサイズの変更
フォントサイズを変更する場合は、Scaleの値を変更することになりますが、線形補間処理となるため文字がぼやけてしまいます。
そのままサイズを変更する方法がわからなかったので、Custom Fonts にフォント (DynamicFont) を追加することで対応しました。
インスペクタから Custom Fonts > Font
に "新規 DynamicFont" を設定して
……
Font > Font Data
にフォントリソースを登録します。
すると、Font > Settings > Size
からフォントサイズを変更できます。
パーティクルの実装
色々実装方法はありそうですが、今回は CPUParticle2D
で実装してみました
Drawing の Texture
に画像を設定するとアニメーションが再生されるようになります。
パーティクルのパラメータ設定例
-
Direction > Spread
の値を「180」(発射範囲を±180度) -
Initial Velocity > Velocity
を「200」(初期速度) -
Initial Velocity > Velocity Random
を「1」(完全にランダム)
- Amount: 16
- Time > Lifetime: 2
- Time > One Shot: チェックを入れる
- Time > Speed Scale: 2
- Time > Explosiveness: 1
- Time > Lifetime Random: 0.5
パーティクルにアタッチするスクリプト
アタッチするスクリプト Particle.gd
には以下のように記述しました
extends CPUParticles2D
func start(x, y, c):
# 位置を設定
position = Vector2(x, y)
# 色を設定
color = c
# 放出開始
emitting = true
func _ready():
pass # Replace with function body.
func _process(delta):
if emitting == false:
# 放出が終了
queue_free()
パーティクルを呼び出すスクリプト
Enemy.gd からのパーティクル呼び出しは、以下のようにしました
- Particle.tscn をプリロードする
- destroy() 関数を作成する
- 敵破壊時に destroy() を呼び出す
extends Area2D
# パーティクルをプリロード
var Particle = preload("res://Scenes/Particle.tscn")
func destroy():
var p = Particle.instance()
p.start(position.x, position.y, Color.green)
var main_node = get_parent()
main_node.add_child(p)
queue_free()
2021.2.22 追記
2Dパーティクルについてさらに調べてみました
→【Godot】2Dパーティクルの使い方
リンク
さらに調査したことをまとめてみました
↓
↓
↓