【Unity】Unity設計パターン:Stateパターンで複雑なAIやキャラクターの状態を管理する

作成: 2025-12-07最終更新: 2026-07-13

if文やswitch文のネスト地獄から脱却しよう。オブジェクト指向の古典的な設計パターンである「Stateパターン」を使い、キャラクターの複雑な状態(待機、攻撃、防御、逃走など)を、クリーンで拡張性の高いステートマシンとして実装する方法を解説します。

敵AIに「巡回」「追跡」「攻撃」と状態を足していったら、Updateメソッドがif-elseの迷路になった——状態を持つキャラクターを作ると、誰もが一度はこの「ネスト地獄」に迷い込みます。状態が5つを超えたあたりで、もう自分でも読めません。

この混沌を整理する古典にして最強の道具が Stateパターン(ステートマシン) です。核心はただ一つ、「状態そのものをクラスにする」こと。この記事では、敵AIを題材に、IStateインターフェースを使ったクリーンなステートマシンの実装を解説します。

ステートマシンのイメージ。状態を表す部屋の間を、キャラクターが遷移の通路で行き来している

この記事でわかること

  • if-elseの肥大化がなぜ起きるのか、Stateパターンが何を解決するのか
  • Context / IState / 具象状態クラスという3つの構成要素
  • OnEnter / OnUpdate / OnExit を持つ敵AIステートマシンの実装
  • 状態の追加が「クラスを1個足すだけ」になる拡張性の正体

Sponsored

ネスト地獄とStateパターンの解決策

プレイヤーキャラクターや敵AIは、ゲームの状況に応じて様々な「状態(ステート)」を持ちます。例えば、敵AIは「巡回状態」「プレイヤー追跡状態」「攻撃状態」「待機状態」「逃走状態」などを切り替えながら行動します。まず、これをif-elseで書いた場合と、Stateパターンで書いた場合の構造を見比べてください。

if-elseのネスト地獄とStateパターンの比較図。左は1つの巨大なUpdateの中に条件分岐が絡み合い、右は巡回・追跡・攻撃がそれぞれ独立した部屋(クラス)に分かれ、矢印(遷移)で結ばれている

左の書き方が、悪名高い「巨大Update」です。

// 悪い例:巨大なUpdateメソッド
void Update()
{
    if (state == "Patrol")
    {
        // 巡回処理
        if (CanSeePlayer())
        {
            state = "Chase";
        }
    }
    else if (state == "Chase")
    {
        // 追跡処理
        if (IsInAttackRange())
        {
            state = "Attack";
        }
        else if (!CanSeePlayer())
        {
            state = "Patrol";
        }
    }
    else if (state == "Attack")
    {
        // 攻撃処理
        if (!IsInAttackRange())
        {
            state = "Chase";
        }
    }
    // ... さらに状態が増えていく
}

状態が増えるたびにelse ifが伸び、遷移条件が絡み合い、1箇所の修正が別の状態を壊すようになります。

このような「状態に応じた振る舞いの違い」をエレガントに管理するのが Stateパターン です。核心は、「状態」そのものをクラスとして表現し、状態ごとの振る舞いをそのクラスのメソッドとして実装することです。そして、状態を持つ本体(コンテキスト)は、現在の状態オブジェクトへの参照を保持し、処理をその状態オブジェクトに「委譲」します。

Stateパターンの構成要素

Stateパターンは、主に3つの要素で構成されます。

Stateパターンの構成要素の図。Context(敵AI本体)が現在の状態への参照を1つだけ持ち、IStateインターフェースを実装したPatrol・Chase・Attackの各状態クラスと差し替え可能につながっている
  1. Context (コンテキスト): 状態を持つ本体。この例では敵AIキャラクター。現在の状態オブジェクト (IState) への参照を保持し、状態遷移の責任を持ちます。
  2. IState (インターフェース): すべての状態クラスが実装すべき共通のインターフェースを定義します。例えば、OnEnter()(その状態に入った時の処理)、OnUpdate()(その状態での毎フレームの処理)、OnExit()(その状態から出る時の処理)といったメソッドを定義します。
  3. Concrete State (具象状態): IStateインターフェースを実装した具体的な状態クラス。PatrolState, ChaseState, AttackStateなどがこれにあたります。各クラスは、その状態における具体的な振る舞いを実装します。

補足: OnEnterOnUpdateOnExitという3点セットは、「部屋に入るとき・部屋にいる間・部屋を出るとき」と考えると覚えやすいです。アニメーション開始は入室時、移動処理は在室中、エフェクトの後始末は退室時——と、処理の置き場所が自然に決まります。

Unityでの実装例:敵AIのステートマシン

