UnityNovelProj Unity で VisualNovel を作成する

url: https://github.com/ganyariya/gnovel/pull/15
title: "18-3 を実装する: AutoReader by ganyariya · Pull Request #15 · ganyariya/gnovel"
description: "18-3: AutoReader を仮実装する待機シグナルにあわせて AutoReader も待機させるユーザ入力がされたら Auto 状態をキャンセルできるようにする"
host: github.com
favicon: https://github.githubassets.com/favicons/favicon.svg
image: https://opengraph.githubassets.com/46c0d6e225b4be4aec35438d0dc75aad1eea109359cdd48a0f58eacbd17c1133/ganyariya/gnovel/pull/15
url: https://www.youtube.com/watch?v=QIm0dH8fOxE&list=PLGSox0FgA5B58Ki4t4VqAPDycEpmkBd0i&index=61
title: "Make a Visual Novel in Unity 2023 - Episode 18 (part3) Auto Reader"
description: "Let's create the system to allow the system automatically progress through the visual novel by using auto read estimations.Other Links: - Discord Server   - ..."
host: www.youtube.com
favicon: https://www.youtube.com/s/desktop/1c6b3028/img/favicon_32x32.png
image: https://i.ytimg.com/vi/QIm0dH8fOxE/maxresdefault.jpg

オート・スキップモードの仕組み

オートモードの挙動は以下のようになっています。

  1. プレイヤーが AutoMode を設定する
  2. 「1文」を通常通り TypeWriter 1 文字ずつ描画する
    1. このとき、文章表示時間開始タイミング を計測しておく
  3. 「1文」の表示が終わったら ()、「快適な読み時間」となるようにそのまま 秒待機する
    1. len「1文」/ CHARACTER_COUNT_PER_SECOND をベースにして読み時間 を求める
      1. padding を足しつつ、 clamp で lower, upper bound も行う
    2. で TypeWriter で1文字ずつ描画するのにかかった時間が求まるため、 を実際の待機時間とすればよい
  4. 上記の待機時間が立ったら、プログラム側から次に進ませる処理を行えばいい

快適な読み時間 についてはもっとよい計算式や仕組みがありますが、重要なポイントは以下です。

  • AutoMode が設定されたら、推定読み時間をもとに強制的にプログラム側から「次に進ませる処理」を叩く
    • 次に進ませる処理 API (Method) をプログラム側に用意しておくとよい
      • AutoReader クラスから叩きやすくなる
    • 今回の実装だと 次に進ませる処理 というわかりやすいエンドポイントがなかったため、次自分で作るときは改善したい
  • SkipMode は AutoMode の応用
    • 待機時間がないバージョン
  • 読み時間の開始は文章の表示し始めだが、待機する処理自体は「すべての文字表示が終わってから」おこなう

改善点

  • Dialogue クラスに「次に進める」というメソッドを用意しておくことで、それを呼ぶだけでよいようにする
    • 今回の実装では 「クリックされた bool」を変更しており、Dialogue がその bool 値を定期的に見て処理を進める、という面倒な感じになっている
  • AutoReader と TextArchitect, ConversationManager がごちゃごちゃになっているので疎結合化したい

AutoMode の実装

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
 
namespace Core.DisplayDialogue
{
    public class AutoReader : MonoBehaviour
    {
        private const int DEFAULT_CHARACTERS_PER_SECOND = 18;
        private const float READ_TIME_PADDING = 0.3f;
        private const float LOWER_BOUND_READ_TIME = 0.5f;
        private const float UPPER_BOUND_READ_TIME = 10.0f;
        private const string AUTO_MODE_TEXT = "AutoMode";
        private const string SKIP_MODE_TEXT = "SkipMode";
 
        private ConversationManager conversationManager;
        private DisplayTextArchitect textArchitect => conversationManager.textArchitect;
 
        [SerializeField] private TextMeshProUGUI statusText;
 
        public bool skip { get; set; }
        public float speed { get; private set; }
 
        private Coroutine runningCoroutine;
        private bool isRuning => runningCoroutine != null;
 
        // DialogueSystemController から初期化する
        // TODO: 相互参照になっていてうーんという感じ
        public void Initialize(ConversationManager conversationManager)
        {
            this.conversationManager = conversationManager;
 
            statusText.text = string.Empty;
        }
 
        public void Enable(string modeText)
        {
            if (isRuning) return;
            StartCoroutine(AutoRead());
            statusText.text = modeText;
        }
 
        public void Disable()
        {
            if (!isRuning) return;
            StopCoroutine(runningCoroutine);
            runningCoroutine = null;
            statusText.text = string.Empty;
        }
 
        public void ToggleAuto()
        {
            // https://youtu.be/QIm0dH8fOxE?list=PLGSox0FgA5B58Ki4t4VqAPDycEpmkBd0i&t=1283
            // 上記と異なりシンプルな Toggle にする
            if (isRuning) Disable();
            else Enable(AUTO_MODE_TEXT);
        }
 
