mcpField
U
unity-npc-behavior

unity-npc-behavior

github.com/nice-wolf-studio開発4065 回インストール6 週間前に更新
リポジトリ

unity-npc-behavior について

Unity の NPC 行動設計向けスキルです。知覚、判断、アクション、陣営、記憶、小隊連携を設定可能な層に分け、コードを変更せずに NPC の特性を調整できます。

主な機能

  1. 01交換可能な感覚と統合ターゲットリスト
  2. 02キャラクタータイプ別の判断プロファイル
  3. 03中断に対応するアクションライフサイクル
  4. 04設定可能な陣営関係と実行時変更
  5. 05確信度が減衰する NPC の記憶
  6. 06小隊の役割割り当てと戦術連携

活用シーン

  1. 01NPC の種類ごとの感覚設定
  2. 02衛兵と市民で異なる判断方針の設定
  3. 03中断時の攻撃やダメージ適用の取り消し
  4. 04記憶に基づく捜索や小隊戦術の構築

インストール

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

$npx skills add https://github.com/nice-wolf-studio/unity-claude-skills --skill unity-npc-behavior

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

同梱ファイルreferences/npc-system-scaffolds.md

詳細

NPC の振る舞い -- デザインパターンへの翻訳

前提となるスキル: unity-physics-queries(知覚レイキャスト、NonAlloc クエリパターン)、unity-state-machines(意思決定 FSM/BT アーキテクチャ)、unity-game-architecture(イベントバス、Service Locator、コンポーネント合成)

Claude のデフォルトの NPC は、レイキャスト、意思決定、移動をすべて 1 つのメソッド内で行う Update() を持つ単一の MonoBehaviour です。これはテストも拡張もできません。知覚ロジックを変更するには意思決定ロジックにも手を入れる必要があり、新しい感覚を追加するにはメソッド全体を書き直さなければならず、各 NPC が個別に独立しているため、部隊の連携も不可能です。これらのパターンでは、プログラマーの介入なしにデザイナーが設定できるよう、NPC の振る舞いを差し替え可能なレイヤーに分解します。


パターン:知覚システムのアーキテクチャ

デザインの意図:NPC は視覚、聴覚、近接によって世界を感知します。NPC のタイプによって感覚は異なります。たとえば、警備兵は遠距離まで見えますが聴覚は弱く、犬は視界が狭い一方で聴覚に優れています。感覚は組み合わせ可能なコンポーネントであり、統一されたターゲットリストに情報を送ります。

誤った例(Claude のデフォルト):

// Raycast inside decision code -- can't swap senses, no batching, untestable
public class EnemyAI : MonoBehaviour
{
    void Update()
    {
        // Perception and decision jammed together
        var dir = (player.position - transform.position).normalized;
        if (Physics.Raycast(transform.position, dir, out var hit, 20f))
        {
            if (hit.collider.CompareTag("Player"))
            {
                // Immediately decide and act -- no separation
                Chase(hit.collider.transform);
            }
        }
    }
}

正しい例:

/// <summary>
/// Pluggable sense interface. Each sense is a separate component on the NPC.
/// Cross-ref: unity-physics-queries for OverlapSphereNonAlloc and raycast patterns.
/// </summary>
public interface IPerceptionSense
{
    /// <summary>Evaluate targets within range. Called by PerceptionComponent on staggered schedule.</summary>
    void EvaluateTargets(Collider[] scratchBuffer, List<PerceivedTarget> results);

    /// <summary>Maximum range of this sense for broad-phase overlap sphere.</summary>
    float MaxRange { get; }
}

/// <summary>
/// Central perception hub. Collects results from all IPerceptionSense components.
/// Maintains a deduplicated KnownTargets list for the decision layer.
/// </summary>
public class PerceptionComponent : MonoBehaviour
{
    [SerializeField] private float _updateInterval = 0.2f;
    [SerializeField] private LayerMask _detectionLayers;

    private readonly List<PerceivedTarget> _knownTargets = new();
    private IPerceptionSense[] _senses;
    private readonly Collider[] _scratchBuffer = new Collider[32];

    public IReadOnlyList<PerceivedTarget> KnownTargets => _knownTargets;
}

ひな形(完全な実装):

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

/// <summary>Data class representing a target detected by a perception sense.</summary>
[Serializable]
public class PerceivedTarget
{
    /// <summary>The transform of the detected target.</summary>
    public Transform Transform;

    /// <summary>Last known world-space position of the target.</summary>
    public Vector3 LastKnownPosition;

    /// <summary>Normalized awareness level (0 = barely noticed, 1 = fully aware).</summary>
    public float Awareness;

    /// <summary>Time.time when this target was last perceived.</summary>
    public float LastSeenTime;

    /// <summary>Which sense detected this target.</summary>
    public string SenseType;
}

/// <summary>
/// Pluggable sense interface. Attach multiple senses as components on the NPC GameObject.
/// </summary>
public interface IPerceptionSense
{
    /// <summary>Evaluate nearby colliders and populate results with perceived targets.</summary>
    void EvaluateTargets(Collider[] scratchBuffer, List<PerceivedTarget> results);

    /// <summary>Maximum detection range for this sense.</summary>
    float MaxRange { get; }
}

/// <summary>
/// Central perception component. Gathers all IPerceptionSense components, staggers
/// updates to avoid per-frame cost, and maintains a deduplicated known-targets list.
/// </summary>
public class PerceptionComponent : MonoBehaviour
{
    [SerializeField] private float _updateInterval = 0.2f;
    [SerializeField] private LayerMask _detectionLayers;

    private readonly List<PerceivedTarget> _knownTargets = new();
    private IPerceptionSense[] _senses;
    private readonly Collider[] _scratchBuffer = new Collider[32];
    private float _nextUpdateTime;