ここからは、実際に動く敵AIを組み立てます。完成形の全体像はこうなります。

敵AIステートマシンの実例図。巡回状態の敵がプレイヤーを発見すると追跡状態へ、攻撃範囲に入ると攻撃状態へ、見失うと巡回へ戻る遷移サイクル

Step 1: IStateインターフェースの定義

まず、すべての状態クラスの設計図となるインターフェースを定義します。

// IState.cs
public interface IState
{
    // この状態に入った時に一度だけ呼ばれる
    void OnEnter(EnemyAI context);

    // この状態である間、毎フレーム呼ばれる
    void OnUpdate();

    // この状態から出る時に一度だけ呼ばれる
    void OnExit();
}
Sponsored

Step 2: 具象状態クラスの実装

次に、具体的な状態クラスをそれぞれ作成します。各状態クラスは、自分自身のロジックと、他の状態へ遷移する条件のチェックを行います。

// PatrolState.cs
using UnityEngine;

public class PatrolState : IState
{
    private EnemyAI enemy;

    public void OnEnter(EnemyAI context)
    {
        this.enemy = context;
        Debug.Log("巡回状態に移行");
        // 巡回アニメーションを開始するなどの処理
    }

    public void OnUpdate()
    {
        // 巡回ロジックをここに実装

        // プレイヤーを発見したら、追跡状態に遷移する
        if (enemy.CanSeePlayer())
        {
            enemy.ChangeState(new ChaseState());
        }
    }

    public void OnExit()
    {
        // 巡回アニメーションを停止するなどの処理
    }
}

// ChaseState.cs
using UnityEngine;

public class ChaseState : IState
{
    private EnemyAI enemy;

    public void OnEnter(EnemyAI context)
    {
        this.enemy = context;
        Debug.Log("追跡状態に移行");
    }

    public void OnUpdate()
    {
        // 追跡ロジック(プレイヤーを追いかける)をここに実装

        // 攻撃範囲内に入ったら、攻撃状態に遷移
        if (enemy.IsInAttackRange())
        {
            enemy.ChangeState(new AttackState());
        }
        // プレイヤーを見失ったら、巡回状態に戻る
        else if (!enemy.CanSeePlayer())
        {
            enemy.ChangeState(new PatrolState());
        }
    }

    public void OnExit() { }
}

// AttackState, etc... も同様に実装

Step 3: Contextクラスの実装

最後に、状態を管理する本体であるEnemyAIクラスを実装します。このクラスは、現在の状態を保持し、Update処理を現在の状態オブジェクトに委譲します。

// EnemyAI.cs
using UnityEngine;

public class EnemyAI : MonoBehaviour
{
    private IState currentState;

    void Start()
    {
        // 初期状態を設定
        ChangeState(new PatrolState());
    }

    void Update()
    {
        // 現在の状態のUpdate処理を呼び出す
        if (currentState != null)
        {
            currentState.OnUpdate();
        }
    }

    // 状態を切り替えるメソッド
    public void ChangeState(IState nextState)
    {
        // 現在の状態があれば、終了処理を呼び出す
        if (currentState != null)
        {
            currentState.OnExit();
        }

        // 新しい状態に切り替え、初期化処理を呼び出す
        currentState = nextState;
        currentState.OnEnter(this);
    }

    // 状態クラスから使われるヘルパーメソッド群
    // ここでは骨組みを示すための仮実装。次の実践セクションで「本物」に置き換えます
    public bool CanSeePlayer() { /* プレイヤーが見えるかどうかの判定ロジック */ return false; }
    public bool IsInAttackRange() { /* 攻撃範囲内かどうかの判定ロジック */ return false; }
}

EnemyAIUpdateが「現在の状態に丸投げするだけ」の数行になった点に注目してください。状態ごとの複雑さは、それぞれの状態クラスに閉じ込められました。

Sponsored

Stateパターンのメリット

このパターンの最大のご利益は、「状態の追加」が既存コードを壊さないことです。

状態追加の拡張性の図。稼働中のPatrol・Chase・Attackの隣に、新しいFlee(逃走)状態のクラスを1個置くだけで追加が完了し、既存の状態クラスには手を入れない
  • 関心の分離: 各状態のロジックが、それぞれのクラスに完全に分離されます。EnemyAIクラスは、状態ごとの詳細な振る舞いを知る必要がなくなります。
  • 拡張性: 新しい状態(例: FleeState - 逃走状態)を追加したい場合、IStateを実装した新しいクラスを作成するだけで済み、既存のコードへの影響を最小限に抑えられます。
  • 可読性と保守性: if-elseのネスト地獄がなくなり、コードが非常にクリーンで読みやすくなります。各状態の振る舞いを修正したい場合も、対応するクラスを修正するだけです。

