📖

Inkというノベルテキスト管理ツールを使ってみた

に公開

どういうツールなのか

https://www.inklestudios.com/ink/
無料のプログラマブルなテキストや会話管理ツールといったところです。
前作のホラーゲームで使用しました。

使い方はこちらを参考にして頂ければと思います。
https://qiita.com/sanpuru6320/items/2d9c7cdb9b8b14ea6bae

良かったところ

  1. 翻訳データが扱いやすい

一部のシステムテキストを除いて、会話テキストは全てこのツールに乗っています。
画像のように日本語テキストで組み終わった後に、この.inkファイルごと翻訳出来ると、対応している会話箇所が分かりやすいのです。

  1. 分岐処理が作りやすい

テキスト選択によって分岐していく場合に、エクセルやスプレッドシートだと、どこに飛ぶのかだんだん分からなくなります。
ですが画像のように直観的に分かると、とても良いかなと思います。

  1. 会話が再生できる

これはInkエディターの機能です。
エディター右枠に実際の会話を再生出来るので、Unityで確認しなくてもちょっとだけ整理しやすくなります。
画像では no_job_men_first を再生しています。

  1. タグのフックと再帰選択が出来る

(#WAIT)のように独自のタグを定義して、Unity側でフックさせることが出来ます。
選択肢の中から正しいものを選ばないと次のステップに進まないようにさせることも出来るので、ここも便利かなと思っています。

難しいところ(少し面倒なところ)

  1. パーサーを作らないといけない

inkからunityに取り込むところまではライブラリがやってくれます。
そこからどのノット(章みたいなもの)を読むかはこちらが指定しないといけないので、表示したい内容に合わせてパーサーを作らないといけません。
※そんなに難しくないです

  1. wikiを熟読したほうが良い

ここが一番面倒です。
結構踏み込んだ使い方まで書かれているので、迷ったらここを参照すると良いと思います。

Inkエディターのwiki的なもの。
https://github.com/inkle/ink/blob/master/Documentation/WritingWithInk.md

Unityライブラリ側のwiki的なもの。
https://github.com/inkle/ink/blob/master/Documentation/RunningYourInk.md#getting-started-with-the-runtime-api

サンプル

長いですが前作のホラーゲームで作成したパーサーサンプルを置いておきます。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cysharp.Threading.Tasks;
using Ink.Runtime;
using R3;
using UnityEngine;

/// <summary>
/// Inkのシナリオデータから各種テキスト表示
/// </summary>
public class InkStoryFacilitator : MonoBehaviour
{
	private static InkStoryFacilitator _instance;
	public static InkStoryFacilitator Instance => _instance;

	[SerializeField] private OverlayText _overlayText;
	[SerializeField] private SelectText _selectText;
	[SerializeField] private UnderText _underText;
	[SerializeField] private PhoneChat _phoneChat;

	[Header(".ink形式のSteamingAssets配下にあるファイル名")]
	[SerializeField] private string _inkRawFileName;
	private Story _inkStory;

	// REACTIONタグが設定されていたときに通知される
	public Subject<Unit> OnReaction = new Subject<Unit>();

	// WAITタグが設定されていたときに通知される
	public Subject<Unit> OnWait = new Subject<Unit>();

	// テキスト表示を一時的に止めている
	public bool WaitText = true;

	private enum KnotHeaderTag
	{
		OVERLAY,// オーバーレイ
		UNDER,  // 下部テキスト
		CONV,   // 会話
		PHONE_CHAT, // スマホチャット
	}

	private enum KnotUnderTag
	{
		VO, // 下部テキストのV.O.ノットのヘッダーではなくテキストに入れてある
		IMM, // すぐにテキストを出したいときに入れる
	}

	private enum ActionTag
	{
		// 相手の会話: テキスト表示は止めないで、何らかのアクションを起こす
		REACTION,
		// 自分の会話: テキスト表示を一時的に止めて、テキスト自体の表示も一時的に消す(相手の会話で止めると、読んでいる最中に消えてしまうので自分の選択肢に限定する)
		// 選択タグはバグで行末に#を入れてもTagに入ってこないので注意
		WAIT,
	}

	private enum ChatTag
	{
		// 初期状態
		None,
		// 左テキスト
		LEFT_,
		// 右テキスト
		RIGHT_
	}

	void Awake()
	{
		_instance = this;
	}

	public void InitializeFromLanguage(string language)
	{
		var inkFilePath = $"{Application.streamingAssetsPath}/{_inkRawFileName}_{language}.ink";
		var inkTextRaw = File.ReadAllText(inkFilePath);
		var compiler = new Ink.Compiler(inkTextRaw);
		_inkStory = compiler.Compile();
	}

	// ノット名でシナリオを指定する
	public async UniTask Show(string knotName)
	{
		Debug.Log($"INK START READING : {knotName}");
		// シナリオ読み込み
		_inkStory.ChoosePathString(knotName);
		// ノットのシナリオ上部に定義したTag
		var tags = _inkStory.TagsForContentAtPath(knotName);
		if (tags == null)
		{
			Debug.LogError("ノットの先頭にタグを忘れています");
		}
		// 実行
		var textTask = UniTask.CompletedTask;
		foreach (var tag in tags)
		{
			// テキストタイプ
			if (tag == $"{KnotHeaderTag.OVERLAY}")
			{
				textTask = Read_Overlay();
			}
			else if (tag == $"{KnotHeaderTag.UNDER}")
			{
				textTask = Read_Under();
			}
			else if (tag == $"{KnotHeaderTag.CONV}")
			{
				textTask = Read_Conv();
			}
			else if (tag == $"{KnotHeaderTag.PHONE_CHAT}")
			{
				textTask = Read_PhoneChat();
			}
		}

		await textTask;
		Debug.Log($"INK END READING {knotName}");
	}

	private async UniTask Read_Overlay()
	{
		Debug.Log("Read_Overlay");
		_overlayText.RestoreText();
		while (_inkStory.canContinue)
		{
			var t = _inkStory.Continue();
			Debug.Log(t);

			await _overlayText.ShowLine(t);
		}
	}

	private async UniTask Read_Under()
	{
		Debug.Log("Read_Under");
		while (_inkStory.canContinue)
		{
			var t = _inkStory.Continue();

			// 下部テキストの表示が早すぎることが多いので初回表示から少し遅らせる
			// 次の行に移るときも同じ間を開けてから表示する
			var immediate = _inkStory.currentTags.Contains($"{KnotUnderTag.IMM}");
			await UniTask.Delay(TimeSpan.FromSeconds(immediate ? 0.25f : Constant.READ_UNDER_TEXT));
			Debug.Log(t);

			if (_inkStory.currentTags.Contains($"{KnotUnderTag.VO}"))
			{
				// テキスト側で入れても良い
				t = $"<i>{t}</i>";
			}

			await _underText.Show(t);
		}
	}

	private async UniTask Read_Conv()
	{
		Debug.Log("Read_Conv");
		_selectText.Active(true);
		while (_inkStory.canContinue)
		{
			var t = _inkStory.Continue();
			Debug.Log(t);

			// タグの確認
			if (_inkStory.currentTags.Contains($"{ActionTag.REACTION}"))
			{
				OnReaction.OnNext(Unit.Default);
			}

			// 選択する前にタグがあるか確認
			var isWaitTag = _inkStory.currentChoices.Any(choice => choice.tags == null ? false : choice.tags.Contains($"{ActionTag.WAIT}"));

			// 選択肢テキストを配列に変換
			var choiceTexts = _inkStory.currentChoices
				.Select(c => c.text)
				.ToArray();

			if (choiceTexts.Length > 0)
			{
				var selectIndex = await _selectText.Show(t, false, choiceTexts);
				_inkStory.ChooseChoiceIndex(selectIndex);
			}

			// 自分の会話選択後に待つ
			if (isWaitTag)
			{
				OnWait.OnNext(Unit.Default);
				_selectText.Active(false);
				WaitText = true;
				await UniTask.WaitWhile(() => WaitText);
				_selectText.Active(true);
			}
		}
		_selectText.Active(false);
	}

	private async UniTask Read_PhoneChat()
	{
		Debug.Log("Read_PhoneChat");

		_phoneChat.Active(true);

		// 先にすべて生成する
		while (_inkStory.canContinue)
		{
			var t = _inkStory.Continue();
			Debug.Log(t);

			if (_inkStory.currentTags != null)
			{
				// タグの確認
				var leftMessage = _inkStory.currentTags.FirstOrDefault(tag => tag.Contains($"{ChatTag.LEFT_}"));
				var rightMessage = _inkStory.currentTags.FirstOrDefault(tag => tag.Contains($"{ChatTag.RIGHT_}"));
				if (leftMessage != null)
				{
					// メッセージを生成する
					var userName = leftMessage.Replace($"{ChatTag.LEFT_}", "");
					_phoneChat.CreateLeft(userName);
				}
				else if (rightMessage != null)
				{
					// メッセージを生成する
					var userName = rightMessage.Replace($"{ChatTag.RIGHT_}", "");
					_phoneChat.CreateRight(userName);
				}
			}

			_phoneChat.AddLineText_CurrentMessage(t);
		}

		await _phoneChat.Show();

		// ヒントキーの表示
		IntaractUI.Instance.ShowHintText_C();
		// Cキーが押されたら閉じる
		await SceneObjectCommon.Instance.Inputs.OnPressKeyC.Take(1).FirstAsync().AddTo(this);
		IntaractUI.Instance.HideHintText_C();
		_phoneChat.Active(false);
	}
}

Discussion