【Unity】クイズゲーム(4) 出題と解答の仕組みを作る【クソゲー制作】

クイズゲームを作る(4)

Unity初心者がクイズゲームを作る企画の第4回です。

今回は、問題を出して解答して正誤を判定するという一連の仕組みを作っていきます。

環境
  • Mac mini (M1, 2020)
  • Unity 2022.3.36f1
目次

CSVファイルを用意する

今回、クイズの問題と答えのデータはCSVファイルに格納することにしました。

当初の予定ではデータベースを使いたいと思っていたのですが、ブラウザゲームではSQLiteが使えそうにないのと、かといってMySQLでは大掛かりすぎるということで断念しました。

他にもScriptableObjectとかJSONも検討したのですが、クイズデータを追加していくときのメンテナンス性を考えてCSVにしました。表計算ソフトで扱えるからね。

以下のようなCSVファイルを作成します。カラムはid, question, option1, option2, option3, option4, created_atとしました。

クイズゲーム26

questionが問題、option1-4が選択肢です。option1に正解を入れることで正解を保存するためのカラムを省略しています。出題するときには選択肢の順番をシャッフルします。

で、このCSVファイルの名前をquiz.csvにして、Assets/Resourcesフォルダにインポートします。このフォルダに入れておくと、あとでスクリプトから簡単に読み込めます。

CSVファイルを読み込む

クイズデータを格納したファイルquiz.csvを読み込むためのスクリプトを書きます。

// ...省略...
using System.Linq;

public class TitleController : MonoBehaviour
{
    // ...省略...
    public static List<QuizData> quizDataList = new List<QuizData>(); // クイズデータのリスト

    // スタートボタン
    public void StartGame()
    {
        quizDataList.Clear(); // クイズデータリストをクリア
        ReadCSV(); // クイズデータの読み込み
        // ...省略...
    }

    // ...省略...

    //CSV読み込み関数
    public void ReadCSV()
    {
        TextAsset csvData = Resources.Load<TextAsset>("quiz"); // quiz.csvを読み込む
        string[] lines = csvData.text.Split('\n'); // csvDataを改行で分割し配列に格納
        lines = lines.Where(line => !string.IsNullOrEmpty(line)).ToArray(); // 空行を削除

        var random = new System.Random();
        lines = lines.Skip(1) // ヘッダ行をスキップ
                     .OrderBy(x => random.Next()) // ランダムに並び替え
                     .ToArray();

        // quizDataListに追加
        for (int i = 0; i < lines.Length; i++)
        {
            string[] values = lines[i].Split(',');
            QuizData quizData = new QuizData();
            quizData.id = int.Parse(values[0]);
            quizData.question = values[1];
            quizData.option1 = values[2];
            quizData.option2 = values[3];
            quizData.option3 = values[4];
            quizData.option4 = values[5];
            quizDataList.Add(quizData);
        }
    }
}

// クイズのデータ構造を定義
[System.Serializable]
public class QuizData
{
    public int id;
    public string question;
    public string option1; // 正答
    public string option2;
    public string option3;
    public string option4;
}

quiz.csvを読み込んでquizDataListに格納しています。このとき、

  • 空行の削除
  • ヘッダ行のスキップ
  • 問題の順序のシャッフル

をしています。

LINQというライブラリを使うのでusing System.Linqします。LINQは配列やリストなどの要素を処理するための便利なライブラリです。上記のコードでは.Where.SkipがLINQのメソッドです。

クイズ画面の表示

問題と選択肢の表示

CSVファイルから読み取ったデータをクイズ画面に表示するスクリプトを書きます。

タイトル画面の「スタート」ボタンを押したときに1問目、クイズ画面の「次へ」ボタンを押したときに2問目以降を表示します。先にQuizController.cs「次へ」ボタンを押したときの処理を書きます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using DG.Tweening; // DOTweenを使う場合に追加
using TMPro;
using UnityEngine.UI;
using System.Linq;

public class QuizController : MonoBehaviour
{
    public TextMeshProUGUI questionText; // 問題のテキスト
    public List<TextMeshProUGUI> optionTexts; // 選択肢のテキストを格納するリスト
    public Button finishButton; // 終了ボタン
    public Button nextButton; // 次へボタン
    public List<QuizData> quizDataList = TitleController.quizDataList; // クイズデータのリスト
    public int currentQuestionIndex = 0; // 現在の問題のインデックス

