筆者は攻略サイト『ポケチャン攻略ラボ』の運営者です。 この記事では、サイトの実数値計算機・素早さ比較ツール・対面シミュレーターで使っている「能力値」と「素早さ」の計算部分の実装を解説します。コードはリポジトリから要点だけを抜き出して短くしたものです。
※ポケチャン攻略ラボは非公式のファンサイトです。任天堂・クリーチャーズ・ゲームフリーク・株式会社ポケモンとは関係ありません。
はじめに
「ポケモンチャンピオンズ」では、従来の努力値(0〜252)の代わりに 能力ポイント を振ります。サイトのデータ(mechanics.json)では、次の定数で扱っています。
- レベルは 50 固定、個体値は 31 固定
- 能力ポイントは 1つの能力に最大 32、合計 66 まで
- 性格補正は 1.1/0.9
この記事では、次の3つを順に説明します。
- 能力ポイントから実数値を出す式と、その式を全パターンで確かめるテスト
- 素早さ比較の早見表を、ビルド時(Astro の SSG)に作る方法
- 対面シミュレーターで、ランク・持ち物・特性・おいかぜ・まひ・トリックルームを入れて行動順を決める方法
1. 実数値の式は「種族値+定数+能力ポイント」になる
従来の実数値の式に「Lv.50・個体値31・能力ポイント」を入れると、次のコードになります。
// src/lib/stats.mjs(全文に近い抜粋)
export const SP_MAX_PER_STAT = 32;
export const SP_MAX_TOTAL = 66;
export function calcHP(base, sp) {
if (base === 1) return 1; // ヌケニン
return Math.floor(((base * 2 + 31) * 50) / 100) + 60 + sp;
}
/** nature: 1.1 / 1.0 / 0.9 */
export function calcStat(base, sp, nature = 1.0) {
const raw = Math.floor(((base * 2 + 31) * 50) / 100) + 5 + sp;
// 浮動小数誤差を避けるため整数演算
const n = Math.round(nature * 10);
return Math.floor((raw * n) / 10);
}
(base * 2 + 31) * 50 / 100 は base + 15.5 なので、切り捨てると base + 15 です。つまり、式は次のように単純になります。
- HP = 種族値 + 75 + 能力ポイント
- HP以外 = floor((種族値 + 20 + 能力ポイント)× 性格補正)
能力ポイントは 1ポイントで実数値が1上がる ことになります(性格補正がかかる能力では、切り捨ての関係で 0 または 2 上がることもあります)。「仮想敵より1だけ速くする」といった調整がしやすい仕組みです。
この簡略式と実装が一致することは、テストで確かめています。
// tests/stats.test.mjs(抜粋)
test('簡略式(HP=種族値+75+P, 他=(種族値+20+P)×補正)と一致', () => {
for (let b = 5; b <= 255; b++) for (const p of [0, 1, 2, 31, 32]) {
assert.equal(calcHP(b, p), b + 75 + p);
assert.equal(calcStat(b, p, 1.0), b + 20 + p);
assert.equal(calcStat(b, p, 1.1), Math.floor((b + 20 + p) * 11 / 10));
assert.equal(calcStat(b, p, 0.9), Math.floor((b + 20 + p) * 9 / 10));
}
});
test('既知値: 最速ガブリアス(S102)=169, エンペルトHP84+32=191', () => {
assert.equal(calcStat(102, 32, 1.1), 169);
assert.equal(calcHP(84, 32), 191);
});
記事を書くにあたって、種族値 2〜255 × 能力ポイント 0〜32 の 全組み合わせ(254 × 33 通り)でも同じ比較をしました。不一致は 0 件でした。
性格補正を整数で掛ける理由
raw * 1.1 を浮動小数で計算すると、x.99999… のような値になって1ずれることがあり得ます。そこで、補正を Math.round(nature * 10)(11/10/9)という整数にしてから raw * n / 10 を切り捨てています。
ただし正直に書くと、raw を 1〜1000 の範囲で調べたところ、Math.floor(raw * 1.1) と Math.floor(raw * 11 / 10) が食い違う値はありませんでした(0.9 も同じ)。今の入力範囲では実害が出ない、念のための書き方です。
入力欄の丸め
能力ポイントの入力欄は「各0〜32、合計66」に収まるように丸めます。丸める部分は DOM に触らない純粋関数にして、Node のテストで確かめています。
// src/lib/sp-input.mjs(抜粋)
export function normalizeSpInput(raw, othersTotal = 0) {
const s = String(raw ?? '').trim();
if (s === '' || s === '-') return { value: null, changed: false, reason: '' }; // 入力途中は触らない
const n = Number(s);
if (!Number.isFinite(n)) return { value: 0, changed: true, reason: 'range' };
let v = Math.floor(n), reason = v !== n ? 'int' : '';
if (v < 0) { v = 0; reason = 'range'; }
if (v > SP_MAX_PER_STAT) { v = SP_MAX_PER_STAT; reason = 'range'; }
const room = Math.max(0, SP_MAX_TOTAL - Math.max(0, Number(othersTotal) || 0));
if (v > room) { v = room; reason = 'total'; } // ほかの欄と合わせて66まで
return { value: v, changed: v !== n, reason };
}
// tests/sp-input.test.mjs(抜粋)
assert.deepEqual(normalizeSpInput('33'), { value: 32, changed: true, reason: 'range' });
assert.deepEqual(normalizeSpInput('12.7'), { value: 12, changed: true, reason: 'int' });
assert.deepEqual(normalizeSpInput('32', 64), { value: 2, changed: true, reason: 'total' });
assert.deepEqual(normalizeSpInput('99', 60), { value: 6, changed: true, reason: 'total' });
ブラウザ側の attachSpClamp は、input/change イベントを キャプチャフェーズ で受け取ります。こうすると、計算処理のハンドラより先に値が直るので、「画面の値」と「計算に使った値」がずれません。
2. 素早さの早見表はビルド時に作る
素早さ比較ツールの表は、Astro のページのフロントマター(ビルド時に1回だけ実行される部分)で作っています。全ポケモンとメガシンカについて、4つの「素早さライン」を calcStat で出して並べます。
// src/pages/tools/speed.astro(フロントマター・抜粋)
const rows = entries.map((p) => ({
p,
fastest: calcStat(p.s, 32, 1.1), // 最速: 能力ポイント32 + 性格1.1
fast: calcStat(p.s, 32, 1.0), // 準速: 32 + 補正なし
none: calcStat(p.s, 0, 1.0), // 無振り
slowest: calcStat(p.s, 0, 0.9), // 最遅: 0 + 0.9
})).sort((a, b) => b.fastest - a.fastest || b.p.s - a.p.s);
例えば、素早さの種族値が100のポケモンなら、最速 167・準速 152・無振り 120・最遅 108 になります。執筆時点のデータでは、表は 263匹(フォルム別)+メガシンカ 82種の 345行 です。HTML に数値が全部入っているので、JavaScript が動かなくても表は読めます。
ブラウザ側の JavaScript がするのは、相手側のランク補正(+2〜−2)と「その他の倍率」(1.5倍・2倍・0.5倍)を掛け直して、自分の素早さと比べて色を付けることだけです。
// src/pages/tools/speed.astro(クライアント側・抜粋)
import { applyRank } from '../../lib/stats.mjs';
function run() {
const mine = Number(me.value) || 0;
document.querySelectorAll('#sp-table td[data-v]').forEach((td) => {
const v = Math.floor(applyRank(Number(td.dataset.v), Number(rank.value)) * Number(mul.value));
td.textContent = String(v);
// 自分が速い / 同速 / 遅い で色分け
td.style.background = !mine ? '' : mine > v ? '#ffe1e1' : mine === v ? '#fff4c2' : '#e3edfd';
});
}
// src/lib/stats.mjs(抜粋)
/** 素早さの能力ランク補正(-6〜+6) */
export function applyRank(stat, rank) {
const r = Math.max(-6, Math.min(6, rank));
return r >= 0 ? Math.floor((stat * (2 + r)) / 2) : Math.floor((stat * 2) / (2 - r));
}
ランク補正は、+n なら (2+n)/2 倍、−n なら 2/(2+n) 倍です。
ここで気になったのが、早見表と対面シミュレーターで計算が食い違わないか という点です。シミュレーター側(duel.mjs)は別の書き方をしています。
// src/lib/duel.mjs(抜粋)
const BOOST_TABLE = [1, 1.5, 2, 2.5, 3, 3.5, 4];
export const boosted = (stat, n) => (n >= 0 ? Math.floor(stat * BOOST_TABLE[n]) : Math.floor(stat / BOOST_TABLE[-n]));
/** 4096 基準の補正(0.5 は切り捨て) */
export const modify = (v, m4096) => Math.floor((v * m4096 + 2047) / 4096);
そこで、次の2点を Node で総当たりして確かめました。
-
applyRank(s, r)とboosted(s, r):s = 1〜2000、r = −6〜+6 で不一致 0 件 - 早見表の
Math.floor(v * 1.5)・* 2・* 0.5と、シミュレーターのmodify(v, 6144)・modify(v, 8192)・modify(v, 2048):v = 1〜5000 で不一致 0 件
倍率が 1.5・2・0.5 のときは小数部分が 0 か 0.5 にしかなりません。そのため、「+2047 して 4096 で割る」丸めでも、単純な切り捨てと同じ結果になります。
3. シミュレーターの素早さ:ランク → 補正の連結 → まひ
対面シミュレーターでは、行動順を決めるたびに speedNow で今の素早さを計算します。
// src/lib/duel.mjs(抜粋)
const WEATHER_SPEED = { swiftswim: 'rain', chlorophyll: 'sun', sandrush: 'sand', slushrush: 'snow' };
const TERRAIN_SPEED = { surgesurfer: 'electric' };
/** 素早さ(ランク → 持ち物・特性の補正 → まひ) */
export function speedNow(P, s) {
let v = boosted(s.mon.stats.s, s.st.spe); const mods = [];
// 持ち物(こだわりスカーフは mod4096: 6144)。使った・なくした持ち物は数えない
if (s.item && s.item === s.mon.item?.id) for (const e of s.mon.item?.fx ?? []) if (e.kind === 'speed') mods.push(e.mod4096);
// すいすい・ようりょくそ・すなかき・ゆきかき・サーフテール
if (WEATHER_SPEED[abOf(s)] === P.weather || (TERRAIN_SPEED[abOf(s)] && TERRAIN_SPEED[abOf(s)] === P.terrain)) mods.push(8192);
// かるわざ(持ち物をなくした後)
if (abOf(s) === 'unburden' && s.mon.item?.id && !s.item) mods.push(8192);
// おいかぜ(自分の側に出ている間)
if (P.tw?.[sideKey(s)] > 0 && P.turn <= P.tw[sideKey(s)]) mods.push(8192);
if (mods.length) v = modify(v, chainMods(mods));
if (s.status === 'par') v = Math.floor(v * P.mech.parSpeed); // まひ: 0.5(mechanics.json)
return v;
}
// src/lib/damage.mjs(抜粋)
export const chainMods = (mods) => mods.reduce((a, m) => Math.floor((a * m + 2048) / 4096), 4096);
ポイントは、補正を1つずつ掛けて丸めるのではなく、4096 基準の倍率どうしを先に連結(chainMods)してから、1回だけ modify で掛ける ことです。例えばスカーフ(6144)とおいかぜ(8192)を連結すると 12288(=3倍)になります。
実際に素早さ 169(最速ガブリアスの値)を入れて、speedNow の出力を確かめました。
| 条件 | 計算 | 結果 |
|---|---|---|
| こだわりスカーフ | 169 × 1.5 | 253 |
| スカーフ+おいかぜ | 169 × (6144→12288) | 507 |
| スカーフ+おいかぜ+ランク+1 | 253 × 3 | 759 |
| スカーフ+まひ | 253 × 0.5 | 126 |
| すいすい(雨) | 169 × 2 | 338 |
| すいすい(雨)+おいかぜ | 169 × 4 | 676 |
ランク補正が最初に入るので、「ランク+1・スカーフ・おいかぜ」は 169 → 253(ランク)→ 759(×3)の順です。
この順番と倍率は、テストで固定しています。
// tests/duel.test.mjs(抜粋)
test('ランク補正後の素早さ・まひ・スカーフ・すいすい', () => {
assert.equal(boosted(101, 1), 151); assert.equal(boosted(101, -1), 67);
assert.equal(boosted(100, 6), 400); assert.equal(boosted(100, -6), 25);
const P = { weather: 'rain', terrain: 'none', mech };
assert.equal(speedNow(P, s({ stats: { ...big, s: 101 }, side: { status: 'par' } })), 50);
assert.equal(speedNow(P, s({ stats: { ...big, s: 101 }, item: IT('choicescarf', [{ kind: 'speed', mult: 1.5, mod4096: 6144 }]) })), 151);
assert.equal(speedNow(P, s({ stats: { ...big, s: 101 }, ability: AB('swiftswim') })), 202);
});
4. 行動順:優先度 → せんせいのツメ → 素早さ → 乱数
1ターンの行動順は、キューを並べ替えて決めます。素早さはターンの途中でも変わる(かそく・ランク変化・おいかぜなど)ので、1回行動するたびに並べ直します(コードのコメントにある通り、第8世代以降の仕様に合わせています)。
// src/lib/duel.mjs(抜粋)
for (const x of acts) {
x.prio = prioOf(P, x.side, x.a);
x.frac = x.prio <= 0 && hasItem(x.side, 'quickclaw') && r() < 0.2 ? 0.1 : 0; // せんせいのツメ
x.tie = r(); // 同速用の乱数
}
const sortQ = () => queue.sort((x, y) =>
(y.prio + y.frac) - (x.prio + x.frac) // 1. 優先度(せんせいのツメは +0.1 扱い)
|| actSpeed(P, y.side) - actSpeed(P, x.side) // 2. 素早さ
|| x.tie - y.tie); // 3. 同速は乱数
while (queue.length) { sortQ(); const x = queue.shift(); /* … 技を使う */ }
せんせいのツメを「優先度に 0.1 を足す」形にしているので、優先度の違う技を追い越すことはなく、同じ優先度の中でだけ先に動きます。
トリックルームは「10000 − 素早さ」を 8192 で割った余り
トリックルーム中は遅い順になりますが、単純に符号を反転するのではなく、Pokémon Showdown の getActionSpeed に合わせて次のように計算しています。
// src/lib/duel.mjs(抜粋)
/** トリックルーム中か(使ったターンを含めて5ターン) */
const trOn = (P) => !!P.tr && P.turn <= P.tr;
/** 行動順に使う素早さ(トリックルーム中は (10000−素早さ) を13ビットで切る) */
const actSpeed = (P, s) => (trOn(P) ? (10000 - speedNow(P, s)) % 8192 : speedNow(P, s));
% 8192 があるので、素早さが 1809 以上 になると値が一周します。
| 素早さ | トリックルーム中の値 |
|---|---|
| 50 | 1758 |
| 169 | 1639 |
| 1808 | 0 |
| 1809 | 8191 |
| 2000 | 8000 |
つまり、トリックルーム中でも素早さが 1809 以上あれば、逆にいちばん先に動きます。Lv.50 では普通は届かない値ですが、例えば素早さ 223(執筆時点のデータでの最速の最大値)にランク+6 で 892、さらにスカーフ+おいかぜ(×3)で 2676 になります。計算上は起こり得るので、Showdown と同じ式をそのまま使っています。
なお、Showdown の getActionSpeed はトリックルームでないときも 13 ビットで切っています。このコードの補正の組み合わせでは、ランク+6 にスカーフ・特性・おいかぜ(最大 ×6)を全部重ねても 892 × 6 = 5352 で 8192 に届かないため、トリックルーム中だけ % 8192 を入れています。
おいかぜとトリックルームのターン数や順番も、テストで確かめています。
// tests/duel.test.mjs(抜粋)
test('おいかぜ(使ったターンを含めて4ターン素早さ2倍)・トリックルーム(5ターン、遅い順。もう一度使うと解除)', () => {
// (筆者注)自分(素早さ100)は毎ターンおいかぜを選ぶ。相手は素早さ150
assert.deepEqual(firsts(prepare({ me: mon({ stats: { ...HUGE, s: 100 }, moves: [tw], strategy: 0 }), opp, env }), 6, 5, 260), ['BAAABA']);
// 始めからトリックルーム(残り2ターン): 遅い自分が先 → 3ターン目は相手
assert.deepEqual(firsts(prepare({ /* … */ init: { field: { trickRoom: 2 } } }), 3, 5, 262), ['AAB']);
});
'BAAABA' は、各ターンで先に動いた側の並びです。1ターン目は自分が遅いので相手(B)が先に動き、自分がおいかぜを使います。2〜4ターン目は自分(A)が先です。5ターン目はおいかぜが切れて相手が先になります。自分は5ターン目に(後攻で)おいかぜを張り直すので、6ターン目はまた先に動きます。2〜4ターン目のおいかぜは、すでに吹いているので失敗します。
まとめ
- 能力ポイント制の実数値は「HP=種族値+75+P」「HP以外=floor((種族値+20+P)×補正)」に単純化できます。種族値 2〜255 × P 0〜32 の全組み合わせで実装と一致しました。
- 入力の丸めは純粋関数にしてテストし、ブラウザではキャプチャフェーズで計算より先に値を直します。
- 素早さの早見表はビルド時に作って HTML に埋め込み、ブラウザではランクと倍率を掛け直すだけにしています。早見表とシミュレーターの計算方法が違っても結果が同じになることは、総当たりで確かめました。
- シミュレーターの素早さは「ランク → 4096 基準の補正を連結して1回掛ける → まひ」の順です。行動順は1回行動するたびに並べ直し、トリックルーム中は
(10000 − 素早さ) % 8192を使います。
実際に動くツールはこちらです(ポケチャン攻略ラボの素早さ比較ツール)。
https://pokechamp.elggum.com/tools/speed/