実践:巡回→発見→追跡→見失うまで動く警備員AI

骨組みができたので、CanSeePlayer()を本物にして「動く警備員」を完成させます。ステルスゲームの見張り、ホラーゲームの徘徊者、アクションゲームの哨戒兵——どれも中身はこの1体の応用です。目標の挙動は「 巡回中にプレイヤーを発見→追跡→見失っても2秒は粘る→最後に見た場所を調べる→いなければ巡回へ戻る 」です。

警備員AIの行動サイクル。巡回中にプレイヤーを発見し、追跡し、壁の裏に見失ったら2秒ねばって捜索し、巡回へ戻る4コマの流れ

まずEnemyAIを完成させます。ポイントは2つ——「距離+遮蔽Raycast」の最小の視界判定と、 状態インスタンスの使い回し(遷移のたびにnewしない)です。

// EnemyAI.cs(完成版)
using UnityEngine;

public class EnemyAI : MonoBehaviour
{
    [Header("感知設定")]
    public Transform player;
    public float sightRange = 8f;      // 気づける距離
    public LayerMask obstacleMask;     // 壁など遮蔽物のレイヤー

    [Header("移動設定")]
    public float moveSpeed = 2f;       // 巡回・捜索の速さ
    public float chaseSpeed = 4f;      // 追跡の速さ

    // 追跡・捜索が使う「最後に見た場所」
    public Vector3 LastKnownPosition { get; set; }

    // 状態は最初に1セット作って使い回す(毎回newしない)
    public PatrolState Patrol { get; private set; }
    public ChaseState Chase { get; private set; }
    public SearchState Search { get; private set; }

    private IState currentState;

    void Awake()
    {
        Patrol = new PatrolState();
        Chase = new ChaseState();
        Search = new SearchState();
    }

    void Start() { ChangeState(Patrol); }

    void Update() { currentState?.OnUpdate(); }

    public void ChangeState(IState nextState)
    {
        currentState?.OnExit();
        currentState = nextState;
        currentState.OnEnter(this);
    }

    // 本物の視界判定: 「距離内」かつ「間に壁がない」なら見えている
    public bool CanSeePlayer()
    {
        Vector3 toPlayer = player.position - transform.position;
        if (toPlayer.magnitude > sightRange) return false;

        // 壁に遮られていたら見えない
        if (Physics.Raycast(transform.position, toPlayer.normalized,
                toPlayer.magnitude, obstacleMask)) return false;

        LastKnownPosition = player.position; // 見えている間は更新し続ける
        return true;
    }

    // 現在の状態を色で見えるようにするヘルパー(各状態のOnEnterから呼ぶ)
    public void SetStateColor(Color color)
    {
        GetComponentInChildren<Renderer>().material.color = color;
    }
}

次に、この記事の主役である「見失っても粘る」ChaseStateと、「調べに行く」SearchStateです。

// ChaseState.cs(見失い猶予つき)
using UnityEngine;

public class ChaseState : IState
{
    private EnemyAI enemy;
    private float lostTimer; // 見失ってからの経過時間

    public void OnEnter(EnemyAI context)
    {
        enemy = context;
        lostTimer = 0f; // 使い回すので、入室のたびに必ずリセット
        enemy.SetStateColor(Color.red);
    }

    public void OnUpdate()
    {
        if (enemy.CanSeePlayer())
        {
            lostTimer = 0f; // 見えている間は猶予をリセット
        }
        else
        {
            // 見失った瞬間には諦めない。2秒だけ粘る
            lostTimer += Time.deltaTime;
            if (lostTimer >= 2f)
            {
                enemy.ChangeState(enemy.Search);
                return;
            }
        }

        // 最後に見た場所へ向かう(猶予中も走り続ける)
        enemy.transform.position = Vector3.MoveTowards(
            enemy.transform.position, enemy.LastKnownPosition,
            enemy.chaseSpeed * Time.deltaTime);
    }

    public void OnExit() { }
}
// SearchState.cs — 最後に見た場所を調べて、いなければ巡回へ
using UnityEngine;

public class SearchState : IState
{
    private EnemyAI enemy;
    private float searchTimer;

    public void OnEnter(EnemyAI context)
    {
        enemy = context;
        searchTimer = 0f;
        enemy.SetStateColor(Color.yellow);
    }

