diff --git a/packages/core/src/feature/__tests__/pokemon-sv.spec.ts b/packages/core/src/feature/__tests__/pokemon-sv.spec.ts new file mode 100644 index 0000000..558501e --- /dev/null +++ b/packages/core/src/feature/__tests__/pokemon-sv.spec.ts @@ -0,0 +1,126 @@ +import { + evaluatePokemon, + findUncoveredThreats, + recommendDefenseTypes, + recommendOffenseTypes, + summarizePartyDefense +} from "@hinagata-next/core/feature/pokemon-sv-analysis"; +import { SV_POKEDEX } from "@hinagata-next/core/feature/pokemon-sv-dex"; +import { + POKE_TYPE_LIST, + typeEffectiveness +} from "@hinagata-next/core/feature/pokemon-sv-types"; + +describe("typeEffectiveness", () => { + it("単タイプへの相性を計算できる", () => { + expect(typeEffectiveness("fire", ["grass"])).toBe(2); + expect(typeEffectiveness("fire", ["water"])).toBe(0.5); + expect(typeEffectiveness("fire", ["normal"])).toBe(1); + expect(typeEffectiveness("ground", ["flying"])).toBe(0); + }); + + it("複合タイプは掛け算になる", () => { + // でんき → みず/ひこう(ギャラドス)は4倍 + expect(typeEffectiveness("electric", ["water", "flying"])).toBe(4); + // こおり → ドラゴン/ひこう(カイリュー)は4倍 + expect(typeEffectiveness("ice", ["dragon", "flying"])).toBe(4); + // でんき → ドラゴン/じめん(ガブリアス)は無効 + expect(typeEffectiveness("electric", ["dragon", "ground"])).toBe(0); + // ほのお → みず/いわは1/4 + expect(typeEffectiveness("fire", ["water", "rock"])).toBe(0.25); + }); +}); + +describe("recommendOffenseTypes", () => { + it("選択タイプへ抜群を取れる攻撃タイプを挙げる", () => { + const result = recommendOffenseTypes(["water"]); + const attackTypes = result.map(r => r.attackType); + expect(attackTypes).toContain("electric"); + expect(attackTypes).toContain("grass"); + expect(attackTypes).toHaveLength(2); + }); + + it("多くのタイプをカバーできる攻撃タイプが先頭に来る", () => { + const result = recommendOffenseTypes(["dragon", "flying", "ice"]); + // こおり技はドラゴン・ひこうの両方に抜群 + expect(result[0].attackType).toBe("ice"); + expect(result[0].coveredTypes).toEqual( + expect.arrayContaining(["dragon", "flying"]) + ); + }); +}); + +describe("recommendDefenseTypes", () => { + it("はがねはドラゴン・フェアリーどちらも半減できる", () => { + const result = recommendDefenseTypes(["dragon", "fairy"]); + const steel = result.find(r => r.defenseType === "steel"); + expect(steel).toBeDefined(); + expect(steel?.resistedTypes).toEqual( + expect.arrayContaining(["dragon", "fairy"]) + ); + expect(steel?.weakTypes).toHaveLength(0); + }); +}); + +describe("evaluatePokemon", () => { + it("耐性と攻撃面の両方を評価する", () => { + const nattorei = { name: "ナットレイ", types: ["grass", "steel"] } as const; + const result = evaluatePokemon( + { name: nattorei.name, types: [...nattorei.types], imageId: 598 }, + ["water", "fairy"] + ); + // みず・フェアリーどちらも半減以下 + expect(result.resistedTypes).toEqual( + expect.arrayContaining(["water", "fairy"]) + ); + // くさ技でみずに、はがね技でフェアリーに抜群 + expect(result.coveredTypes).toEqual( + expect.arrayContaining(["water", "fairy"]) + ); + expect(result.totalScore).toBeGreaterThan(0); + }); +}); + +describe("summarizePartyDefense", () => { + it("パーティの弱点・耐性を攻撃タイプごとに集計する", () => { + const party = [ + { name: "ギャラドス", types: ["water", "flying"] as const, imageId: 130 }, + { name: "ガブリアス", types: ["dragon", "ground"] as const, imageId: 445 } + ].map(p => ({ name: p.name, types: [...p.types], imageId: p.imageId })); + const result = summarizePartyDefense(party); + const electric = result.find(r => r.attackType === "electric"); + expect(electric?.weakMembers).toEqual(["ギャラドス"]); + expect(electric?.resistMembers).toEqual(["ガブリアス"]); + const ice = result.find(r => r.attackType === "ice"); + expect(ice?.weakMembers).toEqual(["ガブリアス"]); + expect(result).toHaveLength(POKE_TYPE_LIST.length); + }); +}); + +describe("findUncoveredThreats", () => { + it("攻守どちらでも対策できていないタイプを返す", () => { + const party = [{ name: "ウインディ", types: ["fire" as const], imageId: 59 }]; + // みずには攻守とも対応できない / くさは半減かつ抜群を取れる + expect(findUncoveredThreats(party, ["water", "grass"])).toEqual(["water"]); + }); +}); + +describe("SV_POKEDEX", () => { + it("名前が重複していない", () => { + const names = SV_POKEDEX.map(p => p.name); + expect(new Set(names).size).toBe(names.length); + }); + + it("タイプは1〜2個", () => { + SV_POKEDEX.forEach(p => { + expect(p.types.length).toBeGreaterThanOrEqual(1); + expect(p.types.length).toBeLessThanOrEqual(2); + }); + }); + + it("画像IDが重複していない", () => { + const ids = SV_POKEDEX.map(p => p.imageId); + expect(new Set(ids).size).toBe(ids.length); + ids.forEach(id => expect(id).toBeGreaterThan(0)); + }); +}); diff --git a/packages/core/src/feature/pokemon-sv-analysis.ts b/packages/core/src/feature/pokemon-sv-analysis.ts new file mode 100644 index 0000000..05cc8d9 --- /dev/null +++ b/packages/core/src/feature/pokemon-sv-analysis.ts @@ -0,0 +1,148 @@ +import { type SvPokemon } from "@hinagata-next/core/feature/pokemon-sv-dex"; +import { + POKE_TYPE_LIST, + typeEffectiveness, + type PokeType +} from "@hinagata-next/core/feature/pokemon-sv-types"; + +export type OffenseRecommendation = { + attackType: PokeType; + // 選択した相手タイプのうち、この攻撃タイプで抜群を取れるもの + coveredTypes: PokeType[]; +}; + +export const recommendOffenseTypes = ( + threatTypes: PokeType[] +): OffenseRecommendation[] => + POKE_TYPE_LIST.map(attackType => ({ + attackType, + coveredTypes: threatTypes.filter( + t => typeEffectiveness(attackType, [t]) > 1 + ) + })) + .filter(r => r.coveredTypes.length > 0) + .sort((a, b) => b.coveredTypes.length - a.coveredTypes.length); + +export type DefenseRecommendation = { + defenseType: PokeType; + // 選択した相手タイプの攻撃を半減以下で受けられるもの + resistedTypes: PokeType[]; + // 逆に弱点を突かれてしまうもの + weakTypes: PokeType[]; +}; + +export const recommendDefenseTypes = ( + threatTypes: PokeType[] +): DefenseRecommendation[] => + POKE_TYPE_LIST.map(defenseType => ({ + defenseType, + resistedTypes: threatTypes.filter( + t => typeEffectiveness(t, [defenseType]) < 1 + ), + weakTypes: threatTypes.filter(t => typeEffectiveness(t, [defenseType]) > 1) + })) + .filter(r => r.resistedTypes.length > 0) + .sort( + (a, b) => + b.resistedTypes.length - + b.weakTypes.length - + (a.resistedTypes.length - a.weakTypes.length) + ); + +const defensePoint = (eff: number) => { + if (eff === 0) { + return 2; + } + if (eff <= 0.25) { + return 1.5; + } + if (eff < 1) { + return 1; + } + if (eff === 1) { + return 0; + } + if (eff <= 2) { + return -1; + } + return -2; +}; + +export type PokemonEvaluation = { + pokemon: SvPokemon; + // 相手の攻撃タイプを半減以下で受けられるもの + resistedTypes: PokeType[]; + // 弱点を突かれてしまうもの + weakTypes: PokeType[]; + // タイプ一致技で抜群を取れる相手タイプ + coveredTypes: PokeType[]; + totalScore: number; +}; + +export const evaluatePokemon = ( + pokemon: SvPokemon, + threatTypes: PokeType[] +): PokemonEvaluation => { + const resistedTypes = threatTypes.filter( + t => typeEffectiveness(t, pokemon.types) < 1 + ); + const weakTypes = threatTypes.filter( + t => typeEffectiveness(t, pokemon.types) > 1 + ); + const coveredTypes = threatTypes.filter(t => + pokemon.types.some(own => typeEffectiveness(own, [t]) > 1) + ); + const defenseScore = threatTypes.reduce( + (acc, t) => acc + defensePoint(typeEffectiveness(t, pokemon.types)), + 0 + ); + return { + pokemon, + resistedTypes, + weakTypes, + coveredTypes, + totalScore: defenseScore + coveredTypes.length + }; +}; + +export const rankPokemonCandidates = ( + pokedex: SvPokemon[], + threatTypes: PokeType[] +): PokemonEvaluation[] => + pokedex + .map(p => evaluatePokemon(p, threatTypes)) + .sort((a, b) => b.totalScore - a.totalScore); + +export type PartyTypeSummary = { + attackType: PokeType; + // このタイプの攻撃が弱点になるメンバー + weakMembers: string[]; + // このタイプの攻撃を半減以下で受けられるメンバー + resistMembers: string[]; +}; + +// パーティ全体で、各攻撃タイプに対する耐性の分布をまとめる +export const summarizePartyDefense = ( + party: SvPokemon[] +): PartyTypeSummary[] => + POKE_TYPE_LIST.map(attackType => ({ + attackType, + weakMembers: party + .filter(p => typeEffectiveness(attackType, p.types) > 1) + .map(p => p.name), + resistMembers: party + .filter(p => typeEffectiveness(attackType, p.types) < 1) + .map(p => p.name) + })); + +// 選択した相手タイプのうち、パーティの誰も対策できていないものを洗い出す +export const findUncoveredThreats = ( + party: SvPokemon[], + threatTypes: PokeType[] +) => + threatTypes.filter( + t => + !party.some(p => + p.types.some(own => typeEffectiveness(own, [t]) > 1) + ) && !party.some(p => typeEffectiveness(t, p.types) < 1) + ); diff --git a/packages/core/src/feature/pokemon-sv-dex.ts b/packages/core/src/feature/pokemon-sv-dex.ts new file mode 100644 index 0000000..24d1dd4 --- /dev/null +++ b/packages/core/src/feature/pokemon-sv-dex.ts @@ -0,0 +1,142 @@ +import { type PokeType } from "@hinagata-next/core/feature/pokemon-sv-types"; + +export type SvPokemon = { + name: string; + types: PokeType[]; + // PokeAPIのポケモンID(フォルム違いは10000番台の専用ID) + imageId: number; +}; + +// 公式アートワーク画像のURL(PokeAPI sprites リポジトリのミラーを参照) +export const pokemonArtworkUrl = (p: SvPokemon) => + `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${p.imageId}.png`; + +// SVランクバトルでよく見かけるポケモンの抜粋 +export const SV_POKEDEX: SvPokemon[] = [ + { name: "カイリュー", types: ["dragon", "flying"], imageId: 149 }, + { name: "ガブリアス", types: ["dragon", "ground"], imageId: 445 }, + { name: "ボーマンダ", types: ["dragon", "flying"], imageId: 373 }, + { name: "サザンドラ", types: ["dark", "dragon"], imageId: 635 }, + { name: "ドラパルト", types: ["dragon", "ghost"], imageId: 887 }, + { name: "セグレイブ", types: ["dragon", "ice"], imageId: 998 }, + { name: "ブリジュラス", types: ["steel", "dragon"], imageId: 1018 }, + { name: "キングドラ", types: ["water", "dragon"], imageId: 230 }, + { name: "シャリタツ", types: ["dragon", "water"], imageId: 978 }, + { name: "カミッチュ", types: ["grass", "dragon"], imageId: 1011 }, + { name: "ハバタクカミ", types: ["ghost", "fairy"], imageId: 987 }, + { name: "テツノツツミ", types: ["ice", "water"], imageId: 991 }, + { name: "テツノブジン", types: ["fairy", "fighting"], imageId: 1006 }, + { name: "テツノドクガ", types: ["fire", "poison"], imageId: 994 }, + { name: "テツノカイナ", types: ["fighting", "electric"], imageId: 992 }, + { name: "テツノワダチ", types: ["ground", "steel"], imageId: 990 }, + { name: "テツノコウベ", types: ["dark", "flying"], imageId: 993 }, + { name: "テツノイバラ", types: ["rock", "electric"], imageId: 995 }, + { name: "テツノカシラ", types: ["steel", "psychic"], imageId: 1023 }, + { name: "テツノイワオ", types: ["rock", "psychic"], imageId: 1022 }, + { name: "トドロクツキ", types: ["dragon", "dark"], imageId: 1005 }, + { name: "アラブルタケ", types: ["grass", "poison"], imageId: 986 }, + { name: "スナノケガワ", types: ["electric", "ground"], imageId: 989 }, + { name: "イダイナキバ", types: ["ground", "fighting"], imageId: 984 }, + { name: "サケブシッポ", types: ["fairy", "psychic"], imageId: 985 }, + { name: "チヲハウハネ", types: ["bug", "fighting"], imageId: 988 }, + { name: "ウネルミナモ", types: ["water", "dragon"], imageId: 1009 }, + { name: "タケルライコ", types: ["electric", "dragon"], imageId: 1021 }, + { name: "ウガツホムラ", types: ["fire", "dragon"], imageId: 1020 }, + { name: "パオジアン", types: ["dark", "ice"], imageId: 1002 }, + { name: "ディンルー", types: ["dark", "ground"], imageId: 1003 }, + { name: "イーユイ", types: ["dark", "fire"], imageId: 1004 }, + { name: "チオンジェン", types: ["dark", "grass"], imageId: 1001 }, + { name: "サーフゴー", types: ["steel", "ghost"], imageId: 1000 }, + { name: "ガチグマ(アカツキ)", types: ["ground", "normal"], imageId: 10272 }, + { name: "ウーラオス(いちげき)", types: ["fighting", "dark"], imageId: 892 }, + { + name: "ウーラオス(れんげき)", + types: ["fighting", "water"], + imageId: 10191 + }, + { + name: "ランドロス(れいじゅう)", + types: ["ground", "flying"], + imageId: 10021 + }, + { name: "オーガポン", types: ["grass"], imageId: 1017 }, + { name: "オーガポン(かまど)", types: ["grass", "fire"], imageId: 10274 }, + { name: "オーガポン(いど)", types: ["grass", "water"], imageId: 10273 }, + { name: "オーガポン(いしずえ)", types: ["grass", "rock"], imageId: 10275 }, + { name: "ヒードラン", types: ["fire", "steel"], imageId: 485 }, + { name: "クレセリア", types: ["psychic"], imageId: 488 }, + { name: "サンダー", types: ["electric", "flying"], imageId: 145 }, + { name: "ミミッキュ", types: ["ghost", "fairy"], imageId: 778 }, + { name: "キョジオーン", types: ["rock"], imageId: 934 }, + { name: "ヘイラッシャ", types: ["water"], imageId: 977 }, + { name: "ドオー", types: ["poison", "ground"], imageId: 980 }, + { name: "キラフロル", types: ["rock", "poison"], imageId: 970 }, + { name: "デカヌチャン", types: ["fairy", "steel"], imageId: 959 }, + { name: "マスカーニャ", types: ["grass", "dark"], imageId: 908 }, + { name: "ラウドボーン", types: ["fire", "ghost"], imageId: 911 }, + { name: "ウェーニバル", types: ["water", "fighting"], imageId: 914 }, + { name: "ソウブレイズ", types: ["fire", "ghost"], imageId: 937 }, + { name: "グレンアルマ", types: ["fire", "psychic"], imageId: 936 }, + { name: "ドドゲザン", types: ["dark", "steel"], imageId: 983 }, + { name: "コノヨザル", types: ["fighting", "ghost"], imageId: 979 }, + { name: "イルカマン", types: ["water"], imageId: 964 }, + { name: "ブロロローム", types: ["steel", "poison"], imageId: 966 }, + { name: "クエスパトラ", types: ["psychic"], imageId: 956 }, + { name: "カラミンゴ", types: ["flying", "fighting"], imageId: 973 }, + { name: "ハラバリー", types: ["electric"], imageId: 939 }, + { name: "ミミズズ", types: ["steel"], imageId: 968 }, + { name: "ガケガニ", types: ["rock"], imageId: 950 }, + { name: "オリーヴァ", types: ["grass", "normal"], imageId: 930 }, + { name: "ノココッチ", types: ["normal"], imageId: 982 }, + { name: "リククラゲ", types: ["ground", "grass"], imageId: 949 }, + { name: "ヤバソチャ", types: ["grass", "ghost"], imageId: 1013 }, + { name: "バンギラス", types: ["rock", "dark"], imageId: 248 }, + { name: "メタグロス", types: ["steel", "psychic"], imageId: 376 }, + { name: "ハッサム", types: ["bug", "steel"], imageId: 212 }, + { name: "ウルガモス", types: ["bug", "fire"], imageId: 637 }, + { name: "アーマーガア", types: ["flying", "steel"], imageId: 823 }, + { name: "ナットレイ", types: ["grass", "steel"], imageId: 598 }, + { name: "ドリュウズ", types: ["ground", "steel"], imageId: 530 }, + { name: "ジバコイル", types: ["electric", "steel"], imageId: 462 }, + { name: "エンペルト", types: ["water", "steel"], imageId: 395 }, + { name: "トゲキッス", types: ["fairy", "flying"], imageId: 468 }, + { name: "サーナイト", types: ["psychic", "fairy"], imageId: 282 }, + { name: "エルレイド", types: ["psychic", "fighting"], imageId: 475 }, + { name: "ニンフィア", types: ["fairy"], imageId: 700 }, + { name: "ブラッキー", types: ["dark"], imageId: 197 }, + { name: "エーフィ", types: ["psychic"], imageId: 196 }, + { name: "グライオン", types: ["ground", "flying"], imageId: 472 }, + { name: "カバルドン", types: ["ground"], imageId: 450 }, + { name: "マンムー", types: ["ice", "ground"], imageId: 473 }, + { name: "パルシェン", types: ["water", "ice"], imageId: 91 }, + { name: "ラプラス", types: ["water", "ice"], imageId: 131 }, + { name: "ギャラドス", types: ["water", "flying"], imageId: 130 }, + { name: "ペリッパー", types: ["water", "flying"], imageId: 279 }, + { name: "ルンパッパ", types: ["water", "grass"], imageId: 272 }, + { name: "トリトドン", types: ["water", "ground"], imageId: 423 }, + { name: "ドヒドイデ", types: ["poison", "water"], imageId: 748 }, + { name: "ブルンゲル", types: ["water", "ghost"], imageId: 593 }, + { name: "マリルリ", types: ["water", "fairy"], imageId: 184 }, + { name: "アシレーヌ", types: ["water", "fairy"], imageId: 730 }, + { name: "ゲッコウガ", types: ["water", "dark"], imageId: 658 }, + { name: "ガオガエン", types: ["fire", "dark"], imageId: 727 }, + { name: "ウインディ", types: ["fire"], imageId: 59 }, + { name: "リザードン", types: ["fire", "flying"], imageId: 6 }, + { name: "ロトム(ウォッシュ)", types: ["electric", "water"], imageId: 10009 }, + { name: "ロトム(ヒート)", types: ["electric", "fire"], imageId: 10008 }, + { name: "モロバレル", types: ["grass", "poison"], imageId: 591 }, + { name: "キノガッサ", types: ["grass", "fighting"], imageId: 286 }, + { name: "ゲンガー", types: ["ghost", "poison"], imageId: 94 }, + { name: "ヨノワール", types: ["ghost"], imageId: 477 }, + { name: "オーロンゲ", types: ["dark", "fairy"], imageId: 861 }, + { name: "クレッフィ", types: ["steel", "fairy"], imageId: 707 }, + { name: "ピクシー", types: ["fairy"], imageId: 36 }, + { name: "カイリキー", types: ["fighting"], imageId: 68 }, + { name: "ローブシン", types: ["fighting"], imageId: 534 }, + { name: "オオニューラ", types: ["fighting", "poison"], imageId: 903 }, + { name: "ポリゴン2", types: ["normal"], imageId: 233 }, + { name: "カビゴン", types: ["normal"], imageId: 143 }, + { name: "ハピナス", types: ["normal"], imageId: 242 }, + { name: "ミロカロス", types: ["water"], imageId: 350 }, + { name: "ランターン", types: ["water", "electric"], imageId: 171 } +]; diff --git a/packages/core/src/feature/pokemon-sv-types.ts b/packages/core/src/feature/pokemon-sv-types.ts new file mode 100644 index 0000000..9bb6e64 --- /dev/null +++ b/packages/core/src/feature/pokemon-sv-types.ts @@ -0,0 +1,192 @@ +export type PokeType = + | "normal" + | "fire" + | "water" + | "electric" + | "grass" + | "ice" + | "fighting" + | "poison" + | "ground" + | "flying" + | "psychic" + | "bug" + | "rock" + | "ghost" + | "dragon" + | "dark" + | "steel" + | "fairy"; + +export const POKE_TYPE_LIST: PokeType[] = [ + "normal", + "fire", + "water", + "electric", + "grass", + "ice", + "fighting", + "poison", + "ground", + "flying", + "psychic", + "bug", + "rock", + "ghost", + "dragon", + "dark", + "steel", + "fairy" +]; + +export const POKE_TYPE_LABEL: Record = { + normal: "ノーマル", + fire: "ほのお", + water: "みず", + electric: "でんき", + grass: "くさ", + ice: "こおり", + fighting: "かくとう", + poison: "どく", + ground: "じめん", + flying: "ひこう", + psychic: "エスパー", + bug: "むし", + rock: "いわ", + ghost: "ゴースト", + dragon: "ドラゴン", + dark: "あく", + steel: "はがね", + fairy: "フェアリー" +}; + +// 第9世代(SV)のタイプ相性表。等倍(1)のマスは省略している +const TYPE_CHART: Record>> = { + normal: { rock: 0.5, ghost: 0, steel: 0.5 }, + fire: { + fire: 0.5, + water: 0.5, + grass: 2, + ice: 2, + bug: 2, + rock: 0.5, + dragon: 0.5, + steel: 2 + }, + water: { fire: 2, water: 0.5, grass: 0.5, ground: 2, rock: 2, dragon: 0.5 }, + electric: { + water: 2, + electric: 0.5, + grass: 0.5, + ground: 0, + flying: 2, + dragon: 0.5 + }, + grass: { + fire: 0.5, + water: 2, + grass: 0.5, + poison: 0.5, + ground: 2, + flying: 0.5, + bug: 0.5, + rock: 2, + dragon: 0.5, + steel: 0.5 + }, + ice: { + fire: 0.5, + water: 0.5, + grass: 2, + ice: 0.5, + ground: 2, + flying: 2, + dragon: 2, + steel: 0.5 + }, + fighting: { + normal: 2, + ice: 2, + poison: 0.5, + flying: 0.5, + psychic: 0.5, + bug: 0.5, + rock: 2, + ghost: 0, + dark: 2, + steel: 2, + fairy: 0.5 + }, + poison: { + grass: 2, + poison: 0.5, + ground: 0.5, + rock: 0.5, + ghost: 0.5, + steel: 0, + fairy: 2 + }, + ground: { + fire: 2, + electric: 2, + grass: 0.5, + poison: 2, + flying: 0, + bug: 0.5, + rock: 2, + steel: 2 + }, + flying: { + electric: 0.5, + grass: 2, + fighting: 2, + bug: 2, + rock: 0.5, + steel: 0.5 + }, + psychic: { fighting: 2, poison: 2, psychic: 0.5, dark: 0, steel: 0.5 }, + bug: { + fire: 0.5, + grass: 2, + fighting: 0.5, + poison: 0.5, + flying: 0.5, + psychic: 2, + ghost: 0.5, + dark: 2, + steel: 0.5, + fairy: 0.5 + }, + rock: { + fire: 2, + ice: 2, + fighting: 0.5, + ground: 0.5, + flying: 2, + bug: 2, + steel: 0.5 + }, + ghost: { normal: 0, psychic: 2, ghost: 2, dark: 0.5 }, + dragon: { dragon: 2, steel: 0.5, fairy: 0 }, + dark: { fighting: 0.5, psychic: 2, ghost: 2, dark: 0.5, fairy: 0.5 }, + steel: { + fire: 0.5, + water: 0.5, + electric: 0.5, + ice: 2, + rock: 2, + steel: 0.5, + fairy: 2 + }, + fairy: { + fire: 0.5, + fighting: 2, + poison: 0.5, + dragon: 2, + dark: 2, + steel: 0.5 + } +}; + +export const typeEffectiveness = (attack: PokeType, defenders: PokeType[]) => + defenders.reduce((acc, d) => acc * (TYPE_CHART[attack][d] ?? 1), 1); diff --git a/packages/web/src/app/party/page.tsx b/packages/web/src/app/party/page.tsx new file mode 100644 index 0000000..70c7854 --- /dev/null +++ b/packages/web/src/app/party/page.tsx @@ -0,0 +1,14 @@ +import { makeSubPageMetadata } from "~/feature/defaultMetadata"; +import { PAGE_PARTY } from "~/feature/page-path"; +import PartyBuilderScene from "~/component/PartyBuilderScene"; +import ASSETS_OGP from "~/asset/meta/ogp.png"; + +export const metadata = makeSubPageMetadata({ + page: PAGE_PARTY, + subPageTitle: "ポケモンSV パーティ編成ツール", + shareImageAsset: ASSETS_OGP +}); + +const PageParty = () => ; + +export default PageParty; diff --git a/packages/web/src/component/PartyBuilderScene.tsx b/packages/web/src/component/PartyBuilderScene.tsx new file mode 100644 index 0000000..5c8cef6 --- /dev/null +++ b/packages/web/src/component/PartyBuilderScene.tsx @@ -0,0 +1,497 @@ +"use client"; + +import styled from "@emotion/styled"; +import { Fragment, useMemo, useState } from "react"; +import { alphaColor, em, px } from "~/common/css-util"; +import { + findUncoveredThreats, + rankPokemonCandidates, + recommendDefenseTypes, + recommendOffenseTypes, + summarizePartyDefense +} from "@hinagata-next/core/feature/pokemon-sv-analysis"; +import { + pokemonArtworkUrl, + SV_POKEDEX, + type SvPokemon +} from "@hinagata-next/core/feature/pokemon-sv-dex"; +import { + POKE_TYPE_LABEL, + POKE_TYPE_LIST, + type PokeType +} from "@hinagata-next/core/feature/pokemon-sv-types"; +import MockActionButton from "~/component/MockActionButton"; +import MockStaticLayout from "~/component/MockStaticLayout"; + +const MAX_PARTY_SIZE = 6; +const DEFAULT_CANDIDATE_COUNT = 10; + +const TYPE_COLOR: Record = { + normal: "#9fa19f", + fire: "#e62829", + water: "#2980ef", + electric: "#f5a500", + grass: "#3fa129", + ice: "#3dcef3", + fighting: "#ff8000", + poison: "#9141cb", + ground: "#915121", + flying: "#81b9ef", + psychic: "#ef4179", + bug: "#91a119", + rock: "#afa981", + ghost: "#704170", + dragon: "#5060e1", + dark: "#624d4e", + steel: "#60a1b8", + fairy: "#ef70ef" +}; + +const Section = styled.section({ + border: `solid ${px(1)} #ccc`, + borderRadius: px(8), + padding: em(1), + display: "grid", + gap: em(0.8) +}); + +const SectionTitle = styled.h2({ + fontSize: em(1.1), + fontWeight: "bold" +}); + +const SectionNote = styled.p({ + fontSize: em(0.85), + opacity: 0.7 +}); + +const TypeBadge = styled.span<{ pokeType: PokeType }>(({ pokeType }) => ({ + display: "inline-block", + backgroundColor: TYPE_COLOR[pokeType], + color: "#fff", + borderRadius: px(4), + padding: `${px(2)} ${px(8)}`, + fontSize: em(0.8), + lineHeight: 1.5, + whiteSpace: "nowrap" +})); + +const BadgeFlow = styled.span({ + display: "inline-flex", + flexWrap: "wrap", + gap: px(4), + verticalAlign: "middle" +}); + +const CheckboxGrid = styled.div({ + display: "flex", + flexWrap: "wrap", + gap: `${px(6)} ${px(12)}` +}); + +const CheckboxLabel = styled.label({ + display: "inline-flex", + alignItems: "center", + gap: px(4), + cursor: "pointer" +}); + +const ListRow = styled.div({ + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: px(8), + padding: `${px(6)} 0`, + borderBottom: `solid ${px(1)} #eee` +}); + +const RowMain = styled.div({ + flexGrow: 1, + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: px(8) +}); + +const PokemonName = styled.span({ + fontWeight: "bold" +}); + +const PokemonThumbnail = styled.img({ + width: px(56), + height: px(56), + objectFit: "contain", + flexShrink: 0 +}); + +const DetailText = styled.span({ + fontSize: em(0.8), + opacity: 0.8, + display: "inline-flex", + alignItems: "center", + gap: px(4), + flexWrap: "wrap" +}); + +const DefenseSummaryGrid = styled.div({ + display: "grid", + gridTemplateColumns: "auto 1fr", + gap: `${px(4)} ${px(10)}`, + alignItems: "center" +}); + +const SummaryCell = styled.div<{ danger?: boolean }>(({ danger }) => ({ + fontSize: em(0.85), + padding: `${px(2)} ${px(6)}`, + borderRadius: px(4), + backgroundColor: danger ? alphaColor("#e62829", 0.15) : undefined +})); + +const WarningText = styled.p({ + color: "#c62828", + fontWeight: "bold", + fontSize: em(0.9) +}); + +const EmptyText = styled.p({ + opacity: 0.6, + fontSize: em(0.9) +}); + +const SearchInput = styled.input({ + fontSize: "inherit", + padding: `${px(4)} ${px(8)}`, + border: `solid ${px(1)} #ccc`, + borderRadius: px(4), + width: em(14) +}); + +const PokemonThumb = ({ pokemon }: { pokemon: SvPokemon }) => ( + +); + +const TypeBadgeList = ({ types }: { types: PokeType[] }) => ( + + {types.map(t => ( + + {POKE_TYPE_LABEL[t]} + + ))} + +); + +const PartyBuilderScene = () => { + const [threats, setThreats] = useState([]); + const [party, setParty] = useState([]); + const [showAllCandidates, setShowAllCandidates] = useState(false); + const [searchText, setSearchText] = useState(""); + + const isPartyFull = party.length >= MAX_PARTY_SIZE; + + const toggleThreat = (t: PokeType) => + setThreats(prev => + prev.includes(t) ? prev.filter(v => v !== t) : [...prev, t] + ); + + const addToParty = (p: SvPokemon) => + setParty(prev => + prev.length < MAX_PARTY_SIZE && !prev.some(m => m.name === p.name) + ? [...prev, p] + : prev + ); + + const offenseRecommendations = useMemo( + () => recommendOffenseTypes(threats), + [threats] + ); + + const defenseRecommendations = useMemo( + () => recommendDefenseTypes(threats).slice(0, 6), + [threats] + ); + + const candidates = useMemo( + () => + rankPokemonCandidates( + SV_POKEDEX.filter(p => !party.some(m => m.name === p.name)), + threats + ), + [threats, party] + ); + + const visibleCandidates = useMemo( + () => + showAllCandidates + ? candidates + : candidates.slice(0, DEFAULT_CANDIDATE_COUNT), + [candidates, showAllCandidates] + ); + + const partyDefenseSummary = useMemo( + () => summarizePartyDefense(party), + [party] + ); + + const uncoveredThreats = useMemo( + () => findUncoveredThreats(party, threats), + [party, threats] + ); + + const searchResults = useMemo(() => { + if (!searchText) { + return []; + } + return SV_POKEDEX.filter( + p => p.name.includes(searchText) && !party.some(m => m.name === p.name) + ).slice(0, 8); + }, [searchText, party]); + + return ( + + + 対策したい相手のタイプを選ぶと、パーティに入れたいタイプやポケモンの候補を提案します。 + + +
+ ① 対策したい相手のタイプ + + {POKE_TYPE_LIST.map(t => ( + + toggleThreat(t)} + /> + {POKE_TYPE_LABEL[t]} + + ))} + +
+ +
+ ② パーティに含めたいタイプ + {threats.length ? ( + <> +
+ + 攻め:これらのタイプの技があると弱点を突けます + + {offenseRecommendations.map(r => ( + + + {POKE_TYPE_LABEL[r.attackType]} + + + → + + に抜群 + + + ))} +
+
+ + 受け:これらのタイプは相手の技を半減以下にできます + + {defenseRecommendations.map(r => ( + + + {POKE_TYPE_LABEL[r.defenseType]} + + + + を半減 + {r.weakTypes.length ? ( + <> + / + + が弱点 + + ) : null} + + + ))} +
+ + ) : ( + 上でタイプを選択してください。 + )} +
+ +
+ ③ おすすめポケモン候補 + {threats.length ? ( + <> +
+ {visibleCandidates.map(c => ( + + + + {c.pokemon.name} + + + {c.resistedTypes.length ? ( + <> + 受け◎: + + + ) : null} + {c.coveredTypes.length ? ( + <> + 攻め◎: + + + ) : null} + {c.weakTypes.length ? ( + <> + 弱点△: + + + ) : null} + + + addToParty(c.pokemon) } + } + > + パーティに追加 + + + ))} +
+ {candidates.length > DEFAULT_CANDIDATE_COUNT ? ( +

+ setShowAllCandidates(v => !v) + }} + > + {showAllCandidates + ? "表示を減らす" + : `すべて表示(${candidates.length}件)`} + +

+ ) : null} + + ) : ( + + タイプを選択すると、相性の良いポケモンを提案します。 + + )} +
+ +
+ + ④ パーティ({party.length}/{MAX_PARTY_SIZE}) + + {party.length ? ( +
+ {party.map(p => ( + + + + {p.name} + + + + setParty(prev => prev.filter(m => m.name !== p.name)) + }} + > + 外す + + + ))} +
+ ) : ( + まだメンバーがいません。候補から追加できます。 + )} +
+ 名前で検索して追加 + setSearchText(e.target.value)} + /> + {searchResults.map(p => ( + + + + {p.name} + + + addToParty(p) } + } + > + パーティに追加 + + + ))} +
+ {threats.length && party.length ? ( + uncoveredThreats.length ? ( + + ⚠️ 対策できていないタイプ:{" "} + {uncoveredThreats.map(t => POKE_TYPE_LABEL[t]).join("・")} + (弱点を突けるメンバーも、半減で受けられるメンバーもいません) + + ) : ( + + ✅ 選択した相手タイプは、攻めか受けのどちらかで全員カバーできています。 + + ) + ) : null} + {party.length ? ( +
+ + パーティの耐性チェック(弱点になるメンバーが2体以上いて、半減で受けられるメンバーがいないタイプは赤く表示) + + + {partyDefenseSummary + .filter(s => s.weakMembers.length || s.resistMembers.length) + .map(s => { + const danger = + s.weakMembers.length >= 2 && !s.resistMembers.length; + return ( + + + {POKE_TYPE_LABEL[s.attackType]} + + + {s.weakMembers.length ? ( + <>弱点: {s.weakMembers.join("・")} + ) : null} + {s.weakMembers.length && s.resistMembers.length + ? " / " + : null} + {s.resistMembers.length ? ( + <>半減: {s.resistMembers.join("・")} + ) : null} + + + ); + })} + +
+ ) : null} +
+
+ ); +}; + +export default PartyBuilderScene; diff --git a/packages/web/src/feature/page-path.ts b/packages/web/src/feature/page-path.ts index 0530ab9..77575f1 100644 --- a/packages/web/src/feature/page-path.ts +++ b/packages/web/src/feature/page-path.ts @@ -5,3 +5,4 @@ const PAGE_ROOT = new PageEntry(BASE_URL); export const PAGE_TOP = PAGE_ROOT; export const PAGE_ABOUT = PAGE_ROOT.child("about"); +export const PAGE_PARTY = PAGE_ROOT.child("party");