mcpField
S
state-machine

state-machine

github.com/jame581開発929197 回インストール2 週間前に更新
リポジトリ

state-machine について

state-machine は、Godot 4.3 以降の有限ステートマシンを GDScript と C# で解説するスキルです。プロジェクトの複雑さに応じて、列挙型、ノード、リソース、階層型、並列型の設計を選ぶための指針を提供します。

主な機能

  1. 013種類の基本 FSM 方式を比較
  2. 02GDScript と C# の例
  3. 03階層型と並列型のパターン
  4. 04遷移とライフサイクルのチェックリスト

活用シーン

  1. 01キャラクター動作の方式選定
  2. 02エディター設定可能な AI ステートの設計
  3. 03大規模 FSM の関心事ごとの分割

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/jame581/godotprompter --skill state-machine

インストール後、Claude Code で/state-machineを入力して読み込まれたか確認します。

同梱ファイルreferences/hierarchical-and-parallel.md

詳細

Godot 4.3+ のステートマシン

複雑さに応じて適切な FSM パターンを選びましょう。すべての例は、非推奨 API を使わずに Godot 4.3+ を対象としています。

関連スキル: 移動状態の統合には player-controller、AI の状態パターンには ai-navigation、リソースベースの状態設定には resource-pattern、FSM で駆動する AnimationTree の状態には animation-system、ステートマシンとしての会話フローには dialogue-system、キャスターの状態制御(詠唱中/スタン中)には ability-system、FSM と併用するビヘイビアツリーが必要な場合の LimboAI アドオンの HSM(BTState)には limboai、GDScript のみで使える BT の代替には beehave を参照してください。

アドオンを選ぶ場合: このスキルでは、組み込みの FSM パターン(enum、ノードベース、リソースベース)を扱います。エージェントに完全なビヘイビアツリーが必要な場合は、代わりに limboai(C++ + HSM、Godot 4.6+)または beehave(GDScript のみ、Godot 4.1+)を参照してください。


1. アプローチの比較

アプローチ複雑さ最適な用途
Enum ベース低シンプルなオブジェクト、状態が 5 個未満
ノードベース中複雑な動作を持つキャラクター
リソースベース高データ駆動型またはエディターで設定可能な AI

推奨する際は、採用しなかったアプローチと、判断を決めた要因(状態数、独立した関心事、デザイナーによる編集)を明示してください。「7 つの状態それぞれに独自の enter/exit ロジックがあり、1 つのスクリプトでは手に負えなくなるため、match を使う enum ではなくノードベース」のようにします。代替案を明示しない選択は、覆すことができません。


2. アプローチ 1:Enum ベース(最もシンプル)

状態数が少なく、重要な enter/exit ロジックがない場合に使用します。

GDScript
extends CharacterBody2D

enum State { IDLE, PATROL, CHASE, ATTACK }

@export var patrol_range: float = 200.0
@export var chase_range: float = 300.0
@export var attack_range: float = 50.0
@export var speed: float = 80.0

var current_state: State = State.IDLE
var patrol_target: Vector2 = Vector2.ZERO

@onready var player: Node2D = get_tree().get_first_node_in_group("player")


func _physics_process(delta: float) -> void:
	match current_state:
		State.IDLE:
			_state_idle()
		State.PATROL:
			_state_patrol()
		State.CHASE:
			_state_chase()
		State.ATTACK:
			_state_attack()

	move_and_slide()


func _state_idle() -> void:
	velocity = Vector2.ZERO
	if _player_in_range(chase_range):
		current_state = State.CHASE
	elif randf() < 0.005:
		patrol_target = global_position + Vector2(randf_range(-patrol_range, patrol_range), 0.0)
		current_state = State.PATROL


func _state_patrol() -> void:
	var direction := (patrol_target - global_position)
	if direction.length() < 4.0:
		current_state = State.IDLE
		return
	velocity = direction.normalized() * speed
	if _player_in_range(chase_range):
		current_state = State.CHASE


func _state_chase() -> void:
	if not is_instance_valid(player):
		current_state = State.IDLE
		return
	if _player_in_range(attack_range):
		current_state = State.ATTACK
		return
	if not _player_in_range(chase_range):
		current_state = State.PATROL
		return
	velocity = (player.global_position - global_position).normalized() * speed


func _state_attack() -> void:
	velocity = Vector2.ZERO
	if not _player_in_range(attack_range):
		current_state = State.CHASE


func _player_in_range(range: float) -> bool:
	if not is_instance_valid(player):
		return false
	return global_position.distance_to(player.global_position) <= range
C# 相当
using Godot;

public partial class SimpleEnemy : CharacterBody2D
{
    private enum State { Idle, Patrol, Chase, Attack }

    [Export] public float PatrolRange { get; set; } = 200f;
    [Export] public float ChaseRange  { get; set; } = 300f;
    [Export] public float AttackRange { get; set; } = 50f;
    [Export] public float Speed       { get; set; } = 80f;

    private State _currentState = State.Idle;
    private Vector2 _patrolTarget = Vector2.Zero;
    private Node2D _player;