    // 次へボタン
    public void NextQuestion()
    {
        quizGroup.interactable = false; // クイズ画面のボタンを押せないように
        transform.DOLocalRotate(new Vector3(0, 90, 0), 0.4f) // Y軸で90度回転
        .OnComplete(() => // 完了したら
        {
            // 問題の入れ替え処理
            currentQuestionIndex++; // 問題のインデックス更新
            CreateQuizPanel(currentQuestionIndex); // クイズ画面を作成
            transform.DOLocalRotate(new Vector3(0, 0, 0), 0.4f).SetDelay(0.4f); // もとに戻す
            quizGroup.interactable = true; // クイズ画面のボタンを押せるように
        });
    }

    // ...省略...

    // クイズ画面生成関数
    public void CreateQuizPanel(int index)
    {
        QuizData currentQuiz = quizDataList[index]; // 現在のクイズデータを取得
        var options = new List<string> { currentQuiz.option1, currentQuiz.option2, currentQuiz.option3, currentQuiz.option4 }; // 選択肢をリストに格納
        string correctAnswer = currentQuiz.option1; // 正答を記録
        var random = new System.Random();
        options = options.OrderBy(x => random.Next()).ToList(); // 選択肢をシャッフルする
        correctAnswerIndex = options.IndexOf(correctAnswer); // 選択肢の中で正答がどこにあるかを調べる
        questionText.text = currentQuiz.question; // 問題をUIに設定
        // 選択肢をUIに設定
        for (int i = 0; i < options.Count; i++)
        {
            optionTexts[i].text = options[i];
        }
        finishButton.interactable = false; // 終了ボタンを押せないように
        nextButton.interactable = false; // 次へボタンを押せないように
    }
}

TitleController.csでスタートボタンを押したときの処理を書きます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using DG.Tweening; // DOTweenを使う場合に追加
using System.Linq;

public class TitleController : MonoBehaviour
{
    // ...省略...
    public static List<QuizData> quizDataList = new List<QuizData>(); // クイズデータのリスト
    public QuizController quizController; // QuizController

    // スタートボタン
    public void StartGame()
    {
        quizDataList.Clear(); // クイズデータリストをクリア
        ReadCSV(); // クイズデータの読み込み
        quizController.currentQuestionIndex = 0; // 現在の問題のインデックスの初期化
        quizController.CreateQuizPanel(0); // 1問目のクイズ画面
        titleGroup.interactable = false; // タイトル画面のボタンを押せないように
        quizGroup.interactable = true; // クイズ画面のボタンを押せるように
        quizPanel.SetActive(true); // クイズ画面を表示
        quizPanel.transform.localPosition = new Vector3(540f, 0f, 0f); // クイズ画面を右に配置
        transform.DOLocalMoveX(-540f, 0.4f); // タイトル画面を左にスライド
        quizPanel.transform.DOLocalMoveX(0f, 0.4f); // クイズ画面を左にスライド
    }

    //...省略...
}

// ...省略...

以上で「スタート」「次へ」ボタンを押したときにquizDataListに格納されているデータがクイズ画面に表示されるようになりました。

何問目、メッセージ、サルの表情、マルバツの表示

クイズ画面では以下の表示をするので、そのためのスクリプトを書いていきます。

  • 今、何問目か
  • 正解、不正解、時間切れのメッセージ
  • サルの表情(正解なら笑顔、不正解ならショックの顔)
  • 選択肢のうちどれが正解で不正解かのマルバツ表示
クイズゲーム28

何問目

今、何問目かはcurrentQuestionIndexに入っています。このインデックスは0から始まるので+ 1して表示します。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using DG.Tweening; // DOTweenを使う場合に追加
using TMPro;
using UnityEngine.UI;
using System.Linq;

public class QuizController : MonoBehaviour
{
    public TextMeshProUGUI questionCountText; // 何問目テキスト
    public int currentQuestionIndex = 0; // 現在の問題のインデックス
    // ...省略...

    // ...省略...

    // クイズ画面生成関数
    public void CreateQuizPanel(int index)
    {
        // ...省略...
        questionCountText.text = "<size=48><b>" + (index + 1).ToString() + "</b></size> 問目"; // 何問目
    }
}

メッセージ

正解、不正解、時間切れのメッセージを表示するための関数を作ります。

