1. HOME»
  2. プログラミング・Web»
  3. Unity»
  4. 【絶対できる!】Unityでの2Dノベルゲームの作り方を詳しく解説

【絶対できる!】Unityでの2Dノベルゲームの作り方を詳しく解説

人魚との触れ合いを描いた小説、「ELENA 人魚と過ごした時間」のゲーム化を目指し、日々コツコツと開発を進めているのですが、もう少し時間がかかりそうです。

さて、そんなわけで、今回はUnityによるゲーム開発の状況を少しだけお見せしながら、Unityによるノベルゲーム開発の方法を紹介していきたいと思います。

目次
  1. 今回作成するノベルゲームについて
  2. Unityのダウンロードとインストールについて
  3. 新しいプロジェクトの作成
  4. シーンの名前を変更
  5. ノベルゲームの基本となる階層を作る
  6. それぞれのオフジェクトの設定
  7. GameManagerの作成
  8. テキストの表示
  9. 背景画像の表示
  10. イベントCGの表示
  11. 画像(Prefab)の削除
  12. テキストを1文字ずつ表示
  13. まとめ

今回作成するノベルゲームについて

まず、今回作成するノベルゲームについてです。
次のようなゲームを作成します。(早送りしています)

完成したノベルゲームのプレビュー

今回、行ないたいのは、次のようなものです。

  • クリックで文字を次のものに切り替える
  • 文字を一文字ずつ表示
  • 背景画像の表示
  • イベントCGの表示
  • 外部テキストファイルの読み込み
  • 外部テキストファイルによる、背景画像とイベントCGの操作

では、はじめていきましょう。

Unityのダウンロードとインストールについて

Unityのインストールがまだの方は、以下の記事を参考にしてください。

Windows

Mac

新しいプロジェクトの作成

では、今回のノベルゲーム開発用に新しいプロジェクトを作成します。

テンプレートから2D」を選択し、プロジェクト名を付け「プロジェクトを作成(Create project)」をクリックします。

プロジェクトの新規作成

シーンの名前を変更

デフォルトではシーンの名前がSampleSceneになっているので変更します。
変更するには、プロジェクト(Project)にあるAssetsの中のScenesから「SampleScene」を右クリックし、「名前を変更(Rename)」をクリックします。

シーンの名前を変更

今回は「MainScene」に変更しました。

シーンの名前をMainSceneに変更

次のようなダイアログが表示された場合は、「再ロード(Reload)」をクリックします。

再ロードをクリック

ノベルゲームの基本となる階層を作る

今回作りたいノベルゲームの階層は、次のようにしようと思います。

Unityで開発する、ノベルゲームの階層

一番後ろに「背景」を、その手前に「イベントCG」を表示します。
その手前にメッセージボックスを、さらにその手前にメッセージを表示します。

今回、立ち絵は使いませんが、背景の上に画像を表示する、という意味ではイベントCGの表示と同じですので、工夫すればでできると思います。

ではさっそく、この階層を作っていきましょう。

ヒエラルキー(Hierarchy)のなにもないところで右クリックし、「UI」、「パネル(Panel)」と選択します。

パネルを作成

すると「Canvas」が作られ、その中にパネルが作られますので、名前を「Game」に変更します。

パネルの名前を「Game」に変更

さらに、「Game」オブジェクトを右クリックして、「空のオブジェクトを作成(Create Empty)」をクリックします。

空のオブジェクトを作成

オブジェクトの名前は「Background」にします。

オブジェクト名をBackgroundに変更

再び「Game」オブジェクトを選択して、同じ手順で「空のオブジェクト」を作成し、名前を「Event」にします。

オブジェクト名をEventに変更

こんどは、パネルを作成します。
Gameオブジェクトをクリックし、「UI」から「パネル(Panel)」を選択します。

メッセージウィンドウのパネルの作成

名前は、「MessageWindow」にします。

パネルの名前をMessageWindowに変更

さらに、MessageWindowオブジェクトを右クリックして、「UI」から「テキスト – TextMeshPro(Text – TextMeshPro)」を選択します。

TextMeshProの作成

次のような画面が開いたら、「Import TMP Essentials」をクリックします。

「Import TMP Essentials」をクリック

すると、「Text(TMP)」オブジェクトが作られます。
(TMP Importerは閉じてしまって構いません)

「Text(TMP)」オブジェクトが作られた

この「Text(TMP)」オブジェクトは、「MainText」という名前に変更します。

「Text(TMP)」オブジェクトを、「MainText」という名前に変更

これで、今回のノベルゲーム開発での、階層ができました。

それぞれのオフジェクトの設定

つづいて、さきほど作ったオブジェクトの設定をしていきましょう。

Main Cameraの設定

まず、Main Cameraの設定をしていきましょう。
ヒエラルキー(Hierarchy)で、「Main Camera」を選択します。

Main Cameraオブジェクトを選択

インスペクター(Inspector)から、背景(Background)の色を黒に変えます

背景色を黒に変更

これでMain Cameraの設定の完了です。

Canvasの設定

つづいて、Canvasの設定をしていきます。
ヒエラルキー(Hierarchy)から、「Canvas」を選択しましょう。

Canvasを選択

インスペクター(Inspector)から、以下の部分を変更します。

  • レンターモード(Render Mode)を「スクリーンスペース – カメラ(Screen Space – Camera)」に変更。
  • レンダーカメラ(Render Camera)を、「Main Camera」に変更。
  • UIスケールモード(UI Scale Mode)を「画面サイズに拡大(Scale With Screen Size)」に変更。
  • さらに、参照解像度(Reference Resolution)を「1520」x「720」に変更。
  • マッチ(Match)を「1」に変更。
