プログラミング
エルデンリングのローテクAI
The Low-Tech AI of Elden Ring (nega.tv)
要約
この記事は、『エルデンリング』を含むフロム・ソフトウェアのゲームにおけるNPCのAIが、意外にも「ローテク」な実装であることを解説しています。Havok Script(Luaベース)で実装されたAIは、状態をスタックで管理するプッシュダウンオートマトン「Goal」を主要なツールとして使用しており、起動時に世界の状況やランダム性に基づいて次の行動を選択し、特定のイベント発生時には「Interrupt」によって行動を中断・変更する仕組みが採用されています。これにより、シンプルながらも複雑で予測不可能なNPCの挙動を実現しています。
全文翻訳
The Low-Tech AI Of Elden Ring (エルデンリングのローテクAI)
フロム・ソフトウェアは、ソウルシリーズ全体にわたって多様で手強いNPCとの遭遇で評判ですが、AIの意思決定そのものの実装は、おそらく意外なほどローテクです。コードの大部分がHavok Script(Havok製のゲーム指向Lua実装)で実装されているため、霧の壁の裏側を覗いて、それらがどのように実装されているかを見るのは非常に簡単です。
なお、以下に続くものはオリジナルな研究ではなく、私は単に他者が抽出、デコンパイル、リバースエンジニアリングに尽力したコードを読んでいるだけです。
Goals (目標)
フロム・ソフトウェアのAIアプローチの主要なツールはGoal1であり、これはAIが取り得るユニークな状態に対する彼ら独自の用語です。Goalはインスタンス化の際にパラメータ化でき、Actor自体に格納されたデータにアクセスできますが、それ以外は実際には単なる関数の不変なテーブルです。
最もシンプルな選択肢は、状態を有限状態機械(Finite State Machine)または階層型有限状態機械(Hierarchical Finite State Machine)として整理することでしょうが、フロム・ソフトウェアはさらに一歩進んで、システムに状態のスタックを与えています。これにより、FSMがプッシュダウンオートマトン(Pushdown Automaton; PDA)に変わります。
これは完全に抽象的な定義なので、Wikipediaから戻ってきたら、具体的に上から説明しましょう。
各フレームでActorはGoalのスタックの一番上にあるGoalを更新します。Goalが更新されると、さらにGoalをサブGoalとしてスタックにプッシュでき、その最上位のものが次のフレームで実行されます。Goalの更新関数は、Continue、Success、またはFailureのいずれかを示す値を返します。Continueはスタックを変更せず、他の2つはGoalがスタックからポップされる原因となります。Failureは、さらに実行されていない他のすべてのGoalを、親Goal(このサブGoalをプッシュしたGoal)までスタックからポップさせます。
たとえば、CoolBossBattleというGoalを定義するとします。その実行中に、一連のAttackサブGoalをプッシュするかもしれません。これらのAttack Goalはさまざまな手段でパラメータ化できますが、主なものはアニメーションID2です。
```
[ GOAL STACK ]
3: Attack (R2, Combo) <<<<-- 現在更新中
2: Attack (R2, Repeat)
1: Attack (R2, Finisher)
0: CoolBossBattle
```
数秒後、最初の攻撃がヒットし、その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では、このスタックのルートを「Top Level Goal」と呼んでいます。私は現在実行中のGoalをスタックの「トップ」と呼ぶことで混乱させていますが、これらは別々のものだと覚えておいてください。
Activate (アクティベート)
Goalは、いくつかの関数をコールバックとして定義しており、最もAIロジックが含まれるのは通常activateです。これはGoalが初めて更新されるときに呼び出され、その後、サブGoalをすべて使い果たして再び実行を開始するたびに呼び出されます。
ボスや通常のNPCのGoalの場合、Activate内のコードは、世界やActorからのコンテキストとランダム性(Actor自体からも生成される)を組み合わせて、Actorが取る次の行動を選択する役割を担います。
最も広く使用されているアプローチは、一般的なコードを使用して、いくつかのアクション(単なる関数)の中から重み付きランダム選択を実行し、勝者を選択することです。
CoolBossBattleに戻り、今回はいくらか雑な擬似コードで見てみましょう…
```
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::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, // action_giga_death_ray
65.0, // action_leap_attack
0.0, // action_ground_slam
10.0, // action_light_attack_combo
10.0, // action_heavy_attack_combo
]
} 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からの単純な乱数ロールとHP閾値処理です。
Actorのトップレベルの戦闘Goalよりも単純な他のGoalは、おそらくいくつかのサブGoalをプッシュするだけで、Goalパラメータからいくつかのデータを読み取るかもしれません。ネスティングにより、単純な構成要素から非常に複雑な動作を構成することが可能になります。
Interrupts (割り込み)
Goalに定義されているもう一つの主要なコールバックはInterruptです。その名の通り、これはGoalが外部イベントに即座に対応できるようにするもので、そのほとんどはActor自体に設定されています。
私の理解では、割り込みはバブルアップします。つまり、現在実行中のGoalとその親で再帰的に割り込みが実行され、Goalがなくなるか、割り込みコールバックのいずれかが割り込みを処理したことを示すtrueを返すまで続きます。
たとえば、CoolBossが火をつけられた瞬間に激怒して攻撃の嵐に突入するようにしたい場合、次のようなものを実装するかもしれません。
```
fn interrupt(&self, goals: &Goals, actor: &Actor, interrupt: Interrupt) {
match interrupt {
// If I start burning, attack!
SpecialEffectActivate { target, special_effect, } => {
if target == Target::Self && special_effect == SpecialEffect::Fire {
// Since there might still be other things running when
// interrupt is called we need to unwind so we're on top again.
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;
}
}
// If somebody uses an item they might be in for it.
UseItem => {
let fate = actor.next_random();
if fate < 0.5 {
goals.clear_sub_goals();
action_light_attack_combo(goals, actor);
}
}
// Perform a ground slam if I get attacked from underneath.
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%の確率で現在の行動を即座に中断し…