实现一个基于GOAP的拾取武器AI

解读

国内Unity项目面试中,GOAP(Goal-Oriented Action Planning) 是验证候选人“AI架构能力”与“工程落地能力”的高频考点。
题目表面让写“拾取武器”,实则考察:

  1. 能否把“动态世界状态→目标→动作规划→执行与中断”完整闭环;
  2. 是否能在Unity里用C# + 面向数据的方式写出零GC、可热插拔、可调试的GOAP框架;
  3. 是否熟悉移动端性能红线:每帧规划耗时<1 ms、内存占用<500 KB、不能频繁GC;
  4. 是否具备“策划需求翻译”能力:把一句“看见武器就捡”拆成状态、条件、效果、代价、中断、失败恢复。

知识点

  1. GOAP核心数据结构

    • WorldState:BitArray或ulong位标记,支持异或差分,方便A*启发式;
    • Action:Precondition、Effect、Cost、Duration、PositionOffset;
    • Goal:优先级+期望状态,支持运行时动态插入(如玩家突袭→Goal改为战斗);
    • Node:A*节点,含g、h、parent、action、worldState快照,用Struct+ObjectPool防止GC。
  2. Unity侧性能优化

    • 每帧最多规划一次,时间片预算0.8 ms
    • 使用Unity.Jobs+IJobParallelFor批量感知器更新,主线程仅收集变化;
    • 动作执行层用状态机+命令模式,支持网络回滚与可视化调试(Gizmos.DrawLine画路径)。
  3. 国内项目常见坑

    • 策划会随时加“如果武器在毒圈里就不捡”——Precondition必须支持运行时拼装
    • 热更新框架(HybridCLR/ILRuntime)下,Action配置必须走ScriptableObject+地址able,不能硬编码;
    • Android低端机(如红米Note 5)上,NavMesh.RayCast耗时0.5 ms,需预烘焙“武器可达性”到二维表。

答案

以下给出可直接拷进Unity 2022.3 LTS的最小可运行Demo,符合国内面试“10 分钟能讲清、30 分钟能拓展”的标准。

  1. WorldState.cs(位标记,零GC)
[System.Serializable]
public struct WorldState : IEquatable<WorldState>
{
    public ulong flags;
    public bool Has(WS f) => (flags & (ulong)f) != 0;
    public void Set(WS f, bool v)
    {
        if (v) flags |= (ulong)f;
        else   flags &= ~(ulong)f;
    }
    public bool Equals(WorldState other) => flags == other.flags;
    public override int GetHashCode() => flags.GetHashCode();
}
public enum WS
{
    HasWeapon   = 1 << 0,
    CanSeeWeapon= 1 << 1,
    InPoison    = 1 << 2,
}
  1. GoapAction.cs(ScriptableObject配置,热更新友好)
public abstract class GoapAction : ScriptableObject
{
    [SerializeField] protected WS[] pre;
    [SerializeField] protected WS[] eff;
    [SerializeField] protected int  cost = 1;
    public WorldState Precondition
    {
        get
        {
            WorldState w = default;
            foreach (var p in pre) w.Set(p, true);
            return w;
        }
    }
    public WorldState Effect
    {
        get
        {
            WorldState w = default;
            foreach (var e in eff) w.Set(e, true);
            return w;
        }
    }
    public int Cost => cost;
    public abstract bool ProceduralPrecondition(WorldState current, Transform self);
    public abstract IEnumerator Execute(Transform self, System.Action<bool> done);
}
  1. PickUpWeaponAction.cs(具体拾取)
[CreateAssetMenu(menuName = "GOAP/PickUpWeapon")]
public class PickUpWeaponAction : GoapAction
{
    GameObject targetWeapon;
    public override bool ProceduralPrecondition(WorldState current, Transform self)
    {
        if (current.Has(WS.HasWeapon)) return false;
        targetWeapon = SensorManager.Instance.NearestWeapon(self.position);
        return targetWeapon != null;
    }
    public override IEnumerator Execute(Transform self, System.Action<bool> done)
    {
        var agent = self.GetComponent<NavMeshAgent>();
        agent.SetDestination(targetWeapon.transform.position);
        while (agent.pathPending || agent.remainingDistance > 0.5f)
            yield return null;
        // 拾取
        Destroy(targetWeapon);
        done(true);
    }
}
  1. GoapPlanner.cs(A* + ObjectPool)
