10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

UnityでゲームAIのUtility AIを作ってみる

Posted at

image.png

はじめに

プレイヤーが居てキャラクターを動かすタイプのゲームではNPCや敵のAIは不可欠です。私の作りたいゲームでもNPCや敵のAIは登場します。
ただ、どうも自分はAIというのを作るのが苦手で、良い感じに作れない。あとAIも各アプローチ毎に色々な機能やパラメータがあるけど、何をどう使って欲しいのか、どの機能をどう組み合わせるべきかもよく分からない。
なのでもっと根本から実際に作って、作って学んでみようという試みです。

Utility AI

UtilityAIは Utility(効用)で行動を選択するAI です。各アクションの「効用(=その行動の美味しさ)」を評価・比較して、最もスコアが高い行動を選択する意思決定のAIです。
要するに状況や雰囲気から、現状もっとも良いと思われる行動を選択して実行するというものです。

例えば「残りHP」「敵の残りHP」「残弾数」「自身のスタミナ」「敵を認識してるか」「敵との距離」「遮蔽物までの距離」などのNPCを取り巻く状況を元にスコアを計算し、その値を参考に「その場で射撃する」「近づいて攻撃する」か「範囲攻撃を撃つ」か「回復する」か「リロード等、体勢を立て直す」か「遮蔽物に逃げる」かを選択します。

また、その値を評価する際にスコアの重み付けやAnimationCurveで評価を調整することで、臆病・勇敢など NPC ごとの“性格付け”も行うことができます。

行動選択の代表である StateMachine と比較すると、以下のようなメリットがありそうです。

  • ステートのトランジションが爆発しにくい(見通しが良い)
  • 優先度の競合を自然に解決できる
  • 行動の追加が簡単で、拡張性が高い

ただし「特定の条件からしかトランジションしない」「厳密な状態管理をしたい」場合はStateMachineの方が良さそうです。そのため両者を組み合わせるアプローチも考えられます。
例えばパトロール・追跡・攻撃などはしっかりとStateMachineに任せ、攻撃の手段の選択や細かい判断をUtilityAIで判断するというハイブリットな構造です。

またUtilityAIはNPCの行動制御だけでなく、「複数の選択肢から最適なものを選ぶ」 という用途全般に使用できそうです。
例えばプレイヤーが攻撃ボタンを押した瞬間に、プレイヤーの状況を参照して最も効果的な武器を選択するようなアシストとしても期待できます。

では、ここから実装の話に入ります。

Utility AIを作ってみる(基礎構造)

UtilityAIの構造は単純です。スコアを計算し、選択するだけです。なので、動作的には以下のようになります。

  1. アクション毎にスコアを計算する
  2. 最もスコアが高いアクションを選択する
  3. アクションが完了するまで待機する
    割り込みを許可している場合は、その間も定期的に再評価して、よりスコアの高いアクションが出たら切り替える。

そこで、今回は以下のような構造にしました。

  • アクション(実行内容とスコアの評価)
  • ランナー(アクションを再生する)
  • セレクター(実行するアクションの選定)
  • ブレイン(上3つの制御)

元々はスコアの評価も分けて外部で計算させる事を考えていました。殆どのサードパーティアセットもスコア計算は独立していて、汎用的に計算できるようにしています。ただ、その設定作業がひたすらに面倒だったのと、スコア計算を外部にするとブラックボード(計算の元になるデータ郡)の注入も汎用的に作らなければいけなくて面倒くさいので、アクションに統合しました。

実行するアクションを定義する

まずアクションを定義します。アクションとなるのは以下のインターフェースを継承した対象になります。

アクションは必ず OnEnter() から入り、Tick()で毎フレーム処理を実行し、Tick()がTrueを返せば終了、 OnExit() で終わるという想定です。
また他のアクションが再生中でも、より良い選択肢が発生したら割り込めるように CanInterruptを用意し、この値がTrueの場合は現在の処理中でもスコア計算を行うようにしています。
想定では攻撃中のように姿勢が崩れているなら CanInterruptを True にして割り込み不可、移動中など姿勢が安定してるなら状況判断で他の行動に切り替える事を想定しています。

