实现一个基于GOAP的拾取武器AI
解读
国内Unity项目面试中,GOAP(Goal-Oriented Action Planning) 是验证候选人“AI架构能力”与“工程落地能力”的高频考点。
题目表面让写“拾取武器”,实则考察:
- 能否把“动态世界状态→目标→动作规划→执行与中断”完整闭环;
- 是否能在Unity里用C# + 面向数据的方式写出零GC、可热插拔、可调试的GOAP框架;
- 是否熟悉移动端性能红线:每帧规划耗时<1 ms、内存占用<500 KB、不能频繁GC;
- 是否具备“策划需求翻译”能力:把一句“看见武器就捡”拆成状态、条件、效果、代价、中断、失败恢复。
知识点
-
GOAP核心数据结构
- WorldState:BitArray或ulong位标记,支持异或差分,方便A*启发式;
- Action:Precondition、Effect、Cost、Duration、PositionOffset;
- Goal:优先级+期望状态,支持运行时动态插入(如玩家突袭→Goal改为战斗);
- Node:A*节点,含g、h、parent、action、worldState快照,用Struct+ObjectPool防止GC。
-
Unity侧性能优化
- 每帧最多规划一次,时间片预算0.8 ms;
- 使用Unity.Jobs+IJobParallelFor批量感知器更新,主线程仅收集变化;
- 动作执行层用状态机+命令模式,支持网络回滚与可视化调试(Gizmos.DrawLine画路径)。
-
国内项目常见坑
- 策划会随时加“如果武器在毒圈里就不捡”——Precondition必须支持运行时拼装;
- 热更新框架(HybridCLR/ILRuntime)下,Action配置必须走ScriptableObject+地址able,不能硬编码;
- Android低端机(如红米Note 5)上,NavMesh.RayCast耗时0.5 ms,需预烘焙“武器可达性”到二维表。
答案
以下给出可直接拷进Unity 2022.3 LTS的最小可运行Demo,符合国内面试“10 分钟能讲清、30 分钟能拓展”的标准。
- 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,
}
- 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);
}
- 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);
}
}
- 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);
}
}
- 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)。
拓展思考
- 多人同步:武器刷新的权威在后端,GOAP 的 WorldState 需要加入“服务器已确认”位,避免本地预演与服务器不一致;
- 技能驱动的 GOAP:把“拾取”升级为“拾取并附魔”,需把连续数值(血量、MP) 离散化成区间位,否则 A* 状态爆炸;
- 行为树混合:国内大厂(腾讯天美、网易雷火)普遍采用“高层 GOAP 规划 + 底层行为树执行”,面试可主动提及:规划层只到“去武器旁”,落地用行为树做“闪避、跳跃、翻墙”等细节,减少 A* 节点数;
- GPU 加速规划:对于 1000 个僵尸同时捡武器,可把 A* 展开成并行波前算法,在 ComputeShader 里跑,主线程只回读最优路径,已在《逆水寒》手游中落地,单帧 0.3 ms;
- 策划调试工具:基于 Unity Editor 的GraphView写可视化面板,让策划拖拽 Precondition/Effect,实时生成 ScriptableObject,减少程序重复工作量——面试提到这一点可直接加分。