ゲーム化!tomo_manaのブログ

ゲーム化!tomo-manaのブログ

Unityでゲームを作る方法について紹介しています

Unity学習#24 (Unity 2019.4.1f1) 画面に文字を1文字ずつ表示する#1

今回は、RPGやノベライズゲームでおなじみの、画面に文字を1文字ずつ表示する処理を実装します。この機能は、コードを公開して下さっている方がいて、とても助かりました。今回は、Submitを押すごとに表示が切り替わるように実装し、次回は前の文章を残したまま次の文章を続けていくように修正します。

概要

基本的な処理は以下のサイトを参考にしています。
ボタン押下からの経過時間で表示される文字数を算出することで、画面更新の遅れを吸収できるようにしています(表示速度が可変)。とても勉強になります。

tsubakit1.hateblo.jp


文字を出力する部分はそのまま使わせていただいたので、主に差分について以下にまとめます。
上記のコードに以下の変更を加えます。

テキスト予約:連続で文字出力を指示した時に、キューイングする。
ウィンドウ管理へのキーキャンセル通知:文字を表示している間、SubmitCancelを押されたら文字を一気に表示する機能を実装しますが、その時にウィンドウ管理でウィンドウを増やしたり閉じたりするためにSubmitCancelを使ってしまうので、予約したテキストを全部表示し切るまではウィンドウを増やしたり閉じたりしないように抑止します。これはウィンドウ側でキーを使ったからウィンドウ管理ではキーを無視してね、と通知することで実現します。

修正順序

(1) 上記サイトのコードをコピーし、重複している定義名を自分用のコードに合わせます(文字列を格納する変数など)。
(2) テキスト予約の追加:表示する文字の配列をキューに置き換えます。
(3) キーイベントの合わせ込み:マウスクリックをキー入力(OnSubmit)に置き換えます。
(4) キーキャンセルの通知(ウィンドウ側):表示する文字が残っている間は OnSubmit()false(キーを消化した)を返すようにします。この処理のために、bool 型の戻り値を持つ独自の OnSubmit() を定義します。
(5) キーキャンセルの受付(ウィンドウ管理側):ウィンドウにキーを渡した後、OnSubmit() の戻り値を見て自身の処理を実行するかどうか判断するようにします。

修正コード

ウィンドウ画面

TextWindow.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;            // TextMeshProUGUI
using UnityEngine.EventSystems;

public class TextWindow : Window, IWindowSubmitHandler, IWindowCancelHandler
{
    private TextMeshProUGUI textArea;
    // 画面更新を指示
    private bool updateRequest;
    // 今処理している文字列
    private string pooledString = null;
    // テキスト予約
    private Queue<string> pooledStrings = new Queue<string>();
    
    public void Write(string str)
    {
        pooledStrings.Enqueue(str);
        updateRequest = true;
    }
    
    public override void Awake()
    {
        base.Awake();
        textArea = gameObject.transform.GetChild(0).gameObject.GetComponent<TextMeshProUGUI>();
        updateRequest = true;
    }
    
    public void OnDisable()
    {
        updateRequest = false;
        pooledString = null;
        textArea.text = null;
    }
    
    // Start is called before the first frame update
    void Start()
    {
    }
    
    // 以下、サイトのコードをほぼほぼそのまま使用しています(少し変更しました)
    [SerializeField][Range(0.001f, 0.3f)]
    float intervalForCharacterDisplay = 0.05f;  // 1文字の表示にかかる時間
    
    private float timeUntilDisplay = 0;         // 表示にかかる時間
    private float timeElapsed = 1;              // 文字列の表示を開始した時間
    private int lastUpdateCharacter = -1;       // 表示中の文字数
    
    // 次の文字列を出力する
    void SetNextLine()
    {
        // ここはテキスト予約処理に置き換え
        pooledString = pooledStrings.Dequeue();
        
        // 想定表示時間と現在の時刻をキャッシュ
        timeUntilDisplay = pooledString.Length * intervalForCharacterDisplay;
        timeElapsed = Time.time;
        
        // 文字カウントを初期化
        lastUpdateCharacter = -1;
    }
    
    // 文字を1文字ずつ表示する
    void UpdateTextFadeIn()
    {
        int displayCharacterCount = (int)(Mathf.Clamp01((Time.time - timeElapsed) / timeUntilDisplay) * pooledString.Length);
        
        // 表示文字数が前回の表示文字数と異なるならテキストを更新する
        if( displayCharacterCount != lastUpdateCharacter ){
            textArea.text = pooledString.Substring(0, displayCharacterCount);
            lastUpdateCharacter = displayCharacterCount;
        }
        Canvas.ForceUpdateCanvases();
    }
    