スコアの計算は Evaluate() で行います。どの様にスコアを計算するかは自由ですが、0以上で正規化することを想定しています。

public interface IAction
{
    /// <summary>
    /// アクションの実行優先度を評価したスコアを取得します。
    /// </summary>
    float Evaluate();

    /// <summary>
    /// 他のアクションへの切り替えを許可するかを示します。
    /// </summary>
    bool CanInterrupt { get; }

    /// <summary>
    /// アクションの実行が開始されるときに一度呼ばれます。
    /// </summary>
    void OnEnter();

    /// <summary>
    /// アクションの実行が終了したときに一度呼ばれます。
    /// </summary>
    void OnExit();

    /// <summary>
    /// フレームごとの更新処理を行います。処理が完了したら true を返します。
    /// </summary>
    bool Tick();
}

アクションは以下のコードで実行します。

このクラスは外部からSetAction で新しくアクションを登録し、毎フレームでActionRunner.Tick()を実行することを想定しています。

アクションを登録した時ActiveAction.OnEnter() を即座に実行します。またアクションが終了するまでActiveAction.Tick()を再生し、アクションが終了したら ActiveAction.OnExit()をコールします。

再生中も割り込みで別のアクションを SetAction() することも可能です。その場合、即座に再生中のアクションのOnExitを呼び出し、新しいアクションに割り振ります。

public class ActionRunner
{
    /// <summary>
    /// 現在再生中のアクションです。
    /// </summary>
    public IAction ActiveAction { get; private set; }

    /// <summary>
    /// 新しいアクションをアクティブにし、必要であれば前のアクションの終了処理を行います。
    /// 同じアクションが指定された場合、一度停止してから再度開始します。
    /// </summary>
    public void SetAction(IAction action)
    {
        if( action == null ) throw new ArgumentNullException(nameof(action));
        
        ActiveAction?.OnExit();
        ActiveAction = action;
        ActiveAction.OnEnter();
    }
    
    /// <summary>
    /// アクションをキャンセルして終了します
    /// </summary>
    public void Abort()
    {
        ActiveAction?.OnExit();
        ActiveAction = null;
    }

    /// <returns>
    /// アクションが完了した、または割り込み可能なため
    /// 次のアクション選択処理を行ってよい場合に true を返します。
    /// 実行中のアクションを継続し、まだ再評価すべきでない場合は false を返します。
    /// </returns>
    public bool Tick()
    {
        if (ActiveAction == null)
            return true; // 実行するアクションがない

        if (ActiveAction.Tick())
        {
            ActiveAction.OnExit();
            ActiveAction = null;
            return true; // 現在のアクションが完了した
        }
        else
        {
            // 割り込みが可能なら他のアクションもチェック
            return ActiveAction.CanInterrupt;
        }
    }
}

最もスコアが高いアクションを選択するセレクター

次のアクションを選定する際に、再生するアクションを選択するコードを定義します。
内容は単純で、登録したアクションのスコアを計算して一番高いアクションを返すだけです。

TryGetActionでアクションを取得し返します。この時、全てのスコアが0ならば再生できるアクションが無いので、そのまま沈黙します。

また、IAction.CanInterruptがTrueの際に、スコアが同じ2つのアクションが行ったり来たりするケースに対処するため、現在進行中のアクションには少しだけボーナスを付与します。

public class ActionSelector
{
    private const float MinimumAcceptableScore = 0f; // 実行するに値するアクション無しと評価する値
    private const float CurrentActionBonus = 0.03f; // 現在再生中のアクションに与えるボーナス値
    private readonly IAction[] _actions;

    /// <summary>
    /// 選択対象のアクション群を受け取ってセレクタを初期化します。
    /// 一度登録したアクションを変更する事は想定していません。
    /// </summary>
    public ActionSelector(params IAction[] actions)
    {
        Assert.IsNotNull(actions, "actionsがNullです。");
        Assert.IsTrue(actions.Length > 0, "再生可能なアクションがありません");
        _actions = actions;
    }

