【Unity】ScenarioFlowによるシナリオ実装#3-1(DOTweenとTextMeshProでのセリフ表示)
はじめに
こんにちは.伊都アキラです.
シナリオ実装ライブラリであるScenarioFlowを利用してUnityでシナリオ進行を実現することを目的として,#1ではScenarioFlow自体の利用方法について,#2ではシナリオ進行において非同期処理を利用するためのTaskFlowの利用方法について解説をしてきました.
#3からはいよいよ,これまでに解説したScenarioFlowの機能を踏まえ,サンプルゲーム「おとぎ話のアリス」に見られるようなシナリオ進行を実装していきます.
サンプルゲームと完全に同じものを製作するわけではありませんが,セリフの表示や背景の変更に始まり,自動進行やスキップ進行,セーブ機能など,一般的なシナリオ進行に必要となるであろう機能は一通り解説をしていきます.
これからの解説を通して,ScenarioFlowで実際にシナリオを実装する際の指針を理解していただくことが目標となります.また,解説中に実装した機能はライブラリとして公開し,少なくともサンプルゲームレベルのシナリオ進行はいつでも楽に実現ができるようにしたいと考えています.
さて,まず初めに実装する機能は,セリフの表示です.キャラクター同士の会話あるいは独り言,ナレーションなどを表示するための機能を作成していきます.
プロジェクトのセットアップ
初めに,Unityの新たなプロジェクトを作成しましょう.
プロジェクト名はAliceInSampleGame,テンプレートは2Dとしています.
また,Unityのバージョンは2021.3.24f1を使用しています.
ライブラリのインポート
サンプルゲームの作成で利用する各ライブラリをプロジェクトにインポートします.
以下のライブラリを,.unitypackageファイルやアセットストアなどからプロジェクトにインポートしてください.添えているバージョンは,解説で使用しているバージョンです.
DOTweenについて
上で挙げた中で,この解説で初めて利用するのがDOTweenです.これは,プログラムコードによってオブジェクトをアニメーションさせる機能を提供するライブラリです.
例えば,ゲーム画面上のキャラクターを動かしたり,透明度を変更してフェードアウトあるいはフェードインをさせたりする場合,基本的にはAnimation Clipを作成したり,スクリプトで適切に変数を書き換え足りすると思います.
しかし,Animation Clipは表現力が高い一方で,一つのアニメーションを作る手順や複数のアニメーション間の切り替えを行う手順が煩雑に思えます,そして,スクリプト単体による実装ではアニメーションの取り回しは良くなるものの,表現力が高いアニメーションを作ろうとするとどうしても作業量が増えがちです.
そこでDoTweenを利用すると,豊富な表現のアニメーションをスクリプト単体で,簡単に作成することができます.また,UniTaskがDOTweenをサポートしているので,あるアニメーションの終了をawaitで待つといったこともでき,UniTaskと組み合わせることでさらに利便性が高くなります.特にScenarioFlowはUniTaskによる非同期処理を前提としているので,DOTweenはScenarioFlowとの相性も最高です.
サンプルゲームを作製していくにあたり,アニメーションの実装はすべてDOTweenで行います.DOTweenをよく知らないという方も,適宜解説を加えるので問題ありません.使用していく中で,その便利さを実感できると思います.
DOTweenのセットアップ
上部のメニューバーの,Tools/Demigiant/DOTween Utility Panelから,DOTween Utility Panelを開き,Setup DOTween...(add/remove Modules) をクリックします.
しばらくすると,次のようなパネルが現れます.
DOTween Proを持っている方は,DoTween Pro / DOTween TimelineのTextMesh Proにチェックを入れ,Applyをクリックしてください.
DOTween Pro - TextMesh Proにチェック - Apply
次に,DOTween Utility PanelのCreate ASMDEF... をクリックします.
クリック後,パネルが以下のように変わるはずです.
Create ASMDEFがRemove ASMDEFに変わっている
UniTaskのDOTween対応
DOTweenで作ったアニメーションをawaitしたり,UniTaskに変換したりするためには,UniTaskのDOTweenに対するサポートを有効化する必要があります.
DOTweenをアセットストア経由でインポートした場合,次の手順を踏みます.
- Edit/Project SettingsのPlayerを開く
- Other SettingsのScript Compilation - Scripting Define SymbolsにUNITASK_DOTWEEN_SUPPORTを追加し,Applyをクリック
次のコードでコンパイルエラーが発生していなければ,UniTaskのDoTween対応は成功しています.
もし上記の手順を踏んでもエラーが発生する場合は,Visual Studioを再起動したり,Unityを再起動したりしてみてください.
using Cysharp.Threading.Tasks;
using DG.Tweening;
using UnityEngine;
public class GameManager : MonoBehaviour
{
private async UniTaskVoid Start()
{
SpriteRenderer spriteRenderer = null;
await spriteRenderer
.DOFade(0.0f, 0.0f)
.ToUniTask(cancellationToken: this.GetCancellationTokenOnDestroy());
}
}
Scene名の変更と画面サイズの設定
プロジェクト作成時点では,SampleSceneが開かれていると思います.
Scene名をGameSceneに変更してください.
そして,スクリーンのサイズをFree AspectからFull HDに変更してください.
セリフを表示するUIの準備
さて,プロジェクトのセットアップが完了したら,次はシーン上にセリフを表示するためのオブジェクトを配置していきます.
セリフ,すなわちテキストの表示に使用するのは,TextMeshProです.
オブジェクトの配置と設定
まずは,HierarchyにCanvasを作成します.
名前はCanvasUIとし,Canvasコンポーネント及びCanvas Scalerの設定は以下の通りとします.
CanvasUIの設定
CanvasのRender Cameraには,Main Cameraをアタッチしています.
また,Order in Layerは10であることに注意してください.
次に,TextMeshProを配置します.
キャラクターの名前を表示するためのTextActorNameと,キャラクターのセリフを表示するためのTextLine,この2つのTextMeshProオブジェクトを作成してください.
2つのTextMeshProのポジション,フォントサイズなどは適当に調整しておきます.
上の画面では,
- TextActorName
- Font Size: 60
- Alignment: Center-Middle
- TextLine
- Font Size: 56
- Alignment: Center-Top
にしています.
TextMeshProの日本語対応
TextMeshProを配置しましたが,このままでは日本語が文字化けします.
日本語が上手く表示されない
これは,現在TextMeshProのFont Assetとして初期設定されているLiberationSans SDFが日本語に対応していないからです.
日本語対応のフォントデータから,日本語に対応したFont Assetを生成します.
上部メニューバーのWindow/TextMeshPro/Font Asset Createrから,Font Asset Createrを開きます.
そして,Character SetをCustom Charactersに変更し,その下のCustom Character Listに,使いたい文字をすべて入力します.
ただし,当然ですがすべての文字を手入力するのは不可能です.
これに関しては,アルファベットや漢字などの文字をまとめてくださっている方がいますので,以下のテキストファイルの文字をすべてコピー&ペーストします(元リンク).
次に,Font Assetを生成する元となるフォントデータを用意します.
日本語に対応していれば何でも良いですが,サンプルゲームでは以下の2つのフォントを使用します.
試しに,あずきフォントBからFontAssetを生成してみます.
ダウンロードしたazukiB.ttfをUnityにインポートし,Font Aset CreaterのSource Font Fileにドラッグ&ドロップします.その他の設定も,以下を参照してください.
そして,Generate Font Atlasを押しましょう.
Font Assetの生成が完了したら,Save as... をクリックして保存します.
生成完了
SDFファイル(Font Asset)と.ttfファイル
このazukiB SDFを,先ほど作成した2つのTextMeshProのFont Assetとして設定します.
すると,日本語の文字化けが解消されたはずです.
azukiB SDFを設定
日本語が正しく表示されている
同じ手順を源柔ゴシックの方でも行います.
こちらはたくさんのフォントデータが含まれていますが,GenJyuuGothicX-P-Bold.ttfを使用します.
あずきフォントと源柔ゴシック,2つのFont Assetを生成出来たら,ひとまずTextActorNameとTextLineにはあずきフォントの方を設定しておきます.
ところで,文字の色は白にしていますが,このままだと背景が明るい色になった場合に見にくくなってしまいます.そのため,アウトラインを設定します.Azuki B SDF MaterialのOutlineについて,Colorを黒,Ticknessを0.2に設定しましょう.
アウトラインの設定
アウトラインができている
これで,背景の色がどうであっても文字が見えやすくなります.
セリフを表示するプログラムの作成
ここから,先ほど配置したTextMeshProに,キャラクターのセリフを表示するためのプログラムを実装していきます.
要求
セリフを表示させるにあたって,どのような機能が求められるかという話題です.
今回は,以下のような機能のセリフ表示プログラムを作成していきます.
- セリフ表示
- 1文字ずつ順番に文字を表示する
- 文字を表示する際,徐々に透明度を下げていく
- セリフの表示前に,前のセリフを消去する
- 話者の名前が同一のとき,名前は消さない
- リッチテキストを使用できる
- セリフ消去
- 徐々にセリフの透明度を上げる
- 名前とセリフの両方を消去する
上の,2つのメソッド「セリフ表示」と「セリフ消去」を作成します.
ポイントは3つです.
1つ目は,文字の透明度を徐々に下げる処理を,1文字ずつ順番に行うことです.
1文字ずつセリフを表示するだけであれば簡単ですが,瞬時に1文字を表示するよりも透明度を少しずつ変えることで表示した方が滑らかに見えるので,そちらの方が良いでしょう.
2つ目は,セリフ表示の際は前のセリフを消しますが,話者の名前が同じであるときにはセリフのみを消し,名前の表示は消さないということです.同じキャラクターが話しているのに名前のテキストが出たり消えたりしているのは不自然ですから,妥当な分岐であると言えるでしょう.
また,「セリフ表示」メソッドの中で使われるセリフの消去と,「セリフ消去」メソッドは別物です.前者は前述のような分岐を行いますが,後者は問答無用で名前とセリフの両方を消します.
3つ目は,リッチテキストを使用できるということです.リッチテキストとは,TextMeshProで使用できるタグのことで,例えば<b>ボールド文字</b>
のようにすることで,タグに囲まれた文字を太字にすることができます.
これをポイントの1つ目と両立するのはとても大変なことですが,これは大変便利な機能であり,セリフの表現力を高めるためにも必須でしょう.
プログラムの実装
では,プログラムを作成します.
LineWriter.csを新規作成し,以下のようなコードを記述してください.
using Cysharp.Threading.Tasks;
using DG.Tweening;
using ScenarioFlow;
using System;
using System.Linq;
using System.Threading;
using TMPro;
namespace AliceStandard.Line
{
[ScenarioMethod("line")]
public class LineWriter : IReflectable
{
//キャラクターの名前を表示するテキスト
private readonly TextMeshProUGUI textActorName;
//キャラクターのセリフを表示するテキスト
private readonly TextMeshProUGUI textLine;
//1文字ごとの表示間隔
private readonly float characterInterval;
//1文字の表示にかける時間
private readonly float characterDuration;
//1文字の透明度の変化のさせ方
private readonly Ease characterEase;
//テキストを一斉に表示/非表示させる時にかける時間
private readonly float textDuration;
//テキストを一斉に表示/非表示させる時の透明度を変化させる方法
private readonly Ease textEase;
public LineWriter(Settings settings)
{
this.textActorName = settings.TextActorName ?? throw new ArgumentNullException(nameof(settings.TextActorName));
this.textLine = settings.TextLine ?? throw new ArgumentNullException(nameof(settings.TextLine));
this.characterInterval = settings.CharacterInterval;
this.characterDuration = settings.CharacterDuration;
this.characterEase = settings.CharacterEase;
this.textDuration = settings.TextDuration;
this.textEase = settings.TextEase;
}
[ScenarioMethod("write", "キャラクターのセリフを表示する")]
public async UniTask WriteLineAsync(string actorName, string line, CancellationToken cancellationToken)
{
try
{
//前のキャラクターと異なるか
var isDifferentActor = textActorName.text != actorName;
//セリフを消す
//前のセリフと異なるキャラクターなら名前も消す
await UniTask.WhenAll(
isDifferentActor ? SetTextAlphaAsync(textActorName, 0.0f, cancellationToken) : UniTask.CompletedTask,
SetTextAlphaAsync(textLine, 0.0f, cancellationToken));
//セリフとキャラクターの名前を設定
textActorName.text = actorName;
textLine.text = line;
//セリフを表示
//前のセリフと異なるキャラクターなら名前も表示
await UniTask.WhenAll(
isDifferentActor ? SetTextAlphaAsync(textActorName, 1.0f, cancellationToken) : UniTask.CompletedTask,
VisualizeTextInOrderAsync(textLine, cancellationToken));
}
finally
{
//最終的なテキストの状態
textActorName.alpha = 1.0f;
textActorName.text = actorName;
textLine.alpha = 1.0f;
textLine.text = line;
}
}
[ScenarioMethod("erase", "キャラクターのセリフを非表示にする")]
public async UniTask EraseLineAsync(CancellationToken cancellationToken)
{
try
{
await UniTask.WhenAll(
SetTextAlphaAsync(textActorName, 0.0f, cancellationToken),
SetTextAlphaAsync(textLine, 0.0f, cancellationToken));
}
finally
{
//最終的なテキストの状態
textActorName.alpha = 0.0f;
textLine.alpha = 0.0f;
}
}
//テキストを順に表示する
private async UniTask VisualizeTextInOrderAsync(TextMeshProUGUI textMeshProUGUI, CancellationToken cancellationToken)
{
textMeshProUGUI.ForceMeshUpdate();
var textInfo = textMeshProUGUI.textInfo;
foreach(var charIndex in Enumerable.Range(0, textInfo.characterCount))
{
//文字のColor情報を取得
var characterInfo = textInfo.characterInfo[charIndex];
var materialIndex = characterInfo.materialReferenceIndex;
var vertexIndex = characterInfo.vertexIndex;
var colors32 = textInfo.meshInfo[materialIndex].colors32;
//透明度を徐々に変える
await DOTween.ToAlpha(
() => characterInfo.color,
color =>
{
colors32[vertexIndex] = color;
colors32[vertexIndex + 1] = color;
colors32[vertexIndex + 2] = color;
colors32[vertexIndex + 3] = color;
textMeshProUGUI.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32);
},
1.0f,
characterDuration).SetEase(characterEase).ToUniTask(cancellationToken: cancellationToken);
//少し待つ
await UniTask.Delay(TimeSpan.FromSeconds(characterInterval), cancellationToken: cancellationToken);
}
}
//テキストの透明度を徐々に変える
private async UniTask SetTextAlphaAsync(TextMeshProUGUI textMeshProUGUI, float alpha, CancellationToken cancellationToken)
{
await textMeshProUGUI.DOFade(alpha, textDuration).SetEase(textEase).ToUniTask(cancellationToken: cancellationToken);
}
[Serializable]
public class Settings
{
public TextMeshProUGUI TextActorName;
public TextMeshProUGUI TextLine;
public float CharacterInterval;
public float CharacterDuration;
public Ease CharacterEase;
public float TextDuration;
public Ease TextEase;
}
}
}
オプション:DOTweenProによるプログラム
LineWriter.cs
のVisualizeTextInOrderAsync
の中身は,DOTweenProを使うと次のように書き換えることができます.
//テキストを順に表示する
private async UniTask VisualizeTextInOrderAsync(TextMeshProUGUI textMeshProUGUI, CancellationToken cancellationToken)
{
using (var lineAnimator = new DOTweenTMPAnimator(textMeshProUGUI))
{
foreach (var charIndex in Enumerable.Range(0, lineAnimator.textInfo.characterCount))
{
//1文字ずつ透明度を変える
await lineAnimator
.DOFadeChar(charIndex, 1.0f, characterDuration)
.SetEase(characterEase)
.ToUniTask(cancellationToken: cancellationToken);
//少し待つ
await UniTask.Delay(TimeSpan.FromSeconds(characterInterval), cancellationToken: cancellationToken);
}
}
}
DOFadeCharによって,TextMeshPro
のcharIndex番目の文字の透明度をcharacterDuration秒で1.0まで変化させることができます.
また,この拡張メソッドはTextMeshPro
に対してではなく,操作対象のTextMeshPro
をラップしたDOTweenTMPAnimatorに対して定義されています.
DOTweenTMPAnimatorはIDisposableを実装しているので,using
文で確実にDispose
されるようにしています.
今回の場合はDOTweenProを使っても使わなくても得られる動作は同じですが,
- コードが簡潔になる
- 透明度の変化以外のアニメーションをつけるのが楽
という点で,大きな利点があります.
実際,1文字ずつ透明度を変更するだけでも,かなり複雑なコードを書かなくてはならないということが今回は分かったかと思います.しかし,DOTweenProを利用すればより豊富なアニメーションを簡単に実装できます.
もちろん,DOTweenPro以外でもテキストに簡単にアニメーションをつけることができるアセットはあるでしょうから,それらを使用して実装しても良いでしょう.
ScenarioFlowでシナリオ実装を行う大きな利点の一つは,カスタマイズ性の良さです.
重要なポイントを見ていきます.
SetTextAlphaAsync
まず,DOTweenのメソッドについて解説します.
DOTweenでは,様々なUnityのコンポーネントに対して拡張メソッドが定義されており,基本的にはsomeComponent.DO_XXXX(someType param) という形をとります.
例えば,SetTextAlphaAsync
メソッド内には次のような記述が見られます.
await textMeshProUGUI
.DOFade(alpha, textDuration)
.SetEase(textEase)
.ToUniTask(cancellationToken: cancellationToken);
これは,TextMeshProUGUI
に対して定義されている拡張メソッドDOFadeを使用しています.
意味としては,透明度をalphaまで,textDuration秒で変化させるとなります.
指定した秒数で徐々に値を変化させてくれるので,これでアニメーションとなるわけです.
また,DOFade
の後にはSetEaseとありますが,これは値をどのように変化させるかを指定するためのものです.
例えば,2秒で透明度を1から0に変化させるとしても,最初の1秒は大きく値を変化させて最後の1秒は少しずつ値を変化させたり,またはその逆にしたりと,変化のさせ方は様々です.
ちなみにSetEase
には,Ease列挙子を渡します.
Easeには,Linear(線形) や,InQuad(二次関数) などがあります.
Easeの種類に関しては,こちらが参考になるでしょう.
最後にToUniTaskですが,これは名前から分かる通り,DOTweenで作成したアニメーションをUniTaskに変換する拡張メソッドです.これで,アニメーションの終了をawait
で待つことができます.そして,渡したCancellationToken
によってアニメーションのキャンセルも実行できます.
ちなみに,DOTweenによるアニメーションはTween型のオブジェクトとして返され,そのメソッドであるKillを呼び出すことでもアニメーションのキャンセルはできますし,Tween
型をそのままawait
することもできます.
Tween tween = textMeshProUGUI.DOFade(0.0f, 1.0f).SetEase(Ease.Linear);
await tween;
tween.Kill();
ただし,CancellationToken
のキャンセルをもって処理のキャンセルとしたいので,ToUniTask
で変換しています.
VisualizeTextInOrderAsync
まず,DOTwen.ToAlphaについてですが,これはColor型の透明度を徐々に変化させるという機能になります.
DOFade
との違いですが,こちらはデリゲートを引数に渡すことで,より柔軟な表現が可能になっています.実際に,ここのコードでは各文字の透明度を変化させています.
//透明度を徐々に変える
await DOTween.ToAlpha(
() => characterInfo.color,
color =>
{
colors32[vertexIndex] = color;
colors32[vertexIndex + 1] = color;
colors32[vertexIndex + 2] = color;
colors32[vertexIndex + 3] = color;
textMeshProUGUI.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32);
},
1.0f,
characterDuration).SetEase(characterEase).ToUniTask(cancellationToken: token);
4つある引数は以下の意味を持ちます.
-
() => characterInfo.color
- 初期値を設定するためのものです.
- ここでは,
characterInfo.color
が初期値になります.
-
color => ...
- 変化させた値をどのように使用するかを記述します.
- 時間とともに,変化させた
color
が渡されます. - ここでは1文字が持つ4つの頂点に,
color
を代入することで各文字の色を変化させています.
- 1.0f
- 変化させる値の最終値を表します.
- すなわち,前述の初期値から1.0まで透明度が変化します.
- characterDuration
- 何秒間かけて値を変化させるかを表します.
テキストの文字を1つずつ変化させている方法について,詳しい説明は割愛しますが,より詳しく知りたいという方は次の記事を参考にしてください.
各設定値
LineWriterの設定値は,内部クラスとして定義されているSettingsをコンストラクタに渡します.
それぞれの設定値の意味は以下の通りです.
- textActorName
- キャラクターの名前を表示する
TextMeshProUGUI
- キャラクターの名前を表示する
- textLine
- キャラクターのセリフを表示する
TextMeshProUGUI
- キャラクターのセリフを表示する
- characterInterval
- セリフの表示の際,1文字の表示が完了したら,次の文字の表示の間に何秒待つか
- characterDuration
- セリフの表示の際,1文字の表示に何秒かけるか
- characterEase
- 1文字の表示を行うとき,透明度の変化の仕方
- textDuration
- 1文字ずつではなく,テキストを全て一斉に表示したり非表示にしたりするとき,何秒かけるか
- textEase
- テキストをすべて一斉に表示したり非表示にしたりするとき,透明度の変化の仕方
プログラムの実行
では,実際に先ほどのプログラムを実行して,意図した結果になっていることを確認します.
今回,新規作成したプロジェクトではまだDecoderもProgressorも実装していないので,ひとまずScenarioMethodとしてではなく,普通のメソッドとして呼び出してみましょう.
次のGameManager.csを作成し,シーン上にGameManagerオブジェクトを配置,アタッチします.
using AliceStandard.Line;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using UnityEngine;
public class GameManager : MonoBehaviour
{
[SerializeField]
LineWriter.Settings lineWriterSettings;
private async UniTaskVoid Start()
{
//エスケープでキャンセル
var cancellationToken = UniTaskAsyncEnumerable.EveryUpdate()
.Select(_ => Input.GetKeyDown(KeyCode.Escape))
.Where(x => x)
.FirstOrDefaultAsync(this.GetCancellationTokenOnDestroy()).ToCancellationToken();
var lineWriter = new LineWriter(lineWriterSettings);
await lineWriter.EraseLineAsync(cancellationToken);
await AwaitSpaceKeyDown();
await lineWriter.WriteLineAsync("アリス", "私はアリス。", cancellationToken);
await AwaitSpaceKeyDown();
await lineWriter.WriteLineAsync("アリス", "好きな動物は<color=orange>うさぎ</color>。", cancellationToken);
await AwaitSpaceKeyDown();
await lineWriter.WriteLineAsync("マッチ売りの少女", "私はマッチ売りの少女。", cancellationToken);
await AwaitSpaceKeyDown();
await lineWriter.WriteLineAsync("マッチ売りの少女", "<color=red>マッチ</color>はいりませんか?", cancellationToken);
await AwaitSpaceKeyDown();
await lineWriter.EraseLineAsync(cancellationToken);
Debug.Log("Finish!");
}
//スペースを押すと次に進む
private UniTask AwaitSpaceKeyDown()
{
return UniTaskAsyncEnumerable.EveryUpdate()
.Select(_ => Input.GetKeyDown(KeyCode.Space))
.Where(x => x)
.FirstOrDefaultAsync(this.GetCancellationTokenOnDestroy());
}
}
そして,設定値を適当に設定し,実行します.
設定値(参考)
スペースキーを押すと次に進み,エスケープを押すとキャンセルされます.
ただし,簡易的なコードなので,エスケープを押すとそれ以上次の処理へは進めなくなることに注意してください.
リッチテキストタグが使える
1文字ずつ,透明度が徐々に変わる
おわりに
今回は,DOTweenとTextMeshProを使って,キャラクターのセリフを表示するためのプログラムを作成しました.
テキストを滑らかに表示するために,1文字ずつ透明度を徐々に変える,また,リッチテキストを使えるようにするという方針をとったため,そのコードは大分長いものになってしまいました.
使えるものがあれば,テキストのアニメーション用のアセットを利用することを検討しても良いでしょう.ScenarioFlowでは拡張性が重視されているので,今回DOTweenを利用したように,簡単にあらゆるアセットをシナリオ進行のために利用することができます.
Discussion