    /// <summary>All currently perceived targets.</summary>
    public IReadOnlyList<PerceivedTarget> KnownTargets => _knownTargets;

    /// <summary>Event raised when a new target is first detected.</summary>
    public event Action<PerceivedTarget> OnTargetDetected;

    /// <summary>Event raised when a target is lost (no sense detects it).</summary>
    public event Action<PerceivedTarget> OnTargetLost;

    void Awake()
    {
        _senses = GetComponents<IPerceptionSense>();
        // Stagger start time so NPCs don't all update on the same frame
        _nextUpdateTime = Time.time + UnityEngine.Random.Range(0f, _updateInterval);
    }

    void Update()
    {
        if (Time.time < _nextUpdateTime) return;
        _nextUpdateTime = Time.time + _updateInterval;

        var previousTargets = new List<PerceivedTarget>(_knownTargets);
        _knownTargets.Clear();

        foreach (var sense in _senses)
        {
            sense.EvaluateTargets(_scratchBuffer, _knownTargets);
        }

        // Deduplicate by Transform
        DeduplicateTargets();

        // Fire events
        foreach (var target in _knownTargets)
        {
            if (previousTargets.Find(t => t.Transform == target.Transform) == null)
                OnTargetDetected?.Invoke(target);
        }
        foreach (var prev in previousTargets)
        {
            if (_knownTargets.Find(t => t.Transform == prev.Transform) == null)
                OnTargetLost?.Invoke(prev);
        }
    }

    private void DeduplicateTargets()
    {
        for (int i = _knownTargets.Count - 1; i >= 0; i--)
        {
            for (int j = i - 1; j >= 0; j--)
            {
                if (_knownTargets[i].Transform == _knownTargets[j].Transform)
                {
                    // Keep the one with higher awareness
                    if (_knownTargets[i].Awareness > _knownTargets[j].Awareness)
                        _knownTargets[j] = _knownTargets[i];
                    _knownTargets.RemoveAt(i);
                    break;
                }
            }
        }
    }
}

/// <summary>
/// Sight sense: FOV cone check + line-of-sight raycast.
/// Uses OverlapSphereNonAlloc (see unity-physics-queries).
/// </summary>
public class SightSense : MonoBehaviour, IPerceptionSense
{
    [SerializeField] private float _range = 20f;
    [SerializeField] private float _fovAngle = 120f;
    [SerializeField] private LayerMask _targetLayers;
    [SerializeField] private LayerMask _obstacleLayers;
    [SerializeField] private Transform _eyePoint;

    /// <inheritdoc/>
    public float MaxRange => _range;

    /// <inheritdoc/>
    public void EvaluateTargets(Collider[] scratchBuffer, List<PerceivedTarget> results)
    {
        var origin = _eyePoint != null ? _eyePoint.position : transform.position;
        int count = Physics.OverlapSphereNonAlloc(origin, _range, scratchBuffer, _targetLayers);

        for (int i = 0; i < count; i++)
        {
            var targetPos = scratchBuffer[i].transform.position;
            var dir = (targetPos - origin).normalized;

            // FOV check
            if (Vector3.Angle(transform.forward, dir) > _fovAngle * 0.5f)
                continue;

            float dist = Vector3.Distance(origin, targetPos);

            // LOS raycast
            if (Physics.Raycast(origin, dir, out var hit, dist, _obstacleLayers | _targetLayers))
            {
                if (hit.collider == scratchBuffer[i])
                {
                    results.Add(new PerceivedTarget
                    {
                        Transform = scratchBuffer[i].transform,
                        LastKnownPosition = targetPos,
                        Awareness = 1f - (dist / _range), // Closer = higher awareness
                        LastSeenTime = Time.time,
                        SenseType = "Sight"
                    });
                }
            }
        }
    }
}

デザイン上の拡張ポイント:新しい感覚を追加するには、NPC プレハブに新しい IPerceptionSense MonoBehaviour を追加します。各感覚について、範囲、角度、更新頻度、ターゲットレイヤーを設定します。PerceptionComponent は GetComponents を使って感覚を自動検出します。

注意点:知覚クエリは負荷が高くなります。_updateInterval によって更新をずらし、すべての NPC が毎フレームクエリを実行しないようにします。更新間隔が 0.2 秒の場合、NPC が 50 体いても、1 フレームあたりに実行するのは 50 体ではなく約 5 体だけです。また、OverlapSphereNonAlloc には事前に確保したバッファーが必要です。近くにいるターゲットの最大予想数に合わせてサイズを設定してください。


パターン:意思決定レイヤーのパターン

デザインの意図:攻撃的、慎重、臆病といった異なる NPC の類型は、同じ知覚を共有しながら、意思決定は異なります。警備兵も市民もプレイヤーを認識しますが、警備兵は攻撃し、市民は逃げます。意思決定ロジックは、類型ごとにデータ駆動で設定します。

誤った例(Claude のデフォルト):

// Decision logic jammed into perception callback -- can't vary by archetype
void OnTargetDetected(PerceivedTarget target)
{
    float dist = Vector3.Distance(transform.position, target.LastKnownPosition);
    if (dist < 5f)
        Attack(target.Transform);
    else if (dist < 15f)
        Chase(target.Transform);
    else
        Patrol();
}

正しい例:

/// <summary>
/// Clean 3-layer separation: Perception -> Blackboard -> Decision -> Action.
/// Decision profiles are ScriptableObject configs per archetype.
/// Cross-ref: unity-state-machines for FSM/BT implementation.
/// </summary>
[CreateAssetMenu(menuName = "NPC/Decision Profile")]
public class DecisionProfile : ScriptableObject
{
    /// <summary>Distance at which NPC engages in combat.</summary>
    public float AggroRange = 10f;

