敵AIに「待機」「巡回」「追跡」「攻撃」を足したら、if 文がどんどん増える。プレイヤーに「移動」「ジャンプ」「攻撃」「被ダメージ」を足したら、is_attacking と is_damaged が同時にtrueになって変な挙動になる。
こうした状態管理の混乱を整理するのが ステートマシン です。難しい設計パターンに見えますが、最初は「今の状態は1つだけ」と決めるだけでも効果があります。
この記事でわかること
- bool管理が破綻しやすい理由
enumとmatchで作る小さなステートマシン- 敵AIの
Idle/Patrol/Chase/Attackの実装- 状態クラス方式へ移行する判断基準
ステートマシンは「今の状態を1つに決める」設計
ステートマシンは、キャラクターや敵 が取りうる状態と、状態が切り替わる条件を整理する考え方です。

敵AIなら、たとえば次のように考えます。
- Idle: その場で待つ。
- Patrol: 決められた範囲を歩く。
- Chase: プレイヤーを見つけて追いかける。
- Attack: 距離が近いので攻撃する。
重要なのは、敵が同時に Patrol と Attack にはならないことです。今どの状態なのかを1つに決め、状態ごとの処理を分けます。
bool地獄を避ける
状態管理を bool だけで始めると、最初は簡単です。
var is_jumping := false
var is_attacking := false
var is_damaged := false
ただし状態が増えるほど、「攻撃中にダメージを受けたら?」「ダメージ中にジャンプ入力が来たら?」「死亡中に攻撃アニメーションが終わったら?」のような組み合わせが爆発します。

ステートマシンでは、現在の状態を1つだけ持ちます。
enum State {
IDLE,
RUN,
ATTACK,
DAMAGE,
}
var current_state: State = State.IDLE
これだけで、「今は攻撃中なのか、被ダメージ中なのか」が明確になります。
実践:enumとmatchで敵AIを作る
まずは小さく、enum と match で敵AIを作ります。
# Enemy.gd
extends CharacterBody2D
class_name Enemy
enum State {
IDLE,
PATROL,
CHASE,
ATTACK,
}
@export var speed: float = 80.0
@export var attack_range: float = 32.0
var current_state: State = State.IDLE
var player: Node2D
var patrol_direction := Vector2.RIGHT
@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var idle_timer: Timer = $IdleTimer
func _ready() -> void:
# 初期状態もchange_state()を通して、入場処理をそろえる
change_state(State.IDLE)
func _physics_process(delta: float) -> void:
# ここでは状態ごとの処理を呼び分けるだけにする
match current_state:
State.IDLE:
_state_idle(delta)
State.PATROL:
_state_patrol(delta)
State.CHASE:
_state_chase(delta)
State.ATTACK:
_state_attack(delta)
このコードの関係を図にすると、current_state を見て、match が対応する状態関数へ振り分けているだけです。

_physics_process() は交通整理だけにします。状態ごとの処理は別関数へ逃がすと、読みやすさが保てます。
func _state_idle(delta: float) -> void:
velocity = Vector2.ZERO
# プレイヤーを見つけたら追跡へ移る
if can_see_player():
change_state(State.CHASE)
func _state_patrol(delta: float) -> void:
velocity = patrol_direction * speed
move_and_slide()
if can_see_player():
change_state(State.CHASE)
func _state_chase(delta: float) -> void:
if player == null:
# 追う対象がいなければ巡回へ戻す
change_state(State.PATROL)
return
var direction := global_position.direction_to(player.global_position)
velocity = direction * speed
move_and_slide()
if global_position.distance_to(player.global_position) <= attack_range:
# 攻撃範囲に入ったら攻撃状態へ
change_state(State.ATTACK)
elif not can_see_player():
change_state(State.PATROL)
func _state_attack(delta: float) -> void:
velocity = Vector2.ZERO
if player == null:
change_state(State.PATROL)
return
if global_position.distance_to(player.global_position) > attack_range:
change_state(State.CHASE)
状態が増えても、どの関数に処理を書くべきかが明確になります。
状態遷移はchange_stateに集約する
状態を変えるときは、current_state = State.CHASE と直接書かず、専用関数を通します。