    /// <summary>
    /// 最も評価値が高いアクションを取得します。
    /// 有効な評価値が存在しない場合には false を返します。
    /// </summary>
    /// <remarks>
    /// 現在実行中のアクションと同じアクションにはボーナスを付与して、2つのアクションが行ったり来たりする動作を防ぎます。
    /// </remarks>
    public bool TryGetAction(IAction activeAction, out IAction result)
    {
        var highestScore = 0f;
        result = null;

        foreach (var action in _actions)
        {
            var score = action.Evaluate() + 
                        (action == activeAction ? CurrentActionBonus : 0f);

            if (highestScore >= score)
                continue; // 同数ならば先に選択されたものを利用する

            highestScore = score;
            result = action;
        }

        // 0以下しか無いならば実行価値無しとみなして選択しない
        return highestScore > MinimumAcceptableScore;
    }
}

ActionSelectorが毎フレームaction.Evaluate()を実行するのを避けるため、思考のタイミングを少しずらすための便利機能も追加しておきます。

ThinkSchedulerはオフセットの分だけ思考するタイミングをずらすためのクラスです。各ブレイン毎に自身のオフセット値を保存しておき、ShouldThink()で思考してよいかを判断します。このクラスを利用することで、IAction.CanInterrupt がTrueの場合に毎フレーム全てのNPCが IAction.Evaluate() を実行することを防ぎます。

ThinkScheduler.ShouldThink()intervalを引数を利用して、周期を調整することもできます。例えばプレイヤーから関心がある(プレイヤーが攻撃中、もしくはプレイヤーへ攻撃を命中させた)のNPCは思考の頻度を上げたり、逆に関係ないNPCや遠距離のNPC、カメラ外のNPCは別に更新頻度を下げます。

それでも運が悪ければ、特定のフレームに集中して思考する可能性もあります。そのあたりは恐らく GetNextOffset をもうちょっと頑張って実装する必要があります。例えばランダムにオフセットをばらつかせたり、NPC生成時に一度だけランダム決定する、あるいはバケットを用意して空いてるバケットのインデックスを取得するなどです。

public static class ThinkScheduler
{
    private static int _globalCounter = 0;
    private const int MaxOffset = 32; // 分割数

    /// <summary>
    /// フレームグループのインデックスを順に返して負荷を分散します。
    /// </summary>
    public static int GetNextOffset()
    {
        _globalCounter = (_globalCounter + 1) % MaxOffset;
        return _globalCounter;
    }

    /// <summary>
    /// 指定された周期とオフセットから、現在フレームで思考処理を走らせるか判定します。
    /// </summary>
    /// <param name="interval">思考の周期:1で毎フレーム、以降はその倍数</param>
    /// <param name="offset">このインスタンスに割り当てられたフレームグループ</param>
    /// <returns>思考してよいフレームであれば true</returns>
    public static bool ShouldThink(int interval, int offset)
    {
        if (interval <= 1)
            return true; // インターバルが無いので毎フレーム実行しても良い

        return ((Time.frameCount + offset) % interval) == 0;
    }
}

実行と選択を統括するブレイン

アクションを収集し、セレクターで選択し、ランナーで実行する、これらの動作をまとめるブレインを作成します。
ブレインは同じオブジェクトの IAction を継承した コンポーネント を収集し、最もスコアが高いアクションを選択、実行します。

今回はIActionをMonoBehaviourで実装しているため、同じオブジェクトに登録したうえでGetComponents()にて一括取得して登録することができます。これはコンポーネントを取得した際の順番があまり関係ない事からの力技です。
実際にはピュアなクラスでアクションを作成した上でDIを利用して注入しても良いですし、クラスの関係を全てコードで記述ても良いです。

[DefaultExecutionOrder(Order)]
public class UtilityBrain : MonoBehaviour
{
    private const int Order = 10; // 他のコンポーネントより少し後に実行する

    [SerializeField, Range(0, 60)] private int _interval = 4;

    private IAction[] _actions;
    private ActionSelector _selector;
    private ActionRunner _runner;

    private int _offset;

    public IAction ActiveAction => _runner.ActiveAction;

    private void Awake()
    {
        _actions = GetComponents<IAction>();
        _selector = new ActionSelector(_actions);
        _runner = new ActionRunner();
        
        _offset = ThinkScheduler.GetNextOffset(); // 思考の周期を調整するオフセットを登録
    }

