TypeScriptとReactを使ってポケモン努力値計算サイトを作ってみた!

TypeScriptとReactを使ってポケモン努力値計算サイトを作ってみた!

TypeScriptとReactを使用してポケモンの努力値計算サイトを作成しました。開発の過程や工夫した点、難しかった点を詳しく解説しています。
Clock Icon2024.10.09

こんにちは!前越です。

今回、TypeScriptとReactの勉強を目的としてポケモン努力値計算サイトを作成しました。

ところで皆さんポケモン育成してますか?!

僕は趣味でポケモンマスターを目指してトレーナー活動を行っていますが、
ポケモンを育成していると「ここにどれだけ努力値を振れば実数値がどれくらいになるんだっけ?」と困ることはありませんか?

もちろん、ありますよね(圧

そんな悩みを解消するために、自分で計算できるサイトを作ってみました!
学習目的ではありますが、作成したサイトはシンプルながら実用的になっていると思います。

どんなサイトか

このサイトは、ユーザーがポケモンの名前を入力すると、PokeAPIを活用して該当するポケモンの詳細情報を表示してくれるサイトです。
さらに、努力値(EV)や個体値(IV)の調整機能も搭載しており、ポケモンの能力をカスタマイズすることができます!

アプリの機能

  • ポケモン検索: 日本語名でポケモンを検索。
  • ポケモン詳細表示: 検索したポケモンの画像、タイプ、特性などを表示。
  • 努力値(EV)の管理: 各ステータスに努力値を割り振り、実数値を計算。
  • 個体値(IV)の設定: 各ステータスに個体値を設定。
  • 目標ステータスの設定: 目標とするステータスに必要な努力値を計算。

ターゲットユーザー

  • ポケモントレーナー
  • ポケモン育成を楽しむ方々

検索前 検索語
スクリーンショット 2024-10-08 18.21.16 スクリーンショット 2024-10-08 18.49.35

以下の URL から実際に試せます。
ポケモン努力値計算サイト


努力値とは?

そもそも散々、努力値、努力値という単語が出てきましたが、知らない人に説明すると...
努力値とは、簡潔に言うと最大510ポイントを各能力に振り分けて実数値を上げることができるポイントのことです。各能力には最大252ポイントまで振ることができます。

簡単に言うと...
隠しステータスみたいなもの!

努力値とは?
努力値(EV: Effort Values)は、ポケモンの能力値をカスタマイズするためのパラメータです。
各ポケモンは、基礎となる「種族値」と、努力値や個体値によって、最終的な「実数値」が決まります。

種族値: ポケモンごとに決まっている基本的な能力値。
個体値(IV: Individual Values): ポケモンごとにランダムに設定される値。
努力値(EV): 釣りやバトルでポケモンが得る経験値に基づいて増加する値。
総合計: 努力値は全ステータスで合計510までしか振ることができません。
個別制限: 各ステータスには最大252まで振ることができます。

データ収集

ポケモンのデータはPokeAPIを使用しました。
このAPIはポケモンに関する豊富なデータを提供しており、検索や詳細情報の取得に非常に便利です。
なんと落とすアイテムなどや色違いのポケモンの画像まで取得できたりします!!!

環境構築

使用技術

  • React(v18.3.1)
  • TypeScript(v4.9.5)
  • Axios(v 1.7.7):API通信を簡単に行うためのライブラリ
  • PokeAPI

プロジェクトのセットアップ

  1. Create React Appでプロジェクトを作成
npx create-react-app my-pokemon-site --template typescript
cd my-pokemon-site
  1. 必要な依存関係をインストール
npm install axios
  1. プロジェクト構造の準備
my-pokemon-site/
├── src/
│   ├── components/
│   │   ├── SearchForm.tsx
│   │   └── PokemonCard.tsx
│   ├── App.tsx
│   ├── App.css
│   └── index.tsx
├── package.json
└── tsconfig.json

実装

App.tsx
アプリ全体の状態管理と主要なロジックを担当します。ポケモンのデータを取得し、検索フォームとポケモンカードコンポーネントを表示します。

App.tsx
import React, { useState } from 'react';
import axios from 'axios';
import SearchForm from './components/SearchForm';
import PokemonCard from './components/PokemonCard';
import './App.css';

const App: React.FC = () => {
const [pokemonData, setPokemonData] = useState<any>(null);

const handleSearch = async (query: string) => {
  if (query === '') {  // クエリが空かどうかをチェック  入力なしの場合を考慮
    alert('ポケモン名を入力してください。');
    return;
  }
  try {
    // 全ポケモン種のリストを取得
    const speciesResponse = await axios.get('https://pokeapi.co/api/v2/pokemon-species?limit=10000');
    const speciesList = speciesResponse.data.results;

    let pokemonEntry = null;
    let japaneseNameEntry = null;

    // 日本語名でフィルタリング
    for (const species of speciesList) {
      const speciesDetail = await axios.get(species.url);
      const names = speciesDetail.data.names;
      japaneseNameEntry = names.find((name: any) => name.language.name === 'ja-Hrkt');

      // 名前が一致する場合
      if (japaneseNameEntry && japaneseNameEntry.name === query) {
        pokemonEntry = speciesDetail.data;
        break;
      }

    }

    if (pokemonEntry) {
      // ポケモンの詳細データを取得
      const pokemonResponse = await axios.get(`https://pokeapi.co/api/v2/pokemon/${pokemonEntry.id}`);
      setPokemonData({
        ...pokemonResponse.data,
        japaneseName: japaneseNameEntry.name,
      });
    } else {
      alert('ポケモンが見つかりませんでした。');
    }
  } catch (error) {
    console.error(error);
    alert('エラーが発生しました。');
  }
};

return (
  <div className="App">
    <h1>ポケモン検索アプリ</h1>
    <SearchForm onSearch={handleSearch} />
    {pokemonData && <PokemonCard pokemonData={pokemonData} />}
  </div>
);
};

export default App;

PokemonCard.tsx
検索結果として取得したポケモンの詳細情報を表示し、努力値(EV)や個体値(IV)を管理できるコンポーネントです。

PokemonCard.tsx
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './PokemonCard.css';

interface Props {
  pokemonData: any; 

// 英語から日本語へのタイプ名のマッピング
const typeNameJPMap: { [key: string]: string } = {
  grass: 'くさ',
  poison: 'どく',
  fire: 'ほのお',
  water: 'みず',
  normal: 'ノーマル',
  flying: 'ひこう',
  bug: 'むし',
  rock: 'いわ',
  electric: 'でんき',
  psychic: 'エスパー',
  ice: 'こおり',
  ghost: 'ゴースト',
  dragon: 'ドラゴン',
  dark: 'あく',
  steel: 'はがね',
  fairy: 'フェアリー',
  fighting: 'かくとう',
  ground: 'じめん',
};

const PokemonCard: React.FC<Props> = ({ pokemonData }) => {
  const [evs, setEvs] = useState<{ [key: string]: number }>({});
  const [desiredStats, setDesiredStats] = useState<{ [key: string]: number }>({});
  const [remainingEVs, setRemainingEVs] = useState<number>(510);
  const [abilities, setAbilities] = useState<string[]>([]);
  const [types, setTypes] = useState<string[]>([]);
  const [ivs, setIvs] = useState<{ [key: string]: number }>({
    hp: 31,
    attack: 31,
    defense: 31,
    'special-attack': 31,
    'special-defense': 31,
    speed: 31,
  });

  // 日本語のステータス名
  const statNameMap: { [key: string]: string } = {
    hp: '体力',
    attack: '攻撃力',
    defense: '防御力',
    'special-attack': '特攻',
    'special-defense': '特防',
    speed: '素早さ',
  };

  // 英語から日本語への特性名のマッピング
  const [abilityNameJPMap, setAbilityNameJPMap] = useState<{ [key: string]: string }>({});

  useEffect(() => {
    const fetchAbilitiesAndTypes = async () => {
      try {
        // 特性を取得し、日本語名をマッピング
        const abilityPromises = pokemonData.abilities.map(async (ab: any) => {
          const abilityResponse = await axios.get(ab.ability.url);
          const names = abilityResponse.data.names;
          const jpName = names.find((name: any) => name.language.name === 'ja-Hrkt')?.name;
          return { english: ab.ability.name, japanese: jpName || ab.ability.name };
        });

        const abilitiesData = await Promise.all(abilityPromises);
        const abilityMap: { [key: string]: string } = {};
        abilitiesData.forEach((ab: any) => {
          abilityMap[ab.english] = ab.japanese;
        });
        setAbilities(abilitiesData.map((ab: any) => ab.japanese));

        // タイプを取得し、日本語名をマッピング
        const typesList = pokemonData.types.map((type: any) => type.type.name);
        const typesJP = typesList.map((typeName: string) => typeNameJPMap[typeName] || typeName);
        setTypes(typesJP);
      } catch (error) {
        console.error('特性やタイプの取得に失敗しました。', error);
      }
    };

    fetchAbilitiesAndTypes();
  }, [pokemonData]);

  // 実数値を計算する関数(レベル50)
  const calculateStat = (base: number, ev: number, iv: number, level: number = 50, isHP: boolean = false) => {
    if (isHP) {
      return Math.floor(((2 * base + iv + Math.floor(ev / 4)) * level) / 100) + level + 10;
    } else {
      return Math.floor(((2 * base + iv + Math.floor(ev / 4)) * level) / 100) + 5;
    }
  };

  const handleEvChange = (statName: string, value: number) => {
    let newValue = value;
    if (newValue < 0) newValue = 0;
    if (newValue > 252) newValue = 252;

    const newEvs = {
      ...evs,
      [statName]: newValue,
    };
    const totalEVs = Object.values(newEvs).reduce((sum, val) => sum + val, 0);
    if (totalEVs > 510) {
      alert('努力値の総合計は510を超えることはできません。');
      return;
    }
    setEvs(newEvs);
    setRemainingEVs(510 - totalEVs);
  };

  const handleIvChange = (statName: string, value: number) => {
    let newValue = value;
    if (newValue < 0) newValue = 0;
    if (newValue > 31) newValue = 31;

    const newIvs = {
      ...ivs,
      [statName]: newValue,
    };
    setIvs(newIvs); // 個体値を更新
  };

  const handleDesiredStatChange = (statName: string, value: number) => {
    setDesiredStats({
      ...desiredStats,
      [statName]: value,
    });
  };

  const resetEVs = () => {
    setEvs({});
    setRemainingEVs(510); // 努力値をリセット
  };

  // 各ステータスのEVを最大に振るハンドラー
  const maximizeEV = (statName: string) => {
    if (remainingEVs <= 0) {
      alert('これ以上努力値を振ることはできません。');
      return;
    }
    const currentEv = evs[statName] || 0;
    const available = 252 - currentEv;
    const toAdd = Math.min(available, remainingEVs);
    const newEvs = {
      ...evs,
      [statName]: currentEv + toAdd,
    };
    setEvs(newEvs);
    setRemainingEVs(remainingEVs - toAdd);
  };

  // 各ステータスのEVをリセットするハンドラー
  const resetEV = (statName: string) => {
    const currentEv = evs[statName] || 0;
    if (currentEv === 0) return;
    const newEvs = {
      ...evs,
      [statName]: 0,
    };
    setEvs(newEvs);
    setRemainingEVs(remainingEVs + currentEv);
  };

  const calculateEVForDesiredStat = (base: number, desiredStat: number, iv: number, level: number = 50, isHP: boolean = false) => {
    let ev = 0;
    let currentStat = calculateStat(base, ev, iv, level, isHP);
    while (currentStat < desiredStat && ev <= 252) {
      ev += 4;
      currentStat = calculateStat(base, ev, iv, level, isHP);
    }
    return ev > 252 ? '不可能' : ev;
  };

  return (
    <div className="pokemon-card">
      <h2>{pokemonData.japaneseName}</h2>
      <img src={pokemonData.sprites.front_default} alt={pokemonData.japaneseName} />

      {/* タイプ表示 */}
      <h3>タイプ</h3>
      <ul className="types">
        {types.map((typeNameJP, index) => {
          // 元の英語のタイプ名を取得(CSSクラス用)
          const englishType = Object.keys(typeNameJPMap).find(key => typeNameJPMap[key] === typeNameJP) || typeNameJP;
          return (
            <li key={index} className={`type ${englishType}`}>
              {typeNameJP}
            </li>
          );
        })}
      </ul>

      {/* 特性表示 */}
      <h3>特性</h3>
      <ul className="abilities">
        {abilities.map((abilityNameJP, index) => (
          <li key={index}>{abilityNameJP}</li>
        ))}
      </ul>

      {/* 残りの努力値 */}
      <h3>残りの努力値: {remainingEVs}</h3>
      <button className="reset-button" onClick={resetEVs}>努力値をリセット</button>

      {/* 能力値、努力値、個体値の入力 */}
      <h3>能力値</h3>
      <ul className="stats">
        {pokemonData.stats.map((stat: any) => {
          const statName = stat.stat.name;
          const isHP = statName === 'hp';
          const statLabel = statNameMap[statName] || statName;
          const baseStat = stat.base_stat;
          const currentEV = evs[statName] || 0;
          const currentIV = ivs[statName] !== undefined ? ivs[statName] : 31; // 個体値

          const actualStat = calculateStat(baseStat, currentEV, currentIV, 50, isHP);
          const desiredStat = desiredStats[statName];
          const requiredEV = desiredStat ? calculateEVForDesiredStat(baseStat, desiredStat, currentIV, 50, isHP) : '-';

          return (
            <li key={statName} className="stat-item">
              <div className="stat-header">
                <span className="stat-name">
                  <strong>{statLabel}</strong>種族値{baseStat}
                </span>
              </div>
              <div className="stat-inputs">
                <label>
                  努力値:
                  <input
                    type="number"
                    min="0"
                    max="252"
                    placeholder="努力値"
                    value={currentEV}
                    onChange={(e) => handleEvChange(statName, Number(e.target.value))}
                  />
                </label>
                <label>
                  個体値:
                  <input
                    type="number"
                    min="0"
                    max="31"
                    placeholder="個体値"
                    value={currentIV}
                    onChange={(e) => handleIvChange(statName, Number(e.target.value))}
                  />
                </label>
                <span className="actual-stat">
                  実数値: {actualStat}
                </span>
                <button className="max-ev-button" onClick={() => maximizeEV(statName)}>努力値をマックス振る</button>
                <button className="reset-ev-button" onClick={() => resetEV(statName)}>リセット</button>
              </div>
              <div className="desired-stat">
                <label>
                  目標値:
                  <input
                    type="number"
                    min="0"
                    placeholder="目標値"
                    value={desiredStats[statName] || ''}
                    onChange={(e) => handleDesiredStatChange(statName, Number(e.target.value))}
                  />
                </label>
                <span className="required-ev">
                  必要努力値: {requiredEV}
                </span>
              </div>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default PokemonCard;

SearchForm.tsx
ユーザーがポケモンの名前を入力して検索できるようにするために、SearchForm.tsxコンポーネントを作成しました。このコンポーネントは、入力フィールドと検索ボタンを提供し、ユーザーの入力を親コンポーネント(App.tsx)に渡します。

SearchForm.tsx
import React, { useState, FormEvent } from 'react';
import './SearchForm.css';

interface SearchFormProps {
  onSearch: (query: string) => void;
}

const SearchForm: React.FC<SearchFormProps> = ({ onSearch }) => {
  const [query, setQuery] = useState<string>('');

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    if (query.trim() !== '') {
      onSearch(query.trim());
      setQuery('');
    }
  };

  return (
    <form className="search-form" onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="ポケモン名を入力"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        className="search-input"
      />
      <button type="submit" disabled={query.trim() === ''}>検索</button>
    </form>
  );
};

export default SearchForm;

動作確認

アプリをローカルで動作させて確認します。

npm start

ブラウザが自動的に開き、http://localhost:3000でアプリが表示されます。ポケモン名を入力して検索すると、該当するポケモンの詳細情報が表示されます。努力値や個体値を調整することで、実数値がリアルタイムで計算されるのを確認できます。

作った感想

工夫した点

  • 日本語対応: PokeAPI自体は英語でデータを取得するため、日本語名への変換が必要でした。全ポケモン種のリストを取得後、日本語名でフィルタリングし、ユーザーが日本語で検索できるように工夫しました。

難しかった点

  • TypeScriptの型設定: TypeScriptの型という概念に初めて触れたため、APIから取得するデータの型定義やコンポーネント間での型のやり取りが難しかったです。適切な型を設定することで、コードの安全性と可読性を向上させることができました。
    型エラーでビルド失敗大量で泣いた

https://github.com/maegoshitoshinori/my-pokemon-site

まとめ

今回、ReactとTypeScriptを使ってポケモン検索アプリを作成しました。
APIからデータを取得し、ユーザー入力に基づいて動的に情報を表示する方法を学びました。また、努力値や個体値の管理機能を実装することで、より実践的なアプリケーションの作り方を理解することができました。

今後は、以下のような機能追加を検討しています:

  1. タイプ検索: ポケモンのタイプで検索を行えるようにする。
  2. 詳細なステータス分析: さらに高度なステータス計算機能を追加。性格での補正や、特性やアイテムを所持していた場合のステータスの増減周りの実装

ReactとTypeScriptを使った開発は、型があるためエラー箇所の発見が容易で、エラーが出た際には型の強制が役立つため、開発の品質向上に繋がります。TypeScriptのおかげで、どこで間違いが発生しているのかを迅速に特定でき、エラーが出た際には型の恩恵を実感することができました。
型エラーありがとう!!!

ぜひ、皆さんもReactとTypeScriptを活用して開発に挑戦してみてください!

アノテーション株式会社について

アノテーション株式会社はクラスメソッドグループのオペレーション専門特化企業です。サポート・運用・開発保守・情シス・バックオフィスの専門チームが、最新ITテクノロジー、高い技術力、蓄積されたノウハウをフル活用し、お客様の課題解決を行っています。当社は様々な職種でメンバーを募集しています。「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、アノテーション株式会社 採用サイトをぜひご覧ください。

この記事をシェアする

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.