Canvasの設定箇所

これでCanvasの設定の完了です。

Gameオブジェクトの設定

つづいて、Gameオブジェクトの設定をしていきます。
ヒエラルキー(Hierarchy)から、「Game」を選択しましょう。

Gameオブジェクトを選択

インスペクター(Inspector)から、以下の部分を変更します。

  • アンカープリセット(Anchor Presets)を、縦が「middle」、横が「center」のものに変更。
  • 幅を「1520」、高さを「720」に変更。
  • 色(Color)を黒くし、Aは「255」に設定。
Gameオブジェクトの設定箇所

これで、Gameオブジェクトの設定は完了です。

Backgroundオブジェクトの設定

つづいて、Backgroundオブジェクトの設定をしていきます。
ヒエラルキー(Hierarchy)から、「Background」を選択しましょう。

Backgroundオブジェクトの選択

インスペクター(Inspector)で、以下の部分を変更します。

  • アンカープリセット(Anchor Presets)を縦横「stretch」のものに変更。
  • 上下左右(Top、Bottom、Left、Right)の値をすべて「0」に変更。
Backgroundオブジェクトの設定箇所

これで、Backgroundオブジェクトの設定は完了です。

Eventオブジェクトの設定

つづいて、Eventオブジェクトの設定をしていきます。
ヒエラルキー(Hierarchy)から、「Event」を選択しましょう。

Eventオブジェクトの選択

インスペクター(Inspector)で、以下の部分を変更します。

  • アンカープリセット(Anchor Presets)を縦横「stretch」のものに変更。
  • 上下左右(Top、Bottom、Left、Right)の値をすべて「0」に変更。
Eventオブジェクトの設定箇所

これで、Eventオブジェクトの設定は完了です。

MessageWindowオブジェクトの設定

つづいて、MessageWindowオブジェクトの設定をしていきます。
ヒエラルキー(Hierarchy)から、「MessageWindow」を選択しましょう。

MessageWindowオブジェクトを選択

インスペクター(Inspector)で、以下の部分を変更します。

  • 上の値を「480」に変更。
  • アンカーピボット(Anchors Pivot)のYの値を「0」に変更。
  • 色(Color)を、R「40」、G「40」、B「40」、A「180」に変更
MessageWindowオブジェクトの設定箇所

これで、MessageBoxオブジェクトの設定の完了です。

MainTextオブジェクトの設定

MainTextオブジェクトの設定をしていきます。
ヒエラルキー(Hierarchy)から、「MainText」を選択しましょう。

MainTextオブジェクトの選択

インスペクター(Inspector)で、以下のように変更します。

  • 幅(Width)を「980」、高さ(Height)を「200」に変更。
MainTextオブジェクトの設定箇所

これで、ノベルゲームの階層が完成しました。
再生してみると、次のようになります。

ノベルゲームを実行

GameManagerの作成

つづいて、スクリプトを書いていきましょう。
まずは、ゲーム全体を管理するための、GameManagerを作っていきます。

プロジェクトからAssetsを選択し、右クリック、「作成」、「フォルダー」をクリックしましょう。

Scriptsフォルダの作成

フォルダ名は「Scripts」にします。

フォルダ名をScriptsに変更

いま作ったScriptsフォルダを開き右クリック、「作成」、「C# スクリプト(C# Script)」をクリックしましょう。

C# スクリプトの作成

ファイル名は「GameManager」とします。

ファイル名をGameManagerに変更すると歯車(ギア)のアイコンになる

ここで、GameManagerのアイコンが歯車(ギア)になってしまいました。
なぜこうなるのかは分からないのですが、すこし心配になりますよね。

そこで、クラスを名前空間で管理することにします。
これで、この歯車(ギア)アイコンも解決できます。

では、たった今作成したファイル「GameManager.cs」を、以下のように書き換えてみてください。

GameManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace NovelGame
{
    public class GameManager : MonoBehaviour
    {
        // Start is called before the first frame update
        void Start()
        {
            
        }

        // Update is called once per frame
        void Update()
        {
            
        }
    }
}

GameManagerクラス全体を、NovelGameという名前空間に入れました。
すると、さきほどの歯車(ギア)アイコンが、いつものC#ファイルのアイコンになります。

GameManagerファイルが、C#ファイルのアイコンになる

さらに、スクリプトを編集していきます。
GameManagerに作った変数やメソッドなどは、他のクラスからも使いたいものがほとんどだと思いますので、Instanceプロパティを作成して、別のクラスから変数関数を呼び出せるようにします。

また、Start、Updateメソッドは、今回は使わないので削除しました。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace NovelGame
{
    public class GameManager : MonoBehaviour
    {
        // 別のクラスからGameManagerの変数などを使えるようにするためのもの。(変更はできない)
        public static GameManager Instance { get; private set; }

        void Awake()
        {
            // これで、別のクラスからGameManagerの変数などを使えるようになる。
            Instance = this;
        }
    }
}

では、ヒエラルキー(Hierarchy)で、「MainScene」右クリックして、「ゲームオブジェクト(GameObject)」から、「空のオブジェクトを作成(Create Empty)」をクリックします。

空のオブジェクトを作成

名前は「GameManager」にします。

空のオブジェクトの名前をGameManagerに変更

さらに、今作成したGameManagerオブジェクトに、さきほどのGameManagerファイルを、ドラッグ&ドロップでアタッチします。

GameManagerファイルをアタッチ

これで、GameManagerを作っていくための準備が整いました。

テキストの表示

つづいて、ノベルゲームに一番大切な、テキストの表示をしていきます。