    private void OnDestroy()
    {
        _runner.Abort();
    }

    private void Update()
    {
        if (!_runner.Tick())
            return; // 命令を実行中です。

        if (!ThinkScheduler.ShouldThink(_interval, _offset))
            return; // 思考の周期と一致しません。

        if (!_selector.TryGetAction(_runner.ActiveAction, out var newAction))
            return; // 実行可能なアクションがありません。

        if (newAction == _runner.ActiveAction)
            return; // newActionは現在実行中のアクションと同じアクションです。

        _runner.SetAction(newAction); // 新しいアクションをセットします。
    }
}

なお、どのアクションにどの程度のスコアが入っているのかが視覚的に認識できないと辛いので、インジケーターとして表示するUIも用意します。デバッグ用となのでOnGUIを使用します。

コードが長いので閉じておきます。

デバッグ用のUI
[RequireComponent(typeof(UtilityBrain))]
public class UtilityBrainIndicator : MonoBehaviour
{
    [Header("Bar Settings")] [SerializeField]
    private float _barWidth = 50f;

    [SerializeField] private float _barHeight = 16f;
    [SerializeField] private float _maxEvaluation = 1f;
    [SerializeField] private float _spacing = 2f;

    [Header("Colors")] [SerializeField] private Color _activeActionColor = Color.red;
    [SerializeField] private Color _inactiveActionColor = Color.black;

    private IAction[] _actions;
    private GUIContent[] _actionNames;

    private UtilityBrain _brain;
    private Camera _camera;

    private void Awake()
    {
        _actions = GetComponents<IAction>();
        _brain = GetComponent<UtilityBrain>();
        _camera = Camera.main;

        _actionNames = new GUIContent[_actions.Length];
        for (var i = 0; i < _actions.Length; i++)
        {
            // アクション名は毎フレーム変わらないので GUIContent を事前生成
            _actionNames[i] = new GUIContent(_actions[i].GetType().Name);
        }
    }

    private void OnGUI()
    {
        if (_actions == null || _actions.Length == 0)
            return; // アクションが登録されていない

        if (!ValidateMainCamera())
            return; // MainCameraが見つからない

        var screenPos = _camera.WorldToScreenPoint(transform.position);

        if (screenPos.z < 0f)
            return; // カメラの背後にある場合は表示しない

        screenPos.y = Screen.height - screenPos.y; // Unity の GUI 座標系は Y 軸が反転しているので調整

        var yOffset = 0f;

        for (var i = 0; i < _actions.Length; i++)
        {
            var action = _actions[i];
            var content = _actionNames[i];

            // 生のスコアとバー用の正規化値を分離
            var score = action.Evaluate();
            var normalized = Mathf.Clamp01(score / Mathf.Max(_maxEvaluation, Mathf.Epsilon));

            var labelSize = GUI.skin.label.CalcSize(content);

            // 評価値のバー(前景)
            DrawActionBar(screenPos, yOffset, normalized, action);

            // アクション名のラベル
            DrawActionLabel(screenPos, yOffset, labelSize, content);

            // スコア表示(毎フレーム変わるので string だけ生成)
            DrawScoreLabel(score, screenPos, yOffset, labelSize);

            yOffset += Mathf.Max(labelSize.y, _barHeight) + _spacing;
        }
    }

    private bool ValidateMainCamera()
    {
        if (_camera)
            return true;

        _camera = Camera.main;
        return _camera;
    }

    private void DrawScoreLabel(float score, Vector3 screenPos, float yOffset, Vector2 labelSize)
    {
        var scoreText = score.ToString("0.00");
        GUI.Label(
            new Rect(screenPos.x, screenPos.y + yOffset, _barWidth, labelSize.y),
            scoreText,
            GUI.skin.label
        );
    }

    private void DrawActionLabel(Vector3 screenPos, float yOffset, Vector2 labelSize, GUIContent content)
    {
        GUI.Label(
            new Rect(screenPos.x + _barWidth, screenPos.y + yOffset, labelSize.x, labelSize.y),
            content,
            GUI.skin.label
        );
    }

