Quizopiaコード説明~ポケモンなきごえクイズ編~

この記事では、ポートフォリオ「Quizopia」のコードの一部を説明しています。

PokemonCryQuizController.php の一部紹介

 // 問題セットを生成するプライベートメソッド
    private function generateQuestionSet()
    {
        $questions = [];
        $usedPokemonIds = [];

        // 5問の問題を生成
        for ($i = 0; $i < 5; $i++) {
            do {
                $randomPokemonId = rand(1, 251);
            } while (in_array($randomPokemonId, $usedPokemonIds));

            $usedPokemonIds[] = $randomPokemonId;
            $options = $this->generateOptions($randomPokemonId);

            $questions[] = [
                'pokemonId' => $randomPokemonId,
                'options' => $options,
            ];
        }

        return $questions;
    }

    // 選択肢を生成するプライベートメソッド
    private function generateOptions($correctId)
    {
        $options = [$correctId];
        
        // 正解以外の3つの選択肢を生成
        while (count($options) < 4) {
            $randomId = rand(1, 251);
            if (!in_array($randomId, $options)) {
                $options[] = $randomId;
            }
        }
        
        // 選択肢をシャッフル
        shuffle($options);
        
        return $options;
    }

1. generateQuestionSet() 関数

  • 5つのランダムなポケモン問題を生成
  • 各問題について重複しないポケモンIDを選択(1-251の範囲)
  • 各問題に4つの選択肢を設定

2. generateOptions($correctId) 関数

  • 正解のポケモンID ($correctId) と、他3つのランダムなポケモンIDを選択肢として生成
  • 選択肢をシャッフルして返す
コード実行途中のイメージ例
// generateQuestionSet内の処理
$randomPokemonId = 25; // 例: ピカチュウのIDが選ばれたとします
$usedPokemonIds = [25]; // 使用済みIDリストに追加

// generateOptions(25)の呼び出し
$options = [25]; // 最初に正解のIDを追加
// 3つのランダムなIDを追加(重複しないように)
$options = [25, 4, 129, 150]; // 例: フシギソウ、コイキング、ミュウツーが追加されたとする
// シャッフル後 
$options = [129, 25, 150, 4]; // シャッフル後はこのような順序になる可能性がある

// generateQuestionSetに戻り、問題を追加
$questions = [
    [
        'pokemonId' => 25, // ピカチュウのID
        'options' => [129, 25, 150, 4] // シャッフルされた選択肢
    ]
];

5回分のループが完了した後(最終状態)

$usedPokemonIds = [25, 149, 6, 151, 39]; // 選ばれた5匹のポケモンID

$questions = [
    [
        'pokemonId' => 25, // ピカチュウ
        'options' => [129, 25, 150, 4] // コイキング, ピカチュウ, ミュウツー, フシギソウ
    ],
    [
        'pokemonId' => 149, // カイリュー
        'options' => [149, 36, 78, 94] // カイリュー, ピクシー, ギャロップ, ゲンガー
    ],
    [
        'pokemonId' => 6, // リザードン
        'options' => [123, 6, 45, 249] // ストライク, リザードン, ラフレシア, ルギア
    ],
    [
        'pokemonId' => 151, // ミュウ
        'options' => [74, 151, 114, 130] // イシツブテ, ミュウ, モンジャラ, ギャラドス
    ],
    [
        'pokemonId' => 39, // プリン
        'options' => [39, 145, 88, 201] // プリン, サンダース, ベトベトン, アンノーン
    ]
];

3. playRandom 関数 – クイズを開始する処理

public function playRandom()
{
    // 新しい問題セットを生成
    $questions = $this->generateQuestionSet();
    Session::put('quiz_questions', $questions);
    Session::put('current_question_index', 0);
    Session::put('score', 0);

    // 現在の問題を取得
    $currentIndex = Session::get('current_question_index');
    $questions = Session::get('quiz_questions');
    $currentQuestion = $questions[$currentIndex];

    return Inertia::render('Play/RandomPokemonCry', [
        'pokemonId' => $currentQuestion['pokemonId'],
        'options' => $currentQuestion['options'],
        'questionCount' => $currentIndex + 1,
        'score' => Session::get('score'),
    ]);
}

この関数はクイズの開始時に実行され、ランダムな問題を生成してクイズの最初の画面を表示します。

具体的な処理の流れ

  1. generateQuestionSet()を呼び出して5問のランダムな問題を作成
  2. 問題セット、現在の問題インデックス(0)、スコア(0)をセッションに保存
  3. 最初の問題(インデックス0)の情報を取得
  4. Inertia.jsを使ってフロントエンド画面を表示するためのデータを渡す

以下は具体的なイメージです。