public class QuizController : MonoBehaviour
{
    public GameObject TimeOutMessageText; // 時間切れメッセージ
    public GameObject CorrectMessageText; // 正解メッセージ
    public GameObject IncorrectMessageText; // 不正解メッセージ
    // ...省略...

    // メッセージ表示関数
    public void ShowMessage(string message)
    {
        switch (message)
        {
            case "timeout":
                TimeOutMessageText.SetActive(true);
                break;
            case "correct":
                CorrectMessageText.SetActive(true);
                break;
            case "incorrect":
                IncorrectMessageText.SetActive(true);
                break;
            case "none":
                TimeOutMessageText.SetActive(false);
                CorrectMessageText.SetActive(false);
                IncorrectMessageText.SetActive(false);
                break;
            default:
                Debug.LogWarning("Unknown message type: " + message);
                break;
        }
    }
}

出題時にはすべてのメッセージを非表示にしたいので次のようにします。

    // クイズ画面生成関数
    public void CreateQuizPanel(int index)
    {
        // ...省略...
        ShowMessage("none"); // メッセージを非表示に
        // ...省略...
    }

サルの表情

サルの表情を切り替える関数を作ります。

public class QuizController : MonoBehaviour
{
    public Image monkeyFace; // サルの顔
    public Sprite monkeyFaceNormal; // 普通のサルの顔画像
    public Sprite monkeyFaceSmile; // 笑顔のサルの顔画像
    public Sprite monkeyFaceShock; // ショックのサルの顔画像
    // ...省略...

    // サルの顔変更関数
    public void ChangeMonkeyFace(string face)
    {
        switch (face)
        {
            case "normal":
                monkeyFace.sprite = monkeyFaceNormal;
                break;
            case "smile":
                monkeyFace.sprite = monkeyFaceSmile;
                break;
            case "shock":
                monkeyFace.sprite = monkeyFaceShock;
                break;
            default:
                Debug.LogWarning("Unknown face type: " + face);
                break;
        }
    }
}

出題時にはサルの表情をノーマルにします。

    // クイズ画面生成関数
    public void CreateQuizPanel(int index)
    {
        // ...省略...
        ChangeMonkeyFace("normal"); // サルの顔をノーマルに
        // ...省略...
    }

マルバツ

答え合わせのときにマルバツマークを選択肢に表示する関数を作ります。

public class QuizController : MonoBehaviour
{
    public List<GameObject> answerMarks; // 選択肢のマルバツオブジェクトを格納するリスト
    public List<Image> answerImages; // 選択肢のマルバツ画像を格納するリスト
    public Sprite correctImage; // 正答の画像
    public Sprite incorrectImage; // 誤答の画像
    // ...省略...

    // マルバツマーク表示関数
    public void ShowAnswerMark(bool show, int correctAnswerIndex)
    {
        // すべての選択肢に誤答の画像を設定
        foreach (var image in answerImages)
        {
            image.sprite = incorrectImage;
        }
        // 正答の選択肢に正答の画像を設定
        if (correctAnswerIndex >= 0 && correctAnswerIndex < answerImages.Count)
        {
            answerImages[correctAnswerIndex].sprite = correctImage;
        }
        // 表示・非表示を設定
        foreach (var mark in answerMarks)
        {
            mark.SetActive(show);
        }
    }
}

出題時にはマルバツマークは非表示にします。

    // クイズ画面生成関数
    public void CreateQuizPanel(int index)
    {
        // ...省略...
        ShowAnswerMark(false, correctAnswerIndex); // マルバツを非表示に
        // ...省略...
    }

カウントダウンタイマー

クイズ画面右上のカウントダウンタイマーを動かすスクリプトを書きます。

public class QuizController : MonoBehaviour
{
    public Image countDownTimer; // カウントダウンタイマーの画像
    public TextMeshProUGUI countDownTimerText; // カウントダウンタイマーのテキスト
    private float totalTime = 15f; // カウントダウンの秒数
    public Coroutine countdownCoroutine; // カウントダウンコルーチンの参照を保持
    // ...省略...

    // 次へボタン
    public void NextQuestion()
    {
        // ...省略...
        .OnComplete(() => // 完了したら
        {
            // ...省略...
            countdownCoroutine = StartCoroutine(StartCountdown()); // カウントダウン開始
        });
    }

