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
オート・スキップモードの仕組み
オートモードの挙動は以下のようになっています。
- プレイヤーが AutoMode を設定する
- 「1文」を通常通り TypeWriter 1 文字ずつ描画する
- このとき、文章表示時間開始タイミング を計測しておく
- 「1文」の表示が終わったら ()、「快適な読み時間」となるようにそのまま 秒待機する
len「1文」/ CHARACTER_COUNT_PER_SECOND
をベースにして読み時間 を求める- padding を足しつつ、 clamp で lower, upper bound も行う
- で TypeWriter で1文字ずつ描画するのにかかった時間が求まるため、 を実際の待機時間とすればよい
- 上記の待機時間が立ったら、プログラム側から次に進ませる処理を行えばいい
快適な読み時間
についてはもっとよい計算式や仕組みがありますが、重要なポイントは以下です。
- 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();
}
}
}
マウスクリックが二重入力される
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」
}