    private void DrawActionBar(Vector3 screenPos, float yOffset, float normalized, IAction action)
    {
        if (normalized <= 0f)
            return;

        var fillWidth = normalized * _barWidth;

        var previousColor = GUI.color;
        GUI.color = action == _brain.ActiveAction ? _activeActionColor : _inactiveActionColor;

        GUI.DrawTexture(
            new Rect(screenPos.x, screenPos.y + yOffset, fillWidth, _barHeight),
            Texture2D.whiteTexture
        );

        GUI.color = previousColor;
    }
}

動かしてみる

準備ができたので、使ってみます。

DummyAction はアクションの実装例です。
下のコードでは、_scoreが最も高いアクションのログを表示する動作をイメージしています。
_canInterruptがFalseを返しているなら割り込みは発生せず、処理が完了するまでアクションを実行します。つまり次のアクションに切り替わるまで1秒かかります。これがTrueの場合、スコアの評価時に最も高いスコアの処理を実行します。

public class DummyAction : MonoBehaviour, IAction
{
    [SerializeField] private bool _canInterrupt;
    [SerializeField] private string _actionName;
    [SerializeField, Range(0f, 1f)] private float _score;

    private const float ActionDuration = 1f;
    private float _endTime;
    
    public float Evaluate() => _score;

    public bool CanInterrupt => _canInterrupt;
    
    public void OnEnter()
    {
        Debug.Log($"{_actionName} Start");
        _endTime = Time.time + ActionDuration; // アクションの終了時間をセット
    }

    public void OnExit()
    {
        Debug.Log($"{_actionName} End");
    }

    public bool Tick()
    {
        if (Time.time > _endTime)
            return true; // アクションを完了する

        return false; // アクションを継続する
    }
}

image.png

このアクションですが、単一の処理ではなく段階的な処理を実行する可能性があります。例えば下のような流れです。

  1. 硬直
  2. 攻撃前のエフェクト
  3. 攻撃モーションの開始
  4. 攻撃モーションが完了
  5. 硬直

このようなシーケンシャルな処理は独自にステートマシンを作る事もできますし、もっと簡易に上から順にコマンドを実行するようなコードを作ることもできますが、UtilityAIの下の階層として BehaviourTree を使用することもできます。他にもAwaitableを使用することもできます。ただしキャンセルの扱いには注意してください。

Unity Behaviorを使用する場合は以下のように記述することができます。この例ではアクションがアクティブになったタイミングでBehaviorTreeが起動し、他のアクションが上書きする、あるいはBehaviorTreeの実行が完了するまで処理を継続します。

public abstract class ActionBase : MonoBehaviour, IAction
{
    [SerializeField, Range(0f, 1f)] private float _score;
    [SerializeField] private BehaviorGraphAgent _agent;

    private void Awake() => _agent.enabled = false;

    public abstract float Evaluate();

    public abstract bool CanInterrupt { get; }

    public void OnEnter()
    {
        _agent.enabled = true;
        _agent.Start();
    }

    public void OnExit()
    {
        _agent.End(); // 外部から他のアクションが割り込みした場合、エージェントを止める。
        _agent.enabled = false;
    }

    public bool Tick()
    {
        if( _agent.Graph.IsRunning )
            return false;
            
        return true; // BehaviorTreeが完了した 
    }
}

ここまでは「仕組みそのもの」を作りました。
次は、各アクションがどのようにスコアを計算するか、その中身を見ていきます。

スコア計算のモデル

ここまでで「アクションを列挙して Evaluate()のスコアが一番高いアクションを実行する」という仕組みはできました。次は各アクションのスコアを計算します。

スコアは基本的にブラックボードと呼ぶパラメーター群を格納するクラスに、判断に必要そうなものを登録しておき、そのパラメーターを使用して計算します。

このスコアを計算するモデルは色々と種類があります。例えば最大効率を求める戦術モデル、欲求を満たす行動を取るモデルなど。何を使うのか、どう使うのか、どのようなパラメーターを収集すればよいのかはゲームに依存するので、このあたりはメモのみとします。

  • 戦術モデル:プロフェッショナル寄り(合理的に強い行動)
  • Needs/Driveモデル:キャラクター寄り