// 生成された$questions配列の例(セッションに保存される)
$questions = [
    [
        'pokemonId' => 25, // ピカチュウ
        'options' => [129, 25, 150, 4] // コイキング, ピカチュウ, ミュウツー, フシギソウ
    ],
    // ... 以下4問分の問題データ
];

// フロントエンドに渡されるデータのイメージ
[
    'pokemonId' => 25, // この音声が再生される
    'options' => [129, 25, 150, 4], // 選択肢として表示される
    'questionCount' => 1, // 「1/5問目」と表示
    'score' => 0 // 現在のスコア
]

4. nextQuestion 関数 – 次の問題へ進む処理

public function nextQuestion()
{
    $currentIndex = Session::get('current_question_index',0);
    $questions = Session::get('quiz_questions', []);
    $currentIndex++;

    if ($currentIndex >= 5) {
        // クイズ終了
        Session::forget(['quiz_questions', 'current_question_index']);
        return redirect()->route('play.random-pokemon-cry');
    }

    Session::put('current_question_index', $currentIndex);

    return Inertia::render('Play/RandomPokemonCry', [
            'pokemonId' => $currentQuestion['pokemonId'],
            'options' => $currentQuestion['options'],
            'questionCount' => $currentIndex + 1,
            'score' => Session::get('score', 0),
        ]);
}

ユーザーが「次へ」ボタンを押した時に実行され、次の問題に進めるか、クイズを終了するかを制御します。

具体的な処理の流れ:

  1. 現在の問題インデックスを取得して1増やす
  2. もし問題が5問すべて終わったら(インデックスが5以上):
    • セッションから問題関連のデータを削除
    • クイズ開始画面にリダイレクト(新しいクイズがスタート)
  3. まだ問題が残っている場合:
    • 新しいインデックスをセッションに保存
    • 同じ画面にリダイレクト(次の問題が表示される)

以下は具体的なイメージです。

/ セッションの状態変化
// 例:2問目が終わって3問目に進む場合
// 変更前
Session::get('current_question_index') = 1; // 2問目(0からカウント)
// 変更後
Session::put('current_question_index', 2); // 3問目に更新

// 例:最後の問題(5問目)が終わった場合
// 変更前
Session::get('current_question_index') = 4; // 5問目
// 変更後
// 新しいクイズを開始

5. updateScore 関数 – スコアを更新する処理

public function updateScore()
{
    $currentScore = Session::get('score', 0);
    Session::put('score', $currentScore + 1);
    return response()->json(['success' => true]);
}

ユーザーが正解を選んだときに呼び出され、スコアを1点増やします。

具体的な処理の流れ:

  1. 現在のスコアをセッションから取得
  2. スコアに1を加えて更新
  3. 成功したことを示すJSONレスポンスを返す(Ajaxリクエスト用)

以下は具体的なイメージです。

// 例:現在のスコアが2点で正解した場合
// 変更前
Session::get('score') = 2;
// 変更後
Session::put('score', 3); // 3点に更新

// フロントエンドに返すレスポンス
{ "success": true }

6. resetQuiz 関数 – クイズをリセットする処理

public function resetQuiz()
{
    // セッションを完全にクリア
    Session::forget(['quiz_questions', 'current_question_index', 'score']);

    return redirect()->route('play.random-pokemon-cry');  
}

クイズを中断して最初からやり直したい場合などに、クイズデータをリセットします。

具体的な処理の流れ:

  1. クイズ関連のセッションデータをすべて削除
  2. 成功したことを示すJSONレスポンスを返す

以下は具体的なイメージです。

// セッションから以下のデータが削除される
- 'quiz_questions'(問題セット)
- 'current_question_index'(現在の問題インデックス)
- 'score'(現在のスコア)

// フロントエンドに返すレスポンス
{ "success": true }

フロントエンド部分の説明(RandomPokemonCry.tsx)

1. コンポーネント構造と状態管理