        public void ToggleSkip()
        {
            skip = !skip;
            if (skip) Enable(SKIP_MODE_TEXT);
            else Disable();
        }
 
        public IEnumerator AutoRead()
        {
            // 実行する会話スクリプト (List<string>) がないとき
            if (!conversationManager.IsRunning)
            {
                Disable();
                yield break;
            }
 
            // 1 文がすべて表示されているなら AutoMode 開始ボタンにあわせて 次の文章を始める 
            if (!textArchitect.IsDisplaying && textArchitect.CurrentText != string.Empty)
                DialogueSystemController.instance.OnUserPromptNextEvent();
 
            while (conversationManager.IsRunning)
            {
                if (!skip)
                {
                    // 文字が表示されはじめるまで待機する
                    while (!textArchitect.IsDisplaying) yield return null;
 
                    var startedTime = Time.time;
                    // 文字表示をおこなっている間は待機する
                    while (textArchitect.IsDisplaying) yield return null;
                    var elapsedTime = Time.time - startedTime;
 
                    var timeToRead = Mathf.Clamp(
                        (float)textArchitect.TmProText.textInfo.characterCount / DEFAULT_CHARACTERS_PER_SECOND,
                        LOWER_BOUND_READ_TIME,
                        UPPER_BOUND_READ_TIME
                    );
                    timeToRead = Mathf.Clamp(
                        timeToRead - elapsedTime, // 文字表示中にかかった秒数は削っておく
                        LOWER_BOUND_READ_TIME,
                        UPPER_BOUND_READ_TIME
                    );
                    timeToRead = timeToRead / speed + READ_TIME_PADDING;
 
                    yield return new WaitForSeconds(timeToRead);
                }
                else
                {
                    textArchitect.ForceComplete();
                    yield return new WaitForSeconds(0.02f);
                }
 
                // 1 文の表示がおわり skip/auto 時間待機も終わったので、次に進める
                DialogueSystemController.instance.OnUserPromptNextEvent();
            }
 
            Disable();
        }
    }
}

マウスクリックが二重入力される

Imgur Imgur

old inputManager ではなく、新しい inputSystem を利用した InputSystemUIInputModule に変更しないとだめらしい。

変更しないと左クリックが二回おこなわれる判定になる。

18-4 バグ修正

https://github.com/ganyariya/gnovel/pull/16

Destroy したオブジェクトにアクセスするとエラーになる

url: https://zenn.dev/allways/articles/9e1a2494dbb171
title: "Unityで安全にGameObjectをDestroyする"
host: zenn.dev
image: https://res.cloudinary.com/zenn/image/upload/s--xFrg0rOE--/c_fit%2Cg_north_west%2Cl_text:notosansjp-medium.otf_55:Unity%25E3%2581%25A7%25E5%25AE%2589%25E5%2585%25A8%25E3%2581%25ABGameObject%25E3%2582%2592Destroy%25E3%2581%2599%25E3%2582%258B%2Cw_1010%2Cx_90%2Cy_100/g_south_west%2Cl_text:notosansjp-medium.otf_37:icecofffeee%2Cx_203%2Cy_121/g_south_west%2Ch_90%2Cl_fetch:aHR0cHM6Ly9zdG9yYWdlLmdvb2dsZWFwaXMuY29tL3plbm4tdXNlci11cGxvYWQvYXZhdGFyL2U1NDVlNDNjYzkuanBlZw==%2Cr_max%2Cw_90%2Cx_87%2Cy_95/v1627283836/default/og-base-w1200-v2.png

Destroy(gameObject) をおこなったとき、該当の gameObject は Unity 内部的に破棄された状態になります。 ただし、 gameObject = null という null 代入がおこなわれるわけではありません。

一方で if (gameObject == null) は true という判定がなされます。 これは == 演算子のオーバーロードがおこなわれており、内部的に gameObject の状態がチェックされるためです。

これは UnrealEngine の UObject の挙動に似ていますね。

null check をする、もしくは Destroy した gameObject には触らない、ということを意識しないといけません。

あるコルーチン X が複数の親コルーチンから待機させられるとエラーになる

another coroutine is already waiting for this coroutine

sharedTask というすでに発行済のコルーチン (=X) があるとします。 このコルーチンが複数の親コルーチンに管理されようとするとエラーが発生します。

というのも、あるコルーチン X は 1 つの親コルーチンのみに yield される という決まりがあるためです。

void Start() { 
	sharedTask = MyCoroutine();
	StartCoroutine(ParentA()); 
	StartCoroutine(ParentA()); 
}
 
IEnumerator ParentA() {
    // ParentA のインスタンス ① が、sharedTask の実行を開始し、完了を待機
    yield return StartCoroutine(sharedTask); // ← ここで sharedTask に「待機中」マークが付く
 
    // ParentA のインスタンス ② が、同じ sharedTask を待機しようとする
    // yield return StartCoroutine(sharedTask); // ← エラー発生!
    // 「another coroutine is already waiting for this coroutine」
}