戦術モデル

戦術モデル(期待・リスク・コスト・機会)は、「この状況なら理屈として一番強い行動はどれか?」「プロフェッショナルな兵士だったら何を選ぶか?」という “合理的な強さ” に寄ったモデルです。この傾向から「戦術的に強い行動を取りたい」場面と相性が良いです。

一方で、「怖がりで逃げがちな敵」や「弾をやたら節約する敵」など、キャラ性そのものを出したい場合は次に紹介する Needs モデルの方が扱いやすいかもしれません。


まずは「期待・リスク・コスト・機会」という枠組みを用意し、スコアの計算は下の式に当てはめて考えるアプローチを考えてみます。

  • 期待(Expectation):成功したときの美味しさ(0~1)
  • リスク(Risk):その行動を行った場合、どれくらい危険か(0~1)
  • コスト(Cost):何を支払うのか(0~1)
  • 機会(Opportunity):今それをやるチャンスがどれくらいあるか(1〜2)

スコアの式は次のような内容です。機会・リスク・コストで「行動そのものの価値」を出して、機会で「今それをやるべきかどうか」を増減させるイメージです。

var score = (期待 - リスク - コスト) * 機会

例えば射撃アクションを行う場合を考えます。
ブラックボードから以下のような情報を拾ってきて評価していきます。

  • 期待
    • 命中時のダメージが多い(武器の威力・クリティカルの有無)
    • 命中率(距離・有効距離・制度・敵の動き・敵のカバー状況)
    • 敵が密集している(キル重視か制圧重視かでウェイトが変化。制圧目的なら有効)
  • リスク
    • 自分が被弾しやすい(カバーが弱い、敵との距離が近い)
    • 敵の火力が高い(DPS、スナイパーが居る、危ない)
    • 自分のHPが少ない(死にそう)
  • コスト
    • 攻撃モーションや硬直時間
    • 他の武器を使用している場合は武器の切り替え時間
    • 弾薬の消費(この一発がどれほど貴重か)
  • 機会
    • 不意打ちができそう(キャラの向きを参照)
    • 敵がスタンで硬直中。リロード中
    • 味方の妨害スキルが発動中
[SerializeField] private float weightExpectation = 1.0f;
[SerializeField] private float weightRisk = 0.8f;
[SerializeField] private float weightCost = 0.5f;

public float Evaluate()
{
    var expectation = CalculateShootExpectation();
    var risk = CalculateShootRisk();
    var cost = CalculateShootCost();
    var opportunity = CalculateShootOpportunity();

    var score =
        weightExpectation * expectation
      - weightRisk * risk
      - weightCost * cost;

    // マイナスなら「やる価値なし」として 0 に丸める
    score = Mathf.Max(score, 0f);
    
    // 今がその行動の「やりどき」かどうかを反映する
    return Mathf.Max(score, 0f) * opportunity;
}

欲求(Needs)ベース モデル

NPCが常に「最適解」だけを取るようなプロフェッショナルAIではなく、もっと雑然としていて、キャラクターらしい振る舞いをしてほしい場合に有効なのが Needs(欲求) ベースのモデルです。

Needs モデルでは NPC が内部にいくつかの「欲求(飢え)」を持っていると考え、その時点で最も満たしたい欲求が強い行動を選択します。
これは『The Sims』などで使われている方式として有名です。


まずブラックボードのパラメーターから、NPC の内部状態を以下のような「0〜1の値」で表します。それぞれが 今どれだけ満たされていないか を表す値です。

  • AggressionNeed(攻撃したい)
  • SurvivalNeed(生存したい)
  • StabilityNeed(弾薬や体勢などの安定)

次は各アクションがそれぞれ欲求をどれだけ満たすのか(あるいは悪化させるのか)をまとめます。
ここで使っている Gain_XXX は「このアクションが、欲求をどれだけ満たせるのか、悪化させるのか」を-1.0~+1.0の範囲で表したものです。
プラスなら「満たす」マイナスなら「悪化させる」方向になります。

