「Godot」0からゲーム作ろう! (発展実装: 有限状態オートマトン(State Machine), SpriteSheetアニメーション, 飛び道具, HPシステム ー2Dゲーム基礎ー)

記事5へようこそ! Coding課のAugustです.

本シリーズは, 完全に0から, あらゆる2Dゲームにも使われるエンジン機能や, ゲーム制作に欠かせない

  • キャラ操作
  • 信号(Signal, ~=UntiyのEvent System)
  • Tweenの作り方
  • User Interface

などを
Godotエンジンを使って解説するものです.

Unity達人でも, Godotエンジンでの制作流れをみて, 感じられるものがあるかもしれません.

Information

発展シリーズは, 基礎より難しい概念を実装する, 若しくは, 知識はすでに前記事に記載しているものを,
概念の説明はある程度のプログラミング力持つことを前提にし, 実装を優先して, サクサク制作を進むものである.

Warning

僕が気付いいないだけで, 前の記事もそうだった かもしれないが

今回の記事は, 多分 結構Programming力&ゲームエンジンの仕組みへの理解が必要. 理解できない可能性は大きいと思う.


本記事は,

  • Spritesheet素材によるアニメーション
  • 重力と簡易地形
  • Finite State Machine (有限状態オートマトン)
  • HPシステム
  • 飛び道具
  • 敵スキル

という順で進みます.

Success

吉報: いよいよ動画(ビデオ)使えるようになる.

成果はこれ(押してプレイ, 全動画3MB以下):


なお, もし 「このゲーム・この機能, どうやって作れる?」に対して興味を持つ方は, (複雑過ぎない機能かつ現段階2D限定)なら, Discordでメッセージくれたら次回の記事で作りたいと思います.


では早速始めましょう.


このリンクには, 今回のファイル全部入れてます.
Github

下準備

Spritesheetとは, 下の図の用に, アニメーションフレームなど, 複数Sprite素材を一つのファイルにまとめて, 均等に分割することができると設定している素材ファイル.

Tilesetも同様に, タイルを並ぶことで自由に組み合わせることができ, 設定が必要だが, ルールを作って自動的に地形を生成させるのも可能

(今回のタイルセットの例は個人的にあまり使いやすくないと思う. その理由は, モジュール性に欠けている. Transitionが綺麗に見えるが, 自動地形生成には優しくない. 特定なタイルが揃う前提で作っているので, 生成ルールの設定するに時間と精神力かける)

アニメーションを全部設定する (注意すべきは, dashなど, loopしない動きはloopを閉じる). アニメーションFPSは主にスピード感を作らせるので, 好きな方に設定して.

更に, 重力を追加し, top-downゲームからplatformingゲームに方向転換するので, 上と下に移動するボタンは削除し, 新しい動作を追加する


HPシステムの実装において, 新しくArea2D Nodeを実装する. HurtboxComponentに名前を変え, Layer & Maskを調整する. (記事3で 「当たり判定は する側と受ける側両方大差ない, 一般的に受ける側に書く方が見えやすい」と言ったため, 今回はそれを実際に実装する)
このArea2Dは, 実際のダメージ判定になるので, 大きく過ぎないように設定しよう.

有限状態オートマトン State Machineを作る

本当は今回みたいな単純ゲームには, 必要のないものだと思うけど, とはいえ, アニメーションの遷移を明瞭化するには, これが丁度いいだと思う.

簡単にいうと,

状態遷移と, 状態の機能を個別に定義するにより, 例えば,

着地していないとジャンプできない;着地している時, ↓キーを押すと臥せることができる.

落下時はジャンプできない, 地面にいないと臥せることもできない.
ジャンプ時に↓キー押すと, ダイビング動作に入る

と書きたい場合,
if y.速度<=0: (Godotだと>=になる, y軸は+で降下するので)

if “↓キー”:

臥せる
if: “ジャンプ”

ジャンプ

で実現できるが,

もし更にケースを増やすと,
if not && not … など,

非常に面倒いし, bugが生じやすい. (僕ができる説明より遥かにわかりやすい解説は沢山存在するので, 自分で検索してみて)


まず

次に, 状態のベースクラスを作る

C#やJavaなどだと, このクラスの他, AbstractかInterfaceを作るのが一般的だが, GDScriptではinterfaceが存在しないため, 自分で頑張るしかいない.

Stateのベース クラス


class_name State
## enter, update, handle_input(event)
extends Node


var root_entity:Node2D
var animation_player:AnimatedSprite2D

