ゴマちゃんフロンティア

アザラシが大好きなエンジニアの開発日記です

【開発日記】チュートリアル機能の実装

time 2018/09/08

というわけで、今回は自作ゲームにおけるチュートリアル機能について考えてみます。

チュートリアル機能とは言いましたが、実際には操作ヘルプといった方がしっくりくるような内容かもしれないです。
一応、単に説明を垂れ流すだけではなく、一定の条件を判断して次の説明に切り替わるので、チュートリアルと半々と言ったところでしょうか。
そんな半々なチュートリアルの作り方を考えて見ました。

チュートリアル表示用UIの作成

まずはチュートリアルのタイトルや説明文を表示するUIを追加します。
凝ったものを作る時間もセンスもありませんが、所詮チュートリアルなので簡単な背景とテキストだけで十分でしょう。

ヒエラルキー的には空のUIオブジェクトを作成し、その子としてTextやImageコンポーネントを持ったUIオブジェクトを作成しました。

表示するタイトルやテキストは後述するチュートリアル管理用クラスから設定されるので、今の段階では適当に入れておきます。
また画面外からスライドするように表示したいので、画面右上から少し右にはみ出した部分に配置します。移動も後述する管理用クラスからiTweenで制御します。

チュートリアル用インタフェースの作成

今後バージョンアップでアクションやルールが増えるかもしれないので、拡張性を考慮した形で設計します。
ということで、手始めにチュートリアルクラス用のインタフェースを定義しておきます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public interface ITutorialTask
{
    /// <summary>
    /// チュートリアルのタイトルを取得する
    /// </summary>
    /// <returns></returns>
    string GetTitle();

    /// <summary>
    /// 説明文を取得する
    /// </summary>
    /// <returns></returns>
    string GetText();

    /// <summary>
    /// チュートリアルタスクが設定された際に実行される
    /// </summary>
    void OnTaskSetting();

    /// <summary>
    /// チュートリアルが達成されたか判定する
    /// </summary>
    /// <returns></returns>
    bool CheckTask();

    /// <summary>
    /// 達成後に次のタスクへ遷移するまでの時間(秒)
    /// </summary>
    /// <returns></returns>
    float GetTransitionTime();
}

ポイントはCheckTask()で、この関数で次のチュートリアルに移行するかどうかを判定します。
例えば移動のチュートリアルであれば「移動キーが入力がされたか」を判定し、攻撃であれば「対応する攻撃ボタンが押されたかどうか」という具合です。
もちろんさらっと解説だけのチュートリアルもあるので、その場合は単にtrueを返すだけでOKです。

各チュートリアルクラス作成

次に上述のインタフェースを実装したチュートリアルクラスを作成します。
すべて紹介すると量が多くなるので、ここでは基本移動と遠距離攻撃の2クラスを紹介します。

using UnityEngine;

public class MovementTask : ITutorialTask
{
    public string GetTitle()
    {
        return "基本操作 移動";
    }

    public string GetText()
    {
        return "WSADキーで上下左右に移動できます。";
    }

    public void OnTaskSetting()
    {
    }

    public bool CheckTask()
    {
        float axis_h = Input.GetAxis("Horizontal");
        float axis_v = Input.GetAxis("Vertical");

        if (0 < axis_v || 0 < axis_h) {
            return true;
        }

        return false;
    }

    public float GetTransitionTime()
    {
        return 2f;
    }
}
using System;
using UnityEngine;

public class AttackTask1 : ITutorialTask
{
    public string GetTitle()
    {
        return "基本操作 攻撃 (1/2)";
    }

    public string GetText()
    {
        return "左クリックで杖から星弾を発射して攻撃します。" + Environment.NewLine + "押し続けると連続発射します。";
    }

    public void OnTaskSetting()
    {
    }

    public bool CheckTask()
    {
        if (Input.GetButton("Attack")) {
            return true;
        }

        return false;
    }

    public float GetTransitionTime()
    {
        return 2f;
    }
}

上記例ではCheckTask()Inputの静的メソッドしか使用していませんが、他のインスタンス等を使用したい場合はコンストラクタで受け取るようにしています。
また、チュートリアルのタスク設定時からの変化で判定したい場合に備えてOnTaskSetting()を用意しておきます。
私のゲームで言うと「協力アクションが必要なゲージが設定時から一定以上溜まったか」を判定するため、コンストラクタでタスク設定時の値とその参照を受け取り、CheckTask()内で初期値から一定以上加算されているかを評価しました。
GetTransitionTime()CheckTask()での達成後から画面外へスライドするまでの猶予時間を返します。達成した瞬間に消えてしまうのはよろしくないため、チュートリアルごとに適度な値を返すようにします。

チュートリアル管理用クラスの作成

最後に作ったチュートリアルを管理するオブジェクトとそれに設定するクラスを作成します。
シーン上に配置するオブジェクトは適当な空のGameObjectでOKです。

トゥイーンライブラリとして「iTween」と「DOTween」を使用しています。本当はどちらか片方のみ使用するようにしたいのですが、どちらも一長一短なので悩ましいところです。

using DG.Tweening;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// ゲーム上のチュートリアルを管理するマネージャクラス
/// </summary>
public class TutorialManager : MonoBehaviour
{
    // チュートリアル用UI
    protected RectTransform tutorialTextArea;
    protected Text TutorialTitle;
    protected Text TutorialText;

