🐈

[Unity]"汎用的で強固なAIエンジンの設計"についての備忘録

2024/01/26に公開

Game Programming Gemsで学習したことについて、Unity風にしたものを備忘録として残します。
自分流に解釈してる部分もあるので注意してください。

概要

汎用的なAIエンジンでは、特にゲームオブジェクト間のコミュニケーション、行動の実装、イベントのデバッグ記録の保持などの機能を持つことが理想的です。

おおよそ、次の内容になります。

  1. ゲームオブジェクトの基本設計:

    • ゲームオブジェクトごとに状態を管理するための基盤(例:クラスや構造体)を設計する。
    • 各ゲームオブジェクトが持つ状態(例:パトロール、攻撃、待機など)を定義する。
  2. メッセージシステムの実装:

    • ゲームオブジェクト間でイベントやコマンドをやり取りするためのメッセージシステムを設計・実装する。
    • メッセージには送り手、受け手、配送時刻などの情報を含める。
  3. 状態マシンの実装:

    • 各ゲームオブジェクトが持つ状態マシンを実装する。この状態マシンはゲームオブジェクトの状態に基づいて行動を決定し、必要に応じて状態間の遷移を行う。
    • 状態マシンはイベント駆動型であり、特定のイベント(例:敵を発見した、目的地に到着したなど)が発生したときに状態遷移や特定のアクションを実行する。
  4. デバッグとログ記録のためのシステムの設計:

    • システムの動作を追跡し、問題を発見・修正するためのログ記録システムを実装する。

全体感としては、EnemyがInterfaceのStateを管理して、それの切り替えにはUniRXのSubscribeを使ってます。

各スクリプトの解説

・Enemy

    void Start()
    {
        // メッセージイベントを購読し、メッセージを受け取った際の処理を定義する
        EnemyEventPublisher.OnMessageReceived.Subscribe(eventInfo =>
        {
            if (eventInfo.enemy == this) ChangeState(eventInfo.state);
        }).AddTo(this); // オブジェクトが破棄された時に購読を自動的に解除する

        // 初期ステート
        stateMachine = new StateMachine();
        stateMachine.ChangeState(new PatrolState(this));
    }

Start関数内で、EnemyEventPublisherに対して購読登録をしています。
受信時、対象が自分であれば、指定されたStateへ切り替えを行っています。

    private void ChangeState(STATE state)
    {
        switch (state)
        {
            case STATE.Patrol:
                stateMachine.ChangeState(new PatrolState(this));
                break;
            case STATE.Attack:
                stateMachine.ChangeState(new AttackState(this));
                break;
        }
    }

    void Update()
    {
        stateMachine.Update();
    }

のちに説明するstateMachineにIStateであるPatrolStateやAttackStateをnewして渡すことで、Stateの切り替えかを実現しています。
UpdateでStateの実行をしています。


・StateMachine

// 状態を表すインターフェース
public interface IState
{
    void Enter();
    void Execute();
    void Exit();
}

// 状態マシン
public class StateMachine
{
    private IState currentState;

    public void ChangeState(IState newState)
    {
        // 現在の状態を終了
        currentState?.Exit();

        // 新しい状態に遷移
        currentState = newState;
        currentState.Enter();
    }

    public void Update()
    {
        // 現在の状態のExecuteを呼び出す
        currentState?.Execute();
    }
}

StateのInterfaceを定義して、EnemyからStateの切り替えや実行をできるようにしています。


・PatrolState
・AttackState

public class PatrolState : IState
{
    private readonly Enemy enemy;

    public PatrolState(Enemy enemy)
    {
        this.enemy = enemy;
    }

    public void Enter()
    {
        Debug.Log("Enter Patrol State");
        // パトロール開始のロジック
    }

    public void Execute()
    {
        Debug.Log("Executing Patrol State");
        // パトロール中の行動を実行
    }

    public void Exit()
    {
        Debug.Log("Exit Patrol State");
        // パトロール終了のロジック
    }
}

この中で具体的なステートに応じたEnemyの処理を書いていきます。
(敵を発見した等の)場合によってはステートを切り替えるPublishMessageWithDelay関数を実行する必要があるかもしれません。


・EnemyEventPublisher

public class EnemyEventPublisher : MonoBehaviour
{
    public struct EventInfo
    {
        public Enemy enemy;
        public Enemy.STATE state;
    }

    // イベントを発行するためのSubject
    private static Subject<EventInfo> onMessageReceived = new Subject<EventInfo>();

    // 他のオブジェクトからアクセス可能なイベントストリーム
    public static IObservable<EventInfo> OnMessageReceived => onMessageReceived;

