Closed73

【Godot Engine】FlappyBirdのチュートリアルを作るメモ

syun77syun77

Godot Engineで FlappyBirdのチュートリアルを作る過程を残しておきます

syun77syun77

"bg_back.png" をキャンバスにドラッグ&ドロップする

syun77syun77

bgノードのインスペクタから Offset > Centered のチェックを外す(左上揃えにする)
Node2D > Transform > Position > x/y を (0, 0) に初期化する

syun77syun77

実行すると背景画像と画面サイズが合っていないので、メニュー > プロジェクト > プロジェクト設定 を選ぶ

プロジェクト設定から 一般タブ > Display > Window を選び、Width を「800」Heightを「480」にすると背景画像が画面に収まるようになる

syun77syun77

「Mainシーン」タブのとなりの「+」をクリックして新規シーンを作成

「+その他ノード」を選ぶ

syun77syun77

KinematicBody2D を選んで「作成」ボタンをクリックする

syun77syun77

作成したノードを「Player」にリネームして Ctrl+S (Cmd+S) で保存しておく

syun77syun77

以下の画像を "player.png" として保存して、プロジェクトに追加する

syun77syun77

"player.png" をキャンバスにドラッグ&ドロップする

syun77syun77

playerノード (Sprite) のインスペクタから、Animation > Hframes の値を「2」に変更する

また Node2D > Transform > Position > x/y の値を (0, 0) にする

すると、プレイヤー画像の1コマ目が原点 (0, 0) に表示されるようになる

syun77syun77

CollisionShape2Dのインスペクタから、Shape の値を CircleShape2Dに設定。
コリジョンサイズをマウス操作で広げる

syun77syun77

ジャンプ処理を RigidBody2D.add_force() で実装

Player.gd
extends RigidBody2D

func _process(delta):
	if Input.is_action_just_pressed("ui_accept"):
		# Spaceキーでジャンプ処理
		# 移動量をリセット
		linear_velocity = Vector2(0, 0)
		
		# ジャンプ
		add_force(Vector2(0, 0), Vector2(0, -100))

しかしコレジャナイ感じに。

syun77syun77

こちらのページによると……
https://kidscancode.org/godot_recipes/physics/godot3_kyn_rigidbody1/

・add_force()
Adds a continuous force to the body. Imagine a rocket’s thrust, steadily pushing it faster and faster. Note that this adds to any already existing forces. The force continues to be applied until removed.

体に継続的な力を加えます。ロケットの推力を想像してみてください。着実にどんどん速く押していきます。これにより、既存の力が追加されることに注意してください。力は取り除かれるまで加えられ続けます。

add_force()は継続的な力を加えるとのこと。
どうやら、RigidBody2D.apply_impulse() が正解のよう

・apply_impulse()
Adds an instantaneous “kick” to the body. Imagine hitting a baseball with a bat.

瞬間的な「キック」を体に加えます。バットで野球を打つことを想像してみてください。

ということで以下のように修正

Player.gd
extends RigidBody2D

func _process(delta):
	if Input.is_action_just_pressed("ui_accept"):
		# Spaceキーでジャンプ処理
		# 移動量をリセット
		linear_velocity = Vector2(0, 0)
		
		# ジャンプ
-		add_force(Vector2(0, 0), Vector2(0, -100))
+		apply_impulse(Vector2(0, 0), Vector2(0, -100))

とりあえず呼び出す関数としてはこれで良さそう

syun77syun77

以下の土管画像を "dokan.png" として保存する

syun77syun77

パラメータを調整。
RigidBody2D > Gravity Scale を 「10」に増やす

ジャンプ力を 「-100」から「-400」に変更

Player.gd
extends RigidBody2D

func _process(delta):
	if Input.is_action_just_pressed("ui_accept"):
		# Spaceキーでジャンプ処理
		# 移動量をリセット
		linear_velocity = Vector2(0, 0)
		
		# ジャンプ
-		apply_impulse(Vector2(0, 0), Vector2(0, -100))
+		apply_impulse(Vector2(0, 0), Vector2(0, -400))

動きが鋭くなりました。

syun77syun77