func set_root(entity: Node2D) -> void:
	root_entity = entity
	animation_player = root_entity.animation_player
	
func enter():
	root_entity.debug_label.text = self.name

func update(_delta: float):
	pass

func exit():
	pass


func handle_input(_event: InputEvent):
	if _event.is_action_pressed("jump"):
		if can_jump():
			jump()
			
	if _event.is_action_pressed("dash"):
		if can_dash() && root_entity.can_dash:
			dash()
			
	if _event.is_action_pressed("shoot"):
		if can_shoot():
			shoot()



func can_dash() -> bool:
	return true  # default: all states can dash

func can_jump()->bool:
	return true

func can_shoot()->bool:
	return true

func jump():
	root_entity.velocity.y = -root_entity.jump_force
	root_entity.state_machine.change_state(StateMachine.Airbourne)

func dash():
	root_entity.state_machine.change_state(StateMachine.Dash)
	
func shoot():
	pass # 記事書いているときはまだ実装していない

一見やや複雑に見えるが,
要は
enter, exit, update, handle_input (名前任意) が存在すればいい.
状態に入る・離脱する時の挙動, 状態にいる時の挙動と, 入力を個別に処理する設定. この4つあればいい.

Interfaceを継承するクラスは, 必ずinterfaceに定義されているメソード(関数)と同じ名前で作らなければならない (内部仕組みは任意). そうしないと, Errorが出る.

GDSciptなら, Errorで知らせられるのではなく, 自分でこれを意識すれば, 結果は同じ.

State Machineのベース

extends Node
class_name StateMachine


var current_state:State
var states := {}

@onready var root_entity:CharacterBody2D = owner

enum {
	Idle,
	Walk,
	Sprint,
	Airbourne,
	Hurt,
	Dash
}
func _ready():
	states[Idle] = $Idle
	states[Walk] = $Walk
	states[Sprint] = $Sprint
	states[Airbourne] = $Airbourne
	states[Hurt] = $Hurt
	states[Dash] = $Dash

	for state in states.values():
		state.call_deferred("set_root",root_entity)


	change_state.call_deferred(StateMachine.Idle)



func change_state(state: int):
	if current_state:
		current_state.exit()
	current_state = states.get(state)
	if current_state:
		current_state.enter()

func update(delta: float):
	if current_state:
		current_state.update(delta)

func handle_input(event: InputEvent):
	if current_state:
		current_state.handle_input(event)

ここで, 上で設計したStateを, 実際に動かせる.

State MachineをPlayerに適用

func _physics_process(delta):
	state_machine.update(delta)

func _input(event):
	state_machine.handle_input(event)

簡単にいうと, State Machineの各状態で定義しているProcess動作を, 実際にGodotエンジンの _process, _input に入れ替える.

例えば, Idle Stateは何もしていない状態で, Walk Stateは歩く状態.
Idleのprocessは, 移動に関するロジックは一切入らない.
移動しているなら, 絶対にIdle状態じゃないことが判明できる. (物理演算を利用していても, 一般的にIdleに入る時, enterで速度を0にするので, 慣性力による移動も打ち消している)

ならば, 「移動している」ことを仮定する機能は, (この状態で)絶対に発動しないので, いちいち確認しなくてもいい.

実際なIdle状態のコードはこう:

extends State
class_name IdleState


func enter():
	super()
	root_entity.velocity.x = 0

	if animation_player.animation == "land":
		return

	animation_player.play("idle")


func update(_delta:float):
	var input_x:float = root_entity.input_x
	if input_x != 0:
		if Input.is_action_pressed("sprint"):
			owner.change_state(StateMachine.Sprint)
		else:
			owner.change_state(StateMachine.Walk)
	if !root_entity.is_on_floor():
		owner.change_state(StateMachine.Airbourne)

	if !animation_player.is_playing(): animation_player.play("idle")


func exit(): pass

Idle状態は, 何もしない時に入るので, Updateには, ただ 「移動しているなら, (もしキーが押されているなら)Sprint状態, あるいはWalk状態に遷移する. もし地面と接触していないなら, Airbourne(浮遊状態)に遷移する」って書けばいい.
Enterには, 慣性力をなくすためにvelocity.x = 0 にする. (しないとどうなるか, 試してみて?)

アニメションを切り替えるだけ.

その他各状態は大体同じ流れで書く. コードはファイル共有リンク参照, かなり長いため.

HPシステムを作る


HP システム, 簡潔にまとめると:

1. 当たり判定を検出する Hurtbox Component
2. 当たり判定を提供する Hitbox Component
3. 最大HP, 現在HP, ダメージ・治療・死亡処理をする Health Component

この3つあればいい (前提: 当たり処理は受ける側で行う)


Hitboxが判定に衝突する, If 衝突対象==Hurtbox Component && 衝突対象.owner は HealthComponentを所持するなら, 現HPをダメージ分減らし, (飛び道具の場合) 自分を消すか, 継続に飛ぶかは設定できる.

HeathComponentの設定は単純,

extends Node
class_name HealthComponent

var max_health:float = 10

signal died 
signal health_changed
signal received_damage

var current_health:float = max_health

func heal(amount:float):
	if amount < 0:
		print("cannot heal a negative amount, 
		current_health %f, heal amount %f" % [current_health, amount])
		return
	else:
		current_health = min(current_health + amount, max_health)
		health_changed.emit()

func full_heal():
	heal(max_health)

func damaged(damage:float):
	current_health = max(current_health - damage, 0)
	health_changed.emit()
	received_damage.emit()
	check_death.call_deferred()
	

func check_death():
	if current_health == 0:
		died.emit()

		owner.queue_free()


func get_health_percent():
	if max_health <= 0: return 0
	
	return min(current_health/max_health,1)

要は, 信号でHPの変動を知らせ, 変動するとHPの割合を計算し, 死んでいるかどうかを調べる. HPが0以下なら, died信号を発出する. (ここでEventSystem Singletonでゲームを終了させない理由は, HealthComponentは重複利用可能な “Component”, ただの部品である. 部品に不必要な特殊ケースは記載する必要はない)

実際にHealthComponentに書いているmax_healthはただのデフォルト値, Playerのスクリプトで再定義できる


説明したように, Hurtboxのスクリプトは,
1. 自分はHPを持つ (じゃないとダメージされようがない)
2. 衝突対象はHitbox

extends Area2D
class_name HurtboxComponent

@onready var health_component:HealthComponent = owner.get_node("HealthComponent")

func _ready():
	area_entered.connect(on_area_entered)
	
	
func on_area_entered(other_area:Area2D):
	if health_component == null: return
	if !(other_area is HitboxComponent): return
	
	
	var hitbox_component = other_area as HitboxComponent
	health_component.damaged(hitbox_component.damage)


=====

Hitboxは更に簡単:

extends Area2D
class_name HitboxComponent

var damage:float = 1

HPと同様に, このダメージはただのデフォルト値. Ownerのスクリプトでいつでも変更できる. (ただし順序要注意, この後で説明する.)

PlayerとEnemy両方にHitbox, Hurtboxを追加し, Layer & Maskを調整する. (Layerは存在すれ空間, Maskは感知する空間)

更に注意すべきことは, これまで body_entered信号を使っていた. それは, Player は CharacterBody2Dだからである. CharacterBody2Dは物理演算込みのNode種類で, そのCollisionは普通は地形のぶつかり判定に使い, 個人的にあまりダメージ判定には使わない (できないわけではない, 個別にロジックを設定して, 「衝突したのは地形ではない」ことを追加で判別すれば別状はない). 更に, もし敵もCharacterBody2Dなら(普通に重力を受け, 移動する敵によくある) BodyのMaskに敵のいるレイヤーを発動すると, 透過できなる




まあ今回の敵はただのNode2D + Areaなので関係ないかもしれない.

次にHP Barを作ろう

extends ProgressBar


@onready var health_component:HealthComponent = owner.get_node("HealthComponent")


func _ready():
	if health_component == null:
		push_error("Health Bar UI Requires Health Component, Which Cannot be Found on [%s]"%owner().name)
		
	health_component.health_changed.connect(on_health_changed)
	update_health_display()


func update_health_display():
	value = health_component.get_health_percent()
	
	
func on_health_changed():
	value = health_component.get_health_percent()


Trivia:

Information

_readyは, NodeがScene Treeに入る時のみ一度だけ実行される

.instaniate()で実行時生成された物体は, add_child()が適応される瞬間こそ実行する
つまり, add_child()の前にパラメーターを弄っても, _ready()に上書きされることになる

Danger

シーン起動時, Nodeの生成順序は, 親Node->子Node->子Nodeの_ready() -> 親Nodeの_ready()

親.health_component.received_damage.connect(_on_received_damage)

health_component は, 親Nodeの_ready()に記録する別の子Nodeへのポインター(厳密的にはちょっと違う)

さて, このコードはErrorでクラッシュする.

なぜ? 親Nodeの_ready()はまだ実行されていないから, このポインターはまだnull・Nilのままであるから.
ここで, ポインターを使うのをやめるか, call_deferred()を使うかしない. (後者推薦)

func _ready():
late_init.call_deferred()

func late_init():
root_entity.health_component.received_damage.connect(_on_received_damage)

飛び道具

必要なものはすべて揃ってある.

さて, Node2Dに, Sprite2D, HitboxComponentを追加して, 最後にこの “VisibleOnScreenNotifier2D”を入れる. これは, ただ「画面範囲にいるか?」を知らせるだけのもの

extends Node2D


@export var damage:int = 2
@export var projectile_speed:float = 2000
@export var direction:Vector2 = Vector2.RIGHT

@onready var hitbox_component: Area2D = $HitboxComponent
@onready var sprite: Sprite2D = $Bullet1
@onready var visible_on_screen_notifier_2d: VisibleOnScreenNotifier2D = $VisibleOnScreenNotifier2D



var current_speed:float = 0
func _ready() -> void:
	bullet_tween.call_deferred()
	visible_on_screen_notifier_2d.screen_exited.connect(queue_free)
	
	self.hitbox_component.damage = damage
	hitbox_component.area_entered.connect(on_area_entered)

func bullet_tween()->void:
	var tween:Tween = create_tween()
	tween.set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)
	tween.tween_method(
		func(value:float): current_speed = value,
		0,
		projectile_speed,
		0.75)