    // カウントダウンタイマーのコルーチン
    public IEnumerator StartCountdown()
    {
        float timeRemaining = totalTime;

        while (timeRemaining > 0)
        {
            timeRemaining -= Time.deltaTime;
            countDownTimer.fillAmount = timeRemaining / totalTime; // タイマーのゲージを動かす
            countDownTimerText.text = Mathf.CeilToInt(timeRemaining).ToString(); // 残り秒数を表示
            yield return null;
        }

        countDownTimerText.text = "0"; // タイマーがゼロになったときの表示

        // 時間切れ処理(未実装)
    }
}

カウントダウンタイマー用の円形の画像を用意します(countDownTimer)。Unityのインスペクターで次のように設定します。

クイズゲーム27
Image TypeFilled画像の一部を塗りつぶすように表示する
Fill MethodRadial 360円形に塗りつぶす
Fill OriginTop上から塗りつぶす
Fill Amount1塗りつぶす割合
Clockwiseチェックを入れる時計回りに塗りつぶす

スクリプトでFill Amountの値を動的に変化させることによって、丸いゲージが減っていく表現をします。

時間切れの処理

15秒以内に解答しなかった場合の処理を書きます。

public class QuizController : MonoBehaviour
{
    public CanvasGroup optionGroup; // 選択肢のCanvas Group
    // ...省略...

    // カウントダウンタイマーのコルーチン
    public IEnumerator StartCountdown()
    {
        // ...省略...

        // 時間切れ処理
        ShowMessage("timeout"); // メッセージ表示
        ChangeMonkeyFace("shock"); // サルの顔を変更
        ShowAnswerMark(true, correctAnswerIndex); // マルバツ表示
        optionGroup.interactable = false; // 選択肢を押せないように
        finishButton.interactable = true; // 終了ボタンを押せるように
        nextButton.interactable = true; // 次へボタンを押せるように
    }

選択肢全体を内包するオブジェクトOptionsCanvas Groupコンポーネントを追加します。そしてoptionGroup変数に入れます。これで、optionGroup.interactable = false;とすることで、選択肢のボタンを押せなくなります。

正解・不正解の処理

選択肢をクリックしたときに、正解・不正解を表示する処理を書きます。

public class QuizController : MonoBehaviour
{
    // ...省略...
    public CanvasGroup optionGroup; // 選択肢のCanvas Group
    public List<Button> optionButtons; // 選択肢のボタンを格納するリスト
    public Button finishButton; // 終了ボタン
    public Button nextButton; // 次へボタン
    private int correctAnswerIndex; // 正答の選択肢インデックス
    public Coroutine countdownCoroutine; // カウントダウンコルーチンの参照を保持


    // クイズ画面生成関数
    public void CreateQuizPanel(int index)
    {
        // ...省略...
        // 各選択肢のボタンにOnClickイベントを設定
        for (int i = 0; i < optionButtons.Count; i++)
        {
            int buttonIndex = i; // 選択肢のインデックスをローカル変数に代入
            optionButtons[i].onClick.AddListener(() => CheckAnswer(buttonIndex));
        }
    }

    // 正誤判定関数
    public void CheckAnswer(int selectedIndex)
    {
        // 答え合わせ処理
        if (selectedIndex == correctAnswerIndex) // 正解だったら
        {
        ShowMessage("correct"); // 正解メッセージを表示
        ChangeMonkeyFace("smile"); // サルの表情を笑顔に
        }
        else // 不正解だったら
        {
        ShowMessage("incorrect"); // 不正解メッセージを表示
        ChangeMonkeyFace("shock"); // サルの表情をショックに
        }
        optionGroup.interactable = false; // 選択肢のボタンを押せないように
        ShowAnswerMark(true, correctAnswerIndex); // マルバツ表示
        finishButton.interactable = true; // 終了ボタンを押せるように
        nextButton.interactable = true; // 次へボタンを押せるように
        StopCoroutine(countdownCoroutine); // カウントダウンを停止
    }
}

以上でうまくいくと思ったのですが、ひとつ問題が。

選ばれた選択肢は黄色になるのですが、選択肢を押せなくするためにoptionGroup.interactable = falseすると、すべてのボタンがグレーになってしまうのです。これはボタンのDisabled Colorがグレーだからです。

ボタンを非活性化しても色は変えたくない。選んだ選択肢はそのまま黄色であってほしいわけです。

というわけで、次のようにしました。

public class QuizController : MonoBehaviour
{
    // ...省略...
    private float selectedR = 253 / 255f;
    private float selectedG = 211 / 255f;
    private float selectedB = 92 / 255f;
    private float normalR = 188 / 255f;
    private float normalG = 205 / 255f;
    private float normalB = 219 / 255f;