画面外に出ないようにスクリプトを以下のように修正する

Player.gd
extends RigidBody2D

+# デバッグ用フラグ
+var is_debug := true

func _process(delta):
	if Input.is_action_just_pressed("ui_accept"):
		# Spaceキーでジャンプ処理
		# 移動量をリセット
		linear_velocity = Vector2(0, 0)
		
		# ジャンプ
		apply_impulse(Vector2(0, 0), Vector2(0, -400))

+	if position.y < 0:
+		# 画面外に出ないようにする
+		linear_velocity = Vector2(0, 0)
		
+	if position.y > 600-64:
+		if is_debug:
+			# 画面外に出ないようにする暫定処理
+			linear_velocity = Vector2(0, 0)
+			# ジャンプ
+			apply_impulse(Vector2(0, 0), Vector2(0, -400))

これで画面外に出なくなる

syun77syun77

ちなみにテスト用に移動させる場合は、「移動モード」で移動させる。
「選択モード」で移動させると、スプライトだけが移動してしまう

なので「移動モード」に変更して移動させると、ルートノードごとまとめて移動できる

syun77syun77

土管シーンの作成。
タブの「+」をクリックして新規にシーンを作成する

「+その他ノード」を選択する

syun77syun77

StaticBody2Dを選んで「作成」ボタンをクリック。
これは、衝突判定はあるが移動しないオブジェクト

syun77syun77

"dokan.png" をキャンバスにドラッグ&ドロップする

syun77syun77

dokanノード(Sprite)を選択して、インスペクタから、Node2D > Transform > Position のところにある回転アイコンをクリックして、Positionの値を (0, 0) にリセットする

syun77syun77

ルートノードの「StaticBody2D」を「Dokan」にリネームしておく

syun77syun77

CollisionShape2Dノードを選んで、インスペクタから Shape を 「RectangleShape2D」に設定する

syun77syun77

Dokan に以下のスクリプトをアタッチする

Dokan.gd
extends StaticBody2D

var velocity = Vector2(-100, 0)

func _process(delta):
	position += velocity * delta
syun77syun77

Mainシーンに Player.tscn と Dokan.tscn をテスト用に配置してみる

Playerと土管がぶつかることが確認できる

syun77syun77

Playerシーンに戻ってアニメーションの実装
Player.gdを以下のように修正する

Player.gd
extends RigidBody2D

# デバッグ用フラグ
var is_debug := true

+# スプライトデータ
+onready var sprite := $player

func _process(delta):
	if Input.is_action_just_pressed("ui_accept"):
		# Spaceキーでジャンプ処理
		# 移動量をリセット
		linear_velocity = Vector2(0, 0)
		
		# ジャンプ
		apply_impulse(Vector2(0, 0), Vector2(0, -400))

	if position.y < 0:
		# 画面外に出ないようにする
		linear_velocity = Vector2(0, 0)
		
	if position.y > 600-64:
		if is_debug:
			# 画面外に出ないようにする暫定処理
			linear_velocity = Vector2(0, 0)
			# ジャンプ
			apply_impulse(Vector2(0, 0), Vector2(0, -400))

+	# アニメーション
+	if linear_velocity.y < 0:
+		# 上昇中
+		sprite.frame = 1
+	else:
+		# 下降中
+		sprite.frame = 0

実行すると上昇時に画像が変化する

syun77syun77

ゲームオーバーの実装
土管との衝突判定を行う

メニュー > プロジェクト > プロジェクト設定 を開く。
一般タブ > Layer Names > 2d Physics から、Layer 1 に 「player」、Layer 2 に「dokan」と指定する

syun77syun77

いったん、PlayerシーンとDokanシーンを保存して閉じる。
そして開き直す(開き直さないと Layer Nameが反映されないため)

syun77syun77

Playerのコリジョンレイヤー、マスクの設定
PlayerシーンのPlayerノードを選択して、インスペクタから PhysicsBody2D > CollisionLayerMask を以下のように設定する。

これは、Playerは 「player」レイヤーに所属し、「dokan」レイヤーと衝突する……という設定

syun77syun77