    public static async UniTask PublishMessageWithDelay(EventInfo message, int delayMilliseconds)
    {
        // 指定された遅延時間だけ待機
        await UniTask.Delay(delayMilliseconds);

        // メッセージを発行する
        onMessageReceived.OnNext(message);
    }
}

UniRXで購読の仕組みを実装しています。
PublishMessageWithDelayが呼ばれたら、指定時間ディレイして、メッセージを送信しています。


・EnemyAITestButton

    public void OnClick()
    {
        var enemy = GameObject.Find("Enemy").GetComponent<Enemy>();
        EnemyEventPublisher.PublishMessageWithDelay(new EnemyEventPublisher.EventInfo { enemy = enemy, state = Enemy.STATE.Attack }, 1000).Forget();
    }

切り替えの動作確認用の処理を書いています。

スクリプト全文

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;

public class Enemy : MonoBehaviour
{
    public enum STATE
    {
        Patrol,
        Attack
    }

    private StateMachine stateMachine;

    void Start()
    {
        // メッセージイベントを購読し、メッセージを受け取った際の処理を定義する
        EnemyEventPublisher.OnMessageReceived.Subscribe(eventInfo =>
        {
            if (eventInfo.enemy == this) ChangeState(eventInfo.state);
        }).AddTo(this); // オブジェクトが破棄された時に購読を自動的に解除する

        // 初期ステート
        stateMachine = new StateMachine();
        stateMachine.ChangeState(new PatrolState(this));
    }

    private void ChangeState(STATE state)
    {
        switch (state)
        {
            case STATE.Patrol:
                stateMachine.ChangeState(new PatrolState(this));
                break;
            case STATE.Attack:
                stateMachine.ChangeState(new AttackState(this));
                break;
        }
    }

    void Update()
    {
        stateMachine.Update();
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using Cysharp.Threading.Tasks;
using System;

public class EnemyEventPublisher : MonoBehaviour
{
    public struct EventInfo
    {
        public Enemy enemy;
        public Enemy.STATE state;
    }

    // イベントを発行するためのSubject
    private static Subject<EventInfo> onMessageReceived = new Subject<EventInfo>();

    // 他のオブジェクトからアクセス可能なイベントストリーム
    public static IObservable<EventInfo> OnMessageReceived => onMessageReceived;

    public static async UniTask PublishMessageWithDelay(EventInfo message, int delayMilliseconds)
    {
        // 指定された遅延時間だけ待機
        await UniTask.Delay(delayMilliseconds);

        // メッセージを発行する
        onMessageReceived.OnNext(message);
    }
}
// 状態を表すインターフェース
public interface IState
{
    void Enter();
    void Execute();
    void Exit();
}

// 状態マシン
public class StateMachine
{
    private IState currentState;

    public void ChangeState(IState newState)
    {
        // 現在の状態を終了
        currentState?.Exit();

        // 新しい状態に遷移
        currentState = newState;
        currentState.Enter();
    }

    public void Update()
    {
        // 現在の状態のExecuteを呼び出す
        currentState?.Execute();
    }
}
using UnityEngine;

public class PatrolState : IState
{
    private readonly Enemy enemy;

    public PatrolState(Enemy enemy)
    {
        this.enemy = enemy;
    }

    public void Enter()
    {
        Debug.Log("Enter Patrol State");
        // パトロール開始のロジック
    }

    public void Execute()
    {
        Debug.Log("Executing Patrol State");
        // パトロール中の行動を実行
    }

    public void Exit()
    {
        Debug.Log("Exit Patrol State");
        // パトロール終了のロジック
    }
}
using UnityEngine;

public class AttackState : IState
{
    private readonly Enemy enemy;

    public AttackState(Enemy enemy)
    {
        this.enemy = enemy;
    }

    public void Enter()
    {
        Debug.Log("Enter Attack State");
        // 攻撃開始のロジック
    }

    public void Execute()
    {
        Debug.Log("Executing Attack State");
        // 攻撃中の行動を実行
    }

    public void Exit()
    {
        Debug.Log("Exit Attack State");
        // 攻撃終了のロジック
    }
}
using Cysharp.Threading.Tasks;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyAITestButton : MonoBehaviour
{
    public void OnClick()
    {
        var enemy = GameObject.Find("Enemy").GetComponent<Enemy>();
        EnemyEventPublisher.PublishMessageWithDelay(new EnemyEventPublisher.EventInfo { enemy = enemy, state = Enemy.STATE.Attack }, 1000).Forget();
    }
}

所感

AIの状態遷移を綺麗に実装できるやり方だと思いました。

Discussion