用一张图概括大概是下面这个样子:
GOAP 我认为还是一种有限状态机模式下的 AI 解决思路,但是有别于有限状态机的是,他的拆分更加细碎,并且亮点在于让 AI 根据一定的规则自行决策要转换到的状态。说着很玄乎,但是我认为就是把原来有限状态机的状态转换的条件做了更细节的抽象。
有限状态机的明显问题是:当状态太多的时候,这个状态机的管理简直是堪称地狱。密密麻麻的像蜘蛛网一样的状态转换会塞满,并且非常难直观地看出来转换的逻辑以及调试 bug。这也是后来为什么大量拥有复杂逻辑的 3A 游戏改用 BehaviourTree 的原因,相比之下,bt 的逻辑流跟直观,更容易调试,也没有那么复杂的连接关系。
那么 GOAP 解决的主要就是状态转换的问题。原来 FSM 中的状态转换说实话是比较简单粗暴的,经过 GOAP 的抽象,它把整个 AI 决策系统分成了下面几个部分:
这里的 Sensor 和 Agent,Planer,Plan 都属于看名字大概就能想到他们在干嘛以及大概需要写哪些代码的功能。这里特别说一下 sensor。在这个视频的 GOAP 的实现中,他是把 Sensor 分得很细节的,它详细地分了“发现敌人“和”敌人进入攻击范围“这两个 sensor。虽然目前我还没太理解到这么做的必要性。
这里重点就是解释一下 Plan 下的一些子内容,这些算是我认为 GOAP 里比较有趣的内容。当然因为目前我还没有把这些整合到我的游戏里,所以我后续的文章中可能还需要再补充说明。
Beliefs 类似一个信息。它类比人类脑中产生的“想法”。比如“我饿了”,“我渴了”,“我感觉到很安全”。这个类是把这些东西串联起的关键。那么对游戏的 AI 来说,这个部分可以用一些代码来实现,比如下面的代码:
其实 Belief 就是两个东西,一个用来标识的名字,一个 condition(具体为一个函数指针,返回 bool)用来标识当前的 Belief 是否 cond 为 true
1factory.AddBelief("Nothing", () => false);
2
3factory.AddBelief("AgentIdle", () => !gamePlayAIAgent.HasPathToWalk());
4factory.AddBelief("AgentMoving", () => gamePlayAIAgent.HasPathToWalk());
5factory.AddBelief("AgentHealthLow", () => health < 30);
6factory.AddBelief("AgentIsHealthy", () => health >= 50);
7factory.AddBelief("AgentStaminaLow", () => stamina < 10);
8factory.AddBelief("AgentIsRested", () => stamina >= 50);
9
10factory.AddLocationBelief("AgentAtDoorOne", 3f, doorOnePosition);
11factory.AddLocationBelief("AgentAtDoorTwo", 3f, doorTwoPosition);
12factory.AddLocationBelief("AgentAtRestingPosition", 3f, restingPosition);
13factory.AddLocationBelief("AgentAtFoodShack", 3f, foodShack);
14
15factory.AddSensorBelief("PlayerInChaseRange", chaseSensor);
16factory.AddSensorBelief("PlayerInAttackRange", attackSensor);
17factory.AddBelief("AttackingPlayer", () => false); // Player can always be attacked, this will never become true
Action 相对来说好理解的多。但是重点在于这个 Action 其实是更高一层抽象的 Action:并不是单独某个动作,它更像是代表一系列动作的集合。这些具体的动作叫做 Strategy,用来代表某些具体的动作。
除此之外还有两个很重要的东西,一个是 Precondition,这个参数是一个 Belief,它代表要执行这个动作的前置 Belief 条件,其实就是前置条件,但是这里最秒的是用 Belief 来代表。
另一个是 Effect,这个代表当执行完毕 Action 中所有的 Strategy 之后添加的 beliefs。事实上这个也不是添加,而是去执行一遍 beliefs 里的 cond 函数
1actions.Add(new AgentAction.Builder("Relax")
2 .WithStrategy(new IdleStrategy(5))
3 .AddEffect(beliefs["Nothing"])
4 .Build());
5
6actions.Add(new AgentAction.Builder("Wander Around")
7 .WithStrategy(new WanderStrategy(gamePlayAIAgent, 10))
8 .AddEffect(beliefs["AgentMoving"])
9 .Build());
10
11actions.Add(new AgentAction.Builder("MoveToEatingPosition")
12 .WithStrategy(new MoveStrategy(gamePlayAIAgent, () => foodShack.position))
13 .AddEffect(beliefs["AgentAtFoodShack"])
14 .Build());
15
16actions.Add(new AgentAction.Builder("Eat")
17 .WithStrategy(new IdleStrategy(5)) // Later replace with a Command
18 .AddPrecondition(beliefs["AgentAtFoodShack"])
19 .AddEffect(beliefs["AgentIsHealthy"])
20 .Build());
21
22actions.Add(new AgentAction.Builder("MoveToDoorOne")
23 .WithStrategy(new MoveStrategy(navMeshAgent, () => doorOnePosition.position))
24 .AddEffect(beliefs["AgentAtDoorOne"])
25 .Build());
26
27actions.Add(new AgentAction.Builder("MoveToDoorTwo")
28 .WithStrategy(new MoveStrategy(navMeshAgent, () => doorTwoPosition.position))
29 .AddEffect(beliefs["AgentAtDoorTwo"])
30 .Build());
31
32actions.Add(new AgentAction.Builder("MoveFromDoorOneToRestArea")
33 .WithCost(2)
34 .WithStrategy(new MoveStrategy(navMeshAgent, () => restingPosition.position))
35 .AddPrecondition(beliefs["AgentAtDoorOne"])
36 .AddEffect(beliefs["AgentAtRestingPosition"])
37 .Build());
38
39actions.Add(new AgentAction.Builder("MoveFromDoorTwoRestArea")
40 .WithStrategy(new MoveStrategy(navMeshAgent, () => restingPosition.position))
41 .AddPrecondition(beliefs["AgentAtDoorTwo"])
42 .AddEffect(beliefs["AgentAtRestingPosition"])
43 .Build());
44
45actions.Add(new AgentAction.Builder("Rest")
46 .WithStrategy(new IdleStrategy(5))
47 .AddPrecondition(beliefs["AgentAtRestingPosition"])
48 .AddEffect(beliefs["AgentIsRested"])
49 .Build());
50
51actions.Add(new AgentAction.Builder("ChasePlayer")
52 .WithStrategy(new MoveStrategy(gamePlayAIAgent, () => beliefs["PlayerInChaseRange"].Location))
53 .AddPrecondition(beliefs["PlayerInChaseRange"])
54 .AddEffect(beliefs["PlayerInAttackRange"])
55 .Build());
56
57actions.Add(new AgentAction.Builder("AttackPlayer")
58 .WithStrategy(new AttackStrategy(animations))
59 .AddPrecondition(beliefs["PlayerInAttackRange"])
60 .AddEffect(beliefs["AttackingPlayer"])
61 .Build());
最后是 Goal,Goal 的设计是最有趣的。顾名思义,他代表了 AI 现在的目标。
代码大概这样,基本看函数名也大概知道有什么东西:
1goals.Add(new AgentGoal.Builder("Chill Out")
2 .WithPriority(1)
3 .WithDesiredEffect(beliefs["Nothing"])
4 .Build());
5
6goals.Add(new AgentGoal.Builder("Wander")
7 .WithPriority(1)
8 .WithDesiredEffect(beliefs["AgentMoving"])
9 .Build());
10
11goals.Add(new AgentGoal.Builder("KeepHealthUp")
12 .WithPriority(2)
13 .WithDesiredEffect(beliefs["AgentIsHealthy"])
14 .Build());
15
16goals.Add(new AgentGoal.Builder("KeepStaminaUp")
17 .WithPriority(2)
18 .WithDesiredEffect(beliefs["AgentIsRested"])
19 .Build());
20
21goals.Add(new AgentGoal.Builder("SeekAndDestroy")
22 .WithPriority(3)
23 .WithDesiredEffect(beliefs["AttackingPlayer"])
24 .Build());
在 GOAPAgent 中,Goal 的存储方式是一个 Stack。虽然目标可能同时只能有一个目标,但是会根据目标栈来逐渐执行每个任务。这里的目的是为了支持 AI 执行多步骤目标的规划,比如我们的目标是:杀死敌人。那么我们目标可以分解为:捡起地上的强力武器-> 利用强力武器攻击敌人-> 杀死敌人。
那么这里 Action 里的 Effect 和 Cond 的重要性就体现出来了,如果捡起武器的 Effect 是“拥有了强力武器”,然后利用强力武器攻击敌人的这个目标的前提条件是“拥有了强力武器”,这样就能在规划目标为“杀死敌人”的时候,自动根据 AI 能够执行的动作来进行动态的目标规划。这也会为游戏带来非常丰富的游戏体验:比如你的敌人可能开始的时候没有感知到地上有强力武器,因此他只会与你呆呆地对射,但是后来他看到了地上有强力的武器,他就可以通过重新规划这个目标,先捡起地上的强力的武器然后对玩家进行攻击。
上面的例子如果要用状态机做最大的问题就是,你的代码是几乎无法复用的。比如捡起强力武器这件事可能完全没有办法复用,但是事实上其他的逻辑可能也需要复用(比如要做一个 AI 捡起强力武器之后主动交给主角的 AI 功能)
具体在 Planer 的代码也是这么操作的,这里就暂时不放了。有兴趣的可以去 https://github.com/adammyhre/Unity-GOAP 看详细的代码。
不过目前我对这套框架的理解还不是特别深,我打算继续实验一下这套框架能给我的游戏带来什么影响。其实我认为这个有趣的地方在于,他的这套思路里是很适合用 LLM 来替换 Planer 的部分的。但是我也能预见到这个系统下有很多难以解决的问题。不过实践出真知,我要实验了才知道。