关于 unity-npc-behavior
面向 Unity NPC 开发的设计模式技能,将感知、决策、动作、阵营、记忆与小队协作拆分为可配置层,帮助设计师调整行为而无需改动代码。
主要功能
- 01可插拔感知组件与目标列表
- 02按原型配置决策档案
- 03支持动作生命周期与中断
- 04可配置阵营关系及运行时变化
- 05带衰减的 NPC 记忆
- 06小队角色分配与战术协作
适用场景
- 01为不同类型 NPC 配置视觉、听觉等感知
- 02让守卫与平民采用不同决策策略
- 03处理攻击蓄力中断和伤害取消
- 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