日本語のテキストを表示できるようにする

まずはフォントの設定をしていきます。
日本語に対応した、お好きなフォントを用意しておいてください。ここでは源ノ角ゴシックを使います。

では、Unityの画面で、Assetsに「Fonts」フォルダを作成します。

Fontsフォルダの作成

さらにFontsフォルダを開き、用意しておいたフォントをドラッグ&ドロップします。

フォントの読み込み

「ウィンドウ(Window)」から、「TextMeshPro」を選択し、「フォントアセットクリエーター(Font Asset Creator)」をクリックします。

フォントアセットクリエーターを開く

ウィンドウが開いたら、次のように指定します。

フォントアセットクリエーターの設定箇所

また、「Custom Character List」には、使いたい文字を入力する必要がありますが、日本語ではとてもたくさんの文字を使いますので、入力は大変です。

しかし、そういった文字をすべてまとめてくださっている方がいますので、そちらを使わせていただこうと思います。(ありがとうございます)

GitHub Gist(kgsi/japanese_full.txt): https://gist.github.com/kgsi/ed2f1c5696a2211c1fd1e1e198c96ee4?h=1

上記からダウンロードしたtxtファイル内容をすべてコピーし、「Custom Character List」に貼り付け「Generate Font Atlas」をクリックします。

日本語に使われる文字をすべて入力して、「Generate Font Atlas」をクリック

「Save」をクリックします。

「Save」をクリック

次のように表示されたら、再び「Save」をクリックします。

再び「Save」をクリック

「フォントアセットクリエーター(Font Asset Creator)」を閉じて、ヒエラルキー(Hierarchy)から「MainText」オブジェクトを選択します。

MainTextオブジェクトを選択

インスペクター(Inspector)のFont Assetから、作ったフォントを指定します。

フォントの指定

これで、日本語のテキストを表示できるようになりました。

日本語テキストが入力可能になった

テキストファイル(novel.txt)を読み込んで、順番に画面に表示

つづいて、テキストデータを読み込んでみましょう。
今回はnovel.txtファイルにシナリオを書いていき、それをスクリプトから読み込む、というい方法にしたいと思います。

novel.txtは以下のようにしました。

novel.txt

気がつけば人魚と話していた。
水面で揺らめく金色の髪。一点の濁りもない水中に見えるのは、コーラルピンクに輝く長い尾鰭。巨大な太陽に照らされながら、その人魚はこちらに手を伸ばした。
薄紅色の柔らかな唇が、優しく上下する。
「食べられる? ゆっくり食べてね」
なんの疑問もなく、人魚から海藻を受け取った。味はせず、美味しいとも不味いとも思わなかった。
波に合わせて体が上下に揺れている。わずかな差で人魚の体も揺れている。彼女の背後に見える陸地には、建物も電線も見当たらなかった。そして、それが当たり前だった。
「寒くない?」
「大丈夫──です」
人魚はくすくすと笑った。
「翔馬、少しずつだけど良くなってきてる」
どうして彼女は俺の名前を知ってるのだろうか──翔馬は不思議な感情を抱いた。しかし、波が体を揺らす心地よさに、いつの間にかその気持ちは消えてしまった。
「ありがとうございます、エレナ──さん」
「ふふ、エレナでいいのよ──」
エレナはにっこりと微笑んだ。

プロジェクトからAssetsを選択し、Textsフォルダを作成します。

Textsフォルダの作成

Textsフォルダを開き、そこにさきほど作成したnovel.txtをドラッグ&ドロップします。

テキストファイルの読み込み

Scriptsフォルダに、C# スクリプト(C# Script)を2つ作成します。
名前は、「MainTextController」、「UserScriptManager」とします。

C# スクリプトを2つ作成

今回、シナリオを記述したnovel.txtファイルには、シナリオだけではなく、背景やイベントCGの追加や削除をするための命令も記述できるようにしたいと思います。

そこでこの記事では、そういったシナリオや命令を書いたものを、「ユーザスクリプト(UserScript)」と呼ぶことにします

画面に表示するシナリオは「MainTextController」によってコントロールし、テキストファイルに記述したユーザスクリプトは「UserScriptManager」で管理することにします。

では、ヒエラルキー(Hierarchy)で、「UserScriptManager」オブジェクトを作成しましょう。
オブジェクトは、「MainScene」右クリックして、「ゲームオブジェクト(GameObject)」から、「空のオブジェクトを作成(Create Empty)」をクリックすることで、作ることができます。

UserScriptManagerオブジェクトの作成

MainTextControllerファイルをMainTextオブジェクトに、UserScriptManagerファイルをUserScriptManagerオブジェクトに、ドラッグ&ドロップでアタッチします。

スクリプトファイルのアタッチ

スクリプトは、次のようにします。
まずは、MainTextControllerからです。

MainTextController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;

namespace NovelGame
{
    public class MainTextController : MonoBehaviour
    {
        [SerializeField] TextMeshProUGUI _mainTextObject;

        // Start is called before the first frame update
        void Start()
        {
            DisplayText();
        }

        // Update is called once per frame
        void Update()
        {
            // クリックされたとき、次の行へ移動
            if (Input.GetMouseButtonUp(0))
            {
                GoToTheNextLine();
                DisplayText();
            }
        }

        // 次の行へ移動
        public void GoToTheNextLine()
        {
            GameManager.Instance.lineNumber++;
        }

        // テキストを表示
        public void DisplayText()
        {
            string sentence = GameManager.Instance.userScriptManager.GetCurrentSentence();
            _mainTextObject.text = sentence;
        }
    }
}

