Token导航 LogoToken导航

Agent Skill · 研究检索

unity-npc-behavior(Unity NPC behavior 搜索)

unity-npc-behavior 用于查找、检索和筛选相关信息,适合在 Codex、Claude、Cursor、Gemini CLI 中需要根据关键词、任务场景或来源线索快速定位候选结果时使用。可结合来源仓库、安装命令和原始 README 继续核验具体用法。安装前建议确认权限范围、维护状态,以及是否会触发联网、命令执行或文件读写。

unity-npc-behavior 是研究检索类 Agent Skill,可用宿主为 Codex、Claude、Cursor、Gemini CLI,通过GitHub 仓库获取,权限为权限未标注;许可证信息还不明确,第三方扫描已通过,按安装量在研究检索类的 73,675 个 Skill 里排第 62,606。

总安装 329周安装 14GitHub Stars 14下载 115核验 2026-05-01

安装说明

本站只整理中文说明,不托管安装包,也不代用户安装。
GitHub
许可证
unknown

安装方式

通过对话安装

复制提示词发给支持本地命令或 Skills 的 AI 助手,先确认命令和权限,再让它执行。

请帮我安装这个 Agent Skill:unity-npc-behavior(Unity NPC behavior 搜索)
来源仓库:https://github.com/nice-wolf-studio/unity-claude-skills
仓库路径:skills/unity-npc-behavior
安装命令:
npx skills add https://github.com/nice-wolf-studio/unity-claude-skills --skill unity-npc-behavior
安装前请先检查当前环境是否支持对应 CLI,并向我确认将要执行的命令、安装目录、联网范围和文件读写权限;确认后再执行。

命令行安装

复制命令到本机终端执行。该命令会通过 npx skills 从第三方来源获取 Skill;本站只展示命令,不托管安装包,也不自动执行。

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

这个 Skill 是做什么的

研究检索类 Skill 主要解决「找资料、核对事实、把来源整理清楚」的问题,通常会让助手先检索再总结。

unity-npc-behavior 用于查找、检索和筛选相关信息。适合在 Codex、Claude、Cursor、Gemini CLI 中根据关键词或任务场景快速定位候选结果。专注于 NPC 行为设计与 AI 逻辑相关的资源检索。安装前需确认权限范围、维护状态及是否触发联网或命令执行。

建议结合原始 README 核验具体功能与使用方式。

来源仓库内路径:skills/unity-npc-behavior

上手步骤

4 步
  1. 1

    确认自己的宿主工具

    公开资料标注支持 Codex、Claude、Cursor、Gemini CLI;先确认自己用的是其中之一,再按对应工具的 Skills 目录放置。

  2. 2

    拿到安装命令

    可以直接复制的命令是 npx skills add https://github.com/nice-wolf-studio/unity-claude-skills --skill u…;命令里的占位符换成自己的路径或密钥再执行。

  3. 3

    安装后重启宿主

    命令执行完要完全退出宿主工具再打开,让新装的 Skill 被重新扫描到,否则列表里可能看不到。

  4. 4

    按最小权限试用

    权限没有明确标注,第一次使用时只给一个无关紧要的任务,观察它到底读了哪些文件和网络。

常见问题

6 条

unity-npc-behavior 怎么安装?

在终端执行 npx skills add https://github.com/nice-wolf-studio/unity-claude-skills --skill unity-npc-b…。装完完全退出宿主工具再打开,让它重新扫描本地 Skill。

unity-npc-behavior 支持哪些 AI 工具?

公开资料标注支持 Codex、Claude、Cursor、Gemini CLI。其他工具只要能读取本地 Skill 目录,一般也能手动放进去,但要自己确认触发方式是否一致。

unity-npc-behavior 需要什么权限?

权限级别没有明确标注。第一次使用时建议只给无关紧要的任务,观察它实际读写了哪些内容。

unity-npc-behavior 是免费的吗?

公开资料没有标注明确许可证,收费和商用限制都无法确认。正式使用前请到来源仓库核对授权说明。

unity-npc-behavior 用的人多吗?

公开数据是累计安装 329 次,近一周安装 14 次,下载 115 次,GitHub 14 星。这些数字来自第三方目录统计,只能当作热度参考,不代表质量或安全性。

unity-npc-behavior 安全吗?

第三方扫描结果为通过,但这只覆盖已知的静态检查项,不能替代自己读一遍源码和安装命令。

值得选的地方和要先想清楚的地方

值得选的地方

  • 同时适配 4 种工具,换客户端不用重新找替代品。
  • 第三方扫描已通过,常见的高风险行为没有命中。

先想清楚的地方

  • 许可证没有明确标注,能不能商用、能不能再分发都不确定。
  • 第三方目录整理,版本更新和行为细节以来源仓库为准。

适不适合你

更适合

  • 已经在用 Codex,想直接扩展它能力的人。
  • 需要处理研究检索类任务、又不想每次都手写提示词的人。