Dokanのコリジョンレイヤー、マスクの設定
DokanシーンのDokanノードを選択して、インスペクタから PhysicsBody2D > Collision の Layer と Mask を以下のように設定する。

syun77syun77

コリジョンレイヤーとマスクを設定した後、問題なく土管に衝突するかどうかを確認する

syun77syun77

土管にぶつかったらプレイヤーを操作できないようにする
Playerノードを選択して、インスペクタから「ノード」タブを選択する

syun77syun77

シグナル一覧から body_entered() を選んで右クリックして「接続」を選ぶ(またはダブルクリック)

syun77syun77

シグナルへの接続確認画面が表示されるので、「接続」を選ぶ

syun77syun77

Player.gd にシグナルを接続する関数が追加される

Player.gd を以下のように修正する (can_jump変数を追加する)

Player.gd
extends RigidBody2D

# デバッグ用フラグ
var is_debug := true

# スプライトデータ
onready var sprite := $player

+# 操作できるかどうか
+var can_jump = true

func _process(delta):
-	if Input.is_action_just_pressed("ui_accept"):
+	if can_jump and Input.is_action_just_pressed("ui_accept"):
		# Spaceキーでジャンプ処理
		# 移動量をリセット
		linear_velocity = Vector2(0, 0)
		
		# ジャンプ
		apply_impulse(Vector2(0, 0), Vector2(0, -400))

	if position.y < 0:
		# 画面外に出ないようにする
		linear_velocity = Vector2(0, 0)
		
	if position.y > 600-64:
		if is_debug:
			# 画面外に出ないようにする暫定処理
			linear_velocity = Vector2(0, 0)
			# ジャンプ
			apply_impulse(Vector2(0, 0), Vector2(0, -400))

	# アニメーション
	if linear_velocity.y < 0:
		# 上昇中
		sprite.frame = 1
	else:
		# 下降中
		sprite.frame = 0

+# 土管にぶつかったときの処理
+func _on_Player_body_entered(body):
+	can_jump = false # 操作できなくする
syun77syun77

これで衝突時の判定ができる……と思いきやできない。
ここを見ると「"Contact Monitoring" にチェックを入れると動くよ!」と書いてある
https://godotengine.org/qa/46811/how-to-get-collision-as-bool-of-rigid-body-to-static-body

ということで、チェックを入れて実行……

しかしシグナルが来ない……

getcollidingbodies() で衝突している body が取れる……との情報もあったのでためしてみたけれど取れなかった……
https://godotengine.org/qa/3456/detect-collision-in-rigid-body-character

syun77syun77

仕方ないので、Playerノードの型を RigidBody2D から KinematicBody2D に変更……

そして Player.gd を大幅修正……

Player.gd
-extends RigidBody2D
+extends KinematicBody2D

# デバッグ用フラグ
var is_debug := true

+# 重力
+const GRAVITY_POWER := 1000

+# ジャンプ力
+const JUMP_POWER := -400

# スプライトデータ
onready var sprite := $player

# 操作できるかどうか
var can_jump = true

+# 移動速度
+var velocity := Vector2()

func _process(delta):
+	# 重力
+	velocity.y += GRAVITY_POWER * delta
	
	if can_jump and Input.is_action_just_pressed("ui_accept"):
		# Spaceキーでジャンプ処理
-		# 移動量をリセット
-		linear_velocity = Vector2(0, 0)
		
		# ジャンプ
-		apply_impulse(Vector2(0, 0), Vector2(0, -400))
+		velocity.y = JUMP_POWER

	if position.y < 0:
		# 画面外に出ないようにする
-		linear_velocity = Vector2(0, 0)
+		velocity.y = 100
		
	if position.y > 600-64:
		if is_debug:
			# 画面外に出ないようにする暫定処理
-			linear_velocity = Vector2(0, 0)
			# ジャンプ
-			apply_impulse(Vector2(0, 0), Vector2(0, -400))
+			velocity.y = JUMP_POWER

	# アニメーション
-	if linear_velocity.y < 0:
+	if velocity.y < 0:
		# 上昇中
		sprite.frame = 1
	else:
		# 下降中
		sprite.frame = 0

