powered by TechFeed
表示モード
Deep Dive

エルデンリングの敵AIは「スタックと乱数」だけで動いていた — 複雑なプランナー不要、シンプルな構造がSoulsborneの凶悪さを生み出す仕組み

6月23日、nega.tvが「The Low-Tech AI Of Elden Ring」と題した記事を公開した。フロムソフトウェアの『エルデンリング』を含むSoulsborneシリーズは、プレイヤーを何度も死に追いやる手強いNPC戦闘で世界中に知られている。その強さの裏には高度なAIプランナーが動いているのでは——という予想を、リバースエンジニアリングで覗いたコードは鮮やかに裏切る。実態は「データ構造としてのスタック」と「重み付き乱数」の組み合わせだ。Behavior TreeもGOAPも使っていない。GoalをスタックにPushして積み、距離に応じた重みで行動を抽選し、interruptで即時割り込む。それだけの構造が、あの理不尽な強さを生み出している。ゲームAIの世界で「複雑な仕組みを使うほど良い体験になる」という思い込みが、静かに崩される内容だ。

6月23日、nega.tvが「The Low-Tech AI Of Elden Ring」と題した記事を公開した。フロムソフトウェアの『エルデンリング』を含むSoulsborneシリーズは、プレイヤーを何度も死に追いやる手強いNPC戦闘で世界中に知られている。その強さの裏には高度なAIプランナーが動いているのでは——という予想を、リバースエンジニアリングで覗いたコードは鮮やかに裏切る。

実態は「データ構造としてのスタック」と「重み付き乱数」の組み合わせだ。Behavior TreeもGOAPも使っていない。GoalをスタックにPushして積み、距離に応じた重みで行動を抽選し、interruptで即時割り込む。それだけの構造が、あの理不尽な強さを生み出している。ゲームAIの世界で「複雑な仕組みを使うほど良い体験になる」という思い込みが、静かに崩される内容だ。


コアの仕組み:スタックで管理される「Goal」

AIの根幹をなすのが「Goal」という概念だ。これはフロム独自の用語で、AIが取りうる状態の単位を指す。

最も素直な実装なら有限状態機械(FSM)を使うところだが、フロムはGoalをデータ構造としてのスタック(後入れ先出し)で管理する。これにより、FSMを拡張したプッシュダウンオートマトン(PDA)の構造になっている。PDAとは、FSMにスタックを加えた計算モデルで、サブゴールの入れ子表現が自然にできる。

毎フレーム、ActorはスタックのトップにあるGoalを更新する。GoalはサブゴールをスタックにPushでき、更新関数はContinue / Success / Failureを返す。SuccessとFailureはGoalをPopし、Failureの場合はさらに親Goalまで巻き戻す。

[ GOAL STACK ]
3: Attack (R2, Combo)           ← 現在実行中
2: Attack (R2, Repeat)
1: Attack (R2, Finisher)
0: CoolBossBattle

Failureが発生すると:

[ GOAL STACK ]
// Attack (R2, Repeat) が失敗 → スタックから除去
// Attack (R2, Finisher) も連動して除去
0: CoolBossBattle               ← 次の行動を選び直す

親GoalのCoolBossBattleが次の行動を選ぶ。シンプルなビルディングブロックを組み合わせるだけで複雑な行動が実現できる。


Activate:重み付きランダム選択で行動を決める

GoalのコールバックのうちAIロジックが最も詰まっているのがactivateだ。Goalが最初に更新されたとき、またはサブゴールを使い切ったときに呼ばれる。

行動選択の主手法は重み付きランダム選択だ。敵との距離やHP閾値に応じて各行動のウェイトを動的に変え、共通ライブラリが抽選する。以下はRustライクな擬似コードの抜粋:

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]   // 近距離:叩きつけを重視
    };
    // アニメーションのクールダウン中はウェイトを0に(直前と同じ行動を封じる)
    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] };
    common::battle_activate(goals, actor, weights, actions);
}

乱数はActor自身から取得するため、再現性の管理も一元化されている。


Interruptが生み出す「凶悪な挙動」

もう一つの重要なコールバックがinterruptだ。外部イベントを即時検知してGoalの流れに割り込む。割り込みは現在実行中のGoalから親Goalへと再帰的にバブルアップし、どれかがtrueを返すと伝播が止まる。

記事が名指しで触れている例がベルベアリングハンターだ。プレイヤーがアイテムを使用した瞬間に検知し、高確率で現在の行動を即座に中断して攻撃に転じる。以下は記事中の擬似コードの抜粋だ:

UseItem => {
    let fate = actor.next_random();
    if fate < 0.5 {
        goals.clear_sub_goals();
        action_light_attack_combo(goals, actor);
    }
}

さらにActorには動的空間ウォッチ領域を設定できる。ボスの背後や足元といったエリアを監視し、プレイヤーが潜り込んだ瞬間に割り込みが発火する。「ボスの背後を取ったのに即座に振り向いて攻撃された」という体験はこの仕組みによるものだ。


設計の合理性:Behavior TreeやGOAPとの比較

記事の結論部分はアーキテクチャ選択の議論として読み応えがある。

  • **Behavior Tree**:毎フレームツリーを上から再評価する必要があり、木が複雑になるほどコストが増える。
  • **GOAP / STRIPS / HTN**:プランニングに探索コストがかかり、デバッグも難しい。
  • PDA(フロム方式):ほぼ常にスタックトップの1つのGoalだけを実行するため高速。FSMと比べて状態数の爆発を抑えやすく、コンバットデザイナーが行動の流れを命令的に記述できる。

データ管理にも潔い割り切りがある。「ブラックボード」機構を使わず、Actor上のfloat配列をインデックスで読み書きするだけだ。

その他、記事が補足している実装上のポイントをまとめると:

  • AIスクリプトは「logicスクリプト」と「battleスクリプト」に分割されており、logicは使い回しが効く設計
  • AttackMoveToSomewhereといった基盤GoalはC++実装でパフォーマンスを確保
  • レベルデザイナーがActor単位でTop Level Goalを設定可能。戦闘Goalの代わりに待機Goalを置くことで、通常機能を保ちつつ非戦闘状態の敵を配置できる

なお、エルデンリングのAIロジックの大部分はHavok Script(HavokによるゲーミングLua実装)で書かれており、リバースエンジニアリングによってコードを覗くことが比較的容易な状態にある。本記事はオリジナル研究ではなく、他者がデコンパイル・リバースエンジニアリングしたコードを著者が読み解いたものだ。


Goalをスタックに積み、距離に応じた重み付きランダムで行動を選び、interruptで即時割り込む。その構成だけでフロムの凶悪な敵AIが成立している。Hacker Newsでもこの記事は話題を呼んでおり、「シンプルな仕組みでも十分なゲーム体験が作れる好例」として多くのゲーム開発者が反応している。

詳細はThe Low-Tech AI Of Elden Ringを参照していただきたい。