首页 / 文章 / 艾尔登法环的低技术AI
← 返回
AI技术

艾尔登法环的低技术AI

✍️ zhirenhun 📅 2026/6/24 👁 28 阅读 ⏱ 30 分钟
📄

艾尔登法环的低技术AI

FROMSOFT 在整个《魂系》系列中以多样且极具挑战性的 NPC 遭遇战闻名,但其 AI 决策的实现方式却出乎意料地低技术。由于大部分代码是用 Havok Script(Havok 提供的面向游戏的 Lua 实现)编写的,因此要窥探雾门之后、了解它们的实现方式其实相当容易。

请注意,以下内容并非原创研究,我只是阅读了他人辛苦提取、反编译和逆向工程的代码。

目标(Goals)

FROMSOFT AI 方法的核心工具是目标(Goal)1,这是他们自己的术语,指代 AI 可以处于的独特状态。目标在实例化时可以参数化,并且可以访问 Actor 本身存储的数据,但除此之外,它们实际上只是一个不可变的函数表。

最简单的方案是将状态组织成有限状态机(FSM)分层有限状态机(HFSM),但 FROMSOFT 更进一步,为系统提供了一个状态栈。这将系统从 FSM 转变为下推自动机(Pushdown Automaton, PDA)

这是一个纯抽象的定义,所以在你从维基百科回来后,让我们从头开始具体地讨论它。

每一帧,Actor 会更新其目标栈顶部的目标。当目标更新时,它可以向栈中推送更多子目标,其中最顶部的子目标将在下一帧执行。目标的更新函数返回一个值,表示继续(Continue)成功(Success)失败(Failure)。继续会让栈保持不变,其他两个值会导致该目标从栈中弹出。失败还会导致所有其他未执行的目标从栈中弹出,直到父目标(推送此子目标的目标)。

例如,我们定义一个名为 CoolBossBattle 的目标。在其执行过程中,它可能会推送一系列 Attack 子目标。这些攻击目标可以通过各种方式参数化,但最主要的方式是动画 ID2

[ GOAL STACK ]

3: Attack (R2, Combo)           <<<<-- 当前正在更新
2: Attack (R2, Repeat)
1: Attack (R2, Finisher)
0: CoolBossBattle

几秒后,第一次攻击命中,该目标成功完成并从栈中弹出。然而下一个攻击失败了,导致栈回退到其父目标。

