ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

大型战斗 NPC 的 GOAP 实践:团队协同攻击与掩体包抄逻辑

大型战斗 NPC 的 GOAP 实践:团队协同攻击与掩体包抄逻辑 大型战斗 NPC 的 GOAP 实践团队协同攻击与掩体包抄逻辑在传统的有限状态机FSM或行为树Behavior Tree中当战斗策划提出“两名步枪手在掩体后提供压制火力一名散弹枪手从左侧侧翼包抄一名医疗兵在后方伺机救援”这类战术需求时行为树的节点连接会迅速膨胀成错综复杂的网状结构。任何新增的战术动作都会导致前置条件判断产生组合爆炸。目标导向型行动计划GOAP, Goal-Oriented Action Planning通过将“状态转移”解耦为“世界状态World State”、“目标状态Goal State”与带有前置条件和后置效果的“动作原子集Action Set”利用 A* 搜索在动作空间中逆向或正向推导执行序列。而在小队协同作战中单一 NPC 的 GOAP 无法感知全局战术必须引入战术黑板Tactical Blackboard与动态权重修正让小队成员在统一的战术意图下分工协作。小队战术黑板与共享世界状态GOAP 动作决策的前提是对环境状态进行符号化。单个士兵的世界状态不仅包括自身生命值、弹药量、掩体占用状态还必须包含小队共享的战术标记如目标被压制程度、当前包抄席位占用情况。using System; using System.Collections.Generic; using Unity.Mathematics; using UnityEngine; // 采用位掩码与紧凑字典表示世界状态避免堆内存分配 public struct SquadWorldState : IEquatableSquadWorldState { public uint Flags; // 基础布尔状态如 TargetInSight, HasCover, LowAmmo public int SuppressiveFireTokens; // 当前正在执行压制射击的人数 public bool FlankRouteOccupied; // 是否已有队员在包抄路径上 public float TargetSuppressionLevel; // 目标被压制进度 [0, 1] public bool HasFlag(uint flag) (Flags flag) ! 0; public void SetFlag(uint flag, bool value) { if (value) Flags | flag; else Flags ~flag; } public bool Equals(SquadWorldState other) { return Flags other.Flags SuppressiveFireTokens other.SuppressiveFireTokens FlankRouteOccupied other.FlankRouteOccupied; } } public static class StateFlags { public const uint InCover 1 0; public const uint TargetVisible 1 1; public const uint FlankPositionReached 1 2; public const uint TargetSuppressed 1 3; public const uint WeaponLoaded 1 4; }动作原子设计包抄与压制火力每个 GOAP 动作定义了三要素Preconditions前置条件执行该动作必须满足的状态。Effects效果动作成功完成后对世界状态的修改。Dynamic Cost动态代价结合物理距离、掩体安全性与角色职业倾向计算出的路径权重。public abstract class GOAPAction { public string ActionName; public float BaseCost 1.0f; public abstract bool CheckProceduralPrecondition(AgentController agent, SquadTacticalBlackboard blackboard); public abstract bool IsPreconditionMet(SquadWorldState state); public abstract SquadWorldState ApplyEffects(SquadWorldState state); public abstract float CalculateDynamicCost(AgentController agent, SquadTacticalBlackboard blackboard); public abstract void OnExecute(AgentController agent); public abstract bool OnTick(AgentController agent, float deltaTime); } // 战术动作 1寻找并移动至侧翼掩体 public class ActionFlankToCover : GOAPAction { public ActionFlankToCover() { ActionName FlankToCover; BaseCost 3.0f; } public override bool CheckProceduralPrecondition(AgentController agent, SquadTacticalBlackboard blackboard) { // 过程式前置条件必须存在可用的侧翼掩体且小队当前包抄席位未满 return !blackboard.IsFlankSlotFull() blackboard.FindBestFlankCover(agent.transform.position, out _); } public override bool IsPreconditionMet(SquadWorldState state) { // 必须在目标已被压制或小队有持续火力时才允许冲刺包抄降低暴毙概率 return state.HasFlag(StateFlags.TargetSuppressed) || state.SuppressiveFireTokens 1; } public override SquadWorldState ApplyEffects(SquadWorldState state) { var next state; next.SetFlag(StateFlags.InCover, true); next.SetFlag(StateFlags.FlankPositionReached, true); next.FlankRouteOccupied true; return next; } public override float CalculateDynamicCost(AgentController agent, SquadTacticalBlackboard blackboard) { // 散弹枪兵或突击兵执行包抄的代价极低狙击手代价极高 float roleMultiplier agent.Role switch { AgentRole.Assault 0.5f, AgentRole.Shotgunner 0.3f, AgentRole.Sniper 5.0f, _ 1.0f }; return BaseCost * roleMultiplier; } public override void OnExecute(AgentController agent) { /* 触发寻路与下蹲移动 */ } public override bool OnTick(AgentController agent, float deltaTime) { return true; /* 返回是否完成 */ } } // 战术动作 2提供持续压制射击 public class ActionProvideSuppression : GOAPAction { public ActionProvideSuppression() { ActionName ProvideSuppression; BaseCost 2.0f; } public override bool CheckProceduralPrecondition(AgentController agent, SquadTacticalBlackboard blackboard) { return agent.CurrentAmmo 10; } public override bool IsPreconditionMet(SquadWorldState state) { return state.HasFlag(StateFlags.InCover) state.HasFlag(StateFlags.TargetVisible); } public override SquadWorldState ApplyEffects(SquadWorldState state) { var next state; next.SetFlag(StateFlags.TargetSuppressed, true); next.SuppressiveFireTokens 1; return next; } public override float CalculateDynamicCost(AgentController agent, SquadTacticalBlackboard blackboard) { // 重机枪兵与步枪兵优先提供压制 return agent.Role AgentRole.Gunner ? 0.4f : BaseCost; } public override void OnExecute(AgentController agent) { /* 启动射击循环并向玩家掩体射击 */ } public override bool OnTick(AgentController agent, float deltaTime) { return true; } }A* 规划器与动态重规划规划器Planner以当前小队与个体的状态作为根节点使用 A* 算法在动作空间中搜索能够达成 Goal 的最优路径序列。public class GOAPPlanner { private class Node { public Node Parent; public float RunningCost; public SquadWorldState State; public GOAPAction Action; } public static QueueGOAPAction Plan(AgentController agent, SquadWorldState startState, FuncSquadWorldState, bool goalCondition, ListGOAPAction availableActions, SquadTacticalBlackboard blackboard) { ListNode openList new ListNode(); HashSetSquadWorldState closedSet new HashSetSquadWorldState(); openList.Add(new Node { Parent null, RunningCost 0, State startState, Action null }); while (openList.Count 0) { // 挑选当前开销最低的节点 openList.Sort((a, b) a.RunningCost.CompareTo(b.RunningCost)); Node current openList[0]; openList.RemoveAt(0); if (goalCondition(current.State)) { // 达成目标反向重构动作执行队列 var plan new StackGOAPAction(); Node curr current; while (curr.Action ! null) { plan.Push(curr.Action); curr curr.Parent; } return new QueueGOAPAction(plan); } closedSet.Add(current.State); foreach (var action in availableActions) { if (!action.CheckProceduralPrecondition(agent, blackboard)) continue; if (!action.IsPreconditionMet(current.State)) continue; SquadWorldState nextState action.ApplyEffects(current.State); if (closedSet.Contains(nextState)) continue; float stepCost action.CalculateDynamicCost(agent, blackboard); openList.Add(new Node { Parent current, RunningCost current.RunningCost stepCost, State nextState, Action action }); } } return null; // 无法构建合法计划 } }掩体评估与侧翼包抄几何判定包抄的核心是计算出敌人掩体视线盲区内的突击点。在实际工程中不能仅靠静态路网节点必须结合射线检测与点积评估掩体朝向与玩家视锥夹角设玩家位置为 $\vec{P}{player}$掩体法线为 $\vec{N}{cover}$掩体位置为 $\vec{P}{cover}$。掩体能有效阻挡玩家射击的条件为$$(\vec{P}{player} - \vec{P}{cover}) \cdot \vec{N}{cover} \cos(\theta_{threshold})$$包抄点有效性判定包抄点 $\vec{P}{flank}$ 必须满足从 $\vec{P}{flank}$ 到 $\vec{P}{player}$ 的连线不会被目标掩体遮挡且 $\vec{P}{flank} - \vec{P}_{player}$ 与掩体原始法线形成大于 60 度的侧向夹角。小队黑板每隔 200ms 会更新一次掩体热度图Cover Heatmap。当机枪兵成功锁定玩家并打出压制时黑板的SuppressiveFireTokens自增使得散弹枪手的ActionFlankToCover前置条件得以满足Planner 立即求解出“掩体移动 - 投掷烟雾弹 - 侧翼包抄 - 近距离开火”的高效战术序列。通过这种解耦设计策划只需要给新兵种配置不同的 Action 基础代价与可用动作池无需重写任何状态跳转逻辑AI 即可在战场中表现出高度默契的战术协作。
返回列表