不太适合

  • 需要明确授权才能用到公司项目里的场景。
  • 需要厂商级技术支持和 SLA 的生产系统。

研究检索类 Skill 对比

6 个同类
Skill总安装GitHub 星权限许可证扫描
find-skills

寻找技能 · Codex、Claude

31,199,81316,486操作浏览器MIT有提醒
self-improving-agent

自我改进 Agent · OpenClaw

16,464,0023,396接触敏感数据MIT-0通过
soultrace

灵魂追踪 · Codex、Claude

7,588,94412需要联网未明确有提醒
self-improving

自我完善 · OpenClaw

6,829,2841,046需要联网MIT-0通过
microsoft-foundry

微软代工厂 · Codex、Claude

6,614,144764调用外部服务未明确有提醒
unity-npc-behavior

Unity NPC behavior 搜索 · 本页

32914权限未标注未明确通过

按安装量取同分类前几名;数字来自第三方目录统计,只作热度参考,不代表质量结论。

SKILL.md 原文

NPC Behavior -- Design Translation Patterns

Prerequisite skills: unity-physics-queries (perception raycasts, NonAlloc query patterns), unity-state-machines (decision FSM/BT architecture), unity-game-architecture (event bus, Service Locator, component composition)

Claude's default NPC is a single MonoBehaviour with an Update() that raycasts, decides, and moves all in one method. This is untestable and unextensible -- changing perception logic requires touching decision logic, adding a new sense means rewriting the entire method, and squad coordination is impossible when each NPC is a self-contained island. These patterns decompose NPC behavior into pluggable layers that designers can configure without programmer intervention.


PATTERN: Perception System Architecture

DESIGN INTENT: NPCs sense the world through sight, hearing, and proximity. Different NPC types have different senses -- a guard has long-range sight but poor hearing; a dog has short sight but exceptional hearing. Senses are mix-and-match components that feed a unified target list.

WRONG (Claude default):

// 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);
            }
        }
    }
}

RIGHT:

/// <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;
}

SCAFFOLD (full implementation):

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"
                    });
                }
            }
        }
    }
}

DESIGN HOOK: New senses = new IPerceptionSense MonoBehaviour on the NPC prefab. Configure range, angle, update rate, and target layers per sense. PerceptionComponent auto-discovers senses via GetComponents.

GOTCHA: Perception queries are expensive. The _updateInterval staggers updates so not every NPC queries every frame. With 50 NPCs at 0.2s interval, only ~5 run per frame instead of 50. Also: OverlapSphereNonAlloc requires a pre-allocated buffer -- size it for your max expected nearby targets.


PATTERN: Decision Layer Patterns

DESIGN INTENT: Different NPC archetypes (aggressive, cautious, cowardly) share the same perception but differ in decisions. A guard and a civilian both see the player -- the guard attacks, the civilian flees. Decision logic is data-driven per archetype.

WRONG (Claude default):

// 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();
}

RIGHT:

/// <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;
}

SCAFFOLD (full implementation):

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");
        }
    }
}

DESIGN HOOK: New NPC archetypes = new DecisionProfile ScriptableObject asset. A "coward" profile sets FleeRange = 15f, AggroRange = 0f. A "berserker" sets FleeRange = 0f, AggroRange = 30f. No code changes.

GOTCHA: NPCBlackboard must be a per-NPC class instance, never a struct (value-type copying breaks shared references) and never static (all NPCs would share one brain). Allocate in Awake().


PATTERN: Action Execution Pipeline

DESIGN INTENT: Once an NPC decides to attack, the action plays out with wind-up animation, strike timing, damage application, and recovery. Actions can be interrupted mid-execution (enemy staggers during wind-up). Actions are not instantaneous.

WRONG (Claude default):

// 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
    }
}

RIGHT:

/// <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;
}

SCAFFOLD (full implementation):

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");
        }
    }
}

DESIGN HOOK: New actions implement INPCAction. Timing values (wind-up, strike, recovery) belong on a ScriptableObject config. Animation event names are string constants -- keep them in a shared AnimationKeys class.

GOTCHA: If interrupted during wind-up, damage must NOT apply. The Exit(wasInterrupted: true) path must cancel any pending damage. Test this case explicitly.


PATTERN: Faction & Relationship System

DESIGN INTENT: NPCs belong to factions with configurable relationships. Guards are hostile to bandits, neutral to civilians, friendly to other guards. Relationships can change at runtime (betrayal, alliance shifts). Perception filters results by faction stance.

WRONG (Claude default):

// 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
}

RIGHT:

/// <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) { /* ... */ }
}

SCAFFOLD (full implementation):

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;
    }
}

DESIGN HOOK: Designers edit the faction relationship matrix in the Inspector on the FactionDatabase asset. Runtime events (quest completion, story beats) call SetRelationshipRuntime to shift alliances. Perception filtering uses FactionMember.IsHostileTo() to decide whether a detected target is a threat.

