JavaScriptでRPGを作る③|CTB戦闘システムをDOM非依存で実装する【Archipelago Saga制作】

アプリの作り方

JavaScriptでRPGを作る③|CTB戦闘システムをDOM非依存で実装する【Archipelago Saga制作】

RPGで一番こんがらがるのが戦闘だ。ダメージ計算・行動順・状態異常・敵AI。これらを画面の描画コードに混ぜて書くと、見た目を直すたびに戦闘が壊れ、テストもできなくなる。Archipelago Saga の戦闘コアは、その逆を行く。計算ロジックを画面(DOM)から完全に切り離し、「何が起きたか」を表すデータだけを返す。第3回は、この CTB(チャージタイム制)戦闘エンジンを実コードで開く。

▶ 第2回(データ駆動設計)で作った敵・スキルのデータを、ここで“動かす”。全体像は連載ハブ完成版の戦闘を遊ぶと、これから作る挙動が掴める。

目次
  1. 第3回のゴール
  2. Step 1. 設計の核:ロジックは「イベント配列」を返すだけ
  3. Step 2. CTB:速さで手番が回る仕組み
  4. Step 3. ダメージ計算:1つの関数に集約する
  5. Step 4. ターンの3フェーズ:begin → perform → end
  6. Step 5. 敵AI:重み付き抽選とテレグラフ
  7. Step 6. 戦闘を組み立てる:G.createBattle(stage)
  8. Step 7. 手を動かす:画面なしで戦闘を回す
  9. 設計Q&A:なぜこの形か
  10. まとめ:第3回の要点

第3回のゴール

「画面に依存しない戦闘ロジック」の作り方を理解する。具体的には、(1) 速さで手番が回ってくる CTB スケジューラ、(2) 属性・会心・状態異常を含むダメージ計算、(3) テレグラフ(予兆)つきの敵AI、を battle-core.js 1ファイルに、DOM を一切触らずに実装する。最後に、画面なしで戦闘を最後まで回す“ヘッドレス”ループも書く。

Step 1. 設計の核:ロジックは「イベント配列」を返すだけ

これが全ての土台だ。戦闘コアの関数は、画面を書き換えない。代わりに 「何が起きたか」を表す素のオブジェクトの配列(イベント配列)を返す。描画やアニメは、それを受け取った画面側(scenes/battle.js)の仕事。

図1:戦闘コアは「画面」を知らない battle-core.js 純ロジック DOMを触らない イベント配列を返す [{t:’damage’,amount:42,crit:true}, …] scenes/battle.js 画面・アニメ 配列を見て描く 画面が無くても core は動く = ヘッドレスで自動テストできる(第6回) 見た目を変えても、戦闘の計算結果は1ミリも変わらない。

例えば「攻撃」を解決する resolveAction は、ダメージを計算して イベントを push して返すだけdocumentinnerHTML も出てこない。

// 攻撃の解決(抜粋):計算して“起きたこと”を配列で返す
targets.forEach(function (tg) {
  var d = damageCalc(actor, tg, action);                 // ① ダメージを計算
  evs.push({ t: "damage", target: tg.uid, amount: d.amount, crit: d.crit, eff: d.eff }); // ② 起きたことを記録
  self.applyDamage(tg, d.amount, evs);                   // ③ HPを減らす(戦闘不能なら faint イベント)
});
return evs;   // ← 画面は触らない。返ってきた配列を見て描くのは scenes/battle.js の仕事

{ t: "damage", amount: 42, crit: true } のような「事実の記録」だけを積んで返す。画面側はこの t(タイプ)を見て、ダメージ数字を飛ばしたり、会心なら画面を揺らしたりする。計算と演出が、完全に分かれているのが分かるだろうか。

Step 2. CTB:速さで手番が回る仕組み

CTB(チャージタイム・バトル)は、FFXのような「速さ順で手番が回る」方式だ。各ユニットは毎時 spd ぶんの charge を貯め、chargeTHRESHOLD(=100)に達すると行動できる。速い者ほど早く・多く回ってくる。

図2:spd で charge を貯め、100 に達した者が動く THRESHOLD 100 Rogue (spd15) 最速で到達 ▶ 先手 Ranger (spd12) Warrior (spd7) 行動後は charge を 100 引く → また貯め直し。速い者ほど手番が多く回る。