    /// <summary>Distance at which NPC flees.</summary>
    public float FleeRange = 3f;

    /// <summary>Minimum awareness level to act on a target.</summary>
    public float AwarenessThreshold = 0.3f;

    /// <summary>Should this NPC call for help when engaging?</summary>
    public bool CallsForHelp = false;

    /// <summary>Preferred combat distance (0 = melee, higher = ranged).</summary>
    public float PreferredCombatDistance = 2f;
}

ひな形(完全な実装):

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

/// <summary>
/// Per-NPC-instance data store. Perception writes, Decision reads.
/// Plain C# class -- testable without MonoBehaviour.
/// </summary>
public class NPCBlackboard
{
    // Common key constants
    public const string KEY_PRIMARY_TARGET = "PrimaryTarget";
    public const string KEY_THREAT_LEVEL = "ThreatLevel";
    public const string KEY_LAST_KNOWN_POS = "LastKnownPosition";
    public const string KEY_HEALTH_PERCENT = "HealthPercent";
    public const string KEY_ALLIES_NEARBY = "AlliesNearby";

    private readonly Dictionary<string, object> _data = new();

    /// <summary>Set a typed value on the blackboard.</summary>
    public void Set<T>(string key, T value) => _data[key] = value;

    /// <summary>Get a typed value. Returns default if key missing.</summary>
    public T Get<T>(string key, T fallback = default)
    {
        return _data.TryGetValue(key, out var val) && val is T typed ? typed : fallback;
    }

    /// <summary>Check if a key exists.</summary>
    public bool Has(string key) => _data.ContainsKey(key);

    /// <summary>Remove a key from the blackboard.</summary>
    public void Clear(string key) => _data.Remove(key);

    /// <summary>Remove all data.</summary>
    public void ClearAll() => _data.Clear();
}

/// <summary>
/// Decision-making component. Reads perception via blackboard, selects behavior
/// based on DecisionProfile. Drives a state machine (see unity-state-machines).
/// </summary>
public class NPCDecisionMaker : MonoBehaviour
{
    [SerializeField] private DecisionProfile _profile;
    [SerializeField] private PerceptionComponent _perception;

    private NPCBlackboard _blackboard;

    /// <summary>The NPC's blackboard instance.</summary>
    public NPCBlackboard Blackboard => _blackboard;

    /// <summary>Event raised when a decision is made.</summary>
    public event Action<string> OnDecision;

    void Awake()
    {
        _blackboard = new NPCBlackboard();
    }

    void Update()
    {
        UpdateBlackboard();
        EvaluateDecision();
    }

    private void UpdateBlackboard()
    {
        var targets = _perception.KnownTargets;
        if (targets.Count > 0)
        {
            var primary = targets[0]; // Highest awareness after dedup
            _blackboard.Set(NPCBlackboard.KEY_PRIMARY_TARGET, primary.Transform);
            _blackboard.Set(NPCBlackboard.KEY_LAST_KNOWN_POS, primary.LastKnownPosition);
            _blackboard.Set(NPCBlackboard.KEY_THREAT_LEVEL, primary.Awareness);
        }
        else
        {
            _blackboard.Clear(NPCBlackboard.KEY_PRIMARY_TARGET);
            _blackboard.Clear(NPCBlackboard.KEY_THREAT_LEVEL);
        }
    }

    private void EvaluateDecision()
    {
        float threat = _blackboard.Get<float>(NPCBlackboard.KEY_THREAT_LEVEL);
        var target = _blackboard.Get<Transform>(NPCBlackboard.KEY_PRIMARY_TARGET);

        if (target == null)
        {
            OnDecision?.Invoke("Patrol");
            return;
        }

        float dist = Vector3.Distance(transform.position, target.position);

        if (threat < _profile.AwarenessThreshold)
        {
            OnDecision?.Invoke("Suspicious");
        }
        else if (dist < _profile.FleeRange)
        {
            OnDecision?.Invoke("Flee");
        }
        else if (dist < _profile.AggroRange)
        {
            if (_profile.CallsForHelp)
                OnDecision?.Invoke("CallForHelp");
            OnDecision?.Invoke("Engage");
        }
        else
        {
            OnDecision?.Invoke("Investigate");
        }
    }
}

デザイン上の拡張ポイント:新しい NPC の類型を追加するには、新しい DecisionProfile ScriptableObject アセットを作成します。「臆病者」プロファイルでは FleeRange = 15f, AggroRange = 0f を設定します。「バーサーカー」では FleeRange = 0f, AggroRange = 30f を設定します。コードの変更は不要です。

注意点:NPCBlackboard は NPC ごとに個別のクラスインスタンスでなければなりません。構造体にすると値型のコピーによって共有参照が壊れ、static にするとすべての NPC が同じ脳を共有することになります。Awake() で割り当ててください。


パターン:アクション実行パイプライン

デザインの意図:NPC が攻撃を決定すると、予備動作のアニメーション、攻撃のタイミング、ダメージの適用、回復を伴ってアクションが実行されます。実行途中でアクションを中断することもできます(予備動作中に敵がよろめかせるなど)。アクションは瞬時に完了するものではありません。

誤った例(Claude のデフォルト):

// Instant execution in decision tick -- no wind-up, no animation, no interruption
void OnDecision(string decision)
{
    if (decision == "Engage")
    {
        target.GetComponent<Health>().TakeDamage(10); // Instant!
        _animator.Play("Attack"); // Animation is cosmetic afterthought
    }
}

正しい例:

/// <summary>
/// Action lifecycle interface. Actions own their execution duration,
/// animation triggers, and exit conditions.
/// </summary>
public interface INPCAction
{
    /// <summary>Called when action begins. Set up animation, timers.</summary>
    void Enter(NPCActionContext context);

    /// <summary>Called each frame while action is active. Returns true when complete.</summary>
    bool Tick(NPCActionContext context, float deltaTime);

    /// <summary>Called when action ends (naturally or via interruption).</summary>
    void Exit(NPCActionContext context, bool wasInterrupted);

    /// <summary>Whether this action can be interrupted by a higher-priority action.</summary>
    bool CanInterrupt { get; }
}

/// <summary>Shared context passed to actions.</summary>
public class NPCActionContext
{
    public Transform Self;
    public Transform Target;
    public Animator Animator;
    public NPCBlackboard Blackboard;
}

ひな形(完全な実装):

using UnityEngine;

/// <summary>
/// Manages the current action lifecycle. Handles transitions and interruption.
/// </summary>
public class ActionExecutor : MonoBehaviour
{
    private INPCAction _currentAction;
    private NPCActionContext _context;

    /// <summary>Whether an action is currently executing.</summary>
    public bool IsExecuting => _currentAction != null;

    /// <summary>The currently executing action, if any.</summary>
    public INPCAction CurrentAction => _currentAction;

    void Awake()
    {
        _context = new NPCActionContext
        {
            Self = transform,
            Animator = GetComponentInChildren<Animator>()
        };
    }

    /// <summary>Inject blackboard reference after creation.</summary>
    public void Initialize(NPCBlackboard blackboard)
    {
        _context.Blackboard = blackboard;
    }

    /// <summary>Begin a new action. Interrupts current if allowed.</summary>
    public bool Execute(INPCAction action, Transform target = null)
    {
        if (_currentAction != null)
        {
            if (!_currentAction.CanInterrupt) return false;
            _currentAction.Exit(_context, wasInterrupted: true);
        }

        _context.Target = target;
        _currentAction = action;
        _currentAction.Enter(_context);
        return true;
    }

    /// <summary>Force-cancel the current action.</summary>
    public void Cancel()
    {
        if (_currentAction != null)
        {
            _currentAction.Exit(_context, wasInterrupted: true);
            _currentAction = null;
        }
    }

    void Update()
    {
        if (_currentAction == null) return;

        bool complete = _currentAction.Tick(_context, Time.deltaTime);
        if (complete)
        {
            _currentAction.Exit(_context, wasInterrupted: false);
            _currentAction = null;
        }
    }
}

/// <summary>
/// Melee attack with 3 phases: wind-up, strike, recovery.
/// Damage only applies during strike phase.
/// </summary>
public class MeleeAttackAction : INPCAction
{
    private enum Phase { WindUp, Strike, Recovery }

    private readonly float _windUpDuration;
    private readonly float _strikeDuration;
    private readonly float _recoveryDuration;
    private readonly float _damage;
    private readonly float _strikeRange;

    private Phase _phase;
    private float _phaseTimer;
    private bool _damageApplied;

    /// <inheritdoc/>
    public bool CanInterrupt => _phase == Phase.WindUp; // Only interruptible during wind-up

    public MeleeAttackAction(float windUp = 0.3f, float strike = 0.15f,
                              float recovery = 0.4f, float damage = 10f, float range = 2f)
    {
        _windUpDuration = windUp;
        _strikeDuration = strike;
        _recoveryDuration = recovery;
        _damage = damage;
        _strikeRange = range;
    }

    /// <inheritdoc/>
    public void Enter(NPCActionContext context)
    {
        _phase = Phase.WindUp;
        _phaseTimer = 0f;
        _damageApplied = false;
        context.Animator?.SetTrigger("MeleeWindUp");
    }

    /// <inheritdoc/>
    public bool Tick(NPCActionContext context, float deltaTime)
    {
        _phaseTimer += deltaTime;

        switch (_phase)
        {
            case Phase.WindUp:
                if (_phaseTimer >= _windUpDuration)
                {
                    _phase = Phase.Strike;
                    _phaseTimer = 0f;
                    context.Animator?.SetTrigger("MeleeStrike");
                }
                break;

            case Phase.Strike:
                if (!_damageApplied && context.Target != null)
                {
                    float dist = Vector3.Distance(
                        context.Self.position, context.Target.position);
                    if (dist <= _strikeRange)
                    {
                        // Apply damage only once, only if in range
                        var health = context.Target.GetComponent<Health>();
                        health?.TakeDamage(_damage);
                        _damageApplied = true;
                    }
                }
                if (_phaseTimer >= _strikeDuration)
                {
                    _phase = Phase.Recovery;
                    _phaseTimer = 0f;
                    context.Animator?.SetTrigger("MeleeRecover");
                }
                break;

            case Phase.Recovery:
                if (_phaseTimer >= _recoveryDuration)
                    return true; // Action complete
                break;
        }

        return false;
    }

    /// <inheritdoc/>
    public void Exit(NPCActionContext context, bool wasInterrupted)
    {
        if (wasInterrupted && !_damageApplied)
        {
            // Interrupted before damage -- no damage applied (design intent)
            context.Animator?.SetTrigger("Interrupted");
        }
    }
}

デザイン上の拡張ポイント:新しいアクションは INPCAction を実装します。タイミング値(予備動作、攻撃、回復)は ScriptableObject の設定に持たせます。アニメーションイベント名は文字列定数です。共通の AnimationKeys クラスにまとめてください。

注意点:予備動作中に中断された場合、ダメージを適用してはいけません。Exit(wasInterrupted: true) の処理では、適用待ちのダメージを必ずキャンセルしてください。このケースを明示的にテストしてください。