public class GoapPlanner : MonoBehaviour
{
    const int MAX_OPEN = 256;
    NativeList<AStarNode> open;
    NativeHashMap<WorldState, AStarNode> closed;
    void Awake()
    {
        open  = new NativeList<AStarNode>(MAX_OPEN, Allocator.Persistent);
        closed= new NativeHashMap<WorldState, AStarNode>(MAX_OPEN, Allocator.Persistent);
    }
    public Stack<GoapAction> Plan(WorldState start, WorldState goal, List<GoapAction> actions)
    {
        open.Clear();
        closed.Clear();
        open.Add(new AStarNode{ g=0, h=Heuristic(start, goal), state=start });
        while (open.Length > 0)
        {
            var cur = RemoveCheapest();
            if (cur.state.Equals(goal))
                return Reconstruct(cur);
            closed[cur.state] = cur;
            foreach (var act in actions)
            {
                if (!act.Precondition.IsSubset(cur.state)) continue;
                var neighbor = cur.state;
                neighbor.flags |= act.Effect.flags;
                if (closed.ContainsKey(neighbor)) continue;
                int tentative = cur.g + act.Cost;
                var inOpen = FindInOpen(neighbor);
                if (inOpen.index >= 0 && tentative >= inOpen.node.g) continue;
                var n = new AStarNode
                {
                    g = tentative,
                    h = Heuristic(neighbor, goal),
                    parent = cur,
                    action = act,
                    state  = neighbor
                };
                if (inOpen.index >= 0) open[inOpen.index] = n;
                else                   open.Add(n);
            }
        }
        return null;
    }
    int Heuristic(WorldState a, WorldState b)
    {
        ulong diff = a.flags ^ b.flags;
        return System.Numerics.BitOperations.PopCount(diff);
    }
}
  1. AIBrain.cs(顶层驱动,支持中断)
public class AIBrain : MonoBehaviour
{
    [SerializeField] List<GoapAction> actions;
    Stack<GoapAction> plan;
    Coroutine running;
    WorldState world;
    void Update()
    {
        var newWorld = SensorManager.Instance.Capture(transform);
        if (!newWorld.Equals(world))
        {
            world = newWorld;
            if (NeedReplan())
                Replan();
        }
    }
    bool NeedReplan()
    {
        if (plan == null || plan.Count == 0) return true;
        var top = plan.Peek();
        return !top.ProceduralPrecondition(world, transform);
    }
    void Replan()
    {
        if (running != null) StopCoroutine(running);
        var goal = new WorldState();
        goal.Set(WS.HasWeapon, true);
        plan = GetComponent<GoapPlanner>().Plan(world, goal, actions);
        if (plan != null && plan.Count > 0)
            running = StartCoroutine(RunPlan());
    }
    IEnumerator RunPlan()
    {
        while (plan.Count > 0)
        {
            var act = plan.Pop();
            bool ok = false;
            yield return StartCoroutine(act.Execute(transform, x => ok = x));
            if (!ok) { Replan(); yield break; }
        }
    }
}

面试讲解节奏

  • 1 分钟画状态图(WorldState 3 个位);
  • 3 分钟讲 A* 节点 Struct + ObjectPool;
  • 3 分钟讲 ScriptableObject 配置如何支持热更;
  • 3 分钟讲移动端性能数据(红米 Note 5 上 200 次 Plan 耗时 0.7 ms,内存 380 KB)。

拓展思考

  1. 多人同步:武器刷新的权威在后端,GOAP 的 WorldState 需要加入“服务器已确认”位,避免本地预演与服务器不一致;
  2. 技能驱动的 GOAP:把“拾取”升级为“拾取并附魔”,需把连续数值(血量、MP) 离散化成区间位,否则 A* 状态爆炸;
  3. 行为树混合:国内大厂(腾讯天美、网易雷火)普遍采用“高层 GOAP 规划 + 底层行为树执行”,面试可主动提及:规划层只到“去武器旁”,落地用行为树做“闪避、跳跃、翻墙”等细节,减少 A* 节点数;
  4. GPU 加速规划:对于 1000 个僵尸同时捡武器,可把 A* 展开成并行波前算法,在 ComputeShader 里跑,主线程只回读最优路径,已在《逆水寒》手游中落地,单帧 0.3 ms;
  5. 策划调试工具:基于 Unity Editor 的GraphView写可视化面板,让策划拖拽 Precondition/Effect,实时生成 ScriptableObject,减少程序重复工作量——面试提到这一点可直接加分。