RandomPokemonCry = ({
    pokemonId,
    options,
    questionCount,
    score: initialScore,
}: {
    pokemonId: number;
    options: number[];
    questionCount: number;
    score: number;
}) => {
    // State管理
    const [pokemons] = useState<Pokemon[]>(pokemonData);
    const [currentQuiz, setCurrentQuiz] = useState<CurrentQuiz | null>(null);
    const [selectedAnswer, setSelectedAnswer] = useState<Pokemon | null>(null);
    const [showResult, setShowResult] = useState<boolean>(false);
    const [isLoading, setIsLoading] = useState<boolean>(false);
    const [isQuizFinished, setIsQuizFinished] = useState<boolean>(false);
    const [score, setScore] = useState<number>(initialScore);
    const TOTAL_QUESTIONS = 5;

このコンポーネントはサーバーから受け取った情報(pokemonId, options, questionCount, score)によって、ポケモンのなき声クイズを表示します。具体的には、以下の状態を管理しています。

  • pokemons: 全ポケモンデータの配列(JSONファイルからインポート)
  • currentQuiz: 現在の問題データ(選択肢と正解)
  • selectedAnswer: ユーザーが選んだ回答
  • showResult: 回答結果を表示するかどうか
  • isLoading: 音声ロード中かどうか
  • isQuizFinished: クイズが終了したかどうか
  • score: 現在のスコア

2. 音声再生機能

// オーディオ参照
const audioRef = useRef<HTMLAudioElement | null>(null);

// 音声再生関数
const playSound = async () => {
    if (isLoading) return;

    try {
        setIsLoading(true);

        // 音声ファイルのパスを設定(M4A形式を使用)
        const paddedId = pokemonId.toString().padStart(3, "0");
        const audioPath = `/audio/pokemon_cries/${paddedId}.m4a`;

        // 既存のオーディオ要素をクリーンアップ
        if (audioRef.current) {
            audioRef.current.pause();
            audioRef.current.removeAttribute("src");
            audioRef.current.load();
        }

        // HTML5 Audio要素を使用
        if (!audioRef.current) {
            audioRef.current = new Audio();
        }
        
        // ...イベントリスナー設定、再生処理など
    } catch (error) {
        // エラーハンドリング
    }
};

この部分はポケモンの鳴き声を再生するための機能です。

  • audioRef: HTML Audio要素への参照
  • playSound関数: 選択されたポケモンIDに基づいて音声ファイルをロードして再生
  • エラーハンドリング: 特にiOSデバイスでの再生問題に対応する詳細な処理

3. クイズゲームのロジック

// 回答チェック関数
const checkAnswer = async (pokemon: Pokemon) => {
    try {
        setSelectedAnswer(pokemon);
        setShowResult(true);

        if (pokemon.pokeapi_id === currentQuiz?.correct.pokeapi_id) {
            // 正解の場合、スコアを更新
            const response = await axios.post(route("play.update-score"));
            setScore(response.data.score || score + 1);
        }
    } catch (error) {
        console.error("回答処理エラー:", error);
        alert("エラーが発生しました。もう一度お試しください。");
    }
};

// クイズ生成関数
const generateQuiz = () => {
    if (isQuizFinished) return;

    // 問題数が上限に達した場合は結果画面を表示
    if (questionCount > TOTAL_QUESTIONS) {
        setIsQuizFinished(true);
        return;
    }

    const selectedPokemons = options
        .map((id) => pokemons.find((p) => p.pokeapi_id === id))
        .filter((p): p is Pokemon => p !== undefined);

    const correctAnswer = selectedPokemons.find(
        (p) => p.pokeapi_id === pokemonId
    );

    if (correctAnswer) {
        setCurrentQuiz({
            choices: selectedPokemons,
            correct: correctAnswer,
        });
        setSelectedAnswer(null);
        setShowResult(false);
    }
};

上記の関数がクイズゲームの中核ロジックを実装しています。

  • checkAnswer: ユーザーが選んだ回答が正解かどうかをチェックし、正解ならplay.update-scoreエンドポイントを呼び出してスコアを更新
  • generateQuiz: サーバーから受け取った選択肢IDを実際のポケモンオブジェクトに変換し、クイズ問題を構築

4. ゲーム進行管理

// 次の問題へ進む関数
const handleNextQuestion = () => {
    if (questionCount >= TOTAL_QUESTIONS) {
        setIsQuizFinished(true);
    } else {
        router.get(route("play.next-question"));
    }
};

// クイズをリセットする関数
const resetQuiz = () => {
    setIsLoading(true); // ボタンを無効化してユーザー操作を防ぐ
    router.get(route("play.reset-pokemon-cry"));
};

上記の関数は、ゲームの進行を管理します。

  • handleNextQuestion: 現在の問題が最後なら結果画面に移動、そうでなければサーバーに次の問題をリクエスト
  • resetQuiz: クイズをリセットしてサーバーから新しい問題セットを取得

5. 副作用とイニシャライズ

// 初回マウント時にクイズを生成
useEffect(() => {
    // 問題数が上限に達した場合は結果画面を表示
    if (questionCount > TOTAL_QUESTIONS) {
        setIsQuizFinished(true);
    } else {
        generateQuiz();
    }

    // モバイルデバイスの場合、オーディオ初期化のためのダミー要素を作成
    if (isMobile) {
        // オーディオ要素の初期化と各種イベントリスナーの設定
    }

    return () => {
        // クリーンアップ処理
    };
}, [pokemonId, options, questionCount]);

このuseEffect部分は、

  • コンポーネントのマウント時にクイズを初期化
  • 問題数が上限を超えている場合は結果画面を表示
  • モバイルデバイス(特にiOS)向けの音声再生初期化処理
  • コンポーネントのアンマウント時のクリーンアップ

6. UI部分

return (
    <PlayMainLayout>
        <div className="container mx-auto px-4 py-8">
            {/* 非表示のオーディオ要素 */}
            <audio
                ref={audioRef}
                style={{ display: "none" }}
                preload="none"
                playsInline
            />

            <Card className="w-full max-w-2xl mx-auto">
                <CardHeader>
                    <CardTitle className="text-center text-2xl">
                        ポケモン なきごえ クイズ
                    </CardTitle>
                    <div className="text-center text-lg">
                        {!isQuizFinished && (
                            <div>
                                もんだい {questionCount}/{TOTAL_QUESTIONS}
                            </div>
                        )}
                        <div>スコア: {score}</div>
                    </div>
                </CardHeader>

                <CardContent>
                    {!isQuizFinished ? (
                        <>
                            {/* なきごえを聞くボタン */}
                            <div className="flex justify-center mb-8">
                                <Button onClick={handlePlayButtonClick} /* ... */}>
                                    <Volume2 className="w-6 h-6" />
                                    {isLoading
                                        ? "よみこみちゅう..."
                                        : "なきごえを きく"}
                                </Button>
                            </div>

                            {/* 選択肢ボタン */}
                            <div className="grid grid-cols-2 gap-4">
                                {currentQuiz?.choices.map((pokemon) => (
                                    <Button
                                        key={pokemon.pokeapi_id}
                                        onClick={() => checkAnswer(pokemon)}
                                        disabled={showResult}
                                        variant={/* 条件付きスタイル */}
                                    >
                                        {pokemon.pokeapi_species_name_ja}
                                    </Button>
                                ))}
                            </div>

                            {/* 結果表示と次の問題ボタン */}
                            {showResult && (
                                <div className="mt-8 text-center">
                                    <p className="text-xl mb-4">
                                        {selectedAnswer?.pokeapi_id ===
                                        currentQuiz?.correct.pokeapi_id
                                            ? "せいかい! 🎉"
                                            : `ざんねん... せいかいは「${currentQuiz?.correct.pokeapi_species_name_ja}」でした`}
                                    </p>
                                    <Button
                                        onClick={handleNextQuestion}
                                        className="text-lg"
                                    >
                                        {questionCount === TOTAL_QUESTIONS
                                            ? "けっかを みる"
                                            : "つぎの もんだい"}
                                    </Button>
                                </div>
                            )}
                        </>
                    ) : (
                        // クイズ終了時の結果表示
                        <div className="text-center">
                            <h2 className="text-2xl mb-4">
                                クイズ しゅうりょう!
                            </h2>
                            <p className="text-xl mb-4">
                                {TOTAL_QUESTIONS}もんちゅう {score}もん せいかい!
                            </p>
                            <Button onClick={resetQuiz} className="text-lg">
                                もういちど ちょうせん する
                            </Button>
                        </div>
                    )}
                </CardContent>
            </Card>
        </div>
    </PlayMainLayout>
);