パターン:陣営と関係システム

設計意図:NPC は設定可能な関係を持つ陣営に所属します。衛兵は盗賊に敵対し、市民に対して中立で、ほかの衛兵には友好的です。関係は実行時に変化する場合があります(裏切り、同盟の変化など)。知覚では陣営の態度に基づいて結果をフィルタリングします。

誤り(Claude のデフォルト):

// Hardcoded tag checks -- adding a new faction means editing every NPC script
void OnPerceiveTarget(PerceivedTarget target)
{
    if (target.Transform.CompareTag("Player")) Attack();
    else if (target.Transform.CompareTag("Bandit")) Attack();
    else if (target.Transform.CompareTag("Guard")) Ignore();
    // Every new faction = more if/else here
}

正しい例:

/// <summary>
/// Faction identity. ScriptableObject asset per faction.
/// </summary>
[CreateAssetMenu(menuName = "NPC/Faction")]
public class Faction : ScriptableObject
{
    /// <summary>Display name for UI.</summary>
    public string DisplayName;

    /// <summary>Faction color for debug gizmos.</summary>
    public Color DebugColor = Color.white;
}

/// <summary>
/// Centralized relationship matrix. One asset for the entire game.
/// </summary>
[CreateAssetMenu(menuName = "NPC/Faction Database")]
public class FactionDatabase : ScriptableObject
{
    // Relationship lookup driven by SO references, not strings or tags.
    public Relationship GetRelationship(Faction a, Faction b) { /* ... */ }
    public void SetRelationship(Faction a, Faction b, Relationship r) { /* ... */ }
}

スキャフォールド(完全な実装):

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

/// <summary>Relationship stance between two factions.</summary>
public enum Relationship { Hostile, Neutral, Friendly }

/// <summary>Faction identity asset.</summary>
[CreateAssetMenu(menuName = "NPC/Faction")]
public class Faction : ScriptableObject
{
    /// <summary>Display name for UI and debug.</summary>
    public string DisplayName;

    /// <summary>Color used in debug gizmos and minimap.</summary>
    public Color DebugColor = Color.white;
}

/// <summary>
/// Stores a relationship between two factions.
/// </summary>
[Serializable]
public class FactionRelationship
{
    public Faction FactionA;
    public Faction FactionB;
    public Relationship Stance = Relationship.Neutral;
}

/// <summary>
/// Central faction database. One instance for the game.
/// Designers edit the relationship list in the Inspector.
/// </summary>
[CreateAssetMenu(menuName = "NPC/Faction Database")]
public class FactionDatabase : ScriptableObject
{
    [SerializeField] private List<FactionRelationship> _relationships = new();
    [SerializeField] private Relationship _defaultRelationship = Relationship.Neutral;

    // Runtime overrides (betrayal, alliance shifts)
    private Dictionary<(Faction, Faction), Relationship> _runtimeOverrides;

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void ResetStatics() { /* No statics to reset in this SO */ }

    /// <summary>Get the relationship between two factions. Checks runtime overrides first.</summary>
    public Relationship GetRelationship(Faction a, Faction b)
    {
        if (a == b) return Relationship.Friendly;

        // Check runtime overrides
        if (_runtimeOverrides != null)
        {
            if (_runtimeOverrides.TryGetValue((a, b), out var overrideR)) return overrideR;
            if (_runtimeOverrides.TryGetValue((b, a), out var overrideR2)) return overrideR2;
        }

        // Check authored relationships (bidirectional)
        foreach (var rel in _relationships)
        {
            if ((rel.FactionA == a && rel.FactionB == b) ||
                (rel.FactionA == b && rel.FactionB == a))
                return rel.Stance;
        }

        return _defaultRelationship;
    }

    /// <summary>Change a relationship at runtime (e.g., betrayal event).</summary>
    public void SetRelationshipRuntime(Faction a, Faction b, Relationship stance)
    {
        _runtimeOverrides ??= new Dictionary<(Faction, Faction), Relationship>();
        _runtimeOverrides[(a, b)] = stance;
    }

    /// <summary>Clear all runtime relationship overrides.</summary>
    public void ClearRuntimeOverrides()
    {
        _runtimeOverrides?.Clear();
    }
}

/// <summary>
/// Component that identifies an entity's faction membership.
/// Attach to any GameObject that participates in the faction system.
/// </summary>
public class FactionMember : MonoBehaviour
{
    [SerializeField] private Faction _faction;
    [SerializeField] private FactionDatabase _database;

    /// <summary>This entity's faction.</summary>
    public Faction Faction => _faction;

    /// <summary>Get this entity's relationship to another faction member.</summary>
    public Relationship GetRelationshipTo(FactionMember other)
    {
        return _database.GetRelationship(_faction, other._faction);
    }

    /// <summary>Check if another entity is hostile.</summary>
    public bool IsHostileTo(FactionMember other)
    {
        return GetRelationshipTo(other) == Relationship.Hostile;
    }

    /// <summary>Check if another entity is friendly.</summary>
    public bool IsFriendlyTo(FactionMember other)
    {
        return GetRelationshipTo(other) == Relationship.Friendly;
    }
}

設計フック:デザイナーは FactionDatabase アセットの Inspector で陣営関係マトリックスを編集します。実行時イベント(クエストの完了、ストーリーの展開)から SetRelationshipRuntime を呼び出して同盟関係を変化させます。知覚フィルタリングでは FactionMember.IsHostileTo() を使って、検出したターゲットが脅威かどうかを判断します。

注意点:関係の検索は双方向に行う必要があります。衛兵->盗賊を敵対的と定義しても、盗賊->衛兵を忘れると、データベースはデフォルトにフォールバックします。GetRelationship の実装は両方の順序を確認しますが、明確にするため、デザイナーは両方を明示的に定義する必要があります。また、実行時の上書きはドメインのリロード時に失われます。必要に応じて、セーブシステムに保存してください。