[ 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 中,它们将栈的根称为顶层目标(Top Level Goal),而我之前将当前执行的目标称为栈的"顶部",可能会造成混淆。请记住这是两个不同的概念。

激活(Activate)

目标由几个用作回调的函数定义,其中包含最多 AI 逻辑的是 activate。这个函数在目标首次更新时被调用,之后每次目标用尽子目标并重新开始执行时也会被调用。

对于 Boss 和普通 NPC 目标,Activate 中的代码负责使用来自世界和 Actor 的上下文,以及随机性(也来自 Actor 本身),来决定 Actor 的下一个动作。

最广泛使用的方法是在多个动作(Actions,本质上是函数)之间进行加权随机选择,然后调用选中的那个。

回到我们的 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 本身是用通用代码定义的目标!
    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::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 随机数和 HP 阈值判断。

比 Actor 的顶层战斗目标更简单的其他目标,可能只需推送几个子目标,或读取目标参数中的一些数据。这种嵌套使得从简单的构建块组合出相当复杂的行为成为可能。

中断(Interrupts)

定义目标的另一个主要回调是 Interrupt(中断)。顾名思义,这允许目标对外部事件立即做出响应,这些事件主要是在 Actor 本身上配置的。

我的理解是,中断会向上冒泡——它会在当前执行的目标上运行中断处理,然后递归地在父目标上运行,直到耗尽所有目标或某个中断回调返回 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 {
                // 由于调用中断时可能还有其他正在运行的任务,
                // 我们需要回退以确保回到栈顶。
                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
}

这被用来实现一些真正邪恶的特性。例如,铃珠猎人(Bell Bearing Hunter)会检测你是否在施法或使用道具,然后以 85% 的几率立即中止当前动作并发动攻击。

它们还利用在 Actor 上配置的动态空间监视区域来触发中断。例如,你可以为 Boss 背后或下方的区域添加监视,当玩家试图取巧时立即调整其行为。

超时(Timeouts)

目标除了各自的状态外,还带有一个以秒为单位的生命周期(lifetime)值。这用于跳出因各种原因卡住的状态,生命周期似乎主要用作一种错误遏制机制

在执行过程中也可以修改父目标的生命周期,以表明正在继续推进。

Actor 数据访问

在许多 AI 决策系统中,你可能听说过像"黑板(blackboard)"这样花哨的数据存储系统。在《魂系》游戏中,每个 Actor 上有一个浮点数数组,目标可以按索引任意读取和写入。我想这已经够用了!

之前没提到的一个回调是 Initialise,通常在 Actor 被分配新的顶层目标时用于重置这些数据。

目标可以通过 Actor 对世界进行一系列查询。据我所知,大部分查询从性能角度来看都是相当"低成本"的。仇恨和锁定系统似乎是在外部处理的,因此即使全部运行在解释型 Lua 上,也能保持目标非常精简。

实际执行(Actual Doing Stuff)

我完全跳过了目标实际上如何执行动作的问题。FROMSOFT 游戏中的绝大部分内容都是动画驱动的。目标说"播放这个攻击动画",然后动画事件携带命中框信息与时机、特效触发器、弹射物创建事件等等。它们还有多种"连击"功能,大致归结为在连击攻击期间,在动画中选择不同的事件集来实现更快的链式动画连接。

在某个时间点,它们全面押注 Havok 中间件。动画使用 Havok Animation Studio(已停用)创作。之前我们提到 AI 脚本使用 Havok Script(也已停用)。物理由 Havok 的物理引擎处理,寻路委托给 Havok AI(未停用,但已更名为 Havok Navigation)。

杂项

它们似乎将 AI 脚本拆分为"逻辑"脚本"战斗"脚本,其中逻辑脚本的可复用性高得多,而战斗脚本通常是定制化的。这看起来非常聪明——将这两者塞进单一层级结构中经常会遇到问题。

关卡设计师可以在关卡本身上为 Actor 配置顶层目标,因此你可以用被动目标而不是通常的战斗目标来布置一些敌人,它们在保持正常功能的同时只会安静待着。

大多数通用代码是相对紧凑的 Lua 片段,但我认为像 AttackMoveToSomewhere 这样承载关键负载的目标是在 C++ 中实现的,这在脚本能力和性能合理性之间取得了极好的平衡。

update 函数有时也被用来检查条件,我猜测这偶尔会造成问题。但只要 Actor 脚本接口保持轻薄,我想还是可以控制住的。(别在脚本里加寻路函数调用……)

我完全跳过了用于高层遭遇逻辑和关卡脚本的事件脚本系统。与 AI 不同,它似乎是完全自定义的,带有一个非常受限的 VM。然而,既然它不是 Lua,很难看出它们是如何实际创作的。如果有人知道关于它们工具链的第一手信息源,那将非常酷!

结论

对于复杂的 AI 系统一直存在大量持续的热捧(GOAP 就是一个例子),但我认为将大量控制权交给设计师和动画师的成功本身就是最好的证明。

下推自动机从根本上也比行为树(Behavior Trees)和规划器快得多。行为树通常需要自上而下地重新评估一棵由脚本节点组成的复杂树,而这里的方案几乎总是在执行栈顶的单个目标4。像 STRIPSGOAPHTN 这样的规划器,则在每个环节中都增加了昂贵的搜索过程。

与 FSM 相比,动态转换的灵活性使得避免状态及转换数量的爆炸变得更加容易。这也使得以命令式方式组合 AI 功能更加合理。当然,与基于规划器的方案相比,它也可读性强得多,因为后者将个体动作从战斗设计师手中移走了。

它能处理比典型魂系 NPC 或 Boss 战更复杂的场景吗?我其实认为它可以走得很远。

更新(Update)

一些 HackerNews 评论者对与行为树的对比感到困惑,指出这个方案与保持活动节点栈的事件驱动型行为树非常相似,并想知道它与"正常"游戏 AI 相比的复杂度。由于我之前的解释比较敷衍,这里试着再展开一些。

首先,虽然事件驱动型行为树确实避免了整个树自上而下重新执行的需求,但并非所有行为树实现都采用这种方式!尤其是更偏学术界和早期的使用者,看起来更像朴素实现。我认为这里的方法——显式地表示执行结构并用命令式代码构建——比试图在行为树之上改造某种执行路径缓存要直接得多

其次,关于更广泛的问题——复杂度——行为树将控制流结构作为节点实现在行为树结构内部(在学术实现中尤其如此),这就是为什么你会看到"条件(Conditions)"、"序列(Sequences)"、"选择器(Selectors)"和"并行(Parallels)"这样的行为树术语。这显著膨胀了树的规模,以及创作的复杂度——以我(有限的!我不是 AI 程序员)的经验。相比之下,FROMSOFT 的风格,即使在今天,状态数量也极低,并依赖这些状态内部的命令式代码来实现大部分控制流。戴上性能评估的帽子来看,这对于避免千刀万剐式的问题至关重要——太多逻辑被囤积在复杂难缠的树结构中,在创作和执行过程中都难以管理。

最后,对于大型游戏来说,这里的代码量就是。FROMSOFT 依赖相对大量的定制行为(每个 Boss 一个或多个),但这些行为与其他大型 AAA 游戏中你能见到的相比,非常小。在行为树的生产实践中,看到数万个节点的树、以及数百个实现控制流和动作的独立节点,并不令人意外。同样,Actor 上的数据模型与此相比也极其简陋,而你可能在"现代"行为树实现中期待丰富的数据。

再次重申之前的观点,规划器与此相比确实复杂,而 FSM 往往出现类似行为树的节点爆炸问题。

在讨论性能时,重要的是将其放在实现需求的背景中考虑。用这里使用的结构(在通用脚本语言之上)做一个"周末级"的实现,就基本足以实现像《艾尔登法环》这样的游戏5。但如果你想构建一个达到类似性能目标的行为树实现,你需要构建一个将创作时的树降低为某种优化表示的系统,外加实现大量的基本控制流和数据传递原语,以及创作、组合和调试树所需的所有工具。朴素的实现很可能是不够的。

那么,无论你选择如何设计这样的系统,都能实现良好的性能吗?是的。但在我看来,走行为树的路线需要多得多的工作。

参考文献

本文大部分信息来自 eladidu readable ds lua,它非常棒,你可以找到许多有趣的定义以及一个小教程。

如果你想要更兴奋一些,还有一堆用于从游戏包中提取数据的工具,以及用于修补各种内容的优秀 Mod 工具。


1 这里不要与你在高级规划系统中可能知道的"目标"概念混淆,例如 STRIPSGOAP(目标导向动作规划)(如经典射击游戏 F.E.A.R 中所见)或 HTN(层级任务网络)。那些系统使用搜索算法来动态寻找将世界推进到目标状态的动作序列。这里的复杂程度远不及那些!↩

2 动画 ID 主要基于 PlayStation 手柄输入,然后通过 NPC 定义中的每个 Actor 值进行偏移。可以通过脚本动态改变偏移量来实现动作集切换!↩

3 我忽略了一个小问题……你需要让你的脚本使得子目标像队列(而非栈)一样工作,以便它们按照被推送的顺序执行。不幸的是,这会稍微复杂化实现和解释,所以我留给读者作为练习。↩

4 我不完全确定它们是只更新栈顶的当前目标,还是递归更新当前活跃的所有目标,但我怀疑可能是后者。这仍然比在行为树中重新评估决策条件高效得多。↩

5 显然这是一个简化说法,希望我表达清楚了——我说的是核心数据结构和框架,而非绘制完整猫头鹰所需的大量外围工作。↩

原文:The Low-Tech AI Of Elden Ring — NEGA.TV

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

AI 游戏开发 游戏AI Havok
← 上一篇
漏洞报告不再特殊
下一篇 →
Rhombus 1.0 正式发布

📌 相关推荐

📄
Rhombus 1.0 正式发布
2026/6/24
提示注入的理论基础:角色混淆(Prompt Injection as Role Confusion)
2026/6/23
GLM-5.2 本地部署指南
2026/6/23
← 返回文章列表