    public override void _Ready()
    {
        _player = GetTree().GetFirstNodeInGroup("player") as Node2D;
    }

    public override void _PhysicsProcess(double delta)
    {
        switch (_currentState)
        {
            case State.Idle:   StateIdle();   break;
            case State.Patrol: StatePatrol(); break;
            case State.Chase:  StateChase();  break;
            case State.Attack: StateAttack(); break;
        }
        MoveAndSlide();
    }

    private void StateIdle()
    {
        Velocity = Vector2.Zero;
        if (PlayerInRange(ChaseRange))
        {
            _currentState = State.Chase;
        }
        else if (GD.Randf() < 0.005f)
        {
            _patrolTarget = GlobalPosition + new Vector2((float)GD.RandRange(-PatrolRange, PatrolRange), 0f);
            _currentState = State.Patrol;
        }
    }

    private void StatePatrol()
    {
        var direction = _patrolTarget - GlobalPosition;
        if (direction.Length() < 4f) { _currentState = State.Idle; return; }
        Velocity = direction.Normalized() * Speed;
        if (PlayerInRange(ChaseRange)) _currentState = State.Chase;
    }

    private void StateChase()
    {
        if (!IsInstanceValid(_player)) { _currentState = State.Idle; return; }
        if (PlayerInRange(AttackRange)) { _currentState = State.Attack; return; }
        if (!PlayerInRange(ChaseRange)) { _currentState = State.Patrol; return; }
        Velocity = (_player.GlobalPosition - GlobalPosition).Normalized() * Speed;
    }

    private void StateAttack()
    {
        Velocity = Vector2.Zero;
        if (!PlayerInRange(AttackRange)) _currentState = State.Chase;
    }

    private bool PlayerInRange(float range) =>
        IsInstanceValid(_player) && GlobalPosition.DistanceTo(_player.GlobalPosition) <= range;
}

Enum ベースから切り替えるタイミング:

  • enter/exit ロジックが状態メソッド間で重複し始める
  • アニメーションとの同期に明示的な enter/exit フックが必要になる
  • match/switch ブロックが約 100 行を超える

3. アプローチ 2:ノードベース(キャラクターに推奨)

各状態を独立したノードにします。StateMachine ノードは、現在アクティブな状態に入力処理とプロセス呼び出しを委譲し、状態は名前を指定して遷移を開始します。

シーンツリー
Player (CharacterBody2D)
└── StateMachine (Node)
    ├── Idle  (State)
    ├── Run   (State)
    ├── Jump  (State)
    └── Attack (State)
State 基底クラス

GDScript(state.gd)

class_name State
extends Node

## Populated by StateMachine._ready()
var entity: CharacterBody2D
var state_machine: StateMachine


## Called when this state becomes active.
func enter() -> void:
	pass


## Called when this state is deactivated.
func exit() -> void:
	pass


## Mirrors _process. Return a state name string to transition, or "" to stay.
func update(delta: float) -> String:
	return ""


## Mirrors _physics_process. Return a state name string to transition, or "".
func physics_update(delta: float) -> String:
	return ""


## Mirrors _unhandled_input.
func handle_input(event: InputEvent) -> String:
	return ""

C#(State.cs)

using Godot;

public partial class State : Node
{
    /// Populated by StateMachine._Ready()
    public CharacterBody2D Entity { get; set; }
    public StateMachine StateMachine { get; set; }

    public virtual void Enter() { }
    public virtual void Exit() { }
    public virtual string Update(double delta) => string.Empty;
    public virtual string PhysicsUpdate(double delta) => string.Empty;
    public virtual string HandleInput(InputEvent @event) => string.Empty;
}
StateMachine クラス

GDScript(state_machine.gd)

class_name StateMachine
extends Node

@export var initial_state: State

var current_state: State
var states: Dictionary = {}


func _ready() -> void:
	for child in get_children():
		if child is State:
			states[child.name] = child
			child.entity = owner as CharacterBody2D
			child.state_machine = self

	if initial_state:
		current_state = initial_state
		current_state.enter()


func _unhandled_input(event: InputEvent) -> void:
	var next := current_state.handle_input(event)
	if next:
		transition_to(next)


func _process(delta: float) -> void:
	var next := current_state.update(delta)
	if next:
		transition_to(next)


func _physics_process(delta: float) -> void:
	var next := current_state.physics_update(delta)
	if next:
		transition_to(next)


func transition_to(state_name: String) -> void:
	if not states.has(state_name):
		push_error("StateMachine: unknown state '%s'" % state_name)
		return
	current_state.exit()
	current_state = states[state_name]
	current_state.enter()

C#(StateMachine.cs)

using System.Collections.Generic;
using Godot;

public partial class StateMachine : Node
{
    [Export] public State InitialState { get; set; }

    public State CurrentState { get; private set; }
    private readonly Dictionary<string, State> _states = new();

    public override void _Ready()
    {
        foreach (var child in GetChildren())
        {
            if (child is State state)
            {
                _states[state.Name] = state;
                state.Entity = Owner as CharacterBody2D;
                state.StateMachine = this;
            }
        }

        if (InitialState != null)
        {
            CurrentState = InitialState;
            CurrentState.Enter();
        }
    }