「次に誰が動くか」を決めるのが advance() だ。各ユニットがあと何時間で 100 に届くかを計算し、一番早い者を選ぶ。全員のチャージをその時間ぶん進め、動いた者は 100 を引く。

B.prototype.advance = function () {
  var alive = this.units.filter(function (u) { return u.alive; });
  var best = null, bestT = Infinity, i;
  for (i = 0; i < alive.length; i++) {
    var spd = effStat(alive[i], "spd");
    var t = (THRESHOLD - alive[i].charge) / spd;   // あと t 時間で 100 に届く
    if (t < bestT) { bestT = t; best = alive[i]; } // 一番早い者が手番
  }
  for (i = 0; i < alive.length; i++) alive[i].charge += effStat(alive[i], "spd") * bestT; // 全員ぶん時間を進める
  best.charge -= THRESHOLD;   // 動いた者はリセット(余りは持ち越し)
  return best;
};

ポイントは effStat(unit, "spd")その時点の速さを取っていること。「鈍足」状態なら spd が 0.6 倍になり(第2回の G.STATUSES)、手番が遅くなる。状態異常がそのまま行動順に効くわけだ。同じロジックで先の手番を覗き見る queuePreview(n) もあり、画面の「行動順バー」はこれを描いている。

Step 3. ダメージ計算:1つの関数に集約する

ダメージの全ルール(攻防比・属性・会心・状態異常・ブレ)を damageCalc 1つに閉じ込める。物理か魔法かで参照ステータスを切り替えるだけで、式は共通だ。

function damageCalc(actor, target, action) {
  var mag = action.kind === "mag";
  var atkStat = effStat(actor,  mag ? "mag" : "atk");   // 魔法なら mag、物理なら atk
  var defStat = effStat(target, mag ? "res" : "def");   // 同様に res / def
  var base = atkStat * (action.power / 100);            // power 100 = 通常攻撃相当
  var raw  = base * (100 / (100 + defStat));            // 防御で減衰(0除算しない安全な式)

  // 会心:基礎4% + 運(luk)×0.4% + 技固有の補正
  var crit = rng.chance(0.04 + effStat(actor, "luk") * 0.004 + (action.crit || 0));
  if (crit) raw *= 1.6;

  // 属性:弱点なら1.5倍、耐性なら0.5倍(第2回の weak/resist を参照)
  var eff = null;
  if (action.element) {
    if (target.weak.indexOf(action.element) >= 0)        { raw *= 1.5; eff = "weak"; }
    else if (target.resist.indexOf(action.element) >= 0) { raw *= 0.5; eff = "resist"; }
  }
  raw *= dmgTakenMult(target);     // 「ガード」等の被ダメージ倍率
  raw *= rng.range(0.92, 1.08);    // ±8% のブレ(毎回同じ数字にしない)
  return { amount: Math.max(1, Math.round(raw)), crit: crit, eff: eff };
}

噛み砕くと、攻撃力×威力を出し、防御で割って減らし、会心・属性・状態・ブレを順に掛ける。100 / (100 + def) という式は「防御が上がるほど減衰するが、ゼロにはならない」定番の形だ。そして全ての分岐で effStat() を通すから、攻撃UP・守備DOWNといった状態異常が自動で計算に乗る。状態の倍率表(G.STATUSES)を一元参照しているのが、ここでも効いている。

Step 4. ターンの3フェーズ:begin → perform → end

戦闘の1手番は、必ず3つの段階で進む。画面側はこの3つを順に呼ぶだけでいい。

図3:1手番は begin → perform → end の繰り返し beginTurn() 手番決定+毒処理 performHero / Auto 行動を解決→イベント endTurn() 状態異常を1ターン消化 ▲ 勝敗がつくまで繰り返す(outcome() が null の間)

// ① 手番の開始:次に動く者を決め、毒など“ターン頭”の処理をする
B.prototype.beginTurn = function () {
  var evs = [], actor = this.advance();              // ← Step2 のスケジューラ
  // 毒:毎ターン頭に最大HPの数%を失う
  if (hasStatus(actor, "poison")) {
    var dmg = Math.max(3, Math.round(actor.maxHP * G.STATUSES.poison.dot));
    evs.push({ t: "poison", target: actor.uid, amount: dmg });
    this.applyDamage(actor, dmg, evs);
  }
  var auto = actor.side === "enemy" || hasStatus(actor, "sleep");  // 敵 or 睡眠は自動
  return { actor: actor, preEvents: evs, auto: auto, skip: !actor.alive };
};

