mcpField
U
unity-npc-behavior

unity-npc-behavior

github.com/nice-wolf-studio开发4065 次安装6 周前更新
代码仓库

关于 unity-npc-behavior

面向 Unity NPC 开发的设计模式技能,将感知、决策、动作、阵营、记忆与小队协作拆分为可配置层,帮助设计师调整行为而无需改动代码。

主要功能

  1. 01可插拔感知组件与目标列表
  2. 02按原型配置决策档案
  3. 03支持动作生命周期与中断
  4. 04可配置阵营关系及运行时变化
  5. 05带衰减的 NPC 记忆
  6. 06小队角色分配与战术协作

适用场景

  1. 01为不同类型 NPC 配置视觉、听觉等感知
  2. 02让守卫与平民采用不同决策策略
  3. 03处理攻击蓄力中断和伤害取消
  4. 04设计 NPC 搜索记忆位置及小队包围战术

安装

复制下面的命令,在终端中运行。

$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(事件总线、服务定位器、组件组合)

Claude 默认的 NPC 是一个单独的 MonoBehaviour,其中的 Update() 会在一个方法里完成射线检测、决策和移动。这种设计无法测试,也难以扩展——更改感知逻辑就必须触及决策逻辑,添加一种新感官就意味着重写整个方法,而当每个 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 都会在每帧查询。若有 50 个 NPC,间隔为 0.2 秒,则每帧只运行约 5 个,而不是 50 个。另外,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 独有的类实例,不能是结构体(值类型复制会破坏共享引用),也不能是静态对象(所有 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() 来判断检测到的目标是否构成威胁。

注意事项:关系查询必须是双向的。如果定义了 Guards->Bandits 为 Hostile,却忘记定义 Bandits->Guards,数据库就会回退到默认值。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 上设置记忆时长和衰减速率。精英守卫的记忆时长设为 60s;基础敌人为 15s。与黑板集成:当最佳记忆的可信度降到阈值以下时,NPC 从“搜索”切换到“返回巡逻”。

注意事项:记忆位置是世界空间快照,而不是实时引用。NPC 根据记忆采取行动时,会调查旧位置。这是有意为之——这样才能形成搜索行为。如果玩家已经移动,NPC 到达的将是空位置,必须重新感知。不要通过更新记忆位置来追踪目标,以此“修复”这一行为。


模式:人群与小队协同

设计意图:多个 NPC 进行战术协同。一群敌人不会全都寻路到同一个点并挤在一起,而是由协调器分配战术角色:一名从左侧包抄,一名从右侧包抄,一名在远处提供火力压制。小队成员阵亡时,会动态重新分配角色。

错误做法(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 配置方案。“Aggressive Surround”采用较高的包抄者比例和较小的交战半径。“Cautious Distance”采用较高的压制者比例和较大的半径。“Ambush”会让所有成员保持预备状态,直到队长开始交战。

注意事项:小队成员阵亡时,运行时的小队规模会改变。协调器必须在 LateUpdate 的空值检查中以及 UnregisterMember 中动态重新分配角色。切勿缓存小队规模或假设成员数量固定。另外:需要使用 NavMesh.SamplePosition 验证计算出的战术位置——包抄位置可能位于导航网格之外(例如墙内)。


反模式

反模式问题解决方案
臃肿的 NPC MonoBehaviour单个 Update() 同时处理感知、决策和行动拆分为 PerceptionComponent + NPCBlackboard + DecisionMaker + ActionExecutor
基于标签检查阵营每个 NPC 都调用 CompareTag("Enemy");新增阵营就需要修改所有脚本使用带有关系矩阵的 FactionDatabase SO
完美记忆布尔值 hasSeenPlayer 永不重置使用带有置信度衰减和清理机制的 MemoryEntry
即时行动在决策 tick 中调用 TakeDamage(),没有预备动作或动画集成使用包含 Enter/Tick/Exit 阶段的 INPCAction 生命周期
独立的群体 AI每个 NPC 都寻路到同一个点,导致挤成一团由 SquadCoordinator 分配分散的位置和战术角色
硬编码决策阈值魔法数字(if (dist < 10))埋在代码中使用包含命名且可调整字段的 DecisionProfile SO
共享静态黑板所有 NPC 共用一个大脑;为一个 NPC 设置“目标”会影响所有 NPC在 Awake() 中创建每个实例专属的 NPCBlackboard 类
每帧进行感知每个 NPC 都在每次 Update() 中执行射线检测使用带随机偏移的 _updateInterval 错开更新

相关技能

  • unity-ai-navigation -- NavMeshAgent 寻路、障碍物规避、区域成本
  • unity-game-loop -- NPC tick 的 Update 顺序、固定与可变时间步长
  • unity-data-driven -- NPC 配置的 ScriptableObject 模式(DecisionProfile、MemoryConfig、SquadTactics)
  • unity-physics-queries -- OverlapSphereNonAlloc、射线检测模式、图层遮罩用法
  • unity-state-machines -- 决策层的 FSM 和行为树架构
  • unity-game-architecture -- 用于 NPC 之间通信的事件总线,以及用于共享系统的 Service Locator

更多资源

评论

支持 Markdown