    public override void _UnhandledInput(InputEvent @event)
    {
        var next = CurrentState.HandleInput(@event);
        if (!string.IsNullOrEmpty(next)) TransitionTo(next);
    }

    public override void _Process(double delta)
    {
        var next = CurrentState.Update(delta);
        if (!string.IsNullOrEmpty(next)) TransitionTo(next);
    }

    public override void _PhysicsProcess(double delta)
    {
        var next = CurrentState.PhysicsUpdate(delta);
        if (!string.IsNullOrEmpty(next)) TransitionTo(next);
    }

    public void TransitionTo(string stateName)
    {
        if (!_states.TryGetValue(stateName, out var next))
        {
            GD.PushError($"StateMachine: unknown state '{stateName}'");
            return;
        }
        CurrentState.Exit();
        CurrentState = next;
        CurrentState.Enter();
    }
}
具体例:IdleState

GDScript(idle_state.gd)

class_name IdleState
extends State


func enter() -> void:
	entity.get_node("AnimationPlayer").play("idle")


func physics_update(delta: float) -> String:
	if not entity.is_on_floor():
		return "Jump"
	if Input.get_axis("move_left", "move_right") != 0.0:
		return "Run"
	return ""


func handle_input(event: InputEvent) -> String:
	if event.is_action_pressed("jump") and entity.is_on_floor():
		return "Jump"
	if event.is_action_pressed("attack"):
		return "Attack"
	return ""

4. アプローチ 3:リソースベース(データ駆動型)

コードを変更せずに、デザイナーが Godot インスペクターで状態を設定する必要がある場合に使用します。

StateData リソース
class_name StateData
extends Resource

@export var state_name: String = ""
@export var animation_name: String = ""
@export var move_speed: float = 0.0
@export var can_transition_to: Array[String] = []

AI コントローラーで Array[StateData] をエクスポートします。デザイナーはインスペクターで各項目を設定できるため、動作の調整や状態の追加にコードの変更は必要ありません。実行時には can_transition_to を読み取って遷移を検証し、アクティブな各状態に応じて animation_name/move_speed を選択します。

using Godot;

[GlobalClass]
public partial class StateData : Resource
{
    [Export] public string StateName { get; set; } = string.Empty;
    [Export] public string AnimationName { get; set; } = string.Empty;
    [Export] public float MoveSpeed { get; set; } = 0f;
    [Export] public Godot.Collections.Array<string> CanTransitionTo { get; set; } = new();
}

AI コントローラークラスで Array[StateData] をエクスポートします([Export] public Godot.Collections.Array<StateData> States)。実行時には、StateName を使ってアクティブな StateData を検索し、AnimationName/MoveSpeed を読み取って動作を駆動します。TransitionTo の呼び出しを制御するには、CanTransitionTo を使用します。


5. 階層型および並列ステートマシン

フラットな FSM が約 8 個を超える状態に拡大した場合や、複数の関心事(移動 + 戦闘 + アニメーション)にまたがる場合は、階層型マシン(各状態がサブステートマシンを持つ。例:OnGround が Idle/Walk/Run を含む)または並列マシン(移動、戦闘、アニメーション用の独立した FSM を並行して実行)に分割します。どちらも状態数が乗算ではなく加算となる構成を保てます。

完全なシーンツリー、HierarchicalState 基底クラス、並列マシンを使ったキャラクターの例、「どちらを選ぶか」の比較表については、references/hierarchical-and-parallel.md を参照してください。それぞれ GDScript と C# の例があります。

6. 判断フローチャート

Start
  │
  ▼
Fewer than 5 states?
  ├─ Yes ──────────────────────────────────► Enum-Based
  └─ No
       │
       ▼
     Multiple independent concerns
     (movement + combat + animation)?
       ├─ Yes ──────────────────────────────► Parallel State Machines
       └─ No
            │
            ▼
          States naturally nest
          (sub-states within states)?
            ├─ Yes ────────────────────────► Hierarchical State Machine
            └─ No
                 │
                 ▼
               Designers need to configure
               states in the Inspector?
                 ├─ Yes ──────────────────► Resource-Based
                 └─ No ──────────────────► Node-Based

7. 実装チェックリスト

  • 実際の複雑さに合ったアプローチ(enum/ノード/リソース)を選択した
  • 採用しなかったアプローチと、その判断要因を明記した
  • すべての状態に明示的な enter() メソッドと exit() メソッド(または同等のもの)がある
  • すべての遷移を明示的に指定した — 状態間の暗黙的なフォールスルーがない
  • 必要に応じて、アニメーションを enter() で開始し、exit() でクリーンアップする
  • 1フレーム内で無限再帰を引き起こす可能性のある循環遷移ループがない
  • 状態数が約8を超えるか、複数の関心事にまたがる場合、フラットなFSMを階層型または並列型に置き換える
  • 並列ステートマシンが同じ状態(例:両方が速度を設定する)を変更しない — 1つのステートマシンにつき1つの関心事とする

コメント