The Low-Tech AI Of Elden Ring
The Low-Tech AI Of Elden Ring
《艾尔登法环》中“低科技”的 AI 设计
FROMSOFT has a reputation for diverse and punishing npc encounters across the entire Soulsborne extended series, but the implementation of the AI decision making itself is perhaps unexpectedly low-tech. Since the majority of the code is implemented in Havok Script (A games-oriented Lua implementation from Havok) it’s pretty easy to take a peek behind the fog wall to see how they’re implemented. Note that none of what follows is original research, I’m just reading the code that others have done the hard work of extracting, decompiling, and reversing.
FROMSOFT 在整个“魂系列”及其衍生作品中,以其多样化且极具挑战性的 NPC 对战而闻名,但其 AI 决策的底层实现却出人意料地“低科技”。由于大部分代码都是用 Havok Script(Havok 公司推出的一种面向游戏的 Lua 实现)编写的,因此我们很容易就能穿过“雾门”,一窥其背后的实现逻辑。请注意,以下内容并非原创研究,我只是在阅读那些由他人辛苦提取、反编译和逆向工程后的代码。
Goals
目标(Goals)
The primary tool of the FROMSOFT AI approach is the Goal, which is their own terminology for a unique state that the AI can be in. Goals can be parametized when instanciated, and can access data stored on the Actor itself, but are otherwise really just an immutable table of functions.
FROMSOFT AI 设计的核心工具是“目标”(Goal),这是他们对 AI 所处独特状态的专属术语。目标在实例化时可以被参数化,并且能够访问存储在 Actor(角色)本身的数据,但除此之外,它们本质上只是一张不可变的函数表。
Now the simplest option would be to organize states into a Finite State Machine or maybe a Hierarchical Finite State Machine, but FROMSOFT go one step further and give the system a stack of states. This turns it from an FSM into Pushdown Automaton (PDA).
最简单的方案是将状态组织成有限状态机(FSM)或分层有限状态机(HFSM),但 FROMSOFT 更进一步,为系统引入了状态栈。这使得它从 FSM 演变成了下推自动机(PDA)。
That’s an entirely abstract definition, so after you get back from wikipedia let’s talk about it concretely from the top down. Each frame Actors will update the Goal on top of their stack of Goals. When the Goal updates, it can then push more Goals as Sub-Goals onto the stack, the topmost of which will execute next frame. The Goal’s update function returns a value indicating either Continue, Success, or Failure. Continue will leave the stack unchanged, the other two will cause the Goal to be popped from the stack. Failure will additionally cause all other unexecuted Goals to be popped from the stack up to the parent Goal (The Goal which pushed this sub-goal).
这是一个非常抽象的定义,所以当你从维基百科查阅回来后,让我们从上到下具体谈谈。每一帧,Actor 都会更新其目标栈顶部的目标。当目标更新时,它可以将更多的目标作为“子目标”推入栈中,其中最顶层的目标将在下一帧执行。目标的更新函数会返回一个值,指示“继续”、“成功”或“失败”。“继续”将保持栈不变,而后两者会导致目标从栈中弹出。“失败”还会导致所有其他未执行的目标从栈中弹出,直到回到父目标(即推送此子目标的目标)。
For example, we might define a Goal called CoolBossBattle, during the course of its execution it might then push a series of Attack Sub-Goals. Those attack Goals can be parametized by various means, but the main one is the animation id.
例如,我们可以定义一个名为 CoolBossBattle 的目标,在执行过程中,它可能会推送一系列“攻击子目标”。这些攻击目标可以通过多种方式进行参数化,但最主要的方式是使用动画 ID。
[ GOAL STACK ]
3: Attack (R2, Combo) <<<<-- Currently Updating
2: Attack (R2, Repeat)
1: Attack (R2, Finisher)
0: CoolBossBattle
After a few seconds the first attack lands, and that Goal completes with success and is popped from the stack. However the next fails, causing the stack to unwind to its parent.
几秒钟后,第一次攻击命中,该目标成功完成并从栈中弹出。然而,下一次攻击失败了,导致栈回退到其父目标。
[ GOAL STACK ]
2: Attack (R2, Repeat) <<<<-- Failed, will be popped from the stack.
1: Attack (R2, Finisher) <<<<-- Will be removed as well.
0: CoolBossBattle
Readying it to chose its next action now that the attempted combo of attacks has ended.
现在,尝试的攻击连招已经结束,它准备选择下一个动作。
[ GOAL STACK ]
2: Attack(L1)
1: Attack(L1)
0: CoolBossBattle <<<<-- Updating, pushes 1, and 2 for the next frame.
Not too complex! In their APIs they refer to the root of this stack as the “Top Level Goal”, which I’ve made confusing by referring to the currently executing goal as the “top” of the stack. So keep in mind those are separate things.
并不复杂!在他们的 API 中,他们将栈的根部称为“顶层目标”(Top Level Goal),而我将其与当前执行的目标(栈顶)混淆了。所以请记住,这是两个不同的概念。
Activate
激活(Activate)
Goals are defined by a few functions used as callbacks, and the one which contains the most AI logic is usually activate. This is called the first time that a Goal is updated, and then every subsequent time that the Goal exhausts its Sub-Goals and starts executing again.
目标由几个用作回调的函数定义,其中包含最多 AI 逻辑的通常是 activate。该函数在目标第一次更新时被调用,之后每当目标耗尽其子目标并重新开始执行时也会被调用。
For boss and regular npc Goals the code in Activate is responsible for choosing the next action that the Actor will take using a mix of context from the world and Actor, and randomness (which also comes from the Actor itself). The most widely used approach uses common code to perform a weighted random selection between a number of Actions (which are just functions), calling the winner.
对于 Boss 和普通 NPC 的目标,Activate 中的代码负责通过结合世界环境、Actor 自身状态以及随机性(同样来自 Actor 本身)来选择 Actor 的下一个动作。最常用的方法是使用通用代码,在多个动作(本质上就是函数)之间进行加权随机选择,并调用获胜的那个。
To return to our CoolBossBattle, this time in some Rusty pseudocode…
回到我们的 CoolBossBattle,这次用一些 Rust 风格的伪代码来演示……
(Code omitted for brevity, as it demonstrates the weighted selection logic)
Modifying the weights dynamically is handled in many different ways, but the most common are simple rng rolls from the actor and hp thresholding. Other, simpler, Goals than the top level battle Goal for an Actor may simply push a few sub-goals, perhaps reading some data from the Goal parameters.
动态修改权重的方法有很多种,但最常见的是简单的 Actor 随机数掷骰和生命值阈值判断。对于比顶层战斗目标更简单的 Actor 目标,它们可能只是简单地推送几个子目标,或许会从目标参数中读取一些数据。