GodotEngine4.1でインベーダーゲーム作ってみた
#下書き
Godot及びGDScriptについての個人の感想です。(書き途中)の具体例として作ってみる。
GodotEngineを使って極力短いコードでインベーダーゲームを作ってみるテスト。
完コピでは無いので、UFOの得点とか乱数テーブルとかは無し。
12時間くらいで作る見積もりを立ててみる
4.1.stableを使いました。
準備
https://scrapbox.io/files/64e1a90608082b001be9015b.png
こんな感じでメインのシーンを作ってみた。
GameLayerに色々と足していく。
Godotの作法として「なんか適当にやってみる」のが良いと思うので、この記事もほぼリアルタイムで適当に書いています。
Invaderノードの作成
インベーダーちゃんがカワイイのでまずは作りたい。動いたらテンションが上がる気がする。
https://scrapbox.io/files/64e1ac178377c7001b9a714f.png
invader.tscnはこんな感じ。当たり判定用のArea2Dをルートノードにしても良いと思う。どっちにしろ適当でいい。
動きについては昔っぽく、ピッ、ピッと何かしらのクロックを受け取って動いてる感じにしたいと思います。
https://scrapbox.io/files/64e1ad8ad5423d001b53df2c.png
色々と実装のやり方は思い浮かびますが、私はとにかく短く書く!というのを意識しています。
10行より5行、2行より1行が良い。分かりやすくブレない目標なので余計なことを考えずに済みます。
んで、こういう単純な動きを実装する時は、まずグラフを作ってみるのが短く書くコツだと思う。
https://scrapbox.io/files/64e1bdd67a7a11001c5bbcce.png
グラフ書いて、なんとなく規則性があれば関数にできます。多分。
code:invader.gd
extends Sprite2D
var step = 0
func move():
step += 1
position.x += 10 * sign(max(0, (step + 5) % 11) * ((((step + 4) / 11) % 2) * 2 - 1))
position.y += 20 * int((step + 5) % 11 == 0)
このシーンを適当に並べて、なんかのタイミングでmove()を呼べばピコッと1ステップ動くようになりました。
分かりづらくて意味不明な数字の羅列だけど、大事な部分は2行。
https://scrapbox.io/files/64e1cfd635613a001b86d0c9.mov
Playerノードの作成
Invaderノードとほとんど一緒。
https://scrapbox.io/files/64e1d79202024d001c92a71f.png
矢印キーで移動させたい。とりあえず_process内でポーリングしてみる。
code:palyer.gd
extends Sprite2D
var norm_pos = 0.5
func _process(_delta):
if Input.is_action_pressed("ui_left"):
norm_pos -= 0.01
elif Input.is_action_pressed("ui_right"):
norm_pos += 0.01
norm_pos = clamp(norm_pos, 0.0, 1.0)
position.x = lerp(40, 260, norm_pos)
ポーリング用のif文は割と定形っぽい感じなので普通に書くことが多いです。
さっきのinvaderチャンのときも使ったんだけど、何かを動かすときに「ここからここまで動かしたい」って時は0.0から1.0の数値を使うのが良いです。多分正規化とか言います。norm_pos(normalized positionのつもり。)は0.0から1.0の数値を取って、最後の行で40から260(プレイヤーを動かしたい範囲)になるようにlerpしています。
たとえば右ボタンを押したらposition.xに1を足す。もし、position.xがしきい値を超えていたらposition.xから1を引く。左ボタンを押したらposition.xから1を引く。もし、position.xがマイナスの値になったらposition.xに0を代入する。とかの条件分岐は書かないです。基本書かない方が短く書けます。
https://scrapbox.io/files/64e1fdead5a3f9001ff41d9f.png
Bulletノードを作る。
ノードの作りは全部おんなじ。でもこういうのは若干メンドイ。
https://scrapbox.io/files/64e1e61b3f8c71001c9e98d2.png
仕様を考えてみると、
プレイヤーはショットを連射できないので、ゲーム内に一つあればいい。
プレイヤーが放つショットだけど、positionを連動させたくは無いのでPlayerの子にはしたくない。
でも、プレイヤーが死んじゃった時とかはショットを出したくないので軽く状態は知りたい。
敵やシールド、敵弾に当たった場合は、画面外に移動。
画面外では移動しない。(一応無駄を省くため、processを動かさない)
無難にイベントシステムをつかう事にしました。
プレイヤーがショットボタンを押したらPlayerノードがそれを感知。
自身が弾を撃てる状態ならBulletノードにイベントを送信。
Bulletノードはイベントを受信。まだ画面内で稼働中ならイベント無視。
画面外にいる時ならプレイヤ位置に移動して発射開始。
まず、Playerノードのコードに_inputを追加。
code:player.gd
extends Sprite2D
signal fire(position)
var norm_pos = 0.5
func _input(event):
if event.is_action_pressed("ui_accept"):
if is_processing():
emit_signal("fire", Vector2(position.x ,position.y - 7))
func _process(_delta):
if Input.is_action_pressed("ui_left"):
norm_pos -= 0.01
elif Input.is_action_pressed("ui_right"):
norm_pos += 0.01
norm_pos = clamp(norm_pos, 0.0, 1.0)
position.x = lerp(40, 260, norm_pos)
プレイヤーが弾を打てない時はprocessを止めてるはず。なのでis_processingで判断。余計なフラグは書かない。
シグナルをBulletに送るだけ。あとはBullet側で判断する。
Bulletの初期位置は一緒に送る。
if文のとこはまとめて書けるけど、読みやすさも多少気にする。削れば1画面で収まるなら即削る。
connect文も基本書かない。エディタで設定する。
code:bullet.gd
extends Sprite2D
@export var SPEED = 100
func _on_player_fire(_pos):
if !is_processing():
set_process(true)
position = _pos
func _process(_delta):
position.y -= SPEED * _delta
set_process(position.y > 0)
ゲームバランス調整の部分は一応エディタから調整できるように@exportしてます。
自分の位置が画面外上部(y < 0)ならprocessを止める。
processが止まってるなら弾が撃てる状態なのでset_process(true)して初期位置に移動。
https://scrapbox.io/files/64e1f1d2b7970b001b17a358.mov
ここまでで書いたコードは35行。空行入れて52行です。
常に行数を確認するのは良いことだと思います。コード量の見積もりを立てれるのは良い事だし、最終的に「100行に収めよう!」とかするのも楽しいし、自然に効率を探ったり関数を調べたりする習慣がつくと思います。
https://scrapbox.io/files/64e1f1e6ed339b001cb3e27c.png
当たり判定を付けて、当たったら爆散させたりしたい。
よく使うやつ。爆発するオブジェクトを子として持たせておくのが無難なように思えるけどあんまり良くない。
スポナーをシングルトンで用意するのが一番楽な気がします。
とりあえずExplosionNode
https://scrapbox.io/files/64e205c9cb3247001bfa0579.png
Nodeを生成したら1秒後に自動で消えるようにする。
code:explosion.gd
extends Sprite2D
func _on_timer_timeout():
queue_free()
自動読み込みにExplosionSpawnerNodeを作ってコード書く。使う時は爆発を表示させる座標をもらう。
code:explosion_spawner.gd
extends Node
@onready var explosion_scene = preload("res://explosion/explosion.tscn")
func explosion(pos: Vector2) -> void:
var _e = explosion_scene.instantiate()
_e.position = pos
get_tree().root.get_node("InvaderGame/GameLayer").add_child(_e)
GDScriptはノードやらリソースやらのパスを良く書く。慣れた方が良いと思います。
当たり判定を設定していく。
当たり判定はCollisionLayerとCollisionマスクの設定をきちんとやればあんまり困らないですが、設計はちゃんとやった方が良いと思います。
https://scrapbox.io/files/64e1f5f03f9881001b2f6215.png
矢印を受ける側はコリジョンが発生した時に爆発したり消えたりする必要があります。
なので、受ける側のCollisionMaskをtrueにする必要があります。
CollisionLayerは今回みたいにあんまりややこしくない場合、各ノードに番号を割り当てるのが無難。
名前も付けたほうが無難。ここサボって毎回困ってる気がするので。
https://scrapbox.io/files/64e1f7d91b9cbb001c88d871.png
なにかに当たったときの挙動はArea2Dのarea_enteredシグナルを受ける関数とかでやれば良し。
「爆発するオブジェクト、点数が入るオブジェクトは共通クラスを作るべきでは?」とか、あんまり考えないほうが良いと思う。
code:player.gd
func _on_area_2d_area_entered(_area):
ExplosionSpawner.explosion(position)
# 残機を減らしたりゲームオーバーにしたりする処理が必要
queue_free()
code:invader.gd
func _on_area_2d_area_entered(_area):
ExplosionSpawner.explosion(position)
# スコアの加算等の処理が必要
queue_free()
code:bullet.gd
unc _on_area_2d_area_entered(_area):
position.y = -1.0
# bulletは爆発しないしqueue_freeもしない。
使いまわしできる所は意外と少ない。
https://scrapbox.io/files/64e20c241b9cbb001c89a332.mov
UFOを作って出現させる。
UFOもスポナーを用意して、タイマーで一定秒数毎にスポーンさせることにしました。
https://scrapbox.io/files/64e21163ed339b001cb52789.png
code:ufo.gd
extends Sprite2D
@export var SPEED = 150.0
func _process(delta):
position.x -= SPEED * delta
if position.x < -100.0:
queue_free()
func _on_area_2d_area_entered(_area):
ExplosionSpawner.explosion(position)
queue_free()
ufoはスポーンしたら横移動するだけ。bulletがon_areaするかposition.xが画面外になったらqueue_free()
https://scrapbox.io/files/64e212583d8d7d001ccb378c.png
code:ufo_spawner.gd
extends Node2D
@onready var ufo_scene = preload("res://ufo/ufo.tscn")
func _on_timer_timeout():
var _ufo = ufo_scene.instantiate()
_ufo.position = position
get_parent().add_child(_ufo)
UfoSpawnerはタイマーのタイムアウトでUFONodeを生成。
とりあえずここまで
https://scrapbox.io/files/64e21297c2c3aa001c5ef879.png
実質67行でここまではいける。
記事書きながら休憩しながらでここまで7時間くらい。
本家は敵が7体以下だとUFOが出ないらしいです。
https://scrapbox.io/files/64e21326afc0e7001bb0f537.mov
疲れた。
短く書くという趣旨はそこそこ達成できていると思わなくもない。
こうやって既存のゲームがある状態は楽
自分のアイデアから設計、実装するのは10倍大変だと思う。
もっかいコードを載せておきます。コメント付き。
code:player.gd
extends Sprite2D
signal fire(position)
var norm_pos = 0.5 #自機のx座標を正規化した数値を入れる変数
#自機が操作可能な時にスペースキーが押下でシグナルを発火
func _input(event):
if is_processing():
if event.is_action_pressed("ui_accept"):
emit_signal("fire", Vector2(position.x ,position.y - 7))
func _process(_delta):
#左右キーの押下を検出したら、norm_posを増減させる
if Input.is_action_pressed("ui_left"):
norm_pos -= 0.01
elif Input.is_action_pressed("ui_right"):
norm_pos += 0.01
norm_pos = clamp(norm_pos, 0.0, 1.0) #norm_posを0.0~1.0の範囲に収める
position.x = lerp(40, 260, norm_pos) #norm_posの値で自機のx座標を線形補間する。
# 自機が敵や敵弾に触れたときの動作
func _on_area_2d_area_entered(_area):
ExplosionSpawner.explosion(position) #爆発のスプライトを発生させる
queue_free() #自身を消す
code:invader.gd
extends Sprite2D
@onready var norm_x = inverse_lerp(74, 196, position.x)
var step = 0
# インベーダーを動かす用のブロック(あとで移動or消す)
var timer = 0.0
func _process(delta):
timer += delta
if timer > 1.0:
timer = 0.0
move()
# この関数が呼ばれたらstepに1加える。それに応じて座標を計算しインベーダーを動かす。
func move():
step += 1
norm.x += 0.1 * sign(((step + 27) % 22 - 11) * int((step + 5) % 22 != 0))
position.x = lerp(74, 196, norm_x)
position.y += 20 * int((step + 5) % 11 == 0)
# インベーダーが何かに当たったら
func _on_area_2d_area_entered(_area):
ExplosionSpawner.explosion(position) # 爆発のスプライトを発生
queue_free() #自身を消す
invader.gdはちょっと書き直した。
https://scrapbox.io/files/64e23b67c2d2ee001b21b54f.png
code: bullet.gd
extends Sprite2D
@export var SPEED = 100
# プレイヤーのfireシグナルが発火したら実行される関数。
func _on_player_fire(_pos):
#自身のprocessが停止中ならプレイヤー位置に移動してprocessを稼働する
if !is_processing():
position = _pos
set_process(true)
#process稼働時は上方向に移動。画面外に出たらprocess停止する。
func _process(_delta):
position.y -= SPEED * _delta
set_process(position.y > 0)
#敵やシールドに当たったら、画面外に移動する(processが停止する)
func _on_area_2d_area_entered(_area:Area2D):
position.y = -1.0
code:explosion.gd
extends Sprite2D
#生まれてから一定時間経過したら自身を消す
func _on_timer_timeout():
queue_free()
code:explosion_spawner.gd
extends Node
#爆発スプライトのシーンを読み込む
@onready var explosion_scene = preload("res://explosion/explosion.tscn")
#呼ばれたら引数の位置に爆発スプライトをinstantiate、GameLayerの子Nodeとしてぶら下げる
func explosion(pos: Vector2) -> void:
var _e = explosion_scene.instantiate()
_e.position = pos
get_tree().root.get_node("InvaderGame/GameLayer").add_child(_e)
code:ufo.gd
extends Sprite2D
@export var SPEED = 150.0
# 横移動
func _process(delta):
position.x -= SPEED * delta
# 画面外に出て、しばらく経ったら自身を消す
if position.x < -100.0:
queue_free()
#弾に当たったら爆発スプライトを出す。自身は消す
func _on_area_2d_area_entered(_area):
ExplosionSpawner.explosion(position)
queue_free()
code:ufo_spawner.gd
extends Node2D
# UFOのシーンを読み込む
@onready var ufo_scene = preload("res://ufo/ufo.tscn")
# 一定時間が経過したら、自身の位置にUFOをinstantiateして親Nodeにぶら下げる
func _on_timer_timeout():
var _ufo = ufo_scene.instantiate()
_ufo.position = position
get_parent().add_child(_ufo)
残りの部分(あとで書く)
やるつもり
敵が弾を出す。
シールド
得点
やる?
タイトル画面
ゲームオーバー
残機