func change_state(next_state: State) -> void:
if current_state == next_state:
return
# 出る処理、状態更新、入る処理を必ず同じ順番にする
_exit_state(current_state)
current_state = next_state
_enter_state(current_state)
状態に入った瞬間の処理と、抜ける瞬間の処理を分けます。
func _enter_state(state: State) -> void:
# 状態に入った瞬間だけ必要な処理をまとめる
match state:
State.IDLE:
sprite.play("idle")
idle_timer.start(1.5)
State.PATROL:
sprite.play("walk")
State.CHASE:
sprite.play("run")
State.ATTACK:
sprite.play("attack")
func _exit_state(state: State) -> void:
# 状態を抜ける時の片付けをまとめる
if state == State.IDLE:
idle_timer.stop()
この形にしておくと、「攻撃状態に入った瞬間だけアニメーションを再生する」「待機状態を抜けたらTimerを止める」といった処理を一箇所にまとめられます。
func _on_idle_timer_timeout() -> void:
# Timerの通知でも、現在状態を確認してから遷移する
if current_state == State.IDLE:
change_state(State.PATROL)
Timerやシグナルから状態を変える場合も、必ず change_state() を通します。
状態クラスへ分けるタイミング
enum + match は小さく始めるには最適です。ただし、状態ごとの処理が長くなってきたら、状態をファイルに分ける方法を検討します。

状態クラス方式では、各状態を IdleState.gd、ChaseState.gd のように分けます。
# State.gd
class_name State
extends Node
var owner_enemy: Enemy
func enter() -> void:
pass
func exit() -> void:
pass
func physics_update(delta: float) -> void:
pass
ChaseState は追跡だけを担当します。
# ChaseState.gd
extends State
func enter() -> void:
owner_enemy.sprite.play("run")
func physics_update(delta: float) -> void:
if owner_enemy.player == null:
owner_enemy.change_state("Patrol")
return
var direction := owner_enemy.global_position.direction_to(owner_enemy.player.global_position)
owner_enemy.velocity = direction * owner_enemy.speed
owner_enemy.move_and_slide()
if owner_enemy.can_attack_player():
owner_enemy.change_state("Attack")
クラス方式はファイルが増えるぶん、最初は少し重いです。状態が4個程度で処理も短いなら、enum + match の方が読みやすいことも多いです。
AnimationTreeとの関係
Godotの AnimationTree にもStateMachineがあります。これは主に アニメーションの状態 を管理する機能です。
この記事のステートマシンは、敵AIやプレイヤー操作など ゲームロジックの状態 を管理します。実制作では、ロジック側の状態が変わったタイミングで、AnimationTree側のアニメーションも切り替えます。
func _enter_state(state: State) -> void:
match state:
State.IDLE:
animation_tree["parameters/playback"].travel("Idle")
State.CHASE:
animation_tree["parameters/playback"].travel("Run")
State.ATTACK:
animation_tree["parameters/playback"].travel("Attack")
アニメーション側の詳しい制御は、 AnimationTreeとステートマシン につながります。
よくあるつまずき
状態変数を直接書き換える
current_state = State.ATTACK をあちこちに書くと、アニメーション再生やTimer停止などの入口/出口処理が抜けます。状態変更は change_state() に集約します。
1つの状態関数が巨大になる
_state_chase() が100行を超えるなら、索敵、移動、攻撃判定をヘルパー関数へ分けます。それでもつらければ状態クラス方式へ移行します。
何でもステートマシンにする
スイッチのON/OFFや、 一度だけ開く宝箱のような単純な状態に大きなステートマシンを作る必要はありません。状態が2つだけで分岐も少ないなら、is_opened のようなboolで十分です。
状態とアニメーションを混同する
Attack 状態だからといって、必ず1つの攻撃アニメーションだけとは限りません。コンボ攻撃、武器種、向きによってアニメーションが分かれることもあります。ロジックの状態とアニメーションの状態は、必要に応じて分けて考えましょう。
おまけ:ビヘイビアツリーを検討する場面
ステートマシンは「今の状態が何か」を整理するのに向いています。一方、敵AIが「隠れる」「回復する」「仲間を呼ぶ」「プレイヤーの背後に回る」のように、優先順位で行動を選ぶようになると、状態遷移だけでは複雑になります。
その場合は ビヘイビアツリー という考え方が候補になります。これは「条件を見て、どの行動を優先するか」を木構造で決める設計です。Godot学習パスでは、後続の ビヘイビアツリーでAIを設計 で扱います。
まとめ
ステートマシンは、プレイヤーや敵AIの状態を整理するための基本パターンです。
is_attacking、is_damagedなどのboolが増えたら導入を検討する。- まずは
enumとmatchで小さく始める。 - 状態変更は
change_state()に集約する。 - 状態ごとの処理が大きくなったらStateクラスへ分ける。
- AnimationTreeのStateMachineとは役割を分けて考える。