行動/評価 Gain_Aggression Gain_Survival Gain_Stability
射撃アクション +0.8(満たせる) + 0.1(敵を蹴散らした) -0.3(弾薬を消費)
逃走アクション -0.5 +1.0 (安全になった +0.1(弾薬を節約)
リロード +0.1 -0.3(無防備なので危険) +1.0(補給完了

スコアの計算式は次のように計算します。

var score = 
      AggressionNeed * Gain_Aggression
    + SurvivalNeed * Gain_Survival
    + StabilityNeed * Gain_Stability;

つまり「今どの要求がどれくらい強いのか」と「行動はどれだけ要求を満たすのか」の掛け算の合計でスコアを算出します。

例えば、瀕死( SurvivalNeed が高い)かつ弾が不安( StabilityNeed も高い)ならば、Gain_SurvivalGain_Stabilityの高い逃亡アクションやリロードが選択される可能性は高くなり、射撃は「今やる価値は低い」と判断されます。


同じ状況でも、NPCの性格によって行動が変わってほしいものです。

  • 攻撃的なNPC
  • 臆病なNPC
  • 弾薬節約タイプのNPC

そこで Needs に「重要度(Importance)」を掛けて
NPC の価値観を表現します。

  • 攻撃的 → ImportanceAggression を上げる
  • 臆病 → ImportanceSurvival を上げる
  • 倹約家 → ImportanceStability を高くする
EffectiveAggressionNeed = AggressionNeed * ImportanceAggression
EffectiveSurvivalNeed   = SurvivalNeed   * ImportanceSurvival
EffectiveStabilityNeed  = StabilityNeed  * ImportanceStability

重要度だけだと、0~1の間を直線的に評価することになります。一方キャラクターはもう少しクセがあります。

  • 弾が減り始めた瞬間から気になりだすキャラクター
  • 残り20%を切るまで全然気にしないキャラクター
  • 常にある程度気にするキャラクター

そこでNeedの値に対してAnimation Curveを使用して、カーブの値を元にスコアを計算することで反応のクセを調整することができます。ただカーブを設定するのは微妙に面倒だったので、必要な要素にだけ使うのが賢いと感じています。

最終的なスコアの計算方法は以下のように計算できます。この時、Scoreは正規化しておきます。

var score = 
       aggressionCurve.Evaluate( AggressionNeed * ImportanceAggression ) * ImportanceAggression
    +  survivalCurve.Evaluate(  SurvivalNeed   * ImportanceSurvival ) * Gain_Survival
    +  stabilityCurve.Evaluate( StabilityNeed  * ImportanceStability ) * Gain_Stability;

Needsモデルは「キャラクター性・個性を出しやすい」や「チューニングの影響が分かりやすい」という特徴があります。

動因(Drive)ベース モデル

欲求(Needs)モデルと似た考えとして動因ベースのモデルというのがあるみたいです。こちらは作れていないので理屈上、そういったものがある、というのが現状の判断ですが、どうもNeedsよりも作りやすいという話もあります。

DriveモデルはNeedsの逆で、「~したい(欲求)」ではなく「ストレスを減らすために行動する」という特徴があります。数式はNeedsと同じですが、視点が違うのでブラックボードから抽出するポイントが逆です。

  • DangerDrive:危険度(被弾しそう、HPが低い、敵が近い)
  • AmmoDrive:弾不足の不安(残弾が少ない)
  • FatigueDrive:疲労(行動しすぎてしんどい)

あとはNeedsと同様に、不安を解消するアクションを定義します。

行動 Reduction_Danger Reduction_Ammo Reduction_Fatigue
射撃 +0.3(敵が減った!) -0.5(弾が尽きそう) -0.2(腕が痺れた)
逃走 +1.0(危険から逃げる) +0.1(弾を温存) -0.3(走るの辛い…)
リロード -0.2(無防備になる危険) 1.0(弾は十分だ) -0.1(少し疲れた)
休む -0.5(無防備に休む危険) 0.0(変化無し) +1.0(生き返る…)

スコアの式もNeedsとほとんど同じです。

score =
      DangerDrive  * Reduction_Danger
    + AmmoDrive    * Reduction_Ammo
    + FatigueDrive * Reduction_Fatigue;
10
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?