+	# 移動と衝突を行う
+	var collision = move_and_collide(velocity * delta)
+	if collision:
+		# 衝突したので動けなくする
+		can_jump = false
+		# 左方向に吹き飛ばす
+		velocity.x -= 300
+		move_and_collide(velocity * delta)

+	if can_jump == false:
+		# 吹っ飛び中は回転する
+		sprite.rotation -= 10 * delta
syun77syun77

上に乗ったときに吹き飛ばない不具合が発生……

衝突時の処理を変更。

Player.gd
	# 移動と衝突を行う
	var collision = move_and_collide(velocity * delta)
	if collision:
		# 衝突したので動けなくする
		can_jump = false
		# 左方向に吹き飛ばす
-		velocity.x -= 300
+		velocity.x = -300
+		velocity.y -= 100
		move_and_collide(velocity * delta)

逆さ土管に衝突したときの処理がおかしくなるかもしれないけれどひとまずこれで……

syun77syun77

土管生成処理の実装
MainシーンからDokanノードを削除

Dokanスクリプト dokan.gd を修正(出現と消滅処理を追加)

dokan.gd
extends StaticBody2D

-var velocity = Vector2(-100, 0)
+var velocity = Vector2(-150, 0)

+# 開始処理
+func start(pos, speed_rate):
+	position = pos
+	velocity *= speed_rate

func _process(delta):
	position += velocity * delta
+	if position.x < -128:
+		# 画面外に出たら消える
+		queue_free()

Mainノードに以下のスクリプトをアタッチする

Main.gd
extends Node2D

# 土管オブジェクト
var Dokan = preload("res://Dokan.tscn")

# 出現間隔
var interval = 3

# 生成タイマー
var timer = interval

# 土管出現回数
var dokan_cnt = 0

func _ready():
	# 乱数を初期化
	randomize()

func _process(delta):
	timer += delta
	if timer > interval:
		# インターバルを超えたら土管を出現させる
		timer -= interval
		_add_dokan()

func _add_dokan():
	# 出現回数をカウントアップ
	dokan_cnt += 1

	# 高さを決める
	var xbase = 800 + 120
	var ybase = rand_range(32, 400-32)
	
	# 土管を生成
	for i in range(2):
		var dokan = Dokan.instance()
		var py = ybase
		if i == 0:
			# 上のドカン
			py += -320
		else:
			# 下のドカン
			py += 320 + 160

		# 土管の出現回数が増えるとスピードアップ
		var speed_rate = 1 + 0.5 * dokan_cnt
		dokan.start(Vector2(xbase, py), speed_rate)
		add_child(dokan)
	
	# インターバルを減らす
	interval = max(0.5, interval-0.2)

ひとまずゲームらしくなりました

syun77syun77

まずは Dokan.gd のコード解説

Dokan.gd
# 開始処理
func start(pos, speed_rate):
	position = pos
	velocity *= speed_rate

これは開始処理(ドカンを生成したときのパラメータ設定)で、posが初期位置、speed_rateが速度倍率となっています。

Dokan.gd
func _process(delta):
	position += velocity * delta
	if position.x < -128:
		# 画面外に出たら消える
		queue_free()

ドカン画像の横幅がおおよそ 128px としてそれを超えたら消滅処理 queue_free() を呼び出しています。

syun77syun77

Main.gd の土管生成処理について

ドカン画像の高さの半分がおおよそ 320px (正確には 305.5px) なので、その高さだけ上下に移動させて、160px の間隔を開けています。

生成タイミングは、time 変数に deltaを足し込み、intervalを超えたら、生成処理を行っています。

syun77syun77

残った問題点として、上の土管に衝突したときの挙動がおかしいのでこれを修正します。

syun77syun77

Player.gd を以下のように修正します

Player.gd
	# 移動と衝突を行う
	var collision = move_and_collide(velocity * delta)
	if collision:
		# 衝突したので動けなくする
		can_jump = false
		# 左方向に吹き飛ばす
		velocity.x = -300