UIはクイズの状態に応じて異なる内容を表示しています。

クイズ進行中
  • 問題番号とスコア表示
  • 「なきごえを きく」ボタン
  • 4つの選択肢ボタン
  • 回答後の結果表示と次の問題へ進むボタン
クイズ終了時
  • 結果概要(全5問中何問正解したか)
  • 「もういちど ちょうせん する」ボタン

まとめ. フロントエンド・バックエンド連携フロー

1.初期表示時
    • サーバー側のplayRandomメソッドが5問分の問題データを生成
    • 最初の問題データをフロントエンドに送信
    • フロントエンドのuseEffectでgenerateQuizを実行し、UIを構築
    2.回答選択時
      • checkAnswer関数が実行され、選択されたポケモンが正解かチェック
      • 正解の場合、axios.post(route(“play.update-score”))でサーバーにスコアを更新させる
      • 結果表示と「つぎのもんだいへ」ボタンを表示
      3.次の問題へ進む時
        • handleNextQuestion関数が実行され、router.get(route(“play.next-question”))で次の問題をリクエスト
        • サーバー側のnextQuestionメソッドが問題インデックスを進め、次の問題データを返す
        • 新しい問題データでページが再レンダリングされる
        4.クイズ終了時
          • 最終問題の結果表示後、結果画面に切り替わる
          • 「もういちどちょうせんする」ボタンが表示される
          • ボタンクリックでresetQuiz関数が実行され、router.get(route(“play.reset-pokemon-cry”))でクイズをリセット
          • サーバー側で新しい問題セットが生成され、最初からクイズが始まる

          このフロントエンドの実装はサーバーサイドのコントローラーと連携して、双方向の通信でクイズゲームの状態を管理しています。

          転職
          Xでは、日々の学びを発信しています