    // 文字の表示が完了しているかどうか
    public bool IsCompleteDisplayText 
    {
        get { return  Time.time > (timeElapsed + timeUntilDisplay); }
    }
    
    public bool OnSubmit(BaseEventData eventData)
    {
        bool r = true;
        
        if( IsCompleteDisplayText ){
            // 文字の表示が完了してるならクリック時に次の行を表示する
            if( 0 < pooledStrings.Count ){
                updateRequest = true;
                r = false;  // キー取得権維持
            }
        } else {
            // 完了してないなら文字をすべて表示する
            timeUntilDisplay = 0;
            r = false;  // キー取得権維持
        }
        return r;
    }
    
    public bool OnCancel(BaseEventData eventData)
    {
        bool r = true;
        
        if( !IsCompleteDisplayText ){
            // 完了してないなら文字をすべて表示する
            timeUntilDisplay = 0;
            r = false;  // キー取得権維持
        }
        return r;
    }
    
    // Update is called once per frame
    void Update()
    {
        if( updateRequest )
        {
            SetNextLine();
            updateRequest = false;
        }
        UpdateTextFadeIn();
    }
}

IWindowSubmitHandler.cs

using UnityEngine.EventSystems;

public interface IWindowSubmitHandler
{
    // @return キーを消化したか
    //   true : キーは消化しない
    //  false : キーは消化した
    bool OnSubmit(BaseEventData eventData);
}

Cancel側も同じ要領で作っています(省略)

ウィンドウ管理側

ウィンドウ管理側の処理は、第13回から実装してきたものです。ここでの説明は省きますが、MonoBehaviour を継承した Window をスタックとして管理しています。

tomo-mana.hatenablog.com


InputUIManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.EventSystems;

public class InputUIManager : MonoBehaviour
{
    // ターゲットとなる操作キャラ
    [SerializeField] PlayerControl2 player;
    
    // 一番最初のメニュー
    [SerializeField] Window root;
    
    // 一番最後のメニュー(ターミネータ)
    [SerializeField] Window terminate;
    
    // メニューのスタック
    private Stack<Window> windows;
    
    // キー入力
    private Vector2 input;
    
    // メニューコンテキスト
    private MenuContext context;
    
    // 現在のウィンドウの処理が完了したかどうか
    private bool completed;
    
    // Start is called before the first frame update
    void Start()
    {
        // 操作キャラの初期化
        if(player == null){
            player = GetComponent<PlayerControl2>();
        }
        
        // スタックの初期化
        windows = new Stack<Window>();
        windows.Clear();
        
        // メニューコンテキストの初期化
        context = new MenuContext();
        context.itemContext = new ItemContext();
        
        // 略
    }

    void AddWindow(Window w)
    {
        windows.Push( w );
        w.SetActive( true );
        w.SetContext( context );
    }
    
    void RemoveWindow(Window w)
    {
        w.SetActive( false );
        windows.Pop();
    }
    
    void ClearAllWindows()
    {
        Window w;
        while( windows.Contains(root) ){
            w = windows.Pop();
            w.SetActive(false);
        }
    }
    
    void OnNavigate(InputValue value)
    {
        input = value.Get<Vector2>();
        
        if( windows.Contains(root) ){
            // メニュー状態
            Window w = windows.Peek();
            w.Navigate(input);
        } else {
            // キャラ操作状態
            if(player != null){
                player.Move(input);
            }
        }
    }
    
    void OnSubmit()
    {
        if( windows.Contains(root) ){
            // メニュー状態
            Debug.Log("Window contains root");
            
            completed = true;
            
            Window w = windows.Peek();
            if( w is IWindowSubmitHandler h ){
                completed = h.OnSubmit(null);
            }
            
            if( completed ){
                if( w == terminate ){
                    ClearAllWindows();
                } else {
                    AddWindow( w.Next() );
                }
            } else {
                Debug.Log("Mordal Window is processing...");
            }
        } else {
            // フィールドでのキャラ操作
            Debug.Log("Window does not contains root");
        }
    }
    
    void OnCancel()
    {
        if( windows.Contains(root) ){
            // メニュー状態
            Window w = windows.Peek();
            if( w is IWindowCancelHandler h ){
                completed = h.OnCancel(null);
            }
            
            if( completed ){
                if( w == terminate ){
                    ClearAllWindows();
                } else {
                    RemoveWindow(w);
                }
            }
        } else {
            // キャラ操作→メニュー画面へ切り替え
            AddWindow( root );
        }
    }
    