つづいて、UserScriptManagerです。
Start、Updateメソッドは、今回使わないので、削除しています。

UserScriptManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;

namespace NovelGame
{
    public class UserScriptManager : MonoBehaviour
    {
        [SerializeField] TextAsset _textFile;

        // 文章中の文(ここでは1行ごと)を入れておくためのリスト
        List<string> _sentences = new List<string>();

        void Awake()
        {
            // テキストファイルの中身を、1行ずつリストに入れておく
            StringReader reader = new StringReader(_textFile.text);
            while (reader.Peek() != -1)
            {
                string line = reader.ReadLine();
                _sentences.Add(line);
            }
        }

        // 現在の行の文を取得する
        public string GetCurrentSentence()
        {
            return _sentences[GameManager.Instance.lineNumber];
        }
    }
}

GameManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace NovelGame
{
    public class GameManager : MonoBehaviour
    {
        // 別のクラスからGameManagerの変数などを使えるようにするためのもの。(変更はできない)
        public static GameManager Instance { get; private set; }

        public UserScriptManager userScriptManager;
        public MainTextController mainTextController;

        // ユーザスクリプトの、今の行の数値。クリック(タップ)のたびに1ずつ増える。
        [System.NonSerialized] public int lineNumber;

        void Awake()
        {
            // これで、別のクラスからGameManagerの変数などを使えるようになる。
            Instance = this;

            lineNumber = 0;
        }
    }
}

ヒエラルキー(Hierarchy)でMainTextオブジェクトを選択し、インスペクター(Inspector)のMain Text Objectに、シーンから「MainText」を追加します。

MainTextオブジェクトの設定

ヒエラルキー(Hierarchy)でGameManagerオブジェクトを選択し、インスペクター(Inspector)で、以下のように設定します。

  • User Script Managerでは、シーンから「UserScriptManager」を選択。
  • Main Text Controllerでは、シーンから「MainText」を選択。
GameManagerオブジェクトの設定

ヒエラルキー(Hierarchy)でUserScriptManagerオブジェクトを選択します。
インスペクター(Inspector)のText Fileで、アセットから「novel」を選択します。

UserScriptManagerオブジェクトの設定

これで再生すると、novel.txtの最初の1行が表示され、クリックで順番にテキストが表示されていきます。

ノベルゲームの実行

背景画像の表示

つづいて、背景画像を表示してみましょう。

フォルダの作成と、使いたい背景画像の追加

ではまず、表示する背景画像を入れておくフォルダを作りましょう。
フォルダ名は「Images」にしました。

Imagesフォルダを作成

さらにImagesフォルダを開いて、「Backgrounds」フォルダを作ります。

Backgroundsフォルダを作成

Backgroundsフォルダを開き、その中に使いたい背景画像をドラッグ&ドロップします。

Backgroundsフォルダに背景画像を追加

これで背景画像を追加できました。

Prefabの作成

今回、画像の表示には、Prefabを使っていこうと思います。
「Background」を右クリックし、「UI」から、「画像(Image)」を選択します。

Imageオブジェクトの作成

いま作った「Image」を選択し、インスペクター(Inspector)で、以下のように変更します。

  • アンカープリセット(Anchor Presets)を縦横「stretch」のものに変更。
  • 上下左右(Top、Bottom、Left、Right)の値をすべて「0」に変更。
Imageオブジェクトの設定箇所

そして、プロジェクト(Project)からAssetsフォルダを開き、ヒエラルキー(Hierarchy)から「Image」オブジェクトを、そこへドラッグ&ドロップします。

ImageオブジェクトのPrefabを作成

これで、「Image」というPrefabを作ることができました。

ImageというPrefabが作られた

ヒエラルキー(Hierarchy)の「Image」はもう削除(Delete)してしまいましょう。

ヒエラルキー(Hierarchy)の「Image」を削除

スクリプトを用意して背景画像を表示する

つづいて、実際に背景画像を表示してみます。
まずは、スクリプトを用意しましょう。ファイル名は「ImageManager」とします。

ImageManagerファイルを作成

さらに、ヒエラルキー(Hierarchy)で、空のオブジェクトを作成し、名前を「ImageManager」にします。

ImageManagerオブジェクトを作成

ヒエラルキー(Hierarchy)のImageManagerオブジェクトに、プロジェクトのImageManagerファイルを、ドラッグ&ドロップします。

ImageManagerファイルをアタッチ

では、「ImageManager」ファイルを編集してみましょう。以下のようにします。
また、Start、Updateメソッドは必要ないので削除しました。

ImageManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

namespace NovelGame
{
    public class ImageManager : MonoBehaviour
    {
        [SerializeField] Sprite _background1;
        [SerializeField] GameObject _backgroundObject;
        [SerializeField] GameObject _imagePrefab;

        // テキストファイルから、文字列でSpriteやGameObjectを扱えるようにするための辞書
        Dictionary<string, Sprite> _textToSprite;
        Dictionary<string, GameObject> _textToParentObject;

        // 操作したいPrefabを指定できるようにするための辞書
        Dictionary<string, GameObject> _textToSpriteObject;

        void Awake()
        {
            _textToSprite = new Dictionary<string, Sprite>();
            _textToSprite.Add("background1", _background1);

            _textToParentObject = new Dictionary<string, GameObject>();
            _textToParentObject.Add("backgroundObject", _backgroundObject);

            _textToSpriteObject = new Dictionary<string, GameObject>();
        }