パターン:NPC の記憶と忘却

設計意図:NPC はプレイヤーを最後に目撃した場所、物音を聞いた場所、または攻撃を受けた場所を記憶します。ただし、記憶は薄れていきます。プレイヤーが 30 秒間隠れていると、衛兵は捜索をやめて巡回に戻ります。NPC によって記憶の持続時間は異なります(エリート衛兵はより長く記憶します)。記憶は調査行動を促します。NPC は現在の位置ではなく、最後に判明している位置へ向かいます。

誤り(Claude のデフォルト):

// Boolean that never resets -- NPC has perfect memory forever
private bool _hasSeenPlayer = false;

void Update()
{
    if (_hasSeenPlayer)
        ChasePlayer(); // Chases forever, even if player hid 5 minutes ago
}

正しい例:

/// <summary>
/// Timestamped memory entry. Confidence decays over time.
/// Position is a world-space snapshot -- it does NOT track the target's current position.
/// </summary>
public struct MemoryEntry
{
    public string StimulusType; // "Sight", "Sound", "Damage"
    public Vector3 Position;     // Where it happened (snapshot, not tracked)
    public float Confidence;     // 1.0 = certain, decays toward 0
    public float Timestamp;      // Time.time when recorded
    public Transform Source;     // Original source (may be null if forgotten)
}

スキャフォールド(完全な実装):

using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// Timestamped memory record. Position is a world-space snapshot at time of perception.
/// </summary>
public struct MemoryEntry
{
    /// <summary>Type of stimulus that created this memory.</summary>
    public string StimulusType;

    /// <summary>World-space position where stimulus occurred (snapshot, not tracked).</summary>
    public Vector3 Position;

    /// <summary>Current confidence level. Decays over time from 1.0 toward 0.</summary>
    public float Confidence;

    /// <summary>Time.time when this memory was created or last refreshed.</summary>
    public float Timestamp;

    /// <summary>The source transform, if still valid. May become null.</summary>
    public Transform Source;
}

/// <summary>
/// Configuration for memory behavior. Per-archetype ScriptableObject.
/// </summary>
[CreateAssetMenu(menuName = "NPC/Memory Config")]
public class MemoryConfig : ScriptableObject
{
    /// <summary>How many seconds until a memory fully decays.</summary>
    public float MemoryDuration = 30f;

    /// <summary>Confidence below this threshold triggers memory pruning.</summary>
    public float PruneThreshold = 0.05f;

    /// <summary>Maximum number of memories to retain.</summary>
    public int MaxMemories = 20;
}

/// <summary>
/// NPC memory system. Stores timestamped memories with confidence decay.
/// Memories drive investigation behavior -- NPC goes to remembered position,
/// not the target's current position.
/// </summary>
public class NPCMemory
{
    private readonly List<MemoryEntry> _memories = new();
    private readonly MemoryConfig _config;

    public NPCMemory(MemoryConfig config)
    {
        _config = config;
    }

    /// <summary>All current memories.</summary>
    public IReadOnlyList<MemoryEntry> Memories => _memories;

    /// <summary>Record a new memory or refresh an existing one from the same source.</summary>
    public void Remember(string stimulusType, Vector3 position, Transform source = null)
    {
        // Refresh existing memory from same source
        for (int i = 0; i < _memories.Count; i++)
        {
            if (_memories[i].Source != null && _memories[i].Source == source)
            {
                _memories[i] = new MemoryEntry
                {
                    StimulusType = stimulusType,
                    Position = position,
                    Confidence = 1f,
                    Timestamp = Time.time,
                    Source = source
                };
                return;
            }
        }

        // Add new memory
        if (_memories.Count >= _config.MaxMemories)
            _memories.RemoveAt(0); // Remove oldest

        _memories.Add(new MemoryEntry
        {
            StimulusType = stimulusType,
            Position = position,
            Confidence = 1f,
            Timestamp = Time.time,
            Source = source
        });
    }

    /// <summary>Query the most confident memory of a given stimulus type.</summary>
    public bool TryGetBestMemory(string stimulusType, out MemoryEntry best)
    {
        best = default;
        float bestConfidence = 0f;

        foreach (var mem in _memories)
        {
            if (mem.StimulusType == stimulusType && mem.Confidence > bestConfidence)
            {
                best = mem;
                bestConfidence = mem.Confidence;
            }
        }

        return bestConfidence > 0f;
    }

    /// <summary>Decay all memories and prune expired ones. Call once per decision tick.</summary>
    public void DecayAndPrune()
    {
        for (int i = _memories.Count - 1; i >= 0; i--)
        {
            float age = Time.time - _memories[i].Timestamp;
            float newConfidence = Mathf.Clamp01(1f - (age / _config.MemoryDuration));

            if (newConfidence <= _config.PruneThreshold)
            {
                _memories.RemoveAt(i);
            }
            else
            {
                var entry = _memories[i];
                entry.Confidence = newConfidence;
                _memories[i] = entry;
            }
        }
    }

    /// <summary>Forget all memories.</summary>
    public void ForgetAll() => _memories.Clear();
}

設計フック:アーキタイプごとの MemoryConfig SO で、記憶の持続時間と減衰率を設定します。エリート衛兵の記憶時間は 60 秒、基本的な敵の記憶時間は 15 秒にします。ブラックボードと連携させ、最も信頼度の高い記憶がしきい値を下回ったとき、NPC が「捜索」から「巡回に戻る」に切り替わるようにします。

