RPGで一番こんがらがるのが戦闘だ。ダメージ計算・行動順・状態異常・敵AI。これらを画面の描画コードに混ぜて書くと、見た目を直すたびに戦闘が壊れ、テストもできなくなる。Archipelago Saga の戦闘コアは、その逆を行く。計算ロジックを画面(DOM)から完全に切り離し、「何が起きたか」を表すデータだけを返す。第3回は、この CTB(チャージタイム制)戦闘エンジンを実コードで開く。
▶ 第2回(データ駆動設計)で作った敵・スキルのデータを、ここで“動かす”。全体像は連載ハブ。完成版の戦闘を遊ぶと、これから作る挙動が掴める。
目次
第3回のゴール
「画面に依存しない戦闘ロジック」の作り方を理解する。具体的には、(1) 速さで手番が回ってくる CTB スケジューラ、(2) 属性・会心・状態異常を含むダメージ計算、(3) テレグラフ(予兆)つきの敵AI、を battle-core.js 1ファイルに、DOM を一切触らずに実装する。最後に、画面なしで戦闘を最後まで回す“ヘッドレス”ループも書く。
Step 1. 設計の核:ロジックは「イベント配列」を返すだけ
これが全ての土台だ。戦闘コアの関数は、画面を書き換えない。代わりに 「何が起きたか」を表す素のオブジェクトの配列(イベント配列)を返す。描画やアニメは、それを受け取った画面側(scenes/battle.js)の仕事。
例えば「攻撃」を解決する resolveAction は、ダメージを計算して イベントを push して返すだけ。document も innerHTML も出てこない。
// 攻撃の解決(抜粋):計算して“起きたこと”を配列で返す
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 を貯め、charge が THRESHOLD(=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つを順に呼ぶだけでいい。
// ① 手番の開始:次に動く者を決め、毒など“ターン頭”の処理をする
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 を並べるだけで、レベルも数も自動でバランスされるのは、ここまでの分離設計の成果だ。

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戦回せば、そのステージの勝率が数分で測れる。「序盤の敵が強すぎないか」を、遊んだ感想でなく勝率の数字で確かめられるわけだ。バランス調整の実験装置としても、ヘッドレスループは働いてくれる。
このループには document も innerHTML も一切出てこない。同じ 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',…}のイベント配列を返すだけ。 - CTB:
spdでchargeを貯め、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回:データ駆動設計 ・ 連載ハブ ・ ▶ 遊ぶ ・ ほかのアプリの作り方