        // 画像を配置する
        public void PutImage(string imageName, string parentObjectName)
        {
            Sprite image = _textToSprite[imageName];
            GameObject parentObject = _textToParentObject[parentObjectName];

            Vector2 position = new Vector2(0, 0);
            Quaternion rotation = Quaternion.identity;
            Transform parent = parentObject.transform;
            GameObject item = Instantiate(_imagePrefab, position, rotation, parent);
            item.GetComponent<Image>().sprite = image;

            _textToSpriteObject.Add(imageName, item);
        }
    }
}

さらに、ユーザスクリプトからの画像表示の命令を取得したいので、「UserScriptManager」ファイルを次のように編集します。

UserScriptManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;

namespace NovelGame
{
    public class UserScriptManager : MonoBehaviour
    {
        [SerializeField] TextAsset _textFile;

        // 文章中の文(ここでは1行ごと)を入れておくためのリスト
        List<string> _sentences = new List<string>();

        void Awake()
        {
            // テキストファイルの中身を、1行ずつリストに入れておく
            StringReader reader = new StringReader(_textFile.text);
            while (reader.Peek() != -1)
            {
                string line = reader.ReadLine();
                _sentences.Add(line);
            }
        }

        // 現在の行の文を取得する
        public string GetCurrentSentence()
        {
            return _sentences[GameManager.Instance.lineNumber];
        }

        // 文が命令かどうか
        public bool IsStatement(string sentence)
        {
            if (sentence[0] == '&')
            {
                return true;
            }
            return false;
        }

        // 命令を実行する
        public void ExecuteStatement(string sentence)
        {
            string[] words = sentence.Split(' ');
            switch(words[0])
            {
                case "&img":
                    GameManager.Instance.imageManager.PutImage(words[1], words[2]);
                    break;
            }
        }
    }
}

また、テキストが次の行へ移動するとき、その行が文章なのか、命令なのかを判断する必要があるので、「MainTextController」ファイルも編集していきます。

MainTextController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;

namespace NovelGame
{
    public class MainTextController : MonoBehaviour
    {
        [SerializeField] TextMeshProUGUI _mainTextObject;

        // Start is called before the first frame update
        void Start()
        {
            // 最初の行のテキストを表示、または命令を実行
            string statement = GameManager.Instance.userScriptManager.GetCurrentSentence();
            if (GameManager.Instance.userScriptManager.IsStatement(statement))
            {
                GameManager.Instance.userScriptManager.ExecuteStatement(statement);
                GoToTheNextLine();
            }
            DisplayText();
        }

        // Update is called once per frame
        void Update()
        {
            // クリックされたとき、次の行へ移動
            if (Input.GetMouseButtonUp(0))
            {
                GoToTheNextLine();
                DisplayText();
            }
        }

        // 次の行へ移動
        public void GoToTheNextLine()
        {
            GameManager.Instance.lineNumber++;
            string sentence = GameManager.Instance.userScriptManager.GetCurrentSentence();
            if (GameManager.Instance.userScriptManager.IsStatement(sentence))
            {
                GameManager.Instance.userScriptManager.ExecuteStatement(sentence);
                GoToTheNextLine();
            }
        }

        // テキストを表示
        public void DisplayText()
        {
            string sentence = GameManager.Instance.userScriptManager.GetCurrentSentence();
            _mainTextObject.text = sentence;
        }
    }
}

「GameManager」ファイルも、ちょっとだけ編集します。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace NovelGame
{
    public class GameManager : MonoBehaviour
    {
        // 別のクラスからGameManagerの変数などを使えるようにするためのもの。(変更はできない)
        public static GameManager Instance { get; private set; }

        public UserScriptManager userScriptManager;
        public MainTextController mainTextController;
        public ImageManager imageManager;

        // ユーザスクリプトの、今の行の数値。クリック(タップ)のたびに1ずつ増える。
        [System.NonSerialized] public int lineNumber;

        void Awake()
        {
            // これで、別のクラスからGameManagerの変数などを使えるようになる。
            Instance = this;

            lineNumber = 0;
        }
    }
}

ImageManagerオブジェクトを選択して、インスペクター(Inspector)を見ると、「Background1」、「Background Object」、「Image Prefab」という項目が増えていますので、それぞれ以下のように指定します。

  • Background1には、「アセット」から「bg1」を指定。
  • BackgroundBackground Objectには、「シーン」から「Background」を指定。
  • Image Prefabには、「アセット」から「Image」を指定。
ImageManagerオブジェクトの設定箇所

さらに、GameManagerオブジェクトを選択して、インスペクター(Inspector)から、Image ManagerにシーンからImageManagerを選択します。

GameManagerオブジェクトの設定箇所

あとは、novel.txtで、以下のように書いてみましょう。

novel.txt

&img background1 backgroundObject
気がつけば人魚と話していた。
水面で揺らめく金色の髪。一点の濁りもない水中に見えるのは、コーラルピンクに輝く長い尾鰭。巨大な太陽に照らされながら、その人魚はこちらに手を伸ばした。
薄紅色の柔らかな唇が、優しく上下する。
「食べられる? ゆっくり食べてね」
なんの疑問もなく、人魚から海藻を受け取った。味はせず、美味しいとも不味いとも思わなかった。
波に合わせて体が上下に揺れている。わずかな差で人魚の体も揺れている。彼女の背後に見える陸地には、建物も電線も見当たらなかった。そして、それが当たり前だった。
「寒くない?」
「大丈夫──です」
人魚はくすくすと笑った。
「翔馬、少しずつだけど良くなってきてる」
どうして彼女は俺の名前を知ってるのだろうか──翔馬は不思議な感情を抱いた。しかし、波が体を揺らす心地よさに、いつの間にかその気持ちは消えてしまった。
「ありがとうございます、エレナ──さん」
「ふふ、エレナでいいのよ──」
エレナはにっこりと微笑んだ。

