游戏AI行为决策——GOAP(目标导向型行动规划)
游戏AI行为决策——GOAP(附代码)
【前世今生】:本文经历了一次大翻新,之前是采用图结构存储GOAP动作与状态关联的方式,后来个人捣鼓时发现这并非明智的选择并且代码还有改良空间,故重新写了这篇文章,并在文末附上采用本文讲述的这种GOAP的Demo(Demo详细说明也在文末)。当然,如果你好奇以前写得多糟糕,这里也保留了一份原本的GOAP实现的Demo……
前言
像先前提到的有限状态机、行为树、HTN,它们实现的AI行为,虽说能针对不同环境作出不同反应,但应对方法是写死了的。有限状态机终究是在几个状态间进行切换、行为树也是根据提前设计好的树来搜索……你会发现,游戏AI角色表现出的智能程度,终究与开发者的设计结构有关,就有限状态机而言,各个状态如何切换很大程度上就影响了AI智能的表现。
那有没有什么决策方法,能够仅需设计好角色需要的动作,而它自己就能合理决定要选择哪些动作完成目标呢?这样的话,角色AI的行为智能程度会更上一层楼,毕竟它不再被写死的决策结构束缚;我们在添加更多AI行为时,也可以简单地直接将它放在角色需要的动作集里就好,减少了工作量,不必像行为树那样,还要考虑节点间的连接。
没错,GOAP就可以做到。(咳咳,虽说为了突出GOAP的特点进行了一番拉踩(ˉ▽ˉ;)。但请注意,并不是说GOAP就比其它决策方法好)。
GOAP的运行逻辑
先介绍下GOAP里的两个概念:「世界状态」 与 「动作」。
世界状态是对 “当前游戏世界是什么样” 的量化表达,它不是单一一个变量,而是游戏内多个关键变量的合集,这些变量共同反映 “当前游戏世界是什么样”。显然,它可以用来描述智能体自身的属性,比如自身的生命值、有无枪支情况等。它也可以用来描述公共的全局信息,比如房间的门是否打开、场景的宝箱位置等。
在GOAP中,世界状态还充当动作执行所需的前提与执行后造成的结果,意味着只有当当前世界状态满足作为前提的世界状态时,动作才能被执行,而动作成功执行后,会将当前世界状态中相关部分改成作为结果的世界状态。
有意思的是,世界状态对 “前提” 和 “结果” 的统一表示能使 GOAP 构建 “动作链”,即当前世界状态在实施了前一个动作的结果后(修改后的世界状态),是否可作为后一个动作的前提(满足的条件)。所以,完全可以将当前世界状态作为起点,并将想要达成的世界状态作为终点,让GOAP搜索出一条使得智能体能从当前状态变成目标世界状态的动作序列(下图中,被分成三份的方块表示一个动作,首部为动作的前提,尾部为动作的结果,首尾那样的大方块表示一个世界状态,里面不同形状和颜色的图形表示这个世界状态里具体的变量):
动作还包含它的实施代价,很多时候当前的世界状态会满足多个动作的前提条件,而在选择某个动作并与其动作结果融合后,又可以满足若干个动作前提……连成了一个动作网络,所以GOAP会采用 A*算法 找出从初始世界状态到目标世界状态的 代价最小 的动作序列。例如,一个NPC以 “拥有木柴” 为目标的话,“拿斧头(成本 2)→砍树(成本 4)” 的累计成本为 6,远低于 “收集树枝(成本 8)”,因此会优先选择前者;只有当 “拿斧头” 的前提 {持有斧头} 不满足时,才会选择高成本的收集树枝方案;
需要注意的是,规划阶段仅通过 “假想的世界状态” 推导动作序列,不修改实际的世界状态。在得到规划出的动作序列后,智能体才会开始正式执行动作所匹配的逻辑,也是在实际执行结束后才会将结果世界状态与当前世界状态相容。因此,像在执行“逃跑”动作时,智能体会在抵达出口后,才将自身世界状态中的“处于出口”设置为真。
在实际执行的过程中,智能体仍会在动作执行时检测是否符合条件,若条件突然不满足,GOAP会中断当前动作并重新规划。例如,NPC规划好 “拿斧头→砍树” 序列后,若前往拿斧头的途中斧头被其他NPC取走,则会立即中断当前动作,重新规划出 “收集树枝” 序列。
当所有序列都成功执行后,GOAP也会重新进行一次规划。至于GOAP是怎么确定目标的,这并不是GOAP关心的事情,这是开发者给定的。一般来说是有多个目标供GOAP选择,且至少有一个目标无论何种情况下都能被选择(保底方案),哪个目标被激活,就让GOAP去执行,至于激活逻辑当然也是开发者定的。
希望我讲明白了它的运作(如果还是感觉有点不懂,可以看看这个视频),下面一起来实现一个简单的GOAP进一步了解吧!顺带提一嘴,在Unity资源商店有免费的GOAP插件,并且做了可视化处理以及多线程优化,各位真的想将GOAP运用于项目的话,更推荐去使用成熟的插件。ˋ( ° ▽、° )
代码实现
本文「世界状态」的实现参考了GitHub上一C语言版本的GOAP。
1. 世界状态
世界状态的设计是整个GOAP里面最关键的一环,但在实现之前,我们还要探讨几个地方。
如何判断当前的世界状态到底算不算满足某个动作的前提条件呢?可以把它们分成以下这几类情况:
首先,要明确一点:动作前提条件的具体变量全都对的上时才算满足。因此,情况1与情况5一目了然:当前世界状态与动作前提完全一致,必然满足,完全不一致,则必然不满足。
情况2——前提是世界状态的子集(也就是说是世界状态的一部分),毫无疑问也满足,世界状态多出来的变量并不影响。情况3则不行,因为当前世界状态只是前提的一小部分,情况4更糟,只是世界状态与前提有部分相同而已,也不行。
而情况1和情况2其实可以算是同一种情况,即:前提条件 是 当前世界状态 的子集,能满足这个条件的世界状态就符合,否则不符合。
让我们思考一个反向的问题:如何判断某个动作的结果有望推进达成目标?这个问题事关反向搜索寻找动作序列,即从目标一路找到一条当前世界状态的满足的动作序列(其实比正向搜索要麻烦不少。
同样分成以下几类情况:
同样可以确定情况1肯定有望推进,情况5则无望。情况2呢?当前动作只满足了目标的一部分,是否有望推进呢?答案是肯定的,能完成一部分也算是有所帮助,如果有幸遇到有另一部分状态作为结果的动作岂不就完成了!
情况3算是超额完成任务了,有望推进。情况4也完成了目标的一部分,也算有望推进。这么看来,只需要 目标与动作结果有交集 就算有望的了……吗?
来看看这种情况:目标为「花圃不缺水」,我们反向搜索出了三个动作:
似乎没毛病,只要「去村庄」+「接雨水」就可以浇水了……但其实这两个动作是不会一起发生的,因为它们的条件存在冲突,去村庄只能发生在晴天,而接雨水只能发生在雨天。
问题棘手了起来,这种问题在正向搜索是不存在的,因为正向搜索是顺着因果下来的,会直接排除掉不满足条件的动作。比如在晴天,它就会排除接雨水,在雨天,它就会排除去村庄。那反向搜索该怎么解决呢?
答案是 判断最新的目标与动作条件是否冲突。如何得到“最新的目标”,又如何判断“冲突“呢?不急,我们挨个说,但请注意以下这些都是只针对反向搜索的:
- 新目标 = (
原目标-原目标 与 动作结果 的交集) 结合动作条件
原目标 与 动作结果 的交集 意味着原目标在该动作结果下被满足的部分,原目标再减去这部分就意味着还需要努力达成的部分,照理来说这就已经是新目标了,为什么还要「结合」动作条件?而且「结合」是什么操作呢?
A 结合 B 其实就是将 A 与 B 并在一起,不过 如果有存在冲突的地方,就以A中的值为主 (有则改之,无则加冕(bushi,例如:
A = {天气雨:false,花圃缺水:false,开心:true}
B = {天气雨:true,花圃缺肥:true}
显然,天气雨是冲突的,我们留下A的 天气雨:false
A 结合 B = {天气雨:false,花圃缺水:false,开心:true,花圃缺肥:true}
我们用之前的例子来说明,先聚焦这一部分:
原目标:花圃不缺水
动作结果:花圃不缺水
动作条件:有水、在村庄
计算新目标:
A = 原目标 与 动作结果 的交集 = 花圃不缺水
B = 原目标 - A = 空
新目标 = B 结合 动作条件 = 空 结合 动作条件 = 动作条件 = 有水、在村庄
得到新目标后,就可以来推断这个动作是否能选了:参与新目标计算的动作条件是否为新目标的子集,是则无冲突,否则有冲突。为什么呢?其实就是因为之前提到的「结合」操作,它将与条件冲突的地方覆盖了,如此一来,条件在有冲突的情况下就必然不会是新目标的子集。
在这个例子中我们可以看到,最终「去村庄」这一条件与新目标冲突,故动作序列不会被采纳。调换去村庄与接雨水的顺序,结果殊途同归:
最终,反向搜索时,某个动作能否推进当前目标就可以这样来判断:
// 动作效果与当前目标存在交集
if (action.effect.IsAnySame(curGoal))
{
// 计算新目标:(当前目标 - 动作效果与当前目标的并集) 结合 动作前置条件
var newSubGoal = MergeWithCondition(curGoal, action.effect, action.precondition);
// 判断当前动作条件是否为新目标的子集
var res = (action.precondition.IsSubsetOf(newSubGoal));
return res;
}
现在,可以探讨世界状态的实现了。可以猜到,在寻找动作序列的过程中一定少不了对世界状态中包含的具体变量的遍历。
在《F.E.A.R》中,采用固定大小的四字节值数组来实现世界状态的存储,数组会预先分配固定索引位对应 AI 决策的关键变量:索引 0 存储TargetDead(目标是否死亡)、索引 1 存储WeaponLoaded(武器是否加载)、索引 2 存储AtNode(当前所处导航节点)等,判断动作前提时,仅需通过索引读取对应变量值即可,相比利用字典键值存储而言,省去了哈希计算。
固定大小数组的实现方式虽高效,但存在 “单变量只能存储单一值” 的局限性,不过可以优化设计来弥补。以下就是《F.E.A.R》中开发团队所用到的一些方案:
-
世界状态数组仅保留 “当前聚焦” 的变量值,其他动态信息由专门子系统管理。例如:敌人有很多可选的攻击目标,不需要把每个目标的“是否在攻击范围内”都纳入数值,而只需纳入“当前目标是否在攻击范围内”,选取当前目标的事由专门的函数来处理。
-
可以在条件判断或作用结果时一同执行额外的函数,来处理一些复杂的判断关系(细节部分在动作章节展开):
// 动作效果与当前目标存在交集
if (action.effect.IsAnySame(curGoal))
{
// 计算新目标:(当前目标 - 动作效果与当前目标的并集) 结合 动作前置条件
var newSubGoal = MergeWithCondition(curGoal, action.effect, action.precondition);
// 判断当前动作条件是否为新目标的子集
var res = action.precondition.IsSubsetOf(newSubGoal);
return res && action.CheckMorePreconditions();
}
在本文的实现中,用 ulong 类型枚举表示世界状态,各个二进制位表示世界状态中的各个具体变量,这样一来二进制位上的0和1就分别表示该位象征的变量值是false还是true。可一个位上为0也有可能是因为没被使用到,那该怎么区分呢?这就可以再加一个用来表示哪些位有被使用的 ulong 类型枚举,如此,用两个 ulong 类型的变量就可以表达清楚世界状态,并且不需要遍历,借助位运算就能判断是否为子集等,是十分高效的。但其长度被限制在了64之内,这意味着你的具体状态数不能超过该数字。
在C#中有位枚举可以很好的帮助我们(直接用普通枚举甚至 ulong 类型变量也是可以的,只不过在调试查看时会比较麻烦,需要自行将数字转成二进制数再比对查看)例如:
[Flags]
public enum E_GoapWorldFlags: ulong
{
None = 0,
花圃成熟 = 1,
天气雨 = 1 << 1,
花圃发病 = 1 << 2,
花圃空缺 = 1 << 3,
花圃缺水 = 1 << 4,
花圃缺肥 = 1 << 5,
在村庄 = 1 << 6,
有钱 = 1 << 7,
有食材 = 1 << 8,
有肥料 = 1 << 9,
有水 = 1 << 10,
有农药 = 1 << 11,
心情好 = 1 << 12,
关系好 = 1 << 13,
疲劳 = 1 << 14,
饥饿 = 1 << 15,
}
完整的世界状态类如下,相关说明都在注释之中,由于泛型枚举不能直接使用位运算操作,所以在进行各项位运算时都统一转化成了 ulong 类型,运算后再转回枚举。其实就是使用位运算实现了那些合并、判断子集等操作。
/// <summary>
/// 泛型版 GOAP 世界状态
/// 用位表示的世界状态,支持任意 [Flags] 枚举类型(例如 E_GoapWorldFlags、E_RobotWorldFlags 等)
/// 泛型约束:TEnum 必须是 Enum,且底层类型建议为 ulong
/// </summary>
public class GoapWorldState<TEnum> where TEnum : Enum
{
/// <summary> 状态值(表面值) </summary>
private TEnum value;
/// <summary> 已使用的位(标记哪些状态被设置过) </summary>
private TEnum used;
private static ulong ToULong(TEnum e) => Convert.ToUInt64(e);
private static TEnum FromULong(ulong v) => (TEnum)Enum.ToObject(typeof(TEnum), v);
public GoapWorldState()
{
value = default;
used = default;
}
public GoapWorldState(TEnum value, TEnum used)
{
this.value = value;
this.used = used;
}
public GoapWorldState(GoapWorldState<TEnum> clonedWorld)
{
value = clonedWorld.value;
used = clonedWorld.used;
}
/// <summary>
/// 设置状态(一个元组只设置一个位的值)
/// </summary>
public void SetStates(params (TEnum state, bool value)[] states)
{
ulong v = ToULong(value);
ulong u = ToULong(used);
for (int i = 0; i < states.Length; ++i)
{
ulong mask = ToULong(states[i].state);
if (states[i].value)
{
v |= mask; // 设置状态位
}
else
{
v &= ~mask; // 清除状态位
}
u |= mask; // 标记该位已被使用
}
value = FromULong(v);
used = FromULong(u);
}
/// <summary>
/// 重新设置状态,会清空原本的value和used(用于重设goal)
/// </summary>
public void ReSetStates(params (TEnum state, bool value)[] states)
{
used = value = default;
SetStates(states);
}
/// <summary>
/// 查询单一具体状态值
/// </summary>
/// <param name="state">单一具体状态</param>
/// <returns>true则该状态为1,否则为0</returns>
public bool GetSingleState(TEnum state)
{
return (ToULong(value) & ToULong(state) & ToULong(used)) != 0;
}
/// <summary>
/// 将自身的value和used复制给other
/// </summary>
/// <param name="other">被粘贴的对象</param>
public void CopyTo(GoapWorldState<TEnum> other)
{
other.value = value;
other.used = used;
}
public void ApplyGlobalEffect(GoapWorldState<TEnum> globalEffect)
{
if (globalEffect == null)
throw new ArgumentNullException(nameof(globalEffect));
// 仅允许覆盖世界状态已有的used位,不新增used位
var allowedBits = ToULong(used) & ToULong(globalEffect.used); // 取交集(只处理世界状态已定义的位)
var newValue = (ToULong(value) & ~allowedBits) | (ToULong(globalEffect.value) & allowedBits);
value = FromULong(newValue);
// 不修改used位(保持世界状态的used位不变)
}
/// <summary>
/// 用效果状态覆盖当前世界状态(动作执行后更新用)
/// 合并规则:仅覆盖效果中已定义的位(effect.used),未被效果定义的位保持当前值不变
/// </summary>
/// <param name="effect">用于覆盖的动作效果状态(仅其已定义的位会生效)</param>
public void OverrideWithEffect(GoapWorldState<TEnum> effect)
{
if (effect == null)
throw new ArgumentNullException(nameof(effect));
ulong v = ToULong(value);
ulong eValue = ToULong(effect.value);
ulong eUsed = ToULong(effect.used);
// 1. 清除当前状态中“效果已定义”的位,2. 用效果的对应值覆盖,3. 保留当前状态中“效果未定义”的位
v = (v & ~eUsed) | (eValue & eUsed);
value = FromULong(v);
used = FromULong(ToULong(used) | eUsed); // 标记所有被效果覆盖的位为“已使用”
}
/// <summary>
/// 判断当前世界状态是否是另一个状态的子集
/// (用于检查目标是否被起始状态覆盖)
/// </summary>
public bool IsSubsetOf(GoapWorldState<TEnum> other)
{
if (other == null) return false;
ulong u = ToULong(used);
ulong oU = ToULong(other.used);
// 1. 自身所有已使用的位必须在other中也被使用
if ((u & oU) != u) return false;
// 2. 自身已使用位的值必须与other对应位一致
return (ToULong(value) & u) == (ToULong(other.value) & u);
}
/// <summary>
/// 判断两个世界状态是否存在交集
/// </summary>
public bool IsAnySame(GoapWorldState<TEnum> other)
{
var commonUsed = ToULong(used) & ToULong(other.used);
if (commonUsed == 0UL)
return false; // 没有共同使用的键
// 求出双方值相同且该位被使用的掩码
var sameMask = ~(ToULong(value) ^ ToULong(other.value)) & commonUsed;
// 若存在至少一个相同位,则说明有交集
return sameMask != 0UL;
}
/// <summary>
/// 实现目标与动作结果去交集合并,再与条件合并(结果A优先)
/// </summary>
/// <param name="goal">目标世界状态</param>
/// <param name="effect">动作结果</param>
/// <param name="precondition">需要合并的动作条件</param>
/// <returns>最终合并后的世界状态(新目标)</returns>
public static GoapWorldState<TEnum> MergeWithCondition(
GoapWorldState<TEnum> goal,
GoapWorldState<TEnum> actionResult,
GoapWorldState<TEnum> condition)
{
ulong gUsed = ToULong(goal.used);
ulong gVal = ToULong(goal.value);
ulong rUsed = ToULong(actionResult.used);
ulong rVal = ToULong(actionResult.value);
ulong cUsed = ToULong(condition.used);
ulong cVal = ToULong(condition.value);
// 步骤1:计算目标与动作结果的去交集,再合并
ulong commonUsed = gUsed & rUsed;
ulong sameValMask = ~(gVal ^ rVal) & commonUsed; // 交集掩码(需去除的部分)
// 结果A的used = 目标和动作结果的所有变量,排除交集部分
ulong aUsed = (gUsed | rUsed) & ~sameValMask;
// 结果A的value = 目标保留部分 + 动作结果保留部分
ulong aVal = (gVal & (gUsed & ~sameValMask)) | (rVal & (rUsed & ~sameValMask));
// 构建最终状态
return new GoapWorldState<TEnum>
{
// 步骤2:结果A与条件合并
used = FromULong(aUsed | cUsed),
// 结果A的变量用A的值,条件独有的变量用条件的值(A覆盖共同变量)
value = FromULong((aVal & aUsed) | (cVal & ~aUsed))
};
}
/// <summary>
/// 获取自身value值与target的value值在自身used上的不同个数
/// </summary>
public int GetDiffBitCountOnSelfUsed(GoapWorldState<TEnum> target)
{
//value在used位上的值,有多少个与target的value在同样位置上不同
var careVal = ToULong(value) & ToULong(used);
var targetCareVal = ToULong(target.value) & ToULong(used);
var diffBits = careVal ^ targetCareVal;
ulong count = 0;
while (diffBits != 0)
{
// 若最低位为1,则计数+1
count += diffBits & 1;
// 右移1位,检查下一个位
diffBits >>= 1;
}
return (int)count;
}
/// <summary>
/// 计算世界状态的哈希值(基于value和used的组合)
/// </summary>
public static int StateHash(GoapWorldState<TEnum> state)
{
if (state == null) return 0;
// 组合value和used的哈希值(使用质数31减少哈希碰撞)
return state.value.GetHashCode() ^ (state.used.GetHashCode() * 31);
}
public static bool IsAllSame(GoapWorldState<TEnum> a, GoapWorldState<TEnum> b)
{
return ToULong(a.value) == ToULong(b.value) && ToULong(a.used) == ToULong(b.used);
}
}
值得一提的是,在上述实现中对于当前世界状态的修改,分成了两部分,一部分是对自身属性相关的状态修改(OverrideWithEffect),另外一部分是对全局公共世界状态的修改(ApplyGlobalEffect)。
GetDiffBitCountOnSelfUsed 是针对反向搜索,衡量新目标与当前世界状态的相似度的函数。它会作为A*搜索的启发式函数,当然,也有其它衡量这种二元属性向量相似度的方法,如SMC系数、Jaccard系数。
PS:在传统人工智能的智能体设计中,对于环境的表示方式主要有三种:
- 原子表示(Atomic):就是单纯描述某个状态有无,通常每个状态都只用布尔值(True/False)表示就可以,比如「有流量」。
- 要素化表示(Factored):进一步描述状态的具体数值,这时,状态可以有不同的类型,可以是字符串、整数、布尔值……在HTN中,我们就是用这种方式实现的。
- 结构化表示(Structured):再进一步,每个状态不但描述具体数值,还存储于其它数据的连接关系,就像数据结构中「图」的节点那样。
2. 动作
动作,首先需要包含之前提到的前提条件和执行结果,它们都由世界状态表示。然后就是代价,衡量动作的执行难度并用于A*搜索。还有动作具体的执行函数,比如开火动作,就可以播放开枪动画并生成向前飞行的子弹。
其余的函数其实是对前提条件与动作结果的一层包装。但有两个函数需要额外说明一下,那就是之前提到的额外的条件判断以及结果影响。
CheckMorePreconditions用于额外的条件判断,这些判断可以是世界状态之外的、难以用世界状态表示的。例如逃跑动作会调用搜索导航网格以判断是否存在安全路径。可想而知,如果是实时记录的话,需要每帧都寻路一遍看看道路是否通畅,所以可以将它放在这个函数中,仅在动作候选时触发。ApplyMoreEffects用于额外的效果执行,作用同理。
接下来会用到一些A*搜索需要继承的接口(这是个人实现的一个通用的A*搜索器,仅供参考):
/// <summary>
/// A星节点接口(关联节点+边)
/// </summary>
/// <typeparam name="T_Node">节点类型</typeparam>
/// <typeparam name="T_Edge">边类型</typeparam>
public interface IAStarNode<T_Node, T_Edge>
where T_Node : IAStarNode<T_Node, T_Edge>, IComparable<T_Node>
{
T_Node Parent { get; set; } // 父节点
T_Edge ParentEdge { get; set; } // 父节点→当前节点的边(关键)
float GCost { get; set; } // 起点到当前节点的累计代价
float HCost { get; set; } // 当前节点到终点的估计代价
float FCost => GCost + HCost; // 总代价
/// <summary>计算与目标节点的启发式距离</summary>
float GetHeuristicDistance(T_Node targetNode);
/// <summary>获取后继节点+对应边</summary>
IEnumerable<(T_Node Successor, T_Edge Edge)> GetSuccessorsWithEdges(object nodeMap);
/// <summary>重置节点的搜索相关状态/// </summary>
void Reset();
/// <summary> 与other相比,是否算相同,用于A星搜索时两个节点的比较</summary>
bool IsSameWith(T_Node other);
}
/// <summary>
/// 边代价接口(统一获取边的代价)
/// </summary>
public interface IAStarEdge
{
float Cost { get; }
}
/// <summary>
/// 路径集合的通用操作接口(抽象添加和清空操作)
/// </summary>
/// <typeparam name="T">集合元素类型</typeparam>
public interface IPathCollection<T> : IEnumerable<T>
{
void Add(T item); // 添加元素(Enqueue/Push)
void Clear(); // 清空集合
bool TryTake(out T item); // 安全取出元素(TryDequeue/TryPop)
}
/// <summary>
/// Queue 的路径集合适配器,正向溯回路径
/// </summary>
public class QueuePathCollection<T> : IPathCollection<T>
{
private readonly Queue<T> _queue;
public QueuePathCollection()
{
_queue = new Queue<T>();
}
public void Add(T item) => _queue.Enqueue(item); // 映射到 Queue.Enqueue
public void Clear() => _queue.Clear();
public bool TryTake(out T item) => _queue.TryDequeue(out item);
// 实现 IEnumerable<T> 接口的迭代器(复用 Queue 的迭代器)
public IEnumerator<T> GetEnumerator() => _queue.GetEnumerator();
// 显式实现非泛型 IEnumerable 接口(调用泛型版本)
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
动作抽象类的实现如下:
public abstract class GoapActionBase<TWorldEnum> : IAStarEdge where TWorldEnum : Enum
{
public GoapWorldState<TWorldEnum> precondition; //动作得以执行的前提条件
public GoapWorldState<TWorldEnum> effect; //动作成功执行后带来的影响,体现在对世界状态的改变
public abstract float Cost { get; }
// 动作前提条件的额外判断,纳入其它非世界状态的条件判断
public abstract bool CheckMorePreconditions();
// 动作执行成功后的额外影响,修改一些非世界状态的变量或者其它
public abstract void ApplyMoreEffects();
// 动作的具体执行函数
public abstract void Execute();
/// <summary>
/// 判断当前世界状态是否满足动作的前提条件
/// 逻辑:前提条件(Precondition)必须是当前世界状态的子集
/// </summary>
public bool MeetCondition(GoapWorldState<TWorldEnum> worldState)
{
if (worldState == null)
return false;
// 检查precondition是否被worldState满足(precondition是worldState的子集)
return precondition.IsSubsetOf(worldState) && CheckMorePreconditions();
}
/// <summary>
/// 判断动作的效果是否是目标的子集
/// </summary>
public bool MeetEffect(GoapWorldState<TWorldEnum> goal)
{
if (goal == null) return false;
return effect.IsSubsetOf(goal);
}
/// <summary>
/// 动作执行后,将效果应用到selfState
/// </summary>
public void Effect_OnRun(GoapWorldState<TWorldEnum> selfState)
{
selfState?.OverrideWithEffect(effect);
}
public void GlobalEffect_OnRun(GoapWorldState<TWorldEnum> worldState)
{
worldState?.ApplyGlobalEffect(effect);
}
/// <summary>
/// 设置动作前提条件(支持链式调用)
/// </summary>
public GoapActionBase<TWorldEnum> SetPrecondition(params (TWorldEnum state, bool value)[] states)
{
precondition.SetStates(states);
return this;
}
/// <summary>
/// 设置动作效果(支持链式调用)
/// </summary>
public GoapActionBase<TWorldEnum> SetEffect(params (TWorldEnum state, bool value)[] states)
{
effect.SetStates(states);
return this;
}
}
3. A星节点
由于在进行规划搜索时,动作之间是动态连接的,就有必要用一个额外的类表示搜索时的节点。反向搜索时它应当包含新目标(正向搜索则应当包含当前世界状态),以及对于的动作。总之就是一个世界状态变量和一个动作变量:
public class GoapNode<TEnum> :
IAStarNode<GoapNode<TEnum>, GoapActionBase<TEnum>>,
IComparable<GoapNode<TEnum>> where TEnum: Enum
{
public GoapWorldState<TEnum> State {get; private set;}
public GoapNode<TEnum> Parent { get; set; }
public GoapActionBase<TEnum> ParentEdge { get; set; }
public float GCost { get; set; }
public float HCost { get; set; }
public float FCost => GCost + HCost;
public GoapNode(GoapWorldState<TEnum> state)
{
State = new GoapWorldState<TEnum>(state);
}
/// <summary>
/// 启发式函数
/// </summary>
public float GetHeuristicDistance(GoapNode<TEnum> target)
{
return State.GetDiffBitCountOnSelfUsed(target.State);
}
/// <summary>
/// 生成当前节点的所有后继节点
/// </summary>
public IEnumerable<(GoapNode<TEnum> Successor, GoapActionBase<TEnum> Edge)> GetSuccessorsWithEdges(object nodeMap)
{
var availableActions = nodeMap as IReadOnlyList<GoapActionBase<TEnum>>;
if (availableActions == null)
yield break;
foreach (var action in availableActions)
{
// 动作效果与当前目标存在交集
if (action.effect.IsAnySame(State))
{
// 计算新子目标:(当前目标 - 动作效果与当前目标的并集) ∪ 动作前置条件
var newSubGoal = GoapWorldState<TEnum>.MergeWithCondition(State, action.effect, action.precondition);
if(action.precondition.IsSubsetOf(newSubGoal) && action.CheckMorePreconditions())
yield return (new GoapNode<TEnum>(newSubGoal), action);
}
}
}
public void Reset()
{
Parent = null;
ParentEdge = null;
GCost = 0;
HCost = 0;
}
public int CompareTo(GoapNode<TEnum> other) => FCost.CompareTo(other.FCost);
public bool IsSameWith(GoapNode<TEnum> other)
{
return State.IsSubsetOf(other.State);
}
// 节点相等性判断:状态的value和used完全一致才算相同节点
public override bool Equals(object obj)
{
if (obj is not GoapNode<TEnum> other)
return false;
return GoapWorldState<TEnum>.IsAllSame(State, other.State);
}
// 哈希值基于状态的哈希
public override int GetHashCode()
{
return GoapWorldState<TEnum>.StateHash(State);
}
}
4. A*搜索规划
这里采用的是以前提到的 通用A*搜索器,如果你有自己的A*搜索框架也可以用哦。(其实我自己的A*搜索器也改过好几版了
/// <summary>
/// 简化版泛用A星搜索器(支持按需收集节点/边路径)
/// </summary>
/// <typeparam name="T_Map">搜索空间(如GOAP动作集)</typeparam>
/// <typeparam name="T_Node">节点类型</typeparam>
/// <typeparam name="T_Edge">边类型(需实现IHasCost)</typeparam>
public class AStarSearcher<T_Map, T_Node, T_Edge>
where T_Node : IAStarNode<T_Node, T_Edge>, IComparable<T_Node>
where T_Edge : IAStarEdge
{
private readonly HashSet<T_Node> closeList = new();
private readonly MyHeap<T_Node> openList;
private readonly T_Map nodeMap;
private readonly int maxSteps; // 最大搜索步数(防死循环)
#region 构造函数
public AStarSearcher(T_Map map, int maxHeapSize = 200, int maxSearchSteps = 1000)
{
nodeMap = map;
maxSteps = maxSearchSteps;
openList = new MyHeap<T_Node>(maxHeapSize, isMinHeap: true); // A星默认小根堆
}
#endregion
#region 核心搜索方法
/// <summary>
/// 执行A星搜索,按需收集路径
/// </summary>
/// <param name="start">起点</param>
/// <param name="target">终点</param>
/// <param name="nodePath">接收节点路径(传null或recordNodes=false则不记录)</param>
/// <param name="edgePath">接收边路径(传null或recordEdges=false则不记录)</param>
/// <param name="recordNodes">是否记录节点路径</param>
/// <param name="recordEdges">是否记录边路径</param>
/// <returns>是否成功找到路径</returns>
public bool FindPath(
T_Node start,
T_Node target,
IPathCollection<T_Node> nodePath = null,
IPathCollection<T_Edge> edgePath = null)
{
nodePath?.Clear();
edgePath?.Clear();
closeList.Clear();
openList.Clear();
// 合法性校验
if (start == null || target == null)
{
Debug.LogWarning("起点/终点不能为null");
return false;
}
if (start.IsSameWith(target))
{
Debug.LogWarning("起点与终点相同,无需搜索");
return true;
}
// 起点入队
start.GCost = 0;
start.HCost = start.GetHeuristicDistance(target);
start.Parent = default;
start.ParentEdge = default;
openList.Push(start);
// 搜索主循环
int stepCount = 0;
while (!openList.IsEmpty && stepCount < maxSteps)
{
stepCount++;
var curNode = openList.Peak;
openList.Pop();
// 找到终点:生成路径并返回
if (curNode.IsSameWith(target))
{
GeneratePath(start, curNode, nodePath, edgePath);
return true;
}
// 跳过已探索节点
if (!closeList.Contains(curNode))
{
closeList.Add(curNode);
UpdateSuccessors(curNode, target);// 处理后继节点
}
}
// 搜索失败(无路径/超时)
Debug.LogWarning($"A星搜索失败:{(openList.IsEmpty ? "无可达路径" : $"超过最大步数({maxSteps})")}");
return false;
}
#endregion
#region 辅助方法
/// <summary>处理后继节点(更新代价与父关联)</summary>
private void UpdateSuccessors(T_Node curNode, T_Node target)
{
var successors = curNode.GetSuccessorsWithEdges(nodeMap);
if (successors == null)
return;
foreach (var (sucNode, edge) in successors)
{
if (closeList.Contains(sucNode))
continue;
// 计算新代价(边的代价属于边,而非节点)
float newGCost = curNode.GCost + edge.Cost;
bool isInOpenList = openList.Contains(sucNode);
// 新节点入队 或 已有节点更新代价
if (!isInOpenList)
{
sucNode.GCost = newGCost;
sucNode.HCost = sucNode.GetHeuristicDistance(target);
sucNode.Parent = curNode;
sucNode.ParentEdge = edge;
openList.Push(sucNode);
}
else if (newGCost < sucNode.GCost)
{
sucNode.GCost = newGCost;
sucNode.Parent = curNode;
sucNode.ParentEdge = edge;
openList.UpdateNodePriority(sucNode); // 更新堆优先级
}
}
}
/// <summary>
/// 生成路径
/// </summary>
private void GeneratePath(
T_Node start,
T_Node end,
IPathCollection<T_Node> nodes,
IPathCollection<T_Edge> edges)
{
var recNodes = nodes != null;
var recEdges = edges != null;
if (recNodes || recEdges)
{
var cur = end;
// 回溯路径时:
// 节点:从终点 push 到起点
// 边:从终点对应的 ParentEdge push,最后栈里顺序就是起点→终点
while (cur != null)
{
if (recNodes)
nodes?.Add(cur);
if (recEdges && cur.ParentEdge != null)
edges?.Add(cur.ParentEdge);
if (cur.IsSameWith(start))
break;
cur = cur.Parent;
}
}
}
#endregion
}
5. 代理器
将上述这些整合到一个类中,完成最后的封装。在每次搜索之前都会同步全局世界状态与自身的世界状态形成真正的世界状态节点再进行规划。不同早期我的做法,这次的GOAP规划仅得出动作序列而不进行运行,仿照《F.E.A.R》开发团队的做法,将动作的运行交给 有限状态机(FSM) 来做。
public class GoapAgent<TWorldEnum> where TWorldEnum : Enum
{
public GoapActionBase<TWorldEnum> curAction;//记录当前执行的动作
public GoapWorldState<TWorldEnum> curSelfState; //当前自身状态,主要是存储私有状态
private AStarSearcher<List<GoapActionBase<TWorldEnum>>, GoapNode<TWorldEnum>, GoapActionBase<TWorldEnum>> searcher;
private QueuePathCollection<GoapActionBase<TWorldEnum>> path;
private bool canContinue;//是否能够继续执行,记录动作序列全部是否执行完了
private GoapNode<TWorldEnum> decisionNode; // 临时决策状态
/// <summary>
/// 初始化代理器
/// </summary>
public GoapAgent(List<GoapActionBase<TWorldEnum>> actionSet, AStarSearcher<List<GoapActionBase<TWorldEnum>>, GoapNode<TWorldEnum>, GoapActionBase<TWorldEnum>> searcher = null)
{
curSelfState = new GoapWorldState<TWorldEnum>();
decisionNode = new GoapNode<TWorldEnum>(curSelfState);
path = new QueuePathCollection<GoapActionBase<TWorldEnum>>();
if(searcher == null)
{
this.searcher = new AStarSearcher<List<GoapActionBase<TWorldEnum>>, GoapNode<TWorldEnum>, GoapActionBase<TWorldEnum>>(actionSet);
}
else
{
this.searcher = searcher;
}
}
/// <summary> 设置自身状态值 </summary>
public void SetStatesValue(params (TWorldEnum state, bool value)[] states)
{
curSelfState.SetStates(states);
}
/// <summary>
/// 规划GOAP并得到动作序列
/// </summary>
/// <param name="goal">目标</param>
/// <returns>是否有找到动作序列</returns>
public bool FindActionPath(GoapNode<TWorldEnum> goal)
{
return searcher.FindPath(goal, decisionNode, edgePath:path);
}
/// <summary>
/// 尝试获取新动作
/// </summary>
/// <returns>是否成功获取</returns>
public bool GetNewAcitonInPath()
{
canContinue = path.TryTake(out curAction);
if (canContinue)//如果成功取出动作,就根据动作名,选出对应函数和动作
{
canContinue = curAction.MeetCondition(decisionNode.State);
}
return canContinue;
}
/// <summary>
/// 动作执行完成后,应用其影响以及收尾函数
/// </summary>
/// <param name="curWorldState">当前世界状态</param>
public void FinishedAction(GoapWorldState<TWorldEnum> curWorldState)
{
curAction.GlobalEffect_OnRun(curWorldState);
curAction.Effect_OnRun(curSelfState);
curAction.ApplyMoreEffects();
}
/// <summary>
/// 更新自身状态的共享部分与当前世界状态同步,通常在FindActionPath前调用
/// </summary>
public void UpdateDecisionState(GoapWorldState<TWorldEnum> worldState)
{
curSelfState.CopyTo(decisionNode.State);
decisionNode.State.OverrideWithEffect(worldState);
}
}
那GOAP要怎么和FSM结合呢?也可以参考《F.E.A.R》开发团队的做法,很多动作其实可以归为一类,比如去某个地方,播放什么动画,因此可以做个有限状态机包含两个状态,分别是“播放当前动画”以及“移动至目的地”。在具体动作的逻辑里,我们就可以简单的设置“当前动画”为XX,再将状态机切换到“播放当前动画”就可以了。
我个人的实现里将“GOAP规划”也作为了一个状态,(其实为了方便还加了一个“到了某地后再播动画”的状态
尾声
Demo 所实现的是以美露莘柯莎 (美露莘可爱捏 为主体的日常生活(想象的),里面涵盖了一些其它东西的个人试验项目,里面有尝试使用贝叶斯网络控制花圃环境变化 (属于杀鸡用牛刀,也有用到三元文法的文本生成器来让柯莎休闲时说说话 (其实还是胡言乱语的水平,但不影响对GOAP的演示,无视就好。
柯莎会在花圃状态不好时进行相应劳作、采收、种植,自身状态不好时也会进行休息、进食等,为了达成目标她会做一些额外劳作,具体的动作与状态关系在 BW_Agent.cs 文件中,总计有15个具体状态与20个可执行动作。
Demo会有个 Unsupported enum type 的报错,这是因为Unity不支持位枚举的序列化显示导致的,但不影响运行也不会有任何运行错误。偶尔会有“A星无法搜索到路径”的警告是因为我设置了“去村庄”与“去城市”的前提条件都必须是“天气雨:false”,这也是唯一穿梭城市与村庄的方法,所以遇到雨天时会无法进行两地的穿梭导致无法获取路径(

浙公网安备 33010602011771号