注意点:記憶位置はワールド空間のスナップショットであり、ライブ参照ではありません。NPC が記憶に基づいて行動すると、古い位置を調査します。これは意図的なもので、捜索行動を生み出します。プレイヤーが移動していれば、NPC は何もない場所に到着し、再び知覚する必要があります。ターゲットを追跡するよう記憶位置を更新して、これを「修正」してはいけません。


パターン:群衆と分隊の連携

設計意図:複数の NPC が戦術的に連携します。敵の集団が全員で同じ地点を目指して経路探索し、互いに重なり合うことはありません。代わりに、コーディネーターが戦術的な役割を割り当てます。1 人は左から側面攻撃し、1 人は右から側面攻撃し、1 人は遠距離から制圧します。分隊メンバーが倒れると、役割は動的に再割り当てされます。

誤り(Claude のデフォルト):

// Every NPC independently pathfinds to player -- they all pile up on the same spot
void OnEngageTarget(Transform target)
{
    _navAgent.SetDestination(target.position); // All 10 enemies go to exact same point
}

正しい例:

/// <summary>
/// Coordinates a squad of NPCs. Assigns tactical roles and spread positions.
/// Cross-ref: unity-ai-navigation for NavMesh position sampling.
/// </summary>
public class SquadCoordinator : MonoBehaviour
{
    // Manages role assignment, tactical positioning, and group awareness.
    // Squad members register/unregister dynamically (spawns, deaths).
}

スキャフォールド(完全な実装):

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

/// <summary>Tactical role within a squad.</summary>
public enum TacticalRole
{
    /// <summary>Leads the engagement, draws attention.</summary>
    Leader,
    /// <summary>Approaches from the side.</summary>
    Flanker,
    /// <summary>Keeps distance, provides ranged pressure.</summary>
    Suppressor,
    /// <summary>Holds position, waits for opening.</summary>
    Reserve
}

/// <summary>
/// Squad-level tactical profile. Configures how the squad behaves as a unit.
/// </summary>
[CreateAssetMenu(menuName = "NPC/Squad Tactics")]
public class SquadTactics : ScriptableObject
{
    /// <summary>Minimum spacing between squad members in meters.</summary>
    public float MinSpacing = 3f;

    /// <summary>Maximum squad engagement radius from target.</summary>
    public float EngagementRadius = 15f;

    /// <summary>How many flankers relative to squad size (0-1).</summary>
    [Range(0f, 1f)] public float FlankerRatio = 0.3f;

    /// <summary>How many suppressors relative to squad size (0-1).</summary>
    [Range(0f, 1f)] public float SuppressorRatio = 0.2f;
}

/// <summary>
/// Shared awareness for the squad. Members read and write group-level data.
/// </summary>
public class SquadBlackboard
{
    /// <summary>Current primary target for the squad.</summary>
    public Transform PrimaryTarget;

    /// <summary>Last known position of the primary target.</summary>
    public Vector3 LastKnownTargetPosition;

    /// <summary>Whether the squad is in active combat.</summary>
    public bool InCombat;

    /// <summary>Assigned positions per member.</summary>
    public readonly Dictionary<Transform, Vector3> AssignedPositions = new();
}

/// <summary>
/// Coordinates a squad of NPCs. Assigns tactical roles and spread positions.
/// Squad members register/unregister as they spawn or die.
/// </summary>
public class SquadCoordinator : MonoBehaviour
{
    [SerializeField] private SquadTactics _tactics;

    private readonly List<Transform> _members = new();
    private readonly Dictionary<Transform, TacticalRole> _roles = new();
    private readonly SquadBlackboard _blackboard = new();

    /// <summary>The squad's shared blackboard.</summary>
    public SquadBlackboard Blackboard => _blackboard;

    /// <summary>Current squad size.</summary>
    public int MemberCount => _members.Count;

    /// <summary>Register a new squad member.</summary>
    public void RegisterMember(Transform member)
    {
        if (_members.Contains(member)) return;
        _members.Add(member);
        ReassignRoles();
    }

    /// <summary>Unregister a squad member (death, despawn).</summary>
    public void UnregisterMember(Transform member)
    {
        _members.Remove(member);
        _roles.Remove(member);
        _blackboard.AssignedPositions.Remove(member);
        ReassignRoles();
    }

    /// <summary>Get the assigned role for a squad member.</summary>
    public TacticalRole GetRole(Transform member)
    {
        return _roles.TryGetValue(member, out var role) ? role : TacticalRole.Reserve;
    }

    /// <summary>Get the assigned tactical position for a squad member.</summary>
    public Vector3 GetAssignedPosition(Transform member)
    {
        return _blackboard.AssignedPositions.TryGetValue(member, out var pos)
            ? pos
            : member.position;
    }

    /// <summary>Set the squad's target and update tactical positions.</summary>
    public void EngageTarget(Transform target)
    {
        _blackboard.PrimaryTarget = target;
        _blackboard.LastKnownTargetPosition = target.position;
        _blackboard.InCombat = true;
        UpdateTacticalPositions();
    }

    /// <summary>Disengage -- clear target and return to passive behavior.</summary>
    public void Disengage()
    {
        _blackboard.PrimaryTarget = null;
        _blackboard.InCombat = false;
        _blackboard.AssignedPositions.Clear();
    }