-		velocity.y -= 100
+		if position.y < collision.position.y:
+			# ドカンより上にプレイヤーがいる
+			velocity.y = -300
+		else:
+			# ドカンより下にプレイヤーがいる
+			velocity.y = 300
		move_and_collide(velocity * delta)

これで上のドカンにぶつかっても大丈夫

syun77syun77

Xolonium-Regular.ttf
をプロジェクトに追加する

syun77syun77

Mainシーンに Label を追加

名前を 「Caption」に変更

syun77syun77

「Caption」ノードから、Control > Custom Fonts > Font > [空] をクリックして、「読込み」を選択する

対象を「すべてのファイル(*)」に変更して、「Xolonium-Regular.ttf」を選ぶ

これでフォントが読み込める……と思ったけどなぜかエラー

syun77syun77

順番が間違っていたようす。
まずは「Caption」ノードから、Control > Custom Fonts > Font > [空] をクリックして、「新規 DynamicFont」を選択する

syun77syun77

作成した Dynamic Font をクリックして、Font > FontData > [空] をクリックして、「読込み」を選ぶ

すると正しくフォントが選べるようになるので "Xolonium-Regular.ttf" を選択して「開く」ボタンをクリックする

syun77syun77

試しに Label > Text に「GAME OVER」と入力すると、指定したフォントで文字が描画されるようになる

syun77syun77

パラメータを以下のように設定します。

  • Rect > Position > (x, y): (0, 120)
  • Rect > Size > (x, y): (800, 256)

  • Custom Fonts > Font > Settings > Size: 64

これにより以下のようなレイアウトとなる

syun77syun77

Player.gd に死亡処理を追加

Player.gd
extends KinematicBody2D

# デバッグ用フラグ
var is_debug := true

# 重力
const GRAVITY_POWER := 1000

# ジャンプ力
const JUMP_POWER := -400

# スプライトデータ
onready var sprite := $player

# 操作できるかどうか
var can_jump = true

# 移動速度
var velocity := Vector2()

func _process(delta):
	# 重力
	velocity.y += GRAVITY_POWER * delta
	
	if can_jump and Input.is_action_just_pressed("ui_accept"):
		# Spaceキーでジャンプ処理
		velocity.y = JUMP_POWER

	if position.y < 0:
		# 画面外に出ないようにする
		velocity.y = 100
		
	if position.y > 600-64:
		if is_debug:
			# 画面外に出ないようにする暫定処理
			# ジャンプ
			velocity.y = JUMP_POWER

	# アニメーション
	if velocity.y < 0:
		# 上昇中
		sprite.frame = 1
	else:
		# 下降中
		sprite.frame = 0

	# 移動と衝突を行う
	var collision = move_and_collide(velocity * delta)
	if collision:
		# 衝突したので動けなくする
		can_jump = false
		# 左方向に吹き飛ばす
		velocity.x = -300
		if position.y < collision.position.y:
			# ドカンより上にプレイヤーがいる
			velocity.y = -300
		else:
			# ドカンより下にプレイヤーがいる
			velocity.y = 300
		move_and_collide(velocity * delta)

	if can_jump == false:
		# 吹っ飛び中は回転する
		sprite.rotation -= 10 * delta
		
+	if position.x < 0 or position.y > 480:
+		# 画面外に出たら消滅する
+		queue_free()
syun77syun77

Main.gdを以下のように修正

Main.gd
extends Node2D

# 土管オブジェクト
var Dokan = preload("res://Dokan.tscn")

# 出現間隔
var interval = 3

# 生成タイマー
var timer = interval

# 土管出現回数
var dokan_cnt = 0

+# キャプション
+onready var caption = $Caption

+# プレイヤー
+onready var player = $Player

func _ready():
	# 乱数を初期化
	randomize()
	
+	# キャプションは初期状態では非表示にしておく
+	caption.visible = false

func _process(delta):
	timer += delta
	if timer > interval:
		# インターバルを超えたら土管を出現させる
		timer -= interval
		_add_dokan()
	
+	if is_instance_valid(player) == false:
+		# プレイヤーが消滅した
+		# キャプションを更新
+		caption.visible = true
+		caption.text = "GAME OVER\n\n RETRY: DOWN KEY"
+		
+		# 下キーが押されたらリトライ
+		if Input.is_action_just_pressed("ui_down"):
+			# Mainシーンを読み込み直してリトライする
+			get_tree().change_scene("res://Main.tscn")

