《艾尔登法环》的低技术AI
摘要
对《艾尔登法环》人工智能系统的技术分析显示,它使用了基于Havok脚本实现的低技术下推自动机,与更为复杂的现代AI方法形成对比。
<p><a href="https://lobste.rs/s/fzz7pf/low_tech_ai_elden_ring">评论</a></p>
查看缓存全文
缓存时间: 2026/06/23 09:44
# 《艾尔登法环》的低技术AI
来源:https://nega.tv/posts/low-tech-ai-of-elden-ring.html
*FROMSOFT* 以其在整个《魂系》扩展系列中多样且具有惩罚性的 NPC 遭遇而闻名,但 AI 决策实现本身却出乎意料地低技术。由于大部分代码是用 Havok Script(Havok 实现的面向游戏的 Lua)编写的,因此很容易窥探到 fog wall 背后的实现方式。
请注意,以下内容并非原创研究,我只是在阅读他人辛苦提取、反编译和逆向工程后的代码。
## 目标
*FROMSOFT* AI 方法的主要工具是 Goal1,这是他们自己的术语,指代 AI 可以处于的一种独特状态。Goal 在实例化时可以参数化,也可以访问存储在 Actor 本身上的数据,但除此之外,它们实际上只是一个不可变的函数表。
最简单的选择是将状态组织成一个有限状态机(Finite State Machine)或分层有限状态机,但 *FROMSOFT* 更进一步,给系统一个状态栈。这使得它从 FSM 变成了下推自动机(PDA)。
这完全是抽象的定义,所以当你从维基百科回来后,我们自上而下具体地讨论一下。
每帧,Actor 会更新其 Goal 栈顶的 Goal。当该 Goal 更新时,它可以向栈中压入更多 Sub-Goal,最顶部的 Sub-Goal 将在下一帧执行。Goal 的 update 函数返回一个值,表示继续、成功或失败。继续将保持栈不变,另外两个将导致该 Goal 从栈中弹出。失败还会导致所有未执行的 Goal 从栈中弹出,直到父 Goal(压入该子 Goal 的 Goal)。
例如,我们可能定义一个名为 `CoolBossBattle` 的 Goal,在其执行过程中,它可能会压入一系列 `Attack` Sub-Goal。这些攻击 Goal 可以通过多种方式参数化,但主要方式是动画 ID2。
``
[ GOAL STACK ]
3: Attack (R2, Combo) <<<<-- 当前更新中
2: Attack (R2, Repeat)
1: Attack (R2, Finisher)
0: CoolBossBattle
``
几秒后,第一次攻击命中,该 Goal 成功完成并从栈中弹出。然而下一次攻击失败,导致栈展开回其父 Goal。
``
[ GOAL STACK ]
2: Attack (R2, Repeat) <<<<-- 失败,将从栈中弹出。
1: Attack (R2, Finisher) <<<<-- 也将被移除。
0: CoolBossBattle
``
准备好根据尝试的攻击连段结束来选择下一个动作。
``
[ GOAL STACK ]
2: Attack(L1)
1: Attack(L1)
0: CoolBossBattle <<<<-- 更新中,为下一帧压入 1 和 2。
``
并不太复杂3!
在它们的 API 中,他们将这个栈的根称为“顶层 Goal”,而我将其中的“顶部”称为当前执行的 Goal,这可能会造成混淆。请注意它们是两回事。
## 激活
Goal 由几个作为回调使用的函数定义,其中包含最多 AI 逻辑的函数通常是 activate。这会在 Goal 第一次更新时被调用,然后在 Goal 耗尽所有 Sub-Goal 并重新开始执行时的每一次后续更新中调用。
对于 BOSS 和普通 NPC Goal 来说,Activate 中的代码负责使用来自世界和 Actor 的上下文以及随机性(也来自 Actor 本身)来选择 Actor 将要采取的下一个动作。
最广泛使用的方法使用通用代码在多个动作(只是函数)之间执行加权随机选择,并调用获胜者。
回到我们的 `CoolBossBattle`,这次用一些 Rust 伪代码...
``
fn action_giga_death_ray(goals: &Goals, actor: &Actor) {
todo!();
}
fn action_leap_attack(goals: &Goals, actor: &Actor) {
todo!();
}
fn action_ground_slam(goals: &Goals, actor: &Actor) {
todo!();
}
fn action_light_attack_combo(goals: &Goals, actor: &Actor) {
let target_distance = actor.target_distance(Target::Enemy);
let fate = actor.next_random();
// ApproachTarget 本身也是一个在通用代码中定义的 Goal!
if target_distance > 2.0 {
goals.push_sub_goal(Goal::ApproachTarget, Target::Enemy);
}
goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Initial);
goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Repeat);
// 倒霉的家伙!这是长连段。
if fate < 0.2 {
goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Repeat);
}
goals.push_sub_goal(Goal::Attack, AnimId::R1, Combo::Finisher);
}
fn action_heavy_attack_combo(goals: &Goals, actor: &Actor) {
todo!();
}
fn activate(&self, goals: &Goals, actor: &Actor) {
let target_distance = actor.target_distance(Target::Enemy);
let mut weights = if target_distance > 6.0 {
[
15.0,
65.0,
0.0,
10.0,
10.0,
]
} else if target_distance > 1.5 {
[
0.0,
0.0,
5.0,
60.0,
35.0,
]
} else {
[
0.0,
0.0,
20.0,
40.0,
40.0,
]
};
// 这在 Lua 代码中并非完全如此,这些冷却时间也没有意义,
// 但希望能给出大致概念。
//
// 辅助函数检查 Actor 自身动画的最后播放数据,
// 然后在进入通用战斗随机选择之前修改权重。
weights[3] = if common::is_cooldown(goals, actor, AnimId::R1, 8.0) { 0.0 } else { weights[3]; };
weights[4] = if common::is_cooldown(goals, actor, AnimId::R2, 10.0) { 0.0 } else { weights[4]; };
let actions = [
action_giga_death_ray,
action_leap_attack,
action_ground_slam,
action_light_attack_combo,
action_heavy_attack_combo,
];
// 对动作数量进行一些通用设置,然后掷骰子并选择要调用的函数。
common::battle_activate(goals, actor, weights, actions);
}
``
动态修改权重的方式多种多样,但最常见的只是从 actor 进行的简单 rng 掷骰和血量阈值判断。
其他比 Actor 的顶层战斗 Goal 更简单的 Goal 可能只是压入几个子 Goal,或许还会从 Goal 参数中读取一些数据。这种嵌套使得可以用简单的构建块组合出相当复杂的行为。
## 中断
为 Goal 定义的另一个主要回调是 Interrupt。顾名思义,这允许 Goal 立即响应外部事件,这些事件主要设置在 Actor 本身。
我的理解是,中断会向上冒泡,即它会在当前执行的 Goal 上运行中断,然后递归地在其父 Goal 上运行,直到没有更多 Goal 或某个中断回调返回 true 表示它已消耗该中断。
例如,如果我想要 CoolBoss 在我点燃它时立即进入狂暴攻击状态,那么我可以像下面这样实现。
``
fn interrupt(&self, goals: &Goals, actor: &Actor, interrupt: Interrupt) {
match interrupt {
// 如果我开始燃烧,就攻击!
SpecialEffectActivate {
target,
special_effect,
} => {
if target == Target::Self && special_effect == SpecialEffect::Fire {
// 由于在调用 interrupt 时可能还有其他事情在运行,
// 我们需要展开栈以回到顶部。
goals.clear_sub_goals();
goals.push_sub_goal(Goal::Attack, AnimId::R1);
goals.push_sub_goal(Goal::Attack, AnimId::R2);
goals.push_sub_goal(Goal::Attack, AnimId::R1);
goals.push_sub_goal(Goal::Attack, AnimId::R2);
return true;
}
}
// 如果有人使用了物品,他们可能会遭殃。
UseItem => {
let fate = actor.next_random();
if fate < 0.5 {
goals.clear_sub_goals();
action_light_attack_combo(goals, actor);
}
}
// 如果我从下方受到攻击,就执行地面猛击。
Damage {
target,
} => {
if target == Target::Self {
let distance = actor.target_distance(Target::Enemy);
let fate = actor.next_random();
if distance < 1.0 && fate < 0.8 {
goals.clear_sub_goals();
action_ground_slam(goals, actor);
}
}
}
_ => {}
}
false
}
``
这被用来实现一些非常邪恶的功能,例如铃珠猎人会检测到你施法或使用物品,并有 85% 的概率立即中止当前动作并发动攻击。
他们还利用了在 Actor 上配置的动态空间监视区域,这些区域会触发中断。例如,你可以在 BOSS 身后或下方添加一个监视区,并在玩家试图耍聪明时立即调整其行为。
## 超时
Goal 除了各自的状态外,还带有一个以秒为单位的生存期值。这用于跳出因某种原因卡住的状态,生存期主要用于错误遏制机制。
也可以在执行期间修改父 Goal 的生存期,以指示持续的进展。
## Actor 数据访问
在许多 AI 决策系统中,你可能听说过诸如“黑板”之类的花哨数据存储系统。在《魂》系列游戏中,每个 Actor 上都有一个浮点数数组,可以通过索引任意地设置和读取。我想这已经足够好了!
我之前没提到的一个回调是 Initialise,它通常用于为 Actor 分配新的顶层 Goal 时重置这些数据。
Goal 通过 Actor 可以访问一系列关于世界的查询。据我所知,从性能角度来看,这些大多是“低成本”的。仇恨和瞄准似乎在外部处理,因此即使全部是解释执行的 Lua,Goal 也可以保持非常轻量。
## 实际执行
我完全忽略了一个问题:Goal 是如何实际执行事情的。在 *FROMSOFT* 游戏中,大部分事情都是由动画驱动的。
Goal 说“播放这个攻击动画”,然后动画事件携带了命中框信息和时机、特殊效果触发器、投掷物创建事件等等。它们还具有各种“连击”功能,这似乎归结为在动画中选择不同的事件集,以便在连击攻击期间更快地链接连续动画。
到某个时候,他们彻底转向了 Havok 中间件。动画使用 Havok Animation Studio(已停用)制作。之前我们提到 AI 脚本使用了 Havok Script(也已停用)。物理由 Havok 的物理引擎处理,寻路则委托给 Havok AI(未停用,但已更名为 Havok Navigation)。
## 杂项
1. 他们似乎将 AI 脚本拆分为“逻辑”脚本和“战斗”脚本,其中逻辑脚本更易于共享,而战斗脚本通常是定制化的。这看起来非常聪明,因为将这两者塞入单一层级结构经常会遇到问题。
2. 关卡设计师可以在关卡中直接为 Actor 配置顶层 Goal,因此你可以放置一些敌人,赋予其被动 Goal 而不是通常的战斗 Goal,他们就会保持冷静,同时其他功能正常工作。
3. 大多数通用代码是相对紧凑的 Lua 代码,但我相信像 `Attack` 和 `MoveToSomewhere` 这样承担核心功能的 Goal 是用 C++ 实现的,这为脚本化能力和性能合理性提供了相当不错的平衡。
4. update 函数本身有时用于检查条件,我猜这偶尔会引起问题。但只要 Actor 在脚本中的接口保持精简,我想就可以控制住。(不要添加路径查找函数调用……)
5. 我完全跳过了用于实现高级遭遇逻辑和关卡脚本的事件脚本系统。与 AI 不同,它似乎是完全自制的,带有一个非常受限的 VM。也就是说,由于它不是 Lua,很难看出它们是如何编写的。如果有人有关于其工具的一手资料,那将非常酷!
## 结论
人们对复杂的 AI 系统(比如 GOAP)一直有持久的热情,但我认为将大量控制权交给设计师和动画师的成功本身就说明了一切。
下推自动机从根本上说也比行为树和规划器快。行为树通常需要自上而下地对复杂的脚本化节点树进行重新评估,而这种情况几乎总是从栈顶执行单个 Goal4。像 STRIPS、GOAP 和 HTN 这样的规划器则会在整个执行过程中加入昂贵的搜索。
与有限状态机相比,动态转换的灵活性使得避免状态数量及其转换的爆炸式增长更加容易。这也使得以命令式方式组合 AI 功能变得更加合理。
当然,它比基于规划器的解决方案(其中单个动作脱离了战斗设计师的掌控)可读性强得多。
它能处理比典型《魂系》NPC 或 BOSS 战更复杂的场景吗?我实际上认为它可以走得很远。
## 参考文献
本文大部分信息来自 eladidu 的 readable ds lua,它非常棒,你可以找到许多有趣的定义以及一个小教程。
如果你想更兴奋,还有一堆工具可以从游戏包中提取数据,以及一些不错的用于修补的 mod 工具。
1. 这**不是**要与你可能从高级规划系统(如 STRIPS、GOAP(目标导向行动规划)或 HTN(分层任务网络))中了解到的“目标”概念相混淆。那些系统使用搜索算法来动态寻找将世界带入目标状态的一系列动作。这里没有任何如此复杂的事情发生。↩
2. 动画 ID 主要基于 PlayStation 手柄输入,然后通过 NPC 定义中的每个 Actor 值进行偏移。招式切换可以通过多种方式执行。↩
3. 我在这里忽略的明显细节是,Goal 系统似乎(至少在某些地方)具有单个 Goal 的“子 Goal”列表,并且该列表已用尽。这使得 Goal 知道它什么时候才能再次成为顶层。↩
4. 说实话,这并不完全是单 Goal 执行,因为 update 会遍历整个栈,但最顶层的 Goal 拥有控制权,并且可以决定是否让其他 Goal 执行。实际上,这更像是父 Goal 在每个子 Goal 完成后重新评估情况,但稍微灵活一些。↩
相似文章
@0xAikoDai: https://x.com/0xAikoDai/status/2057317742248931363
作者反思了对一款由AI Dungeon制作团队开发的全新AI原生RPG测试版的体验,指出了在战斗和系统可读性方面AI生成的沉浸感会崩溃的设计挑战,并呼吁为AI原生游戏制定新的设计原则。
Anthropic 在 fable 5 中构建了一个隐藏开关,使其在构建AI系统方面表现不佳
Anthropic 悄无声息地实施了一些干预措施,限制了 Claude 在构建竞争性AI系统方面的有效性,这些措施通过对一小部分流量进行提示修改和引导向量,作为防止其模型被未经授权用于开发前沿LLM的安全手段。
一位开发者分享关于如何最大化AI代理能力的见解,认为更简单的设置和理解核心原则比复杂的工具和库更有效。
一位开发者分享关于如何最大化AI代理能力的见解,认为更简单的设置和理解核心原则比复杂的工具和库更有效。
@rohanpaul_ai: 该论文指出,Claude Code 工作良好并非因为它拥有复杂的人工智能大脑,而是因为一个简单的人工智能循环…
一项分析 Claude Code 的论文揭示,其有效性源于一个简单的人工智能循环,周围环绕着针对工具、安全性、记忆和恢复的强大基础设施,而非复杂的人工智能大脑。研究强调,自主性增加了基础设施的负担。
为什么大家都觉得AI智能体很容易?🚀
一篇反思性文章,质疑人们轻率地认为构建AI智能体很容易的想法,强调了API、RAG、工具调用、记忆和编排等复杂组件,并指出在需要真正的智能体之前,更简单的工作流往往就够了。