これを再生すると、次のようになります。

ノベルゲームのプレビュー

再生したままの状態で、ヒエラルキー(Hierarchy)を確認すると、Backgroundオブジェクトの中に、「Image(Clone)」が作られているはずです。

「Image(Clone)」が作られているかどうかの確認

イベントCGの表示

つづいて、イベントCGの表示をしていきます。

まず、Imagesフォルダの中に、「EventCG」フォルダを作成します。

EventCGフォルダの作成

作ったEventCGフォルダに、使いたいイベントCGをドラッグ&ドロップします。
今回は2つの画像ファイルを追加しました。

イベントCGの読み込み

ImageManagerを、以下のようにします。

ImageManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

namespace NovelGame
{
    public class ImageManager : MonoBehaviour
    {
        [SerializeField] Sprite _background1;
        [SerializeField] Sprite _eventCG1;
        [SerializeField] Sprite _eventCG2;
        [SerializeField] GameObject _backgroundObject;
        [SerializeField] GameObject _eventObject;
        [SerializeField] GameObject _imagePrefab;

        // テキストファイルから、文字列でSpriteやGameObjectを扱えるようにするための辞書
        Dictionary<string, Sprite> _textToSprite;
        Dictionary<string, GameObject> _textToParentObject;

        // 操作したいPrefabを指定できるようにするための辞書
        Dictionary<string, GameObject> _textToSpriteObject;

        void Awake()
        {
            _textToSprite = new Dictionary<string, Sprite>();
            _textToSprite.Add("background1", _background1);
            _textToSprite.Add("eventCG1", _eventCG1);
            _textToSprite.Add("eventCG2", _eventCG2);

            _textToParentObject = new Dictionary<string, GameObject>();
            _textToParentObject.Add("backgroundObject", _backgroundObject);
            _textToParentObject.Add("eventObject", _eventObject);

            _textToSpriteObject = new Dictionary<string, GameObject>();
        }

        // 画像を配置する
        public void PutImage(string imageName, string parentObjectName)
        {
            Sprite image = _textToSprite[imageName];
            GameObject parentObject = _textToParentObject[parentObjectName];

            Vector2 position = new Vector2(0, 0);
            Quaternion rotation = Quaternion.identity;
            Transform parent = parentObject.transform;
            GameObject item = Instantiate(_imagePrefab, position, rotation, parent);
            item.GetComponent<Image>().sprite = image;

            _textToSpriteObject.Add(imageName, item);
        }
    }
}

ImageManagerオブジェクトを選択し、インスペクター(Inspector)で、Event CG1とEvent CG2にさきほどのイベントCGを指定し、Event Objectには「Event」オブジェクトを指定します。

ImageManagerオブジェクトの設定箇所

あとは、novel.txtに、イベントCGを表示する命令を追加します。

&img background1 backgroundObject
&img eventCG1 eventObject
気がつけば人魚と話していた。
水面で揺らめく金色の髪。一点の濁りもない水中に見えるのは、コーラルピンクに輝く長い尾鰭。巨大な太陽に照らされながら、その人魚はこちらに手を伸ばした。
薄紅色の柔らかな唇が、優しく上下する。
「食べられる? ゆっくり食べてね」
なんの疑問もなく、人魚から海藻を受け取った。味はせず、美味しいとも不味いとも思わなかった。
波に合わせて体が上下に揺れている。わずかな差で人魚の体も揺れている。彼女の背後に見える陸地には、建物も電線も見当たらなかった。そして、それが当たり前だった。
「寒くない?」
「大丈夫──です」
人魚はくすくすと笑った。
「翔馬、少しずつだけど良くなってきてる」
どうして彼女は俺の名前を知ってるのだろうか──翔馬は不思議な感情を抱いた。しかし、波が体を揺らす心地よさに、いつの間にかその気持ちは消えてしまった。
「ありがとうございます、エレナ──さん」
「ふふ、エレナでいいのよ──」
エレナはにっこりと微笑んだ。

これで再生すると、このようになります。

ノベルゲームのプレビュー

イベントCGだけが表示されているように見えますが、ヒエラルキー(Hierarchy)を見ると、さきほど表示した背景画像と、今回のイベントCGが、同時に表示されていることが分かります。

背景画像とイベントCGが同時に表示されているか確認

画像(Prefab)の削除

つづいて、さきほど表示した背景画像やイベントCGのPrefabを、削除できるようにしていこうと思います。

まずはImageManagerファイルに、画像を削除するためのメソッドを作成しましょう。

ImageManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

namespace NovelGame
{
    public class ImageManager : MonoBehaviour
    {
        [SerializeField] Sprite _background1;
        [SerializeField] Sprite _eventCG1;
        [SerializeField] Sprite _eventCG2;
        [SerializeField] GameObject _backgroundObject;
        [SerializeField] GameObject _eventObject;
        [SerializeField] GameObject _imagePrefab;

        // テキストファイルから、文字列でSpriteやGameObjectを扱えるようにするための辞書
        Dictionary<string, Sprite> _textToSprite;
        Dictionary<string, GameObject> _textToParentObject;

        // 操作したいPrefabを指定できるようにするための辞書
        Dictionary<string, GameObject> _textToSpriteObject;

        void Awake()
        {
            _textToSprite = new Dictionary<string, Sprite>();
            _textToSprite.Add("background1", _background1);
            _textToSprite.Add("eventCG1", _eventCG1);
            _textToSprite.Add("eventCG2", _eventCG2);

            _textToParentObject = new Dictionary<string, GameObject>();
            _textToParentObject.Add("backgroundObject", _backgroundObject);
            _textToParentObject.Add("eventObject", _eventObject);

            _textToSpriteObject = new Dictionary<string, GameObject>();
        }

