大型战斗 NPC 的 GOAP 实践:团队协同攻击与掩体包抄逻辑
2026/9/13 7:10:44 网站建设 项目流程

大型战斗 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 : IEquatable<SquadWorldState> { 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 动作定义了三要素:

  1. Preconditions(前置条件):执行该动作必须满足的状态。
  2. Effects(效果):动作成功完成后对世界状态的修改。
  3. 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 Queue<GOAPAction> Plan(AgentController agent, SquadWorldState startState, Func<SquadWorldState, bool> goalCondition, List<GOAPAction> availableActions, SquadTacticalBlackboard blackboard) { List<Node> openList = new List<Node>(); HashSet<SquadWorldState> closedSet = new HashSet<SquadWorldState>(); 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 Stack<GOAPAction>(); Node curr = current; while (curr.Action != null) { plan.Push(curr.Action); curr = curr.Parent; } return new Queue<GOAPAction>(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; // 无法构建合法计划 } }

掩体评估与侧翼包抄几何判定

包抄的核心是计算出敌人掩体视线盲区内的突击点。在实际工程中,不能仅靠静态路网节点,必须结合射线检测与点积评估:

  1. 掩体朝向与玩家视锥夹角
    设玩家位置为 $\vec{P}{player}$,掩体法线为 $\vec{N}{cover}$,掩体位置为 $\vec{P}{cover}$。掩体能有效阻挡玩家射击的条件为:
    $$(\vec{P}
    {player} - \vec{P}{cover}) \cdot \vec{N}{cover} > \cos(\theta_{threshold})$$
  2. 包抄点有效性判定
    包抄点 $\vec{P}{flank}$ 必须满足:从 $\vec{P}{flank}$ 到 $\vec{P}{player}$ 的连线不会被目标掩体遮挡,且 $\vec{P}{flank} - \vec{P}_{player}$ 与掩体原始法线形成大于 60 度的侧向夹角。

小队黑板每隔 200ms 会更新一次掩体热度图(Cover Heatmap)。当机枪兵成功锁定玩家并打出压制时,黑板的SuppressiveFireTokens自增,使得散弹枪手的ActionFlankToCover前置条件得以满足,Planner 立即求解出“掩体移动 -> 投掷烟雾弹 -> 侧翼包抄 -> 近距离开火”的高效战术序列。

通过这种解耦设计,策划只需要给新兵种配置不同的 Action 基础代价与可用动作池,无需重写任何状态跳转逻辑,AI 即可在战场中表现出高度默契的战术协作。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询