// ③ 手番の終了:状態異常の残りターンを1減らし、切れたら消す
B.prototype.endTurn = function (actor) {
  var evs = [], keep = [];
  actor.statuses.forEach(function (s) {
    s.turns -= 1;
    if (s.turns > 0) keep.push(s);
    else evs.push({ t: "expire", target: actor.uid, status: s.id });
  });
  actor.statuses = keep;
  return evs;
};

真ん中の「②行動の解決」は、プレイヤーなら performHero(actor, cmd)(コマンドを受けて技/アイテム/防御/逃走を処理)、敵なら performAuto(actor) が担当する。どちらも最終的に Step 1 の resolveAction を呼び、イベント配列を返す

Step 5. 敵AI:重み付き抽選とテレグラフ

敵の行動は performAuto が決める。第2回で見た actions重み付き抽選rng.weighted)で技を選ぶ。さらにボスには「テレグラフ(予兆)」という、強力な技を1ターン溜めてから次のターンに撃つ仕掛けがある。プレイヤーに「来るぞ」と身構える隙を与える、緊張感の源だ。

B.prototype.performAuto = function (actor) {
  if (hasStatus(actor, "sleep")) return [{ t: "sleepskip", target: actor.uid }];

  // 前のターンに溜めた技があれば、今ここで撃つ
  if (actor.telegraph) {
    var act = actor.telegraph; actor.telegraph = null;
    return this.resolveAction(actor, act, null);
  }
  var choice = rng.weighted(actor.def.actions).action;   // 重み付き抽選
  if (choice.telegraph) {                                 // “溜め”が必要な技なら…
    actor.telegraph = Object.assign({}, choice, { telegraph: false });  // 次ターンに予約
    return [{ t: "telegraph", actor: actor.uid, text: actor.name + "が力を溜めている…!" }];
  }
  return this.resolveAction(actor, choice, null);         // 通常はその場で実行
};

テレグラフは「予約」の仕組みだ。溜め技を引いたら、その手番では telegraph イベントだけ返して技を actor.telegraph に保存。次に手番が来たとき、保存した技を実行する。状態を1つ持たせるだけで「予兆 → 炸裂」の2ターン演出が成立する。

Step 6. 戦闘を組み立てる:G.createBattle(stage)

ここまでの部品を1つの戦闘に組むのがファクトリ G.createBattle(stage)第2回のステージデータ(stage.enemies の id 配列)から敵を生成し、地域に応じてレベルと出現数を動的に決める。

G.createBattle = function (stage) {
  var b = new B();
  b.units = [];
  // パーティを並べる
  G.state.data.party.forEach(function (cls, i) { b.units.push(heroCombatant(cls, i)); });

  if (stage.boss) {
    // ボス戦:固定1体(stage.enemies の最後の id)をボス用レベルで
    var eid = stage.enemies[stage.enemies.length - 1];
    b.units.push(enemyCombatant(eid, 0, "", b.scale, enemyLevel(stage)));
  } else {
    // 通常戦:出現数を動的に決め、stage.enemies からランダムに引く
    var n = enemyCount(stage, partyAvgLv);
    for (var k = 0; k < n; k++) {
      var eid = stage.enemies[rng.int(0, stage.enemies.length - 1)];
      b.units.push(enemyCombatant(eid, k, "", b.scale, enemyLevel(stage)));
    }
  }
  return b;
};

敵のレベルは enemyLevel(stage) が「地域+地域内の進行度」から算出する(序盤Lv1〜5、最終地域Lv25〜30)。同じスライムでも、序盤と終盤では強さが違う。ステージに敵の id を並べるだけで、レベルも数も自動でバランスされるのは、ここまでの分離設計の成果だ。

Archipelago Saga の戦闘・スキル選択画面
組み上がったCTB戦闘のUI。上部の順番バーが速さ順の手番、下部がコマンド(スキルは skills.js のデータ)。戦闘の純ロジックは battle-core.js に分離してあり、この画面は結果を描くだけだ。