func _physics_process(delta: float) -> void:
	self.position += direction * current_speed * delta

func on_area_entered(other:Node2D):
	if other is HurtboxComponent:
		queue_free.call_deferred()


まあ, 射出後徐々に加速するって, 弾丸よりミサイルかよ…はさっておき,

ここでまた call_deferredが現れる (正直必要かどうかはわからない). 要は最後までこれを実行しないことを保証する保険措置みたいなもの.

まず, 弾丸が出る場所を示すNode2D作る.

実際に物体(Scene, .tscnフィル)を呼び出すには, “経路又はUID”.instantiate()
英語の分かる人なら見れば分かるが, instance(インスタンス?, 実体)の動詞である. 要は「実体化する

instantiate後, treeに入れないとただの孤児(Orphan) Nodeなので, 親.add_child(実体)でどこかの子として入れる.
実際にglobal_positionも更新しないと行けない. しないと(0,0)とかで現れ, 「あれ何かバグってねぇ?」と荒れ狂う渦に陥る(経験談)

@export var shoot_cooldown:float = 0.5
@export var bullet:= preload("res://bullet1.tscn")

func shoot():
	if state_machine.current_state == state_machine.states[state_machine.Walk]:
		animation_player.play("walk_shoot")
	else:
		animation_player.play("stand_shoot")
	var bullet_scene:Node2D = bullet.instantiate()
	bullet_anchor.add_child(bullet_scene)
	if !last_faced_right:
		bullet_scene.direction = Vector2.LEFT
		bullet_scene.sprite.flip_h = true

var shoot_time_elapsed:float = 0
func _process(delta: float) -> void:
	shoot_time_elapsed += delta
	if shoot_hold && shoot_time_elapsed >= shoot_cooldown:
		shoot()
		shoot_time_elapsed = 0



_input(event)は便利だが, 一度のinputは一回か発動しない. つまり, 一回押して玉1個出る. これは流石にしんどいので, bool変数でtoggleし, 本ロジックは_processで毎フレーム発動させる. 変数一つを+deltaで経過時間を記録し, クールダウンを実装する.



敵スキル

さて, スライムちゃんにも似たような飛び道具スキルを追加する.

本当は重み付きテーブルを作って敵スキルの出し方にしたかったが, 流石に2種類だけでは割に合わないので, 普通の乱数で

func determine_action():
	var rand_num:float = randf()
	if rand_num < 0.6:
		trigger_jump()
		
	else:
		bubble_attack()
		can_perform_action = false

Final Touches


その後, ダメージ受けると赤くなったり, 数値を調整したりして, 今回の記事はここまでにする.

実際に, 狙う場所はマオス位置に指定するのは簡単にできる. とは言え, 今回はその分のアニメーションがないので, そうするとちょっと違和感あるため, 普通の向き場所に直線状打つことにしている.



以上. 次回はUI, 僕の不得意分野でもある.

コメントを残す

CAPTCHA