[Next.js] PokeAPIでポケモンBMIクイズ作ってみた

# はじめに
今回は初代151匹のポケモンを対象にしたBMIクイズを作成しました。


![スクリーンショット 2024-12-18 20.51.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929063/37b31068-2cd0-471b-dfbb-3d4ce32e5bde.png)


以下のリンクから遊べます
[ポケモンBMIクイズ](https://pokemon-bmi-quiz.vercel.app/)

BMIとは、体重（キログラム）を身長（メートル）の二乗で割った値です。
計算式：BMI = 体重 (kg) ÷ 身長 (m) × 身長 (m)

医学的に理想とされるBMI値は「22」とされており、この値は健康的な体型を保ちながら疾病リスクを最小限に抑えるとされています。
## PokeAPIとは
PokeAPIは簡単に言うとポケモンのデータを取得することができるAPIです。

https://pokeapi.co/

特定のポケモンのデータを取得したい場合、下記のAPIにurlパラメーターとして、そのポケモンの`全国図鑑ID`を指定することができます。
```
https://pokeapi.co/api/v2/pokemon/{全国図鑑ID}
```
例えば全国図鑑No.1である`フシギダネ`というポケモンのデータを取得したい場合、下記のようにリクエストを送ります。
```
https://pokeapi.co/api/v2/pokemon/1
```
そのレスポンスからフシギダネの画像を取得したい場合は、
response -> sprites -> front_default の階層で取得できます。

![xx](https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png)
## 環境
今回はNext.js15を使用しています。ディレクトリ構成はこちらです。
```
.
├── public
│   ├── file.svg
│   ├── globe.svg
│   ├── next.svg
│   ├── vercel.svg
│   └── window.svg
├── src
│   ├── app
│   │   ├── favicon.ico
│   │   ├── globals.css
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── utils
│   │       └── pokemon.ts
├── eslint.config.mjs
├── .gitignore
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── README.md
├── tailwind.config.ts
├── tsconfig.json
└── vercel.json
```
## pokemon.ts
こちらのファイルではPokeAPIを使用して初代151匹のポケモンからランダムで1匹を取得します。そのポケモンの身長、体重からBMIを計算し、必要なデータを返しています。

```typescript:src/app/utils/pokemon.ts
export const getRandomPokemon = async () => {
    const index = Math.floor(Math.random() * 151) + 1;
    const url = "https://pokeapi.co/api/v2/pokemon/" + index;

    try {
        const response = await fetch(url);
        const pokemonData = await response.json();

        // 必要なデータを計算して返す
        return calculateBodyFatPercentage(
            pokemonData.id,
            pokemonData.name,
            pokemonData.sprites.front_default, // 画像URL
            pokemonData.height,
            pokemonData.weight
        );
    } catch (error) {
        console.error("Error fetching Pokémon data:", error);
        throw error;
    }
};

export const calculateBodyFatPercentage = (
    id: number,
    name: string,
    image: string,
    height: number,
    weight: number
) => {
    // 身長 (m) と 体重 (kg) に変換
    const heightInMeters = height / 10; // dm -> m
    const weightInKg = weight / 10; // hg -> kg

    // BMI を計算
    const bmi = weightInKg / (heightInMeters ** 2);

    // 必要な数値を返す
    return {
        id,
        name,
        image,
        heightInMeters: parseFloat(heightInMeters.toFixed(2)),
        weightInKg: parseFloat(weightInKg.toFixed(2)),
        bmi: parseFloat(bmi.toFixed(2)),
    };
};
```
## page.tsx
こちらのファイルは`pokemon.ts`ファイルの関数から取得したポケモンのBMIでクイズを行うトップ画面となっています。
```tsx:src/app/page.tsx
"use client";
import { useEffect, useState } from "react";
import { getRandomPokemon } from "./utils/pokemon";

export default function Home() {
  type PokemonDetails = {
    id: number;
    name: string;
    image: string;
    heightInMeters: number; // 小数点以下2桁にフォーマットされた身長
    weightInKg: number; // 小数点以下2桁にフォーマットされた体重
    bmi: number; // 小数点以下2桁にフォーマットされたBMI
  };

  const [loading, setLoading] = useState(false);
  const [pokemonDataFirst, setPokemonDataFirst] = useState<PokemonDetails>();
  const [pokemonDataSecond, setPokemonDataSecond] = useState<PokemonDetails>();
  const [userIsCorrect, setUserIsCorrect] = useState<boolean | null>(null); // null 初期化
  const [checkAnswer, setCheckAnswer] = useState<boolean>(false);

  useEffect(() => {
    getRandomTwoPokemon();
  }, []);

  const getRandomTwoPokemon = async () => {
    setLoading(true);
    setCheckAnswer(false);
    setUserIsCorrect(null); // 状態をリセット
    try {
      const firstPokemon = await getRandomPokemon();
      let secondPokemon;

      // 2体目のポケモンを取得し、1体目と異なるまでループ
      do {
        secondPokemon = await getRandomPokemon();
      } while (secondPokemon.id === firstPokemon.id);

      setPokemonDataFirst(firstPokemon);
      setPokemonDataSecond(secondPokemon);
      setLoading(false);
    } catch (error) {
      console.error("Error fetching Pokémon data:", error);
      setLoading(false);
    }
  };

  const handleChoice = (choice: "first" | "second") => {
    if (!pokemonDataFirst || !pokemonDataSecond) return;

    const isFirstCorrect = pokemonDataFirst.bmi > pokemonDataSecond.bmi;
    const isCorrect = (choice === "first" && isFirstCorrect) || (choice === "second" && !isFirstCorrect);

    setCheckAnswer(true);
    setUserIsCorrect(isCorrect);
  };

  return (
    <div>
      {loading ? (
        <h1>ロード中...</h1>
      ) : (
        <div className="flex flex-col items-center mt-8">
          <button
            onClick={getRandomTwoPokemon}
            className="px-4 py-2 text-lg bg-blue-500 text-white rounded-lg shadow-md hover:bg-blue-600"
          >
            次の問題へ
          </button>

          <h1 className="text-2xl font-bold mt-4 text-gray-400">BMIが高いのはどっち？</h1>

          {/* クイズ結果を表示 */}
          {userIsCorrect !== null && (
            <h2
              className={`text-xl font-semibold mt-4 ${userIsCorrect ? "text-green-600" : "text-red-600"
                }`}
            >
              {userIsCorrect ? "正解！" : "不正解..."}
            </h2>
          )}

          <div className="flex justify-center gap-8 mt-6">
            {/* ポケモン1 */}
            <div className="flex flex-col items-center bg-gray-100 p-4 rounded-lg shadow-lg">
              <img src={pokemonDataFirst?.image} alt={pokemonDataFirst?.name} className="w-32 h-32" />
              <p className="text-xl font-semibold mt-2 text-gray-800">{pokemonDataFirst?.name}</p>
              {checkAnswer && (
                <>
                  <p className="text-lg font-medium text-gray-700">高さ: {pokemonDataFirst?.heightInMeters} m</p>
                  <p className="text-lg font-medium text-gray-700">重さ: {pokemonDataFirst?.weightInKg} kg</p>
                  <p className="text-lg font-medium text-gray-700">BMI: {pokemonDataFirst?.bmi}</p>
                </>
              )}
              {!checkAnswer && (
                <>
                  <button
                    onClick={() => handleChoice("first")}
                    className="mt-4 px-4 py-2 bg-green-500 text-black rounded-lg shadow-md hover:bg-green-600"
                  >
                    このポケモン
                  </button>
                </>
              )}
            </div>

            {/* ポケモン2 */}
            <div className="flex flex-col items-center bg-gray-100 p-4 rounded-lg shadow-lg">
              <img src={pokemonDataSecond?.image} alt={pokemonDataSecond?.name} className="w-32 h-32" />
              <p className="text-xl font-semibold mt-2 text-gray-800">{pokemonDataSecond?.name}</p>
              {checkAnswer && (
                <>
                  <p className="text-lg font-medium text-gray-700">高さ: {pokemonDataSecond?.heightInMeters} m</p>
                  <p className="text-lg font-medium text-gray-700">重さ: {pokemonDataSecond?.weightInKg} kg</p>
                  <p className="text-lg font-medium text-gray-700">BMI: {pokemonDataSecond?.bmi}</p>
                </>
              )}
              {!checkAnswer && (
                <>
                  <button
                    onClick={() => handleChoice("second")}
                    className="mt-4 px-4 py-2 bg-green-500 text-black rounded-lg shadow-md hover:bg-green-600"
                  >
                    このポケモン
                  </button>
                </>
              )}
            </div>
          </div>
        </div>
      )}
    </div>
  );
}
```
# おわりに
初めての Qiita 記事投稿ということで、不慣れな点や至らない部分があったかもしれませんが、ここまで読んでいただきありがとうございました！

初代ポケモンの中ではカビゴンが1番BMI高いと思っていましたが、ゴローニャの方が高いことを知れて勉強になりました！