Step 7. 手を動かす:画面なしで戦闘を回す

DOM非依存の証明をしよう。画面が1つも無くても、戦闘を最後まで進められる。これがそのまま自動テストの骨組み(第6回)になる。

var b = G.createBattle(stage);     // 第2回のステージデータから戦闘を組む
var result;
while (!(result = b.outcome())) {   // 勝敗(win/lose/flee)がつくまで
  var turn = b.beginTurn();         // ① 次に動く者+ターン頭イベント
  var ev;
  if (turn.skip) ev = [];
  else if (turn.auto) ev = b.performAuto(turn.actor);                          // 敵 or 睡眠
  else ev = b.performHero(turn.actor, { type: "skill", skillId: "fireball", targetUid: "E0" }); // 味方コマンド
  b.endTurn(turn.actor);            // ③ 状態異常を1ターン消化

  // ev=イベント配列。画面ありなら描画、ここでは中身を覗くだけ(=ヘッドレス)
  ev.forEach(function (e) { console.log(e.t, e.amount || "", e.crit ? "CRIT!" : ""); });
}
console.log("戦闘結果:", result);   // "win" / "lose" / "flee"

このループの応用も1つ。コマンド選択を「常にこうげき」の自動戦略に固定して1,000戦回せば、そのステージの勝率が数分で測れる。「序盤の敵が強すぎないか」を、遊んだ感想でなく勝率の数字で確かめられるわけだ。バランス調整の実験装置としても、ヘッドレスループは働いてくれる。

このループには documentinnerHTML も一切出てこない。同じ battle-core を、画面ありでも画面なしでも動かせる。本物のゲームでは、scenes/battle.js がこのループを回しながら、返ってきた各イベント(damage / heal / telegraph / faint …)をアニメーションに翻訳しているだけだ。

🔎 なぜこの分離が「効く」のか:戦闘バランスを変えたいとき、いじるのは damageCalc の式だけ。演出を派手にしたいときは battle.js だけ。片方を直しても、もう片方は壊れない。そして「コンソールエラー0で最後まで戦える」かを機械的に検証できる。これが第6回のヘッドレス検証につながる。

設計Q&A:なぜこの形か

Q. イベント配列を経由せず、コアが直接画面を書き換えた方が速くないか?
A. 速さは変わらないが、失う物が大きい。コアが document を触った瞬間、①画面なしのテスト(Step 7)が不可能になり、②見た目の変更が戦闘ロジックを壊しうる状態に戻り、③「何が起きたか」の記録が残らないのでリプレイやログも作れなくなる。イベント配列は一手間に見えて、テスト・分離・記録の3つを同時に買っている。

Q. ダメージ式の rng.range(0.92, 1.08)(±8%のブレ)は無くてもいいのでは?
A. ゲームとしては無いと単調になる。毎回同じダメージ数字が並ぶと、プレイヤーは数字を読まなくなる。ただしテストの都合上、ブレは種つき乱数(rng)経由でなければならない。Math.random() を直に呼ぶと再現性が消え、「同じ操作で同じ結果」が壊れる。遊びとして揺らし、検証のために種で縛る。両立の鍵が rng の一元化だ。

まとめ:第3回の要点

  • DOM非依存:コアは画面を触らず、{t:'damage',…} のイベント配列を返すだけ。
  • CTBspdcharge を貯め、100到達で行動。advance() が次の手番を計算。
  • ダメージは1関数に集約:攻防比×会心×属性×状態×ブレ。全て effStat() 経由で状態異常が乗る。
  • 3フェーズbeginTurn(手番決定+毒)→ performHero/Auto(解決)→ endTurn(状態消化)。
  • 敵AI:重み付き抽選+テレグラフ(1ターン溜めて次に炸裂)。
  • ヘッドレス:画面なしで戦闘を回せる=自動テストできる。

次回(第4回)は、戦闘の“外側”へ。ワールドマップ・シーン遷移・セーブを扱う。stages.js のデータから蛇行する島マップを描き、クリアでロックを解放し、進行を localStorage に保存する worldmap.js / state.js / storage.js を開く。