    // チュートリアルタスク
    protected ITutorialTask currentTask;
    protected List<ITutorialTask> tutorialTask;

    // チュートリアル表示フラグ
    private bool isEnabled;

    // チュートリアルタスクの条件を満たした際の遷移用フラグ
    private bool task_executed = false;

    // チュートリアル表示時のUI移動距離
    private float fade_pos_x = 350;

    void Start()
    {
        // チュートリアル表示用UIのインスタンス取得
        tutorialTextArea = GameObject.Find("TutorialTextArea").GetComponent<RectTransform>();
        TutorialTitle = tutorialTextArea.Find("Title").GetComponent<Text>();
        TutorialText = tutorialTextArea.Find("Text").GetComponentInChildren<Text>();

        // チュートリアルの一覧
        tutorialTask = new List<ITutorialTask>()
        {
            new MovementTask(),
            new AttackTask1(),
            new AttackTask2(),
        };

        // 最初のチュートリアルを設定
        StartCoroutine(SetCurrentTask(tutorialTask.First()));

        isEnabled = true;
    }

    void Update()
    {
        // チュートリアルが存在し実行されていない場合に処理
        if (currentTask != null && !task_executed) {
            // 現在のチュートリアルが実行されたか判定
            if (currentTask.CheckTask()) {
                task_executed = true;

                DOVirtual.DelayedCall(currentTask.GetTransitionTime(), () => {
                    iTween.MoveTo(tutorialTextArea.gameObject, iTween.Hash(
                        "position", tutorialTextArea.transform.position + new Vector3(fade_pos_x, 0, 0),
                        "time", 1f
                    ));

                    tutorialTask.RemoveAt(0);

                    var nextTask = tutorialTask.FirstOrDefault();
                    if (nextTask != null) {
                        StartCoroutine(SetCurrentTask(nextTask, 1f));
                    }
                });
            }
        }

        if (Input.GetButtonDown("Help")) {
            SwitchEnabled();
        }
    }

    /// <summary>
    /// 新しいチュートリアルタスクを設定する
    /// </summary>
    /// <param name="task"></param>
    /// <param name="time"></param>
    /// <returns></returns>
    protected IEnumerator SetCurrentTask(ITutorialTask task, float time = 0)
    {
        // timeが指定されている場合は待機
        yield return new WaitForSeconds(time);

        currentTask = task;
        task_executed = false;

        // UIにタイトルと説明文を設定
        TutorialTitle.text = task.GetTitle();
        TutorialText.text = task.GetText();

        // チュートリアルタスク設定時用の関数を実行
        task.OnTaskSetting();

        iTween.MoveTo(tutorialTextArea.gameObject, iTween.Hash(
            "position", tutorialTextArea.transform.position - new Vector3(fade_pos_x, 0, 0),
            "time", 1f
        ));
    }

    /// <summary>
    /// チュートリアルの有効・無効の切り替え
    /// </summary>
    protected void SwitchEnabled()
    {
        isEnabled = !isEnabled;

        // UIの表示切り替え
        float alpha = isEnabled ? 1f : 0;
        tutorialTextArea.GetComponent<CanvasGroup>().alpha = alpha;
    }
}

Start()内でチュートリアルを管理するリストを作り、初期値として作ったチュートリアルクラスを設定します。作成したUIオブジェクトの参照もここで取得しておきます。
クラスのフィールドに現在どのチュートリアルが表示されているかを保持し、Update()内でCheckTask()を呼び出してチュートリアルが達成されているか評価していきます。

達成後はSetCurrentTask()を呼び出し、「次のチュートリアルタスクの設定」や「チュートリアル用UIのスライド移動」を行います。その際にインタフェースで定義しておいたOnTaskSetting()を呼び出し、必要に応じてチュートリアルクラス側で処理が行えるようにしておきます。
また達成後から次のチュートリアル表示までにCheckTask()が暴発するのを避けるため、フィールドにtask_executedというフラグを設けて制御しています。

プレイ中に表示が鬱陶しくなった場合も考慮して、Hキーを押すことでON/OFFを切り替えられるようにしました。
その際に詰まってしまったのはチュートリアルUIを非表示にする方法で、GameObject.SetActive()での有効・無効ではiTweenでの移動が中途半端な位置で終わってしまう問題が発生しました。
「親UIオブジェクトにCanvasGroupコンポーネントを設定し、alphaを弄る」ことで非表示が可能になりました。

動作テスト

自作ゲームに組み込んで動かすと以下のような感じです。

特に問題なさそうなので、次のバージョンアップでチュートリアル機能として入れ込もうと思います。

コメント

  • すいません。
    初心者なんですが、
    MovementTaskなどのスクリプトはどうやって入力判定を得ているのでしょうか。
    オブジェクトにアタッチして入力判定を得ようとしたところ、基底クラスを継承していないので出来ませんでした。
    多重継承が良くないと聞きました。他の方法があるのでしょうか。

    ひろと  2021年6月4日 5:29 PM

    • MovementTaskの例では`Input.GetAxis()`で判定しています。
      例のTask系クラスはMonoBehaviourを継承していないので、もし他のコンポーネントで判定したい場合はインスタンス生成時に必要な参照を渡してあげると良いかと思います。

      riberunn  2021年6月7日 12:22 PM

down

コメントする