【Godot】ステートマシンで管理するAIとプレイヤーの状態

作成: 2025-12-06最終更新: 2025-12-16

enumとmatch文を使ったシンプルなステートマシンの実装から、状態クラスによる高度な設計パターンまで、複雑なキャラクターの行動を整理する方法を解説します。

概要

ゲーム開発が進むと、キャラクターのロジックはどんどん複雑になります。「プレイヤーは通常状態、歩き状態、ジャンプ状態、攻撃状態、ダメージ状態がある」「敵は巡回状態、追跡状態、攻撃状態、待機状態がある」。これらをif文やbool型の変数(is_jumping, is_attackingなど)だけで管理しようとすると、コードはすぐにスパゲッティのように絡み合い、バグの温床となります。

この問題を解決するための古典的かつ非常に強力なデザインパターンが、ステートマシン(状態機械) です。

ステートマシンとは?振る舞いを整理する設計図

ステートマシンとは、オブジェクトが取りうる「状態(ステート)」と、ある状態から別の状態へ「遷移(トランジション)」する条件を明確に定義することで、複雑な振る舞いを整理するモデルです。

  • ステート(State): オブジェクトの現在の振る舞いの種類。例:「待機(Idle)」「移動(Move)」「ジャンプ(Jump)」。各状態は、その状態でいる間に実行すべき処理を知っています。
  • トランジション(Transition): ある状態から別の状態へ移る「きっかけ」や「ルール」。例:「ジャンプボタンが押されたら、『待機』から『ジャンプ』へ」「HPが0になったら、どの状態からでも『死亡』へ」。

このモデルを用いることで、「ダメージ中は攻撃できない」といったルールを、「Damage状態ではAttack状態に遷移できない」という明確な形で設計に落とし込むことができます。

基本編: enummatchによるシンプルなステートマシン

Godotで最も手軽にステートマシンを実装できるのが、enum(列挙型)で状態を定義し、match文で処理を分岐させる方法です。

1. 状態の定義

まず、キャラクターが取りうる状態をenumで全て定義します。

# Enemy.gd
extends CharacterBody2D

# 状態をenumで定義。大文字で書くのが慣例
enum State { IDLE, WANDER, CHASE, ATTACK }

# 現在の状態を保持する変数
var current_state: State = State.IDLE

@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var timer: Timer = $Timer
var player: Node2D = null

const SPEED = 50.0

2. 状態遷移の管理

状態遷移のロジックを一元管理するために、change_stateという専用の関数を用意するのが定石です。

# 状態を変更する唯一の窓口
func change_state(new_state: State):
    if current_state == new_state:
        return

    current_state = new_state

    match current_state:
        State.IDLE:
            animated_sprite.play("idle")
            timer.start(2.0)
        State.WANDER:
            animated_sprite.play("walk")
        State.CHASE:
            animated_sprite.play("walk")
        State.ATTACK:
            animated_sprite.play("attack")

3. 各状態の実行処理

_physics_process内で、現在の状態に応じて各フレームの処理をmatch文で振り分けます。

func _physics_process(delta):
    match current_state:
        State.IDLE:
            _idle_state(delta)
        State.WANDER:
            _wander_state(delta)
        State.CHASE:
            _chase_state(delta)
        State.ATTACK:
            _attack_state(delta)

func _idle_state(delta):
    velocity = Vector2.ZERO
    if can_see_player():
        change_state(State.CHASE)

func _wander_state(delta):
    # うろつき処理
    if can_see_player():
        change_state(State.CHASE)

func _chase_state(delta):
    if player:
        var direction = global_position.direction_to(player.global_position)
        velocity = direction * SPEED
        move_and_slide()

    if can_attack_player():
        change_state(State.ATTACK)
    elif not can_see_player():
        change_state(State.IDLE)

func _attack_state(delta):
    velocity = Vector2.ZERO
    if not animated_sprite.is_playing():
        change_state(State.CHASE)

func _on_timer_timeout():
    if current_state == State.IDLE:
        change_state(State.WANDER)

この構造により、_physics_processは交通整理役に徹し、各状態の具体的な振る舞いと遷移条件はそれぞれの関数にきれいにまとまりました。


よくある間違いとベストプラクティス

よくある間違いベストプラクティス
if-elif-elseの乱用match文を積極的に利用する。 matchはコードの意図を明確にし、網羅性のチェックも行いやすい。
巨大なステート関数関数を細かく分割する。 例えば_chase_state内でも、移動処理、索敵処理、遷移判定などを別のヘルパー関数に分ける。
状態遷移ロジックの散在change_state関数に遷移処理を集約する。 状態が変わる際の初期化や後処理は、この関数で行うことで一貫性を保つ。
状態変数を直接書き換える必ずchange_state関数を通して状態を変更する。 これにより、意図しない状態遷移を防ぎ、デバッグが容易になる。
タイマーやシグナルの不使用時間経過やイベント(アニメーション終了など)による遷移には、Timerノードやシグナルを活用する。

応用編: 状態クラスによる高度な実装

enum/match方式はシンプルですが、状態が増えると1つのファイルが長大になります。そこで登場するのが、各状態を個別のクラス(ファイル)として実装するアプローチです。

1. Stateベースクラスの作成

# State.gd
class_name State
extends RefCounted  # メモリ管理を自動化するためRefCountedを継承

var character: Node

func enter():
    pass

func exit():
    pass

func process(delta):
    pass

func physics_process(delta):
    pass

注意: RefCountedを継承することで、参照がなくなった時点で自動的にメモリが解放されます。継承しない場合は手動で解放する必要があります。

2. 具体的な状態クラスの実装

# ChaseState.gd
extends State
class_name ChaseState

func enter():
    character.animated_sprite.play("walk")

func physics_process(delta):
    if not character.can_see_player():
        character.change_state(character.states["IDLE"])
        return

    if character.can_attack_player():
        character.change_state(character.states["ATTACK"])
        return

    if character.player:
        var direction = character.global_position.direction_to(character.player.global_position)
        character.velocity = direction * character.SPEED
        character.move_and_slide()

3. 本体スクリプトの修正

# Enemy.gd
extends CharacterBody2D

var states: Dictionary
var current_state: State

func _ready():
    states = {
        "IDLE": IdleState.new(),
        "CHASE": ChaseState.new(),
        "ATTACK": AttackState.new()
    }
    for state_name in states:
        states[state_name].character = self

    change_state(states["IDLE"])

func change_state(new_state: State):
    if current_state:
        current_state.exit()

    current_state = new_state
    current_state.enter()

func _physics_process(delta):
    if current_state:
        current_state.physics_process(delta)

パフォーマンスと代替パターン

  • パフォーマンス: enum/match方式も、状態クラス方式も、パフォーマンスへの影響はほとんどありません。match文はif-elifチェーンよりも高速に動作することが多く、クラスのメソッド呼び出しのオーバーヘッドも現代のPCやゲーム機では無視できるレベルです。

  • 代替パターン(ビヘイビアツリー): より複雑な意思決定ロジックには ビヘイビアツリー(Behavior Tree) という別の設計パターンが使われることもあります。ビヘイビアツリーは「目標を達成するために、どの行動を優先するか」を木構造で定義するのに適しています。

まとめ

ステートマシンは、複雑化するキャラクターのロジックを整理し、コードをクリーンに保つための必須テクニックです。if文のネストに悩まされたら、それはステートマシンを導入する絶好の機会です。

  • 基本: enummatchで手軽に実装。
  • 応用: 状態クラスでロジックをファイル分割し、保守性を最大化。
  • 鉄則: change_state関数で遷移を一元管理する。