筆者は攻略サイト『ポケチャン攻略ラボ』の運営者です。 この記事では、サイトのタイプ相性チェッカーとダメージ計算で共有している「タイプ相性表」の実装を解説します。コードはリポジトリから要点だけを抜き出して短くしたものです。
※ポケチャン攻略ラボは非公式のファンサイトです。任天堂・クリーチャーズ・ゲームフリーク・株式会社ポケモンとは関係ありません。
はじめに
ポケモンのタイプは 18 種類で、攻撃側の技タイプ × 防御側のタイプごとに倍率が決まります。単タイプなら表を 1 回引けば足りますが、複合タイプでは 2 つのタイプへの倍率を掛け合わせる必要があります。さらに攻略サイトでは、同じ表を次の場所から同時に使います。
- タイプ相性チェッカー(防御側・攻撃側の一覧)
- タイプ相性表のマトリクス表示
- ダメージ計算・対面シミュレーター(
typeMultとして最終ダメージに載せる)
ここでやりがちな失敗は、「ページごとに相性表をコピペする」「等倍も含めた 18×18=324 マスを全部書く」「複合タイプの掛け算を UI 側だけでやって計算側と食い違う」ことです。このサイトでは、相性表を 1 つの JSON に集約し、等倍(1)は省略したスパース表にしています。テストで件数と代表例を固定しているので、表をいじるとすぐ落ちます。
この記事で扱うのは次の 5 点です。
- データ形状(
packages/data/types.json) - ルックアップ関数
eff/effMulti - チェッカー UI(
TypeChecker.astro)とマトリクス(TypeChart.astro) - ダメージ計算への載せ方(
typeMultと切り捨て) - テストでロックしている数字
※テラスタルや「ふゆう」などの特性による無効は、この相性表そのものには含まれません。後述します。
1. 唯一のデータソース:types.json
サイト側の薄いラッパはこうなっています。
// website/src/data/types.mjs(抜粋)
// タイプ・相性表は packages/data/types.json が唯一のデータソース
import data from '@pokechamp/data/types.json' with { type: 'json' };
export const TYPES = data.types.map((t) => ({
id: t.id, ja: t.nameJa, short: t.short, color: t.color, fg: textColorOn(t.color),
}));
export const CHART = data.chart;
export const TYPE_BY_ID = Object.fromEntries(TYPES.map((t) => [t.id, t]));
実体は @pokechamp/data(現在 0.8.7)の types.json です。Web サイトもアプリも同じ JSON を読みます。meta には更新日と照合ソースが入ります。
// packages/data/types.json(meta と types の先頭だけ)
{
"meta": {
"updatedAt": "2026-10-18",
"note": "chart は攻撃側→防御側の倍率。等倍(1)は省略。本編第6世代以降と同一。",
"sources": [
{ "name": "タイプ相性とタイプバランスチェッカー(ゲームエイト)", "url": "…" },
{ "name": "タイプ相性表(PChamp DB)", "url": "…" },
{ "name": "ポケモンタイプ相性表(ポケモン徹底攻略)", "url": "…" }
]
},
"types": [
{ "id": "normal", "nameJa": "ノーマル", "short": "ノ", "color": "#9fa19f" },
{ "id": "fire", "nameJa": "ほのお", "short": "炎", "color": "#e42828" }
// …合計 18 タイプ
]
}
JSON Schema(packages/data/schema/types.schema.json)では、chart の値は 0 / 0.5 / 2 だけを許可しています。等倍の 1 を書かせないことで、「省略ルールを破って等倍を埋める」ミスをスキーマ側でも防いでいます。
2. スパース表:等倍はキーごと無い
chart は「攻撃側 ID → 防御側 ID → 倍率」の二重オブジェクトです。等倍はキーが無い状態です。
// packages/data/types.json(chart の一部)
"dragon": { "dragon": 2, "steel": 0.5, "fairy": 0 },
"electric": {
"water": 2, "electric": 0.5, "grass": 0.5,
"ground": 0, "flying": 2, "dragon": 0.5
},
"ice": {
"fire": 0.5, "water": 0.5, "grass": 2, "ice": 0.5,
"ground": 2, "flying": 2, "dragon": 2, "steel": 0.5
}
件数を数えると次のようになります(テストでも同じ数字を断言しています)。
| 項目 | 値 |
|---|---|
| タイプ数 | 18 |
| 理論上のマス | 18×18 = 324 |
| 明示エントリ(非等倍) | 120 |
| 省略された等倍 | 204 |
| うち 2 倍 | 51 |
| うち 0.5 倍 | 61 |
| うち 0 倍 | 8 |
0 倍は次の 8 組だけです。
| 攻撃 | 防御 |
|---|---|
| ノーマル | ゴースト |
| かくとう | ゴースト |
| ゴースト | ノーマル |
| でんき | じめん |
| じめん | ひこう |
| エスパー | あく |
| どく | はがね |
| ドラゴン | フェアリー |
スパースにした理由は単純で、書く量が約 1/3 になり、差分レビューで「本当に変わったマス」だけが見えるからです。代わりに「キーが無い=1」という約束をルックアップ側で守る必要があります。
3. eff と effMulti:掛け算で複合タイプを扱う
// website/src/data/types.mjs
export function eff(atk, def) {
const v = CHART[atk]?.[def];
return v === undefined ? 1 : v;
}
/** 防御側(単/複合)に対する倍率 */
export function effMulti(atk, defs) {
return defs.filter(Boolean).reduce((m, d) => m * eff(atk, d), 1);
}
ポイントは 3 つです。
- 未定義は 1(スパース表の約束)
-
defsをfilter(Boolean)してから掛ける(単タイプで空文字やnullが混ざっても無視) - 掛け算なので、どちらかが 0 なら全体が 0(2×0=0、0.5×0=0)
代表例(テストと同じ):
eff('dragon', 'fairy') // → 0
effMulti('ice', ['dragon', 'ground']) // → 2 × 2 = 4
effMulti('fire', ['water', 'rock']) // → 0.5 × 0.5 = 0.25
effMulti('ground', ['electric', 'flying']) // → 2 × 0 = 0
対面シミュレーター側にも同じロジックの小さな工場関数があります。チャートを引数で受け取り、同じ掛け算を返します。
// website/src/lib/battle-sim.mjs
export function makeEff(chart) {
const eff = (atk, def) => chart[atk]?.[def] ?? 1;
return (atk, defs) => defs.filter(Boolean).reduce((m, d) => m * eff(atk, d), 1);
}
?? 1 と === undefined ? 1 は、この表では値が 0 になり得るので || 1 にしてはいけない、という点で同じ意図です。0 は falsy なので || だと無効が等倍に化けます。
4. チェッカー UI:倍率ごとにタイプを仕分ける
ページ website/src/pages/tools/type-checker.astro はシェルで、本体は TypeChecker.astro です。クライアント側で effMulti / eff を呼び、結果を 6 段に分けます。
// website/src/components/TypeChecker.astro(script 抜粋)
const GROUPS = [
[4, '4倍(ちょうバツグン)'],
[2, '2倍(バツグン)'],
[1, '等倍'],
[0.5, '0.5倍(いまひとつ)'],
[0.25, '0.25倍(かなりいまひとつ)'],
[0, '0倍(効果なし)'],
];
function renderDef() {
const t1 = $('tc-t1').value;
let t2 = $('tc-t2').value;
if (t2 === t1) t2 = ''; // 同じタイプを選んだら単タイプ扱い
const defs = [t1, t2].filter(Boolean);
const rows = GROUPS.map(([m, label]) => {
const hits = TYPES.filter((a) => effMulti(a.id, defs) === m);
return hits.length
? `<tr><th>${label}</th><td>…バッジ…</td></tr>`
: ''; // 該当ゼロなら行ごと出さない
}).join('');
// …
}
初期選択は「タイプ1=ドラゴン(index 14)」「タイプ2=じめん(index 8)」「攻撃側=こおり(index 5)」です。この組み合わせだと effMulti('ice', ['dragon','ground']) === 4 になり、画面を開いた瞬間に 4 倍の例が見えます。
攻撃側モードは単タイプ相手だけを対象に、2 / 0.5 / 0 の 3 段で仕分けます(等倍は出さない)。防御側モードだけが 4 / 0.25 を含むのは、複合タイプの掛け算結果としてだけ現れる倍率だからです。
マトリクス表示(TypeChart.astro)は 18×18 を静的に描き、記号は ◎=2 / △=0.5 / ×=0 / 空欄=等倍です。ここでも eff(a.id, d.id) を使うので、チェッカーと数字がずれません。
バッジの文字色(WCAG)
タイプ色の上に白文字を置くと読めない色があります。types.mjs では相対輝度からコントラスト比を計算し、白で 4.5:1 を満たす(または白の方が #1a1a1a より良い)なら白、そうでなければほぼ黒にしています。
// website/src/data/types.mjs(抜粋)
export const textColorOn = (bg) =>
(ratio(bg, '#ffffff') >= 4.5 || ratio(bg, '#ffffff') >= ratio(bg, '#1a1a1a')
? '#fff' : '#1a1a1a');
実データだと、例えばノーマル(#9fa19f)やでんき(#e0b000)は暗い字、ほのお(#e42828)やゴースト(#704170)は白字になります。TypeBadge.astro も同じ fg を使います。
5. ダメージ計算では typeMult を切り捨てで載せる
相性表の出力は、ダメージ式では typeMult という 1 つの倍率になります。取り得る値のコメントは次のとおりです。
// website/src/lib/damage.mjs(JSDoc 抜粋)
// @param {number} [p.typeMult] タイプ相性倍率 (0,0.25,0.5,1,2,4)
適用順は「乱数 → STAB → タイプ相性 → やけど → 壁など」で、相性は Math.floor です。
// website/src/lib/damage.mjs(抜粋)
if (typeMult === 0 || p.power <= 0) return Array(16).fill(0);
// …
for (let r = 85; r <= 100; r++) {
let d = Math.floor((base * r) / 100);
if (stab) d = pokeRound(d, stabMod);
d = Math.floor(d * typeMult); // タイプ相性:倍率ごとに切り捨て
// …
}
typeMult === 0 のときは 16 通りすべて 0 を返して早期終了します。computeRolls(calc-core.mjs)では、まず effMulti(引数名は a.eff)で相性を取り、その後に特性・持ち物の無効(fx.immune)や「ゴースト無視」(ignoreGhost)で上書きします。
// website/src/lib/calc-core.mjs(抜粋)
let typeMult = fx.ignoreGhost
? a.eff(fx.moveType, defTypes.filter((x) => x !== 'ghost'))
: a.eff(fx.moveType, defTypes);
if (fx.immune) typeMult = 0;
つまり 相性表は純粋なタイプ×タイプで、特性・持ち物・フィールドによる「相性以外の無効」は別レイヤです。タイプチェッカーの説明文にも「特性(ふゆう など)や持ち物による無効化・半減はこの表には含まれません」と書いてあります。
6. テストでロックしていること
website/tests/stats.test.mjs の「タイプ相性の代表例」が、表の件数と掛け算の代表値を固定しています。
// website/tests/stats.test.mjs
test('タイプ相性の代表例', () => {
assert.equal(TYPES.length, 18);
assert.equal(Object.keys(CHART).length, 18);
assert.equal(eff('dragon', 'fairy'), 0);
assert.equal(effMulti('ice', ['dragon', 'ground']), 4);
assert.equal(effMulti('fire', ['water', 'rock']), 0.25);
assert.equal(effMulti('ground', ['electric', 'flying']), 0);
let zeros = 0;
for (const a in CHART) for (const d in CHART[a]) if (CHART[a][d] === 0) zeros++;
assert.equal(zeros, 8);
let two = 0, half = 0;
for (const a in CHART) for (const d in CHART[a]) {
if (CHART[a][d] === 2) two++;
if (CHART[a][d] === 0.5) half++;
}
assert.equal(two, 51);
assert.equal(half, 61);
});
ここで落ちる典型例は次のようなものです。
- 新しい世代の変更を入れて 0 倍が 9 組になった(または 7 組に減った)
- 等倍を誤って
1で埋めてObject.keys(CHART[a])の数え方が変わった(このテストは 0/2/0.5 の個数なので、等倍を足しても zeros/two/half は同じだが、スキーマが1を拒否する) -
effを|| 1に変えて、ドラゴン→フェアリーが1になる
ダメージ計算の結合テスト(exact.test.mjs や multiacc.test.mjs)も effMulti を computeRolls に渡しているので、表が壊れれば乱数 16 通りの期待値も連鎖して壊れます。
7. この実装に含めていないもの
読者の疑問になりやすいので、コード上の境界をはっきり書いておきます。
| 話題 | この相性表・チェッカー | 別レイヤ |
|---|---|---|
| 複合タイプの 4 / 0.25 / 0 | ✅ effMulti
|
— |
| 特性「ふゆう」「もらいび」などの無効 | ❌ |
calc-effects.mjs の immunity
|
| ノーマル技をフェアリー化する(ピクシーレート等) | ❌ |
typeChange(技タイプを変えてから相性を引く) |
| テラスタル | ❌(リポジトリにテラスタル用の相性分岐は無し) | — |
| サイコフィールドの先制無効 | ❌ |
calc-core.mjs が typeMult を後から 0 にする |
チェッカーは「タイプだけ」を見る道具、ダメージ計算は「タイプ+特性+持ち物+フィールド」を重ねる道具、という分担です。
まとめ
- 相性表は
@pokechamp/dataのtypes.jsonが唯一のソース。等倍は省略し、明示エントリは 120 件(2×51、0.5×61、0×8) -
effは未定義を 1、effMultiは掛け算。0 は乗法で伝播する。|| 1は使わない - チェッカー UI は防御側を 6 段(4 / 2 / 1 / 0.5 / 0.25 / 0)、攻撃側は単タイプ相手の 3 段
- ダメージ計算では
typeMultをMath.floorで載せ、0 なら 16 通りすべて 0 -
stats.test.mjsがタイプ数・0 倍 8 組・2 倍 51・0.5 倍 61・複合の代表例をロックしている
タイプ相性チェッカーは次のページで試せます。