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?

能力ポイント制(各32・合計66)の実数値計算と、行動順を決める素早さ計算をJavaScriptで実装する(ランク・スカーフ・おいかぜ・トリックルーム)

0
Posted at

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

はじめに

「ポケモンチャンピオンズ」では、従来の努力値(0〜252)の代わりに 能力ポイント を振ります。サイトのデータ(mechanics.json)では、次の定数で扱っています。

  • レベルは 50 固定、個体値は 31 固定
  • 能力ポイントは 1つの能力に最大 32、合計 66 まで
  • 性格補正は 1.1/0.9

この記事では、次の3つを順に説明します。

  1. 能力ポイントから実数値を出す式と、その式を全パターンで確かめるテスト
  2. 素早さ比較の早見表を、ビルド時(Astro の SSG)に作る方法
  3. 対面シミュレーターで、ランク・持ち物・特性・おいかぜ・まひ・トリックルームを入れて行動順を決める方法

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/

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?