    // クイズ画面生成関数
    public void CreateQuizPanel(int index)
    {
        // ...省略...
        // 選択肢のDisabledColorをもとに戻す
        foreach (var button in optionButtons)
        {
            ColorBlock colors = button.colors;
            colors.disabledColor = new Color(normalR, normalG, normalB);
            button.colors = colors;
        }
        optionGroup.interactable = true; // 選択肢を押せるように
        // ...省略...
    }

    // 正誤判定関数
    public void CheckAnswer(int selectedIndex)
    {
        // ...省略...
        ColorBlock colors = optionButtons[selectedIndex].colors; // 選択中のボタンのColorBlockを変数に代入
        colors.disabledColor = new Color(selectedR, selectedG, selectedB); // Disabled Colorに色を設定
        optionButtons[selectedIndex].colors = colors; // 選択中のボタンに新しいColorBlockを適用
        optionGroup.interactable = false; // 選択肢のボタンを押せないように
        // ...省略...
    }
}

正誤判定関数で、選択肢のボタンを押せなくする前に選択中ボタンのDisabled Colorを黄色に変更しています。また、クイズ生成画面では選択肢のボタンを押せるようにする前にすべてのボタンのDisabled Colorを薄青に変更しています。

これでボタンを非活性化したときに、ボタンの色が変わらなくなりました。さらっと解説してますが、このコードを書くまでに3日ほど試行錯誤しております。

答え合わせのタイミング

上記のスクリプトでは、選択肢を選んだ瞬間に答え合わせが実行されてしまいます。焦らしたいですよね。「ファイナルアンサー?」みたいにしたいですよね。

というわけで、答え合わせのタイミングをずらす仕組みを導入します。

public class QuizController : MonoBehaviour
{
    // ...省略...
    private bool isAnswerSelected = false;

    // 正誤判定関数
    public void CheckAnswer(int selectedIndex)
    {
        isAnswerSelected = true;
        StartCoroutine(DelayAnswer(selectedIndex));
        StopCoroutine(countdownCoroutine); // カウントダウンを停止
    }

    // 答え合わせを遅らせるコルーチン
    IEnumerator DelayAnswer(int selectedIndex)
    {
        yield return new WaitForSeconds(1f);

        if (isAnswerSelected)
        {
            // 答え合わせ処理
            if (selectedIndex == correctAnswerIndex) // 正解だったら
            {
                ShowMessage("correct"); // 正解メッセージを表示
                ChangeMonkeyFace("smile"); // サルの表情を笑顔に
            }
            else // 不正解だったら
            {
                ShowMessage("incorrect"); // 不正解メッセージを表示
                ChangeMonkeyFace("shock"); // サルの表情をショックに
            }
            ColorBlock colors = optionButtons[selectedIndex].colors; // 選択中のボタンのColorBlockを変数に代入
            colors.disabledColor = new Color(selectedR, selectedG, selectedB); // Disabled Colorに色を設定
            optionButtons[selectedIndex].colors = colors; // 選択中のボタンに新しいColorBlockを適用
            optionGroup.interactable = false; // 選択肢のボタンを押せないように
            ShowAnswerMark(true, correctAnswerIndex); // マルバツ表示
            finishButton.interactable = true; // 終了ボタンを押せるように
            nextButton.interactable = true; // 次へボタンを押せるように
            StopCoroutine(countdownCoroutine); // カウントダウンを停止

            isAnswerSelected = false; // フラグをリセット
        }
    }
}

答え合わせを遅らせるコルーチンDelayAnswer()を作って、その中に答え合わせ処理を入れます。正誤判定関数CheckAnswer()にあった答え合わせ処理は削除して、代わりにDelayAnswer()を実行します。

さいごに

今回はクイズゲームの核となる正誤判定の仕組みを作りました。これができ上がったということは完成間近ですよ。楽しみー。

でわでわ

クイズゲームを作る(4)

この記事が気に入ったら
いいね または フォローしてね!

シェアしてね

コメント

コメントする

目次