        // 画像を配置する
        public void PutImage(string imageName, string parentObjectName)
        {
            Sprite image = _textToSprite[imageName];
            GameObject parentObject = _textToParentObject[parentObjectName];

            Vector2 position = new Vector2(0, 0);
            Quaternion rotation = Quaternion.identity;
            Transform parent = parentObject.transform;
            GameObject item = Instantiate(_imagePrefab, position, rotation, parent);
            item.GetComponent<Image>().sprite = image;

            _textToSpriteObject.Add(imageName, item);
        }

        // 画像を削除する
        public void RemoveImage(string imageName)
        {
            Destroy(_textToSpriteObject[imageName]);
        }
    }
}

つづいて、UserScriptManagerファイルに、画像を削除する命令を作ります。

UserScriptManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;

namespace NovelGame
{
    public class UserScriptManager : MonoBehaviour
    {
        [SerializeField] TextAsset _textFile;

        // 文章中の文(ここでは1行ごと)を入れておくためのリスト
        List<string> _sentences = new List<string>();

        void Awake()
        {
            // テキストファイルの中身を、1行ずつリストに入れておく
            StringReader reader = new StringReader(_textFile.text);
            while (reader.Peek() != -1)
            {
                string line = reader.ReadLine();
                _sentences.Add(line);
            }
        }

        // 現在の行の文を取得する
        public string GetCurrentSentence()
        {
            return _sentences[GameManager.Instance.lineNumber];
        }

        // 文が命令かどうか
        public bool IsStatement(string sentence)
        {
            if (sentence[0] == '&')
            {
                return true;
            }
            return false;
        }

        // 命令を実行する
        public void ExecuteStatement(string sentence)
        {
            string[] words = sentence.Split(' ');
            switch(words[0])
            {
                case "&img":
                    GameManager.Instance.imageManager.PutImage(words[1], words[2]);
                    break;
                case "&rmimg":
                    GameManager.Instance.imageManager.RemoveImage(words[1]);
                    break;
            }
        }
    }
}

テキストファイルでは、もうちょっとシナリオを長くして、画像の表示と削除を試してみます。

novel.txt

&img eventCG1 eventObject
気がつけば人魚と話していた。
水面で揺らめく金色の髪。一点の濁りもない水中に見えるのは、コーラルピンクに輝く長い尾鰭。巨大な太陽に照らされながら、その人魚はこちらに手を伸ばした。
薄紅色の柔らかな唇が、優しく上下する。
「食べられる? ゆっくり食べてね」
なんの疑問もなく、人魚から海藻を受け取った。味はせず、美味しいとも不味いとも思わなかった。
波に合わせて体が上下に揺れている。わずかな差で人魚の体も揺れている。彼女の背後に見える陸地には、建物も電線も見当たらなかった。そして、それが当たり前だった。
「寒くない?」
「大丈夫──です」
人魚はくすくすと笑った。
「翔馬、少しずつだけど良くなってきてる」
どうして彼女は俺の名前を知ってるのだろうか──翔馬は不思議な感情を抱いた。しかし、波が体を揺らす心地よさに、いつの間にかその気持ちは消えてしまった。
「ありがとうございます、エレナ──さん」
「ふふ、エレナでいいのよ──」
エレナはにっこりと微笑んだ。
&rmimg eventCG1
&img background1 backgroundObject
川を眺めていると、兄に会えそうな気がした。
稲生沢川に架かる橋の歩道で、笹倉翔馬は欄干に肘を置いて体重を預けながら、汗で湿った半袖のスクールシャツを肩まで捲った。
この川はすぐ海に出る。人が生まれ、やがて最期を迎えるように、運命に逆らうことなくゆっくりと流れていく。
失われた命は二度と戻らない。過ぎた時間は巻き戻らない。神の定めた理論に抗うことはできない──
翔馬は欄干に置いていた残りわずかのコーラを飲み干した。視界に飛び込んできた空には雲ひとつなく、その果てしない青色が兄との海を連想させる。兄との思い出の海──
「死は黒じゃなくて透明だ」と父が言っていたが、あの日、兄と見た海はまさに透き通っていた。
ペットボトルにキャップをしたとき、背後からトンと肩を叩かれた。翔馬は川を見つめたまま、
「正樹か?」
「あれ、なんで分かったんだ?」
「いつものことだろ」
「確かに」
&img eventCG2 eventObject
笑い声に振り返ると、白い歯を見せる橋本正樹と目が合った。
着崩したシャツ、耳が少し隠れるぐらいの茶髪、日に焼けた肌。左腕の大きな傷は、幼いころに三輪車で転んだときのものらしい。
翔馬とは真逆のタイプだが、どういうわけか馬が合い、一年のころに仲良くなった。
正樹は翔馬の隣で欄干に背中を預け、目の前の人魚像を眺める。橋の四隅に置かれたうちのひとつだ。
「この前、直してもらったバイク、調子いいよ。翔ちゃんのお父さんすげぇな」
「そんなことしか能がないからな」
&rmimg eventCG2
正樹は川の方を向いた。
「いや、すげぇよ。オレもバイク分解してみようかな」

これで再生すると、シナリオと画像がマッチした、ノベルゲームになります。

ノベルゲームのプレビュー

テキストを1文字ずつ表示

最後に、もう少しノベルゲームらしくするために、テキストを1文字ずつ表示できるようにしていきます。

MainTextController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;