    private void ReassignRoles()
    {
        _roles.Clear();
        if (_members.Count == 0) return;

        int flankerCount = Mathf.Max(0, Mathf.FloorToInt(_members.Count * _tactics.FlankerRatio));
        int suppressorCount = Mathf.Max(0, Mathf.FloorToInt(_members.Count * _tactics.SuppressorRatio));

        int idx = 0;

        // First member is leader
        if (idx < _members.Count)
            _roles[_members[idx++]] = TacticalRole.Leader;

        // Assign flankers
        for (int i = 0; i < flankerCount && idx < _members.Count; i++)
            _roles[_members[idx++]] = TacticalRole.Flanker;

        // Assign suppressors
        for (int i = 0; i < suppressorCount && idx < _members.Count; i++)
            _roles[_members[idx++]] = TacticalRole.Suppressor;

        // Remainder are reserve
        while (idx < _members.Count)
            _roles[_members[idx++]] = TacticalRole.Reserve;

        if (_blackboard.InCombat)
            UpdateTacticalPositions();
    }

    private void UpdateTacticalPositions()
    {
        if (_blackboard.PrimaryTarget == null) return;

        var targetPos = _blackboard.PrimaryTarget.position;
        _blackboard.AssignedPositions.Clear();

        foreach (var member in _members)
        {
            var role = GetRole(member);
            Vector3 desiredPos = role switch
            {
                TacticalRole.Leader => targetPos +
                    (member.position - targetPos).normalized * _tactics.MinSpacing,
                TacticalRole.Flanker => CalculateFlankPosition(
                    member, targetPos),
                TacticalRole.Suppressor => targetPos +
                    (member.position - targetPos).normalized * _tactics.EngagementRadius,
                TacticalRole.Reserve => member.position, // Hold position
                _ => member.position
            };

            // Validate position on NavMesh
            if (NavMesh.SamplePosition(desiredPos, out var hit, _tactics.MinSpacing, NavMesh.AllAreas))
                desiredPos = hit.position;

            _blackboard.AssignedPositions[member] = desiredPos;
        }
    }

    private Vector3 CalculateFlankPosition(Transform member, Vector3 targetPos)
    {
        // Perpendicular offset from leader->target line
        var toTarget = (targetPos - transform.position).normalized;
        var perpendicular = Vector3.Cross(toTarget, Vector3.up);

        // Alternate left/right based on member index
        int idx = _members.IndexOf(member);
        float side = (idx % 2 == 0) ? 1f : -1f;

        return targetPos + perpendicular * side * _tactics.MinSpacing * 2f
            + toTarget * -_tactics.MinSpacing;
    }

    void Update()
    {
        if (!_blackboard.InCombat || _blackboard.PrimaryTarget == null) return;

        // Update target position tracking
        _blackboard.LastKnownTargetPosition = _blackboard.PrimaryTarget.position;

        // Periodically refresh tactical positions
        UpdateTacticalPositions();
    }

    /// <summary>Remove destroyed members.</summary>
    void LateUpdate()
    {
        for (int i = _members.Count - 1; i >= 0; i--)
        {
            if (_members[i] == null)
            {
                _members.RemoveAt(i);
                ReassignRoles();
            }
        }
    }
}

設計フック:分隊戦術は SquadTactics SO プロファイルで設定します。「積極的な包囲」では側面攻撃役の比率を高くし、交戦半径を小さくします。「慎重な距離維持」では制圧役の比率を高くし、半径を大きくします。「待ち伏せ」ではリーダーが交戦するまで、全員を予備に留めます。

注意点:メンバーが倒れると、実行時に分隊の人数が変化します。コーディネーターは、LateUpdate の null チェックと UnregisterMember で動的に役割を再割り当てする必要があります。分隊の人数をキャッシュしたり、メンバー数が固定だと想定したりしてはいけません。また、算出した戦術位置の検証には NavMesh.SamplePosition が必要です。側面攻撃位置がナビメッシュの外(壁の中など)にある場合があります。


反パターン

反パターン問題解決策
God NPC MonoBehaviour1 つの Update() で知覚・判断・行動を処理しますPerceptionComponent + NPCBlackboard + DecisionMaker + ActionExecutor に分割します
タグベースの陣営判定すべての NPC で CompareTag("Enemy") を使います。陣営を追加するたびに、すべてのスクリプトを編集する必要があります関係性マトリクスを持つ FactionDatabase SO を使用します
完全な記憶Boolean の hasSeenPlayer がリセットされません信頼度の減衰と整理機能を備えた MemoryEntry を使用します
即時の行動判断の更新中に TakeDamage() を呼び出し、予備動作やアニメーションとの連携がありませんEnter/Tick/Exit フェーズを持つ INPCAction のライフサイクルを使用します
独立した群衆 AIすべての NPC が同じ地点へ経路探索するため、密集しますSquadCoordinator が分散した位置と戦術的な役割を割り当てます
ハードコードされた判断のしきい値マジックナンバー(if (dist < 10))がコードに埋め込まれています名前付きで調整可能なフィールドを持つ DecisionProfile SO を使用します
共有の static blackboardすべての NPC が 1 つの脳を共有するため、1 体の「target」を設定すると全員に適用されますAwake() でインスタンスごとの NPCBlackboard クラスを作成します
毎フレームの知覚処理すべての NPC が毎回の Update() でレイキャストしますランダムなオフセットを加えた段階的な _updateInterval を使用します

関連スキル

  • unity-ai-navigation -- NavMeshAgent による経路探索、障害物回避、エリアコスト
  • unity-game-loop -- NPC の更新処理における Update の順序、固定タイムステップと可変タイムステップ
  • unity-data-driven -- NPC 設定(DecisionProfile、MemoryConfig、SquadTactics)向けの ScriptableObject パターン
  • unity-physics-queries -- OverlapSphereNonAlloc、レイキャストのパターン、レイヤーマスクの使用方法
  • unity-state-machines -- 判断レイヤー向けの FSM とビヘイビアツリーのアーキテクチャ
  • unity-game-architecture -- NPC 間通信のイベントバス、共有システム向けのサービスロケーター

追加リソース

コメント