筆者は攻略サイト『ポケチャン攻略ラボ』の運営者です。 この記事では、サイトのダメージ計算ツールで使っている計算部分の実装を解説します。コードはリポジトリから要点だけを抜き出して短くしたものです。
※ポケチャン攻略ラボは非公式のファンサイトです。株式会社ポケモン・任天堂・ゲームフリーク・クリーチャーズとは関係ありません。
はじめに
対戦ゲーム「ポケモンチャンピオンズ」のダメージ計算機は、一般に「乱数1発(37.5%)」や「確定2発」といった 確定数 を表示します。ただ、この値は多くの場合、急所なし・命中100%・技は1回だけ を前提にしています。
実際の対戦では次の要素も効いてきます。
- 急所(通常 1/24)
- 命中率(90%、85% など)
- 2〜5回連続で当たる技(回数がランダム)
- 追加効果によるランク変化(「かみくだく」の 20% で防御ダウン、「りゅうせいぐん」の使用後の特攻ダウンなど)
- きあいのタスキ、がんじょう、ばけのかわ、オボンのみ、たべのこし
これらを全部入れた「n回の攻撃で倒せる確率」を、モンテカルロ法ではなく厳密値 として出す実装を紹介します。
1. 前提:ダメージは「16通り」の離散分布
ダメージの基本式は本編シリーズと同じで、最後に 0.85〜1.00 の乱数(16段階)を掛けます。チャンピオンズではレベルが50固定なので、レベルの項は定数になります。
// src/lib/damage.mjs(抜粋)
/** 五捨五超入(x × mod/4096) */
export const pokeRound = (v, mod) => Math.floor((v * mod + 2047) / 4096);
export function calcDamage(p) {
// …
const levelTerm = Math.floor((2 * 50) / 5 + 2); // 22
let base = Math.floor(Math.floor((levelTerm * power * A) / D) / 50) + 2;
if (spread) base = pokeRound(base, 3072); // ダブルの複数対象 0.75倍
if (crit) base = pokeRound(base, 6144); // 急所 1.5倍
const out = [];
for (let r = 85; r <= 100; r++) {
let d = Math.floor((base * r) / 100);
if (stab) d = pokeRound(d, stabMod); // タイプ一致(通常 6144 = 1.5倍)
d = Math.floor(d * typeMult); // タイプ相性
// … やけど・壁・持ち物などの最終補正(4096 基準で連結)
out.push(Math.max(1, d));
}
return out; // 乱数16通りのダメージ(昇順)
}
ポイントは 補正ごとに丸める ことです。倍率をまとめて掛けると1ずれることがあります。補正値は 4096 を1倍とする整数で持ち、(v * mod + 2047) / 4096 を切り捨てて「五捨五超入」(0.5 ちょうどは切り捨て)を再現しています。
チャンピオンズ固有の定数(Lv.50・個体値31・能力ポイントは合計66/1つの能力に最大32・乱数 85〜100)は、データファイル mechanics.json にまとめてあります。
2. 1回の攻撃の分布と「畳み込み」
急所・命中・連続技を考えない場合、n回で倒せる確率は、16通りの分布を n 回畳み込んだものが HP 以上になる確率です。HP 以上の値はすべて「倒した」という1つの状態にまとめます。こうすると、状態の数が HP+1 を超えません。
// src/lib/damage.mjs(抜粋)
/** n発で倒せる確率(同じダメージ分布の独立試行) */
export function koChance(rolls, hp, hits) {
let dist = new Map([[0, 1]]);
for (let i = 0; i < hits; i++) {
const next = new Map();
for (const [sum, pr] of dist) for (const r of rolls) {
const s = Math.min(hp, sum + r); // HP以上は hp に吸収
next.set(s, (next.get(s) ?? 0) + pr / rolls.length);
}
dist = next;
}
return dist.get(hp) ?? 0;
}
急所が入るときは、1ヒットの分布を「通常の16通り×(1−急所率)」と「急所の16通り×急所率」の 混合分布 にします。命中率は「外れ(ダメージ0)」に 1 − acc の重みを足すだけです。
// src/lib/prob.mjs(抜粋)
export function hitDist(rolls, critRolls, critRate, cap) {
const p = new Array(cap + 1).fill(0);
for (const d of rolls) p[Math.min(cap, d)] += (1 - critRate) / rolls.length;
if (critRate > 0) for (const d of critRolls ?? rolls) p[Math.min(cap, d)] += critRate / (critRolls ?? rolls).length;
return p;
}
急所のときのダメージは「通常ダメージ×1.5」ではありません。急所フラグを立ててもう一度計算 します。急所では、攻撃側のランクダウンと防御側のランクアップが無視されます(calcDamage の atkRank = Math.max(0, atkRank) の部分)。また、壁の効果もなくなります。そのため、単純に1.5倍した値とは一致しません。
3. 単純な畳み込みでは足りない:状態を持つ完全列挙
次のような要素があると、ダメージは「毎回同じ分布の足し算」では表せなくなります。
- かみくだく:20% で相手の防御が1段階下がり、次のヒットから ダメージが増える
- りゅうせいぐん:使ったあとに自分の特攻が2段階下がる
- マルチスケイル:HP満タンのときだけダメージ半減
- きあいのタスキ・がんじょう:満タンから一撃で倒れるときだけ HP1 で耐える
- オボンのみ:HP が半分以下になったら1回だけ 1/4 回復
そこで、ダメージの合計に加えて次の値も「状態」として持たせ、同じ状態どうしの確率を足し合わせながら進めます(exact.mjs)。
- ランク変化
- 持ち物が残っているか
- がんじょう・ばけのかわが残っているか
// src/lib/exact.mjs(抜粋)
const keyOf = (s) => `${s.d}|${s.as.join(',')}|${s.dd}|${s.ds}|${+s.item}|${+s.sturdy}|${+s.disg}`;
const add = (m, s, p) => { if (p <= 0) return; const k = keyOf(s); const o = m.get(k);
if (o) o.p += p; else m.set(k, { ...s, p }); }; // 同じ状態は合流させる
ダメージは、状態ごとに rollsAt(攻撃側ランク, 防御側ランク, 急所か, HP満タンか, 持ち物が残っているか) を呼んで、そのつど16通りを計算します。結果はキャッシュしているので、同じ組み合わせを計算し直すことはありません。
// src/lib/calc-core.mjs(抜粋)
export function makeRollsAt(base) {
const cache = new Map();
return (as, ds, crit, full, itemOn) => {
const k = `${as}|${ds}|${+crit}|${+full}|${+itemOn}`; let v = cache.get(k);
if (!v) {
const r = computeRolls({ ...base, atkRank: (base.atkRank ?? 0) + as, defRank: (base.defRank ?? 0) + ds,
crit, fullHp: full && base.fullHp !== false, defItem: itemOn ? base.defItem : null });
v = { rolls: r.rolls, consumes: r.consumes }; cache.set(k, v);
}
return v;
};
}
連続技(2〜5回)を「1ヒットずつ」展開する
2〜5回当たる技の回数の確率は 2回 7/20・3回 7/20・4回 3/20・5回 3/20 です。Pokémon Showdown の champions mod(data/mods/champions/scripts.ts)でも、[2,2,2,2,2,2,2,3,3,3,3,3,3,3,4,4,4,5,5,5] から1つを選ぶ実装になっています。
素直に書くと「回数ごとに、ヒット数ぶんの乱数を全部展開する」ことになります。実際には、状態全体をまとめて1ヒットずつ進め、k 回目のヒットのあとで、その割合 P(H=k)/P(H≥k) だけを「ここで終わり」として取り出します。こうすると、2〜5回の技でも5層の計算で済みます。
// src/lib/exact.mjs(抜粋・簡略化)
const pAt = new Array(maxH + 1).fill(0);
for (const [h, ph] of a.hits) pAt[h] += ph;
let geq = pAt.reduce((x, y) => x + y, 0); // P(H ≥ 1)
for (let k = 1; k <= maxH && layer.size; k++) {
const nl = new Map();
// … layer の各状態について 急所(2通り) × 乱数(16通り) を展開して nl に入れる
const f = geq > 0 ? pAt[k] / geq : 1; geq -= pAt[k]; // k回で終わる割合
layer = new Map();
for (const x of nl.values()) {
if (f > 0) emit(x, x.p * f); // 技はここで終わり → 次の行動へ
if (f < 1) add(layer, x, x.p * (1 - f)); // 残りは次のヒットへ
}
}
追加効果は Showdown と同じ考え方で、「1ヒットごとに判定するもの(secondary)」と「技の後に1回だけ起こるもの(self)」に分けています。
// src/lib/exact.mjs(抜粋)
export function splitEffects(effects = []) {
const perHit = [], after = [];
for (const e of effects ?? []) {
if (e.kind !== 'boost') continue;
if (e.target === 'self' && e.chance >= 100) after.push(e); else perHit.push(e);
}
return { perHit, after };
}
例:つららばり(2〜5回)
マンムー(いじっぱり・攻撃の能力ポイント32)の「つららばり」を、ガブリアス(HPの能力ポイント32、HP 215)に撃つ場合です。
| 前提 | 1回目の攻撃で倒せる確率 |
|---|---|
| 1発あたりの乱数(100〜124)を2回だけ(従来の確定数「乱数2発」に相当) | 91.8% |
| ヒット数 2〜5回・急所なし | 97.1% |
| ヒット数 2〜5回・急所 1/24 | 97.4% |
2回で終わる確率は 7/20 なので、0.35 × 0.918 + 0.65 ≈ 0.971 です。急所の分がさらに少し上乗せされます。「91.8%」と「97.4%」では印象がかなり違います。
4. 検算:同じモデルのモンテカルロと突き合わせる
厳密計算にバグがあっても、もっともらしい数字が出てしまいます。そこで、同じモデルを素朴に1回ずつシミュレートする mcAttacks を別に書き、テストで比べています。
// tests/exact.test.mjs(抜粋)
const ex = exactAttacks([at], opts).map((x) => x.ko);
const mc = mcAttacks([at], opts, 40000, rng(7));
for (let t = 0; t < 3; t++) {
const se = Math.sqrt((ex[t] * (1 - ex[t])) / 40000);
assert.ok(Math.abs(ex[t] - mc[t]) < 4 * se + 1e-9); // 標準誤差の4倍以内
}
このテストのケースには、急所 1/8・命中 85%・2〜5回技・きあいのタスキ・かみくだく(20% で防御ダウン)を全部入れています。乱数は seed を指定できる mulberry32 なので、テスト結果は毎回同じになります。
画面側にも「検算用:10,000回試行」のモードを用意しています。厳密値とモンテカルロの値を、利用者自身が見比べられます。
5. 外部の計算機2つと乱数16通りを照合する
自前の計算式が正しいかどうかは、外部の計算機2つと比べて確かめています。
-
Pokémon Showdown damage calculator(
@smogon/calc0.12.0、gen 0 = Champions) - damekei
比べているのは、乱数16通りのダメージ と確定数ラベルです。
照合データの取り方は計算機ごとに違います。
- Showdown 側は
@smogon/calcを Node から直接呼びます。 - damekei 側はヘッドレス Chrome でページを開き、計算履歴に残った16通りを読み取ります。
取得したデータは JSON としてコミットし、テストは毎回この JSON と比べます。
// tests/crosscheck.test.mjs(抜粋)
test('外部計算機どうしの結果が一致している(照合データの前提)', () => {
assert.equal(EXT.results.length, CASES.cases.length);
for (const r of EXT.results) assert.deepEqual(r.showdown.rolls, r.damekei.rolls);
});
for (const c of CASES.cases) {
test(`外部計算機と一致 ${c.id}: ${c.desc}`, () => {
const ext = EXT.results.find((r) => r.id === c.id);
assert.deepEqual(oursFor(c).rolls, ext.showdown.rolls);
});
}
ケースは現在 36例 あります(照合データの取得日は 2026-10-29)。次のような、計算がずれやすい組み合わせを中心に選んでいます。
- イカサマ(相手の攻撃で計算)、ボディプレス(自分の防御で計算)、サイコショック(特殊技だが相手の防御で計算)
- 急所とランク変化の組み合わせ、マルチスケイル
- 天気・フィールド・壁・やけど
- ダブルの複数対象技、メガシンカ、連続技の1発あたり
最初の test は「外部計算機どうしの結果が一致していること」を確かめています。2つの計算機の結果が食い違っていたら、正解が分からないので、そのケースは照合に使えません。そのための前提チェックです。
確定数ラベルについても、外部計算機の表示(Showdown の 84.8% chance to 2HKO など、damekei の「乱数2発(85%)」など)と比べるテストがあります。ただし、グラスフィールドのターン終了時の回復を確定数に入れるかどうかは、Showdown と damekei で扱いが違います。そのため、該当ケースは回復を入れない damekei とだけ比べ、理由をテストのコメントに残しています。
6. Web Worker:入力するたびに計算しても UI を止めない
状態を全部列挙するので、ランク変化や持ち物が絡むと計算量が増えます。そこで計算は Web Worker で行っています。問題は、rollsAt が 関数 なので postMessage で送れないことです。
そこで、Worker には「computeRolls に渡す引数(base)」だけを送り、rollsAt は Worker の中で作り直します。
// src/lib/exact-run.mjs(抜粋)
/** Worker に送れる形にする(関数を取り除く) */
export function packAttack(a) {
const { rollsAt, base, ...rest } = a;
const { eff, ...b } = base ?? {}; // タイプ相性の関数も外す
return { ...rest, base: b };
}
const unpack = (a) => ({ ...a, rollsAt: makeRollsAt({ ...a.base, eff: effMulti }) });
export function runExactJob(job) {
const attacks = job.attacks.map(unpack);
const res = exactAttacks(attacks, job.opts);
// …(急所・命中なしの「乱数のみ」の値や、検算用モンテカルロも同じジョブで計算)
return { res /* , plain, mc, ms */ };
}
// src/lib/exact.worker.mjs(全文)
import { runExactJob } from './exact-run.mjs';
self.onmessage = (e) => {
const { id, job } = e.data;
try { self.postMessage({ id, done: runExactJob(job) }); }
catch (err) { self.postMessage({ id, error: String(err?.message ?? err) }); }
};
呼び出す側は「チャンネル」ごとに Worker を1つ持ちます。入力が変わったときにまだ前の計算が終わっていなければ、その Worker を terminate して作り直します。こうすると、入力のたびに計算が積み上がることがありません。Worker が使えない環境ではメインスレッドで実行します。
// src/lib/exact-client.mjs(抜粋)
export function runExact(channel, job) {
const old = chans.get(channel);
if (old?.pending) { old.w.terminate(); old.pending.reject(new Superseded()); chans.delete(channel); }
// … Worker を作れなければ onMain(job)、postMessage で DataCloneError が出てもメインスレッドで実行
}
runExactJob はブラウザの API に依存しない純粋関数です。そのため、Node のテストで「Worker 経由(packAttack → structuredClone → unpack)の結果」と「直接呼んだ結果」が完全に一致することを確かめられます。
// tests/exact.test.mjs(抜粋)
const packed = packAttack({ ...a, rollsAt: makeRollsAt(base) });
structuredClone(packed); // Worker に送れる(関数が残っていない)
const r = runExactJob({ attacks: [packed], opts, plain: true, plainHp: 300, mc: { trials: 2000, seed: 1 } });
const direct = exactAttacks([{ ...a, rollsAt: makeRollsAt(base) }], opts);
assert.deepEqual(r.res.map((x) => x.ko), direct.map((x) => x.ko));
まとめ
- ダメージは「16通りの離散分布」です。HP 以上を1つの状態にまとめれば、畳み込みで確定数を厳密に計算できます。
- 急所は「×1.5」ではなく、急所フラグを立てて計算し直した16通りを混ぜます。
- ランク変化・持ち物・特性が絡む場合は、状態を持たせて同じ状態どうしを合流させる完全列挙にします。2〜5回技は P(H=k)/P(H≥k) で1ヒットずつ取り出します。
- 厳密計算は、同じモデルのモンテカルロと、外部計算機2つ(36例)でテストしています。
- 関数を
postMessageで送れない問題は、「引数を送って Worker の中で関数を作り直す」ことで解決しました。
実際に動くツールはこちらです(ポケチャン攻略ラボのダメージ計算ツール)。
https://pokechamp.elggum.com/tools/damage-calc/
参考
- Pokémon Showdown
data/mods/champions/scripts.ts(2〜5回連続技の回数、ダブルの複数対象技 0.75倍。MIT)
https://github.com/smogon/pokemon-showdown/blob/master/data/mods/champions/scripts.ts - Pokémon Showdown
sim/battle-actions.ts(急所ランクごとの確率。champions mod はこの部分を上書きしていません)
https://github.com/smogon/pokemon-showdown/blob/master/sim/battle-actions.ts - Pokémon Showdown damage calculator(Champions)
https://calc.pokemonshowdown.com/champions.html - damekei
https://damekei.com/ - Champions Battle Mechanics Research(Smogon Forums)
https://www.smogon.com/forums/threads/champions-battle-mechanics-research.3780372/