GOTCHA: Relationship lookup must be bidirectional. If you define Guards->Bandits as Hostile but forget Bandits->Guards, the database falls back to the default. The GetRelationship implementation checks both orderings, but designers should still define both explicitly for clarity. Also: runtime overrides are lost on domain reload -- persist them in your save system if needed.


PATTERN: NPC Memory & Forgetting

DESIGN INTENT: NPCs remember where they last saw the player, heard a noise, or were attacked. But memories fade -- if the player hides for 30 seconds, the guard stops searching and returns to patrol. Different NPCs have different memory spans (elite guards remember longer). Memory drives investigation behavior: NPCs go to the LAST KNOWN position, not the current one.

WRONG (Claude default):

// 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
}

RIGHT:

/// <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)
}

SCAFFOLD (full implementation):

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();
}

DESIGN HOOK: Memory duration and decay rate on MemoryConfig SO per archetype. Elite guards get 60s memory; basic enemies get 15s. Integrate with blackboard: when best memory confidence drops below threshold, NPC switches from "Search" to "ReturnToPatrol."

GOTCHA: Memory positions are world-space snapshots, NOT live references. When the NPC acts on a memory, it investigates the OLD position. This is intentional -- it creates search behavior. If the player moved, the NPC arrives at an empty spot and must re-perceive. Do NOT "fix" this by updating memory positions to track the target.


PATTERN: Crowd & Squad Coordination

DESIGN INTENT: Multiple NPCs coordinate tactically. A group of enemies doesn't all pathfind to the same point and stack on top of each other. Instead, a coordinator assigns tactical roles: one flanks left, one flanks right, one suppresses from range. When squad members die, roles are reassigned dynamically.

WRONG (Claude default):

// 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
}

RIGHT:

/// <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).
}

SCAFFOLD (full implementation):

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();
            }
        }
    }
}

DESIGN HOOK: Squad tactics = SquadTactics SO profiles. "Aggressive Surround" uses high flanker ratio and small engagement radius. "Cautious Distance" uses high suppressor ratio and large radius. "Ambush" keeps everyone in Reserve until leader engages.

GOTCHA: Squad size changes at runtime as members die. The coordinator must reassign roles dynamically in LateUpdate null-checks and in UnregisterMember. Never cache squad size or assume fixed member count. Also: NavMesh.SamplePosition is needed to validate calculated tactical positions -- a flanking position might be off the navmesh (inside a wall).


Anti-Patterns

Anti-PatternProblemSolution
God NPC MonoBehaviourSingle Update() does perception + decision + actionSeparate into PerceptionComponent + NPCBlackboard + DecisionMaker + ActionExecutor
Tag-based faction checksCompareTag("Enemy") in every NPC; adding a faction means editing all scriptsFactionDatabase SO with relationship matrix
Perfect memoryBoolean hasSeenPlayer never resetsMemoryEntry with confidence decay and pruning
Instant actionsTakeDamage() in decision tick, no wind-up or animation integrationINPCAction lifecycle with Enter/Tick/Exit phases
Independent crowd AIEvery NPC pathfinds to same point, stacking upSquadCoordinator assigns spread positions and tactical roles
Hardcoded decision thresholdsMagic numbers (if (dist < 10)) buried in codeDecisionProfile SO with named, tunable fields
Shared static blackboardAll NPCs share one brain; setting "target" for one sets it for allPer-instance NPCBlackboard class in Awake()
Perception every frameEvery NPC raycasts every Update()Staggered _updateInterval with random offset

Related Skills

  • unity-ai-navigation -- NavMeshAgent pathfinding, obstacle avoidance, area costs
  • unity-game-loop -- Update ordering, fixed vs variable timestep for NPC ticks
  • unity-data-driven -- ScriptableObject patterns for NPC configs (DecisionProfile, MemoryConfig, SquadTactics)
  • unity-physics-queries -- OverlapSphereNonAlloc, raycast patterns, layer mask usage
  • unity-state-machines -- FSM and Behavior Tree architecture for the decision layer
  • unity-game-architecture -- Event bus for NPC-to-NPC communication, Service Locator for shared systems

Additional Resources

平台分布

Codex

34.86%
按下载量换算40

Claude

31.54%
按下载量换算36

Cursor

19.52%
按下载量换算22

Gemini CLI

9.29%
按下载量换算11

安全审计

Gen Agent Trust Hub

通过

Socket

通过

Snyk

通过

权限和风险

权限未标注

当前来源未能明确判断权限范围,默认进入异常复核队列。

安装前确认

本站仅展示第三方公开信息,不托管安装包,不提供自动安装或运行环境。安装前应自行审查源码、依赖和命令行为。目前只有一个公开来源,建议同时核对源仓库。

继续浏览同类 Skills