Godotでステートマシンを実装する
3D空間を動き回るプレイヤーキャラクターのステートマシン化

Godotで3D空間をキー入力によって動き回るプレイヤーキャラクターの動作をステートマシン化します。
ステートマシンというのは有限オートマトンとも呼ばれる、有限個の状態と状態ごとの動作、状態間の遷移機能を持った機構のことです。
ここではプレイヤーキャラクターを待機・移動・ジャンプ・落下の4つの状態に分けてそれぞれの状態で動作の処理を行い、キー入力などの条件で状態間を遷移するようにします。
まずはステートマシンを使わずにCharacterBody3Dのスクリプトの_physics_process()関数内ですべての処理を行っている場合のコードを見てみましょう。
このコードはCharacterBody3D用スクリプトのテンプレートで自動生成されるものです。
extends CharacterBody3D
const SPEED = 5.0
const JUMP_VELOCITY = 4.5
# Get the gravity from the project settings to be synced with RigidBody nodes.
var gravity = ProjectSettings.get_setting("physics/3d/default_gravity")
func _physics_process(delta):
# Add the gravity.
if not is_on_floor():
velocity.y -= gravity * delta
# Handle jump.
if Input.is_action_just_pressed("ui_accept") and is_on_floor():
velocity.y = JUMP_VELOCITY
# Get the input direction and handle the movement/deceleration.
# As good practice, you should replace UI actions with custom gameplay actions.
var input_dir = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
var direction = (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
if direction:
velocity.x = direction.x * SPEED
velocity.z = direction.z * SPEED
else:
velocity.x = move_toward(velocity.x, 0, SPEED)
velocity.z = move_toward(velocity.z, 0, SPEED)
move_and_slide()
player.gd
CollisionShape3D等を追加したうえで上記をそのままアタッチすればキー操作で3D空間を動き回るキャラクターができますが、ここから機能を増やしていこうとするとif文やネストが増えて煩雑になっていきます。
そこでステートマシン化です。
ステートマシン化には、動かしたいCharacterBody3Dの子としてステートマシンノード、さらにその子として個々の状態をあらわすステートノードを追加する必要がありますが、それらはGodotに標準で搭載されていないので自前で作ります。
まずNodeを継承したStateMachineというステートマシン本体のスクリプトを書きます。
コードは以下です。
class_name StateMachine
extends Node
@export var current_state: State
var states: Dictionary = {}
func _ready():
for child in get_children():
if child is State:
states[child.name] = child
child.transitioned.connect(on_child_transitioned)
else:
push_warning("State machine contains child which is not 'State'")
current_state.Enter()
func _process(delta):
current_state.Update(delta)
func _physics_process(delta):
current_state.Physics_update(delta)
func on_child_transitioned(new_state_name: StringName) -> void:
var new_state = states.get(new_state_name)
if new_state != null:
if new_state != current_state:
current_state.Exit()
new_state.Enter()
current_state = new_state
else:
push_warning("Called transition on a state that does not exist")
state_machine.gd
_ready()関数で子のステートノードをすべて取得して辞書に登録し、_process()と_physics_process()では現在のステートの処理を実行、ステート切り替えのシグナルが送られてきたらon_child_transitioned()で指定されたステートに切り替えるという機能を持っています。
状態をあらわすステートのスクリプトはNodeを継承したStateを用意して、そこからそれぞれ継承して作ります。
継承元のStateクラスは以下のようなものです。
class_name State
extends Node
signal transitioned(new_state_name: StringName)
func Enter() -> void:
pass
func Exit() -> void:
pass
func Update(delta: float) -> void:
pass
func Physics_update(delta: float) -> void:
pass
state.gd
ここにはシグナルの宣言と空の関数だけを用意します。継承先で書き換えることでそれぞれの処理を実現します。
待機状態のステートクラスのスクリプトは以下です。
class_name IdleState
extends State
@export var actor: CharacterBody3D
func Physics_update(_delta):
var is_jump_just_pressed: bool = Input.is_action_just_pressed("jump")
var input_dir = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
if is_jump_just_pressed and actor.is_on_floor():
transitioned.emit("JumpState")
if not actor.is_on_floor():
transitioned.emit("FallState")
if input_dir:
transitioned.emit("RunState")
idle_state.gd
キー入力についてはわかりやすい名称のアクションをプロジェクト設定から追加した想定ですので適宜読み替えてください。
ここでは文字通りキー入力等による状態の変化を待機しています。
以下それぞれ移動・ジャンプ・落下のステートのスクリプトです。
class_name RunState
extends State
@export var actor: CharacterBody3D
@export var speed: float = 5.0
func Physics_update(_delta):
var input_dir = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var direction = (actor.transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
var is_jump_just_pressed: bool = Input.is_action_just_pressed("jump")
if not actor.is_on_floor():
transitioned.emit("FallState")
if is_jump_just_pressed and actor.is_on_floor():
transitioned.emit("JumpState")
if direction:
actor.velocity.x = direction.x * speed
actor.velocity.z = direction.z * speed
else:
actor.velocity.x = move_toward(actor.velocity.x, 0, speed)
actor.velocity.z = move_toward(actor.velocity.z, 0, speed)
if actor.velocity.x == 0 and actor.velocity.z == 0:
transitioned.emit("IdleState")
run_state.gd
class_name JumpState
extends State
@export var actor: CharacterBody3D
@export var jump_velocity: float = 4.5
var gravity = ProjectSettings.get_setting("physics/3d/default_gravity")
func Enter():
actor.velocity.y = jump_velocity
func Physics_update(_delta):
actor.velocity.y -= gravity * _delta
if actor.velocity.y < 0:
transitioned.emit("FallState")
jump_state.gd
class_name FallState
extends State
@export var actor: CharacterBody3D
var gravity = ProjectSettings.get_setting("physics/3d/default_gravity")
func Exit():
actor.velocity = Vector3.ZERO
func Physics_update(_delta):
actor.velocity.y -= gravity * _delta
if actor.is_on_floor():
transitioned.emit("IdleState")
fall_state.gd
移動キーが押されれば移動ステートに、ジャンプキーが押されればジャンプステートに、そしてジャンプの勢いがなくなった後や移動で床から外れた時などには落下ステートに、それぞれ遷移して処理を行います。
これらのスクリプトを作成・保存すると、CharacterBody3Dに子ノードを追加する際の選択肢として現れてくれるのでシーンツリーを以下のような構成にします。
CharacterBody3D
└StateMachine
├IdleState
├RunState
├JumpState
└FallState
それぞれのステートで@exportしたactor変数にはインスペクターから親のCharacterBody3Dを指定しておきましょう。
こうすることで、CharacterBody3D自体のスクリプトは以下のようにできます。
extends CharacterBody3D
func _physics_process(delta):
move_and_slide()
player.gd
ここまでで最初のコードとほぼ同じ挙動のままステートマシン化できました。厳密には空中での前後左右移動ができなくなっていますが、それもジャンプと落下のステートにそれぞれ処理を追加すれば再現できます。あるいはステートマシンそのものをふたつ用意して前後左右移動系とジャンプ系の状態を分離・併存させることでも再現できるでしょうか。
全体としてはコードの行数もスクリプトファイルの数も増えていますが、可読性の良さはこの状態でも十分わかるかと思います。ここから機能を拡張していくにしてもやりやすさは段違いでしょう。ゲームの規模が大きく、複雑になっていけばいくほど効果を実感できるはずです。
今回はプレイヤーの操作キャラクターでしたが、キー入力ではなく特定の条件下で動作を変える敵キャラクターの実装にこそこのステートマシンは威力を発揮すると思われます。
例えば、プレイヤーとの距離を測って遠くにいるときは追いかけてくる、逆に一定の距離に近付かれたら逃げる、攻撃範囲に入ったら攻撃する、というような具合です。
他にも色々応用ができそうですね。
参考文献
この記事を書くにあたって以下の記事を参考にさせていただきました。参考というか、ステートマシン本体などはコードそのままです。
以上です
ここまでお読みいただきありがとうございました。
よろしければ購読をお願いします。