    public void OnUpdate()
    {
        // 調べに行く途中で再発見したら追跡へ
        if (enemy.CanSeePlayer())
        {
            enemy.ChangeState(enemy.Chase);
            return;
        }

        // 最後に見た場所へ移動し、着いたら3秒だけ調べる
        enemy.transform.position = Vector3.MoveTowards(
            enemy.transform.position, enemy.LastKnownPosition,
            enemy.moveSpeed * Time.deltaTime);

        if (Vector3.Distance(enemy.transform.position, enemy.LastKnownPosition) < 0.1f)
        {
            searchTimer += Time.deltaTime;
            if (searchTimer >= 3f) enemy.ChangeState(enemy.Patrol);
        }
    }

    public void OnExit() { }
}

PatrolStateは、Step 2のものにenemy.SetStateColor(Color.white)(OnEnter)を足し、遷移先をenemy.ChangeState(enemy.Chase)に変えるだけです(巡回ポイント間の移動はMoveTowardsで同様に書けます)。壁になるCubeを1つ置いてobstacleMaskにそのレイヤーを設定し、プレイヤーを動かしてみてください。 敵が白→赤→黄→白と色を変えながら、発見・追跡・捜索・帰還のドラマを演じ始めます。視界判定を円錐(FOV)にしたくなったら、視界判定(FOV)の記事 の部品がそのまま差し込めます。

わざと壊して、直す

ステートマシン特有の事故を3つ、この警備員で再現してみましょう。

  1. 境界のちらつき(1フレーム往復): ChaseStateの猶予を消して「見えなくなったら即Patrolへ」にすると、視界距離ぎりぎりに立ったプレイヤーに対して敵が 毎フレームPatrol⇄Chaseを往復 し、Consoleが遷移ログの洪水になります。時間の猶予か、「気づく距離8m・諦める距離10m」のように 入りと出の条件に差をつける と安定します
  2. OnExitを呼ばず後始末が残る: 「Chaseの間だけ移動速度を2倍にする」をOnEnterで書き、ChangeStatecurrentState?.OnExit()をコメントアウトしてみてください。巡回に戻った敵が 全力疾走で巡回し続けます。入室時に変えたものは退室時に戻す——OnExitは「部屋の電気を消す」処理です
  3. 状態インスタンスの共有で混線: 敵を3体置くとき、staticにした状態インスタンスを全員で共有すると、lostTimerのような一時データが混線します——敵Aの猶予タイマーを敵Bの視界が巻き戻す、という追いにくいバグです。 「使い回し」は同じ敵の中での話。状態セットは敵ごとにAwakeで作ります(上のコードの形)

仕上げにPlayして、追いかけられている途中で壁の裏に隠れてみてください。警備員はすぐには諦めず、2秒粘ってから最後に見た場所を調べに来て、やがて何事もなかったように巡回へ戻っていく——この一連の「人間くささ」が見えれば完成です。敵を3体に増やしても、それぞれが自分のタイミングで動いていれば、状態の使い回しもうまくいっています。

おまけ:先に知っておくと良いこと

ステートマシンを組めるようになったら、次はこのあたりが視野に入ってきます。

  • Animatorも実はステートマシン: UnityのAnimator Controllerは、アニメーション版のステートマシンです。本記事の考え方がそのままGUIになっており、Animator Controllerの記事 で学べます。「ロジックのステートマシン」と「アニメのステートマシン」を対応させると設計が締まります。
  • ゲーム全体の状態管理: キャラクター単位ではなく「タイトル→プレイ中→ゲームオーバー」というゲーム全体の状態は、GameManagerパターンの記事 で扱います。小さな状態はenumswitch、複雑になったらStateパターン、という段階的な使い分けが現実的です。
  • 状態遷移の通知: 「攻撃状態に入った瞬間にUIを点滅させたい」といった連携は、状態クラスから直接UIを触るのではなく イベント で通知すると、状態クラスを疎結合に保てます。

まとめ

Stateパターンは、複雑な状態遷移を持つオブジェクトの振る舞いを、整理され、拡張可能で、保守しやすい形で実装するための非常に強力な設計パターンです。

  • 状態をクラスとして表現し、状態ごとの振る舞いをそのクラス内にカプセル化する。
  • Context(本体)は、現在の状態オブジェクトへの参照を持ち、処理をそのオブジェクトに委譲する。
  • 状態遷移のロジックは、各状態クラスが自身で管理する。
  • OnEnter(入室)/OnUpdate(在室)/OnExit(退室)の3点セットで、処理の置き場所が自然に決まる。
  • 状態の追加はクラスを1個足すだけ。既存コードを壊さない拡張性が最大の武器。

キャラクターAI、プレイヤーの複雑なアクション(通常時、泳ぎ中、はしご昇り中など)、UIのモーダルウィンドウの管理など、ゲーム開発の様々な場面で応用できます。Updateメソッドがifswitchで肥大化し始めたら、Stateパターンの導入を検討する良いサインです。