AI生成コンテンツの開示:本作の BGM・効果音・キャラクター画像・ボイスは、すべて生成AIの出力物です(BGM=ACE-Step 1.5〈MIT・出力の商用利用可を配布元モデルカードで確認済み〉 / ボイス=Irodori-TTS 系の合成設計音声・実在人物の複製ではありません / 効果音=数値合成 / 画像=画像生成AI〈各サービスの利用条件で出力の商用利用可を確認のうえ使用。透過処理は BiRefNet〈MIT〉を rembg 経由で使用〉)。第三者の著作物は同梱していません。© 2026 Cooliris(本作のコード・構成・データの権利は作者に帰属します)。


👉 ← 第2回:データ駆動設計連載ハブ▶ 遊ぶほかのアプリの作り方

JavaScriptでRPGを作る②|データ駆動設計 — 敵・職業・スキルを“データ追記だけ”で増やす【Archipelago Saga制作】JavaScriptでRPGを作る②|データ駆動設計 — 敵・職業・スキルを“データ追記だけ”で増やす【Archipelago Saga制作】前のページ

JavaScriptでRPGを作る④|ワールドマップ・シーン遷移・セーブ(localStorage)【Archipelago Saga制作】次のページJavaScriptでRPGを作る④|ワールドマップ・シーン遷移・セーブ(localStorage)【Archipelago Saga制作】

ピックアップ記事

  1. New Eden Intelligence Hub の作り方 — EVE Onl…

  2. Claude Mission Control の作り方 — Tauri+Pyth…

  3. 高精度OCRデスクトップアプリの作り方 — PaddleOCR-VLとPyIns…

  4. 競艇予想AIの作り方 — LightGBMで「当たる順位」を学習させる実装ガイド…

関連記事

  1. 都市開発シミュを作る④|4度つまずいて座標系ごと作り直した話 — 真のアイソメグリッド移行【Aurum City制作】
  2. アプリの作り方

    Claude Codeのトークンを削るOSS 11本 — ライセンス本文まで確かめた一覧

    GitHubで「Claude Codeのトークンを削る」と名の付くリポ…

  3. 背景除去アプリの作り方 — PyQt6+ONNX(BiRefNet)でローカル完結の透過ツールを作る
  4. ゲーム感覚でIT・AI用語500選を覚える学習アプリの作り方 — XP・13ランク・実績で「続く」を設計する
  5. JavaScriptでRPGを作る②|データ駆動設計 — 敵・職業・スキルを“データ追記だけ”で増やす【Archipelago Saga制作】

    アプリの作り方

    JavaScriptでRPGを作る②|データ駆動設計 — 敵・職業・スキルを“データ追記だけ”で増や…

    ゲームのボリュームは「コード」でなく「データ」で増やす。職業・スキル・…

注目

AIで、ここまで作れる

AIで作った2D RPGを、ブラウザでそのまま遊べます。その「作り方=最後まで完成させる進め方」も実例つきで公開中。

▶ ゲームを遊ぶやり方を読む

PR

ロリポップ!レンタルサーバー(PR)

レンタルサーバ:ロリポップ!(本サイトの稼働環境・PR)

  1. 競艇予想AIの作り方 — LightGBMで「当たる順位」を学習させる実装ガイド

    アプリの作り方

    競艇予想AIの作り方 — LightGBMで「当たる順位」を学習させる実装ガイド…
  2. WorkSpace Explorer の作り方 — 増えた自作アプリを一画面で束ねる Electron 製の開発司令塔

    アプリの作り方

    WorkSpace Explorer の作り方 — 増えた自作アプリを一画面で束…
  3. アプリの作り方

    Claude Codeのトークンを削るOSS 11本 — ライセンス本文まで確か…
  4. Claude 音声モードが Opus / Sonnet 対応 — 音声が「入力手段」から「実行手段」に変わった

    AI・テック動向

    Claude 音声モードが Opus / Sonnet 対応 — 音声が「入力手…
  5. WordPress 7.1 Beta 1 公開 — 8月19日の iframe 化前に、自サイトのブロックを1行で棚卸しする

    AI・テック動向

    WordPress 7.1 Beta 1 公開 — 8月19日の iframe …
PAGE TOP

TAG CLOUD

ドラッグで回転・クリックでそのタグの記事一覧へ