0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ポケモンのダメージ計算で「乱数16通り×急所×命中×連続技」の確定数を厳密に求める(JavaScript+Web Worker)

0
Posted at

筆者は攻略サイト『ポケチャン攻略ラボ』の運営者です。 この記事では、サイトのダメージ計算ツールで使っている計算部分の実装を解説します。コードはリポジトリから要点だけを抜き出して短くしたものです。
※ポケチャン攻略ラボは非公式のファンサイトです。株式会社ポケモン・任天堂・ゲームフリーク・クリーチャーズとは関係ありません。

はじめに

対戦ゲーム「ポケモンチャンピオンズ」のダメージ計算機は、一般に「乱数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つと比べて確かめています。

比べているのは、乱数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/

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?