func _add_dokan():
	# 出現回数をカウントアップ
	dokan_cnt += 1
	
	# 高さを決める
	var xbase = 800 + 120
	var ybase = rand_range(32, 400-32)
	
	# 土管を生成
	for i in range(2):
		var dokan = Dokan.instance()
		var py = ybase
		if i == 0:
			py += -320
		else:
			py += 320 + 160

		# 土管の出現回数が増えるとスピードアップ
		var speed_rate = 1 + 0.5 * dokan_cnt
		dokan.start(Vector2(xbase, py), speed_rate)
		add_child(dokan)
	
	# インターバルを減らす
	interval = max(0.5, interval-0.2)
syun77syun77

実行してゲームオーバー時の表示とリトライ(下キー)ができることを確認する

syun77syun77

キャプション文字の描画順がおかしいので、CanvasLayer で描画順を制御する

「Caption」ノードをドラッグ&ドロップして、ノードの階層を以下のようにする

「CanvasLayer」ノードを選択して、インスペクタから CanvasLayer > Layer > Layer の値が「1」になっていることを確認する

syun77syun77

実行すると caption 変数で nullエラーとなる

これはノードの階層が変化したため。

Main.gd を以下のように修正

Main.gd
# 土管出現回数
var dokan_cnt = 0

# キャプション
-onready var caption = $Caption
+onready var caption = $CanvasLayer/Caption

# プレイヤー
onready var player = $Player
syun77syun77

あとキャプション文字の位置がずれてしまっている

これは CanvasLayer にぶら下げた影響で Rect > Size の値が変化してしまっているため

syun77syun77

Rect > Size > x を「800」に戻してレイアウトを修正

syun77syun77

実行して「GAME OVER」の文字が最前面に表示されることを確認する

syun77syun77

プレイヤー画像を調整
以下の画像を "player2.png" として保存して、プロジェクトに追加する

syun77syun77

Playerシーンの "player" ノード (Sprite) のインスペクタから割り当てる画像を、"player.png" から "player2.png" に変更

↓↓↓

syun77syun77

横幅が 256px で 1つあたり 64px なので、

256÷64=4

4分割するので、Animation > Hframes に「4」 を指定する

syun77syun77

PlayerスクリプトPlayer.gd_process() を以下のように修正する

Player.gd
func _process(delta):
	# 重力
	velocity.y += GRAVITY_POWER * delta
	
	if can_jump and Input.is_action_just_pressed("ui_accept"):
		# Spaceキーでジャンプ処理
		velocity.y = JUMP_POWER

	if position.y < 0:
		# 画面外に出ないようにする
		velocity.y = 100
		
	if position.y > 600-64:
		if is_debug:
			# 画面外に出ないようにする暫定処理
			# ジャンプ
			velocity.y = JUMP_POWER

	# アニメーション
	if velocity.y < 0:
		# 上昇中
		sprite.frame = 1
	else:
		# 下降中
		sprite.frame = 0
+	if can_jump == false:
+		# ジャンプできない=ダメージ中
+		sprite.frame = 2

	# 移動と衝突を行う
	var collision = move_and_collide(velocity * delta)
	if collision:
		# 衝突したので動けなくする
		can_jump = false
		# 左方向に吹き飛ばす
		velocity.x = -300
		if position.y < collision.position.y:
			# ドカンより上にプレイヤーがいる
			velocity.y = -300
		else:
			# ドカンより下にプレイヤーがいる
			velocity.y = 300
		move_and_collide(velocity * delta)

	if can_jump == false:
		# 吹っ飛び中は回転する
		sprite.rotation -= 10 * delta
		
	if position.x < 0 or position.y > 480:
		# 画面外に出たら消滅する
		queue_free()
syun77syun77

ゲームオーバー時にダメージ中の画像になることを確認する

syun77syun77

ひとまずこれでチュートリアルは完成
これから記事にしていきます。

このスクラップは2021/03/10にクローズされました