    // Update is called once per frame
    void Update()
    {
    }
}

文字を1文字ずつ表示する

冒頭のサイトのコードをそのまま使用しています。
以下に、表示する文字を登録する(キュー型)にする修正について簡単にまとめます。

表示する文字を登録する(テキスト予約)

テキスト予約は単純で、stringの配列をQueueに置き換えることで実現できます。

//public string[] scenarios;
//private int currentLine = 0;
public Queue<string> scenarios = new Queue<string>();

void SetNextLine()
{
//  currentText = scenarios[currentLine];
//  currentLine ++;
    currentText = scenarios.Dequeue();
    
    // 想定表示時間と現在の時刻をキャッシュ
    timeUntilDisplay = pooledString.Length * intervalForCharacterDisplay;
    timeElapsed = Time.time;
    
    // 文字カウントを初期化
    lastUpdateCharacter = -1;
}

キーを押されたら表示中の文字を全て表示する

Submit キーを押した時の処理

上記サイトでは Update() 処理内でマウスクリックのイベントを確認しています。これを OnSubmit() にまとめます。(OnSubmitを使用するために、UnityEngine.EventSystemsISubmitHandler、または独自に定義したSubmitハンドラの実装が必要です)

private bool updateRequest;

public void OnSubmit(BaseEventData eventData)
{
    if( IsCompleteDisplayText ){
//      if(currentLine < scenarios.Length && Input.GetMouseButtonDown(0)){
        if( 0 < scenarios.Count ){  // scenarios : Queue<string> の場合
            updateRequest = true;
        }
    }else{
    // 完了してないなら文字をすべて表示する
//      if(Input.GetMouseButtonDown(0)){
            timeUntilDisplay = 0;
//      }
    }
}

void Update () 
{
    if( updateRequest ){
//      // 文字の表示が完了してるならクリック時に次の行を表示する
//      if( IsCompleteDisplayText ){
//          if(currentLine < scenarios.Length && Input.GetMouseButtonDown(0)){
                SetNextLine();
//          }
//      }else{
//      // 完了してないなら文字をすべて表示する
//          if(Input.GetMouseButtonDown(0)){
//              timeUntilDisplay = 0;
//          }
//      }
    }
}

ウィンドウ管理へのキーキャンセル通知

キーイベントでウィンドウの表示/非表示を切り替える機能を実装していると、上記の1文字ずつ表示している時にキーが押されたら全部表示を実装するために、ウィンドウを管理する側にもキーを無視する処理を追加する必要があります。よく使われる方法としては、先にウィンドウ側の処理を実施した後に、ウィンドウを管理する側でもキーを使えるのかをウィンドウ側から通知する方法です。今回は、ウィンドウを管理する側でキーを使ってよいならtrue、使われてはいけない場合はfalseを通知するようにします。

ウィンドウ側

public bool OnSubmit(BaseEventData eventData)
{
    bool r = true;    // 完了通知
    if( IsCompleteDisplayText ){
        if( 0 < scenarios.Count ){
            updateRequest = true;
            r = false;    // 次の行あり→次の行表示
        }
    }else{
        timeUntilDisplay = 0;
        r = false;    // 1文字ずつ表示→全部表示
    }
}

ウィンドウ管理側

以下は、私のサイト側で作っているウィンドウ管理機能です。キーを使ってよい場合だけ、次のウィンドウの表示などを行うように修正します。

InputUIManager.cs

// 一番最初のメニュー
[SerializeField] Window root;
// 一番最後のメニュー(ターミネータ)
[SerializeField] Window terminate;
// メニューのスタック
private Stack<Window> windows;
// 前の処理が完了したかどうか(Submitでキーを消化したかどうか)
private bool completed;

 void OnSubmit()
{
    Debug.Log("InputUIManager: OnSubmit");
    if( windows.Contains(root) ){
        // メニュー状態
        completed = true;
        
        Window w = windows.Peek();
        if( w is IWindowSubmitHandler h ){
            completed = h.OnSubmit(null);
        }
        
        if( completed ){
            if( w == terminate ){
                ClearAllWindows();
            } else {
                AddWindow( w.Next() );
            }
        } else {
            Debug.Log("Mordal Window is processing...");
        }
    } else {
        // フィールドでのキャラ操作
    }
}

次回やること

冒頭にも書いた通り、文章を続けて表示したい場合に、前の表示を残しながら次の表示を1文字ずつ表示し、だんだん下の行にスクロールしていく処理を実装します。