namespace NovelGame
{
    public class MainTextController : MonoBehaviour
    {
        [SerializeField] TextMeshProUGUI _mainTextObject;
        int _displayedSentenceLength;
        float _time;
        float _feedTime;

        // Start is called before the first frame update
        void Start()
        {
            _time = 0f;
            _feedTime = 0.05f;

            // 最初の行のテキストを表示、または命令を実行
            string statement = GameManager.Instance.userScriptManager.GetCurrentSentence();
            if (GameManager.Instance.userScriptManager.IsStatement(statement))
            {
                GameManager.Instance.userScriptManager.ExecuteStatement(statement);
                GoToTheNextLine();
            }
            DisplayText();
        }

        // Update is called once per frame
        void Update()
        {
            // 文章を1文字ずつ表示する
            _time += Time.deltaTime;
            if (_time >= _feedTime)
            {
                _time -= _feedTime;
                if (!CanGoToTheNextLine())
                {
                    _displayedSentenceLength++;
                    _mainTextObject.maxVisibleCharacters = _displayedSentenceLength;
                }
            }

            // クリックされたとき、次の行へ移動
            if (Input.GetMouseButtonUp(0))
            {
                if (CanGoToTheNextLine())
                {
                    GoToTheNextLine();
                    DisplayText();
                }
            }
        }

        // その行の、すべての文字が表示されていなければ、まだ次の行へ進むことはできない
        public bool CanGoToTheNextLine()
        {
            string sentence = GameManager.Instance.userScriptManager.GetCurrentSentence();
            return (_displayedSentenceLength > sentence.Length);
        }

        // 次の行へ移動
        public void GoToTheNextLine()
        {
            _displayedSentenceLength = 0;
            _time = 0f;
            _mainTextObject.maxVisibleCharacters = 0;
            GameManager.Instance.lineNumber++;
            string sentence = GameManager.Instance.userScriptManager.GetCurrentSentence();
            if (GameManager.Instance.userScriptManager.IsStatement(sentence))
            {
                GameManager.Instance.userScriptManager.ExecuteStatement(sentence);
                GoToTheNextLine();
            }
        }

        // テキストを表示
        public void DisplayText()
        {
            string sentence = GameManager.Instance.userScriptManager.GetCurrentSentence();
            _mainTextObject.text = sentence;
        }
    }
}

これで再生すると、テキストが1文字ずつ表示されます。

ノベルゲームのプレビュー

しかしこのままでは、すべての文字が表示されるまで、次の行へ進めなくなってしまいます。
そこで、まだすべての文字が表示されていないとき、クリックすると、すべての文字が表示されるようにします。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;

namespace NovelGame
{
    public class MainTextController : MonoBehaviour
    {
        [SerializeField] TextMeshProUGUI _mainTextObject;
        int _displayedSentenceLength;
        int _sentenceLength;
        float _time;
        float _feedTime;

        // Start is called before the first frame update
        void Start()
        {
            _time = 0f;
            _feedTime = 0.05f;

            // 最初の行のテキストを表示、または命令を実行
            string statement = GameManager.Instance.userScriptManager.GetCurrentSentence();
            if (GameManager.Instance.userScriptManager.IsStatement(statement))
            {
                GameManager.Instance.userScriptManager.ExecuteStatement(statement);
                GoToTheNextLine();
            }
            DisplayText();
        }

        // Update is called once per frame
        void Update()
        {
            // 文章を1文字ずつ表示する
            _time += Time.deltaTime;
            if (_time >= _feedTime)
            {
                _time -= _feedTime;
                if (!CanGoToTheNextLine())
                {
                    _displayedSentenceLength++;
                    _mainTextObject.maxVisibleCharacters = _displayedSentenceLength;
                }
            }

            // クリックされたとき、次の行へ移動
            if (Input.GetMouseButtonUp(0))
            {
                if (CanGoToTheNextLine())
                {
                    GoToTheNextLine();
                    DisplayText();
                }
                else
                {
                    _displayedSentenceLength = _sentenceLength;
                }
            }
        }

        // その行の、すべての文字が表示されていなければ、まだ次の行へ進むことはできない
        public bool CanGoToTheNextLine()
        {
            string sentence = GameManager.Instance.userScriptManager.GetCurrentSentence();
            _sentenceLength = sentence.Length;
            return (_displayedSentenceLength > sentence.Length);
        }

        // 次の行へ移動
        public void GoToTheNextLine()
        {
            _displayedSentenceLength = 0;
            _time = 0f;
            _mainTextObject.maxVisibleCharacters = 0;
            GameManager.Instance.lineNumber++;
            string sentence = GameManager.Instance.userScriptManager.GetCurrentSentence();
            if (GameManager.Instance.userScriptManager.IsStatement(sentence))
            {
                GameManager.Instance.userScriptManager.ExecuteStatement(sentence);
                GoToTheNextLine();
            }
        }

        // テキストを表示
        public void DisplayText()
        {
            string sentence = GameManager.Instance.userScriptManager.GetCurrentSentence();
            _mainTextObject.text = sentence;
        }
    }
}

これで、まだ文字の表示が途中の場合でも、クリックですべての文字を表示できるようになります。

まとめ

今回はUnity 2Dでの、ノベルゲームの作り方を紹介しました。

ノベルゲームの開発は、他のゲームと比べると簡単ではありますが、テキストを一文字ずつ表示させたり、画像を表示させたりといった、ゲーム開発の基本になる部分が含まれます。

また、今回作成したものは、現在私が開発中のノベルゲームの一部分でもあります。ゲーム公開を目指して、懸命に取り組んでいきたいと思います。

オリジナルゲーム.com