ツナサンド定食

考えていたことや読んだ本の感想、作ったもの書いたものの告知を書いていこうかなぁというブログ

Unityでノベルゲームを作るとできるようになる5つのこと

ADVゲ制クリエイターズ アドベントカレンダー2024の12/15(日)の記事です。

目次

はじめに

ノベルゲームに限らず、ゲームを作ろうと思うときにどのゲームエンジンを使うのかというのは定期的に話題になりますね。

色々あるとは思いますが、まず現代でノベルゲーム作ろうと思うならティラノスクリプト/ティラノビルダーでしょう。

tyrano.jp

吉里吉里風のスクリプト記法や仕様も良いですが、なんといってもユーザーがコミュニティ化してたり、専用の発表場所があったり、コンテストなどエンジン外でのサポートや環境が整っているのが良いですね。

novelgame.jp

novelgame.jp

なかなか他のエンジンには見えないものだし、一開発者がやろうと思ってもなかなかできないので、これを使わない手はないと思います。優しい世界!

非エンジニアで初めてノベルゲーム作ろうと思うんですという人がいれば、まずティラノスクリプトやりましょうって推すと思います。

でも、私自身はUnityを使っています。

その方が私には合っていたから!

手軽さよりもできることの多さを取った結果ですが、吉里吉里やティラノスクリプトのようなノベルゲーム専用エンジンではなく、汎用ゲームエンジンを使うとできるようになることを紹介しようと思います。

note.com

この記事で

あまりおすすめできないエンジン

とされているように、Unityのような汎用ゲームエンジンをノベルゲームで使っている人は少ないかもな〜という肌感があります。

確かにハードルは高いかも。

この記事は何作かノベルゲームを作ったという人向けに、Unityでノベルゲーム作れるようになるとできることの幅が広がるよ! というお話です。

で、誰?

その前に自己紹介を……

「つなくっく」といいます。

今は「レイチェルの思い出」というノベルアドベンチャーゲーム一人で作っています。 絵もシナリオも自分で描いています。

store.steampowered.com

www.youtube.com

公式サイトもあります sandwich-kitchen.com

その前は、三人の女性と同時にデートする「ぎゃるちぇん」というフリーノベルゲームを仲間と作っていました。

www.freem.ne.jp

ストーリーの構造で遊ぶゲームの形を目指しています。

例えばノベルゲームにポイントアンドクリック性のある選択肢があるとか、ノベルゲームにアクションゲームがついているという形ではなく、 ストーリー上の文脈とかストーリー構造が重要になってくるノベルゲームが好きです。

この辺がアイデンティティになっているゲーム。

www.spike-chunsoft.co.jp

www.spike-chunsoft.co.jp

07th-expansion.net

私がUnityを使う理由

1. シナリオ用スクリプトやコアシステムとは独立したUIのしくみ

吉里吉里やArtemisEngineを使っていた時、これがなくて辛かった。。。

組み込みのもの以外でちょっとしたUIを作りたくても、シナリオ演出のための命令でUIを作らねばならなく、かなり辛かった記憶があります。

例えば前述の『ぎゃるちぇん』というゲームのタイトル画面はこんな感じ

;;;バッドエンドから戻ってくる処理

[layopt layer=1 pos=center]
[layopt layer=1 pos=center page=back]

[layopt layer=2 pos=center]
[layopt layer=2 pos=center page=back]

[layopt layer=3 pos=center]
[layopt layer=3 pos=center page=back]

[layopt layer=4 pos=center]
[layopt layer=4 pos=center page=back]

[layopt layer=5 pos=center]
[layopt layer=5 pos=center page=back]



[freeimage layer=0]
[freeimage layer=1]
[freeimage layer=2]
[freeimage layer=3]
[freeimage layer=4]
[freeimage layer=5]
[freeimage layer=6]

[fadeoutse time=800 buf=0]
[fadeoutse time=800 buf=1]


[image storage="black" layer=0 page=back visible=true]
[image storage="blank" layer=6 page=back visible=true]
[image storage="blank" layer=6 page=fore visible=true]
[m0in_out]

[wait time=400]
[image storage="white" layer=base page=back visible=true]


[trans method="crossfade" layer=0 time=400]
[trans method="crossfade" layer=6 time=400]

[wt]
        [layopt layer=0 index=20 page="back"]


    
    [cm]

    [current layer="message0"]
    
    [ct]
    [folay layer=0 time=300]
    [freeimage layer=0]

    [layopt layer=0 page="fore"]
    [backlay]
    [freeimage layer=0]



;==============================================================================================================================




*titlepage|タイトル画面


[stopbgm]

[image storage="white" layer=fore page=fore]

[backlay layer=base]


;;;バッドエンドから戻ってくる処理



    ;「最初に戻る」の有効・無効。有効にする
    [startanchor]
    
    ;メッセージ履歴への出力停止&表示不可にする
    [history output=false enabled=false]
    
    ;栞を無効にする
    [disablestore store=true restore=false]
    
    ;右クリックを禁止する
    [rclick enabled=false]




[current layer="message2"]
[position layer="message2"  margint=10]
[layopt layer="message2" visible]


[image storage="title.jpg" layer=base page=fore visible=true]
;[image storage="black.jpg" layer=base page=fore visible=true]


[disablestore]
[nowait]
[font size=20 color="0xb0c4de" shadow="false"]


[rclick jump=true target="*rclick_return" storage="rclick.ks" enabled=true]

[rclick enabled=false]




;;;▼==============================================================================================================================


;各メニューから戻ってくるラベル
*title_menu_loop
[playbgm storage="GC-MainT_OGver.mp3" loop=true]



;▼==============================================================================================================================



*title_menu_afterBGM
[seopt buf=0 volume=65 pan=0]
    [er]
    [locate x=200 y=370]
    [button graphic="01_start.png" hint="最初から始める" enterse="title_cursor.wav" clickse="title_button.wav" target="*title_menu_start"]
    [locate x=200 y=410]
    [button graphic="02_load.png" hint="続きから始める(セーブデータを参照する)" enterse="title_cursor.wav" clickse="title_button.wav" target=*title_menu_continue]
    
    [locate x=200 y=450]
    [button graphic="04_endlist.png" hint="バッドエンドリスト" enterse="title_cursor.wav" clickse="badendlist_open.wav"  target=*title_badend_list]
    [locate x=200 y=490]
    [button graphic="05_exit.png" hint="ゲームを終了する" enterse="title_cursor.wav" target=*title_exit_game]

    
    ;バッドエンドへ向かうためのフラグをfalseにする(こうしないと何回ロードしても同じ所でハマる)
    [eval exp="f.badend01Flg=false"]
    [eval exp="f.badend02Flg=false"]
    [eval exp="f.badend02Flgm=false"]
    [eval exp="f.badend03Flg=false"]
    [eval exp="f.badend04Flg=false"]
    [eval exp="f.badend05Flg=false"]
    [eval exp="f.badend06Flg=false"]
    [eval exp="f.badend07Flg=false"]
    [eval exp="f.badend08Flg=false"]
    [eval exp="f.badend09Flg=false"]
    [eval exp="f.badend10Flg=false"]
    [eval exp="f.badend11Flg=false"]
    [eval exp="f.badend12AFlg=false"]
    [eval exp="f.badend12BFlg=false"]


@iscript
    System.doCompact();
@endscript

[s]



;●「ゲームを最初から始める」
*title_menu_start
    [fadeoutbgm time=5000]
    ;ウェイト解除
    [delay speed=user]
    
    ;「右クリックサブルーチンの設定を変更」は省略
    
    ;栞に挟めるようにする
    [disablestore store=true restore=true]
    
    ;メッセージ履歴への出力を再開&表示可能にする
    [history output=true enabled=true]

    ;タイトル画面のテーマ曲をフェードアウト
    [fadeoutbgm time=1000]
    
    ;実際のシナリオへジャンプ(シナリオを始める前にトランジションをするところ)
    [jump storage="00-scenario.ks"]
    [s]



;●続きから始める
*title_menu_continue
    ; ロード画面へ

    ;ウェイト解除
    [delay speed=user]
    
    ;栞に挟めるようにする
    [disablestore store=true restore=true]
    
    ;メッセージ履歴への出力を再開&表示可能にする
    [history output=true enabled=true]
    
    ;実際のシナリオへジャンプ(シナリオを始める前にトランジションをするところ)
    [call storage="title_load.ks" target=*load_menu]



;●バッドエンドリスト
*title_badend_list

    [call storage="badend_list.ks"]

[s]


;●ゲームを終わる(タグに組み込めばいい話だが、明示的・これをテンプレとしていろいろ手を加えやすいようここに記述)
*title_exit_game
    [eval exp="kag.closeByScript(%['ask'=>'true'])"]

    ;ダイアログボックスで「いいえ」を選択した場合に、シナリオが終了しないようにする
    [jump target="*title_menu_afterBGM"]

[s]


;▲==============================================================================================================================


;▼==============================================================================================================================

;◇テストプレイ用 シナリオ選択◇[r][r]
省略、リリース時にはコメントアウトする

;▲==============================================================================================================================

[endnowait]
[s]

これの何が辛いのかというと……

  • UI専用の仕組みではなくシナリオスクリプトを再生してラベルに引っ掛けているだけなので、あまり複雑なことはできない
  • ただUIを表現したいだけなのに、メッセージレイヤーをいじらなければならず、シナリオ本編演出に影響を与えてしまう
  • 背景画像の操作と栞への記録という、役割の異なることを同じ場所で行わなければならない
    • レイヤーの裏表の操作も本当はシナリオ演出のためのもの

つまりは仕組みの役割が混在してしまっているので、予想もしないバグを生んでしまう可能性があるということです。

タイトル画面を作っていただけなのにレイヤーの裏表をミスってしまってシナリオ再生部分に影響が出るとか。

ただUI部分に文字を出したいだけなのに、本文の表示に影響が出るとか。

バグというものは何かを作ろうとする上では必ず存在するものなので、最初から完全になくすというのは不可能に近いです。

であればバグの原因をいかに早く究明するかというのが重要になってくるわけですが、役割が混在していると原因の検討もつきにくいというものです。

今作ってる『レイチェルの思い出』はこんな感じ。

コードはこう(一部素材ファイル名は変えています)

using System.Collections;
using UnityEngine;
using Naninovel;
using Naninovel.UI;
using UnityEngine.UI;

namespace Rachel.UI
{
    [RequireComponent(typeof(CanvasGroup))]
    public class TitleUI : CustomUI, ITitleUI
    {
        private IScriptPlayer _player;
        private string titleScriptName;
        private IAudioManager _audioManager;
        private IUIManager _uiManager;
        private ICustomVariableManager _customVariableManager;

        public Image backgroundImage; // 常に表示される背景画像
        public Image foregroundImage; // フェードインするための前景画像
        private Sprite[] BackgroundImages => backgroundImages;    // 表示する画像の配列
        [SerializeField] private Sprite[] backgroundImages = default;

        public float backgroundChangeInterval = 2.0f; // 画像が変わる間隔(秒)
        private int currentBackgroundIndex; // 現在の画像のインデックス
        public float backgroundFadeDuration = 1.0f; // フェードインにかかる時間(秒)

        public Image characterImage;
        private Sprite[] CharacterImages => characterImages;    // 表示する画像の配列
        [SerializeField] private Sprite[] characterImages = default;
        public float characterChangeInterval = .2f; // 画像が変わる間隔(秒)
        private int currentCharacterIndex; // 現在の画像のインデックス

        protected override void Awake ()
        {
            base.Awake();

            _player = Engine.GetService<IScriptPlayer>();
            titleScriptName = Engine.GetConfiguration<ScriptsConfiguration>().TitleScript;
            _audioManager = Engine.GetService<IAudioManager>();
            _uiManager = Engine.GetService<IUIManager>();
            _customVariableManager = Engine.GetService<ICustomVariableManager>();
        }

        public override async UniTask ChangeVisibilityAsync (bool visible, float? duration = null, AsyncToken asyncToken = default)
        {
            if (visible && !string.IsNullOrEmpty(titleScriptName))
                using (new InteractionBlocker())
                    await PlayTitleScript(asyncToken);
            await base.ChangeVisibilityAsync(visible, duration, asyncToken);
        }

        protected virtual async UniTask PlayTitleScript (AsyncToken asyncToken)
        {
            while (Engine.Initializing) await AsyncUtils.WaitEndOfFrameAsync();
            await _player.PreloadAndPlayAsync(titleScriptName);
            asyncToken.ThrowIfCanceled();
            while (_player.Playing) await AsyncUtils.WaitEndOfFrameAsync();
            asyncToken.ThrowIfCanceled();
        }


        private IEnumerator RotateBackgroundImages()
        {
            backgroundImage.sprite = backgroundImages[currentBackgroundIndex]; // 初期画像を背景に設定
            foregroundImage.color = new Color(1f, 1f, 1f, 0f); // 前景を透明に設定
            while (true)
            {
                currentBackgroundIndex = (currentBackgroundIndex + 1) % backgroundImages.Length; // 次の画像のインデックスを更新
                foregroundImage.sprite = backgroundImages[currentBackgroundIndex]; // 新しい画像を前景に設定
                StartCoroutine(FadeImageIn(foregroundImage, backgroundFadeDuration)); // フェードインを開始
                yield return new WaitForSeconds(backgroundChangeInterval + backgroundFadeDuration); // 画像表示の間隔とフェードイン時間を考慮して待機
                backgroundImage.sprite = foregroundImage.sprite; // フェードイン完了後、背景を更新
                foregroundImage.color = new Color(1f, 1f, 1f, 0f); // 前景を再度透明に設定
            }
        }

        private IEnumerator RotateCharacterImages()
        {
            characterImage.sprite = characterImages[currentCharacterIndex];
            while (true)
            {
                characterImage.sprite = characterImages[currentCharacterIndex];
                currentCharacterIndex = (currentCharacterIndex + 1) % characterImages.Length;
                yield return new WaitForSeconds(characterChangeInterval);
            }
        }

        private IEnumerator FadeImageIn(Image img, float duration)
        {
            float elapsedTime = 0f;
            Color c = img.color;
            while (elapsedTime < duration)
            {
                c.a = Mathf.Clamp01(elapsedTime / duration); // アルファ値を徐々に1に近づける
                img.color = c;
                elapsedTime += Time.deltaTime;
                yield return null;
            }
        }

        public override void Show()
        {
            base.Show();
            _audioManager.PlayBgmAsync("タイトル画面で流す音楽", 1f, 0.5f);

            StartCoroutine(RotateBackgroundImages());
            string isClearedVal = _customVariableManager.GetVariableValue("'クリアしたかどうか'");
            bool isCleared = "1" == isClearedVal;
            if (!isCleared)
            {
                StartCoroutine(RotateCharacterImages());
            }

#if UNITY_EDITOR
            bool isDebugMode = "1" == _customVariableManager.GetVariableValue("デバッグモードかどうか");
            if (isDebugMode)
            {
                DebugScenarioListUI debugScenarioListUI = _uiManager.GetUI<DebugScenarioListUI>();
                debugScenarioListUI.Show();
            }
#else
            if (Debug.isDebugBuild)
            {
                DebugScenarioListUI debugScenarioListUI = _uiManager.GetUI<DebugScenarioListUI>();
                debugScenarioListUI.Show();
            }
#endif
        }

        public override void Hide()
        {
            _audioManager.StopBgmAsync("タイトル画面からスタートする時のSE", 10f);
            base.Hide();
        }
    }
}

各ボタンや配置リソースはこうなっています。

そして『START』というメニューを押した時のコードがこんな感じ。

using System;
using System.Linq;
using UnityEngine;
using Naninovel;
using TitleUI = Rachel.UI.TitleUI;

namespace Rachel
{
    public class TitleNewGameButton : AbstructButton
    {
        [Tooltip("Services to exclude from state reset when starting a new game.")]
        [SerializeField] private string[] excludeFromReset = Array.Empty<string>();

        private string startScriptName;
        private string titleScriptName;

        private IUIManager uiManager;
        private IScriptPlayer scriptPlayer;
        private IStateManager stateManager;
        private IScriptManager scriptManager;
        private ICustomVariableManager customVariableManager;
        private IAudioManager audioManager;

        protected override void Awake ()
        {
            base.Awake();

            scriptManager = Engine.GetService<IScriptManager>();
            startScriptName = scriptManager.Configuration.StartGameScript;
            if (string.IsNullOrEmpty(startScriptName))
                startScriptName = scriptManager.Scripts.FirstOrDefault()?.Name;
            uiManager = Engine.GetService<UIManager>();
            scriptPlayer = Engine.GetService<IScriptPlayer>();
            stateManager = Engine.GetService<IStateManager>();
            customVariableManager = Engine.GetService<ICustomVariableManager>();
            audioManager = Engine.GetService<IAudioManager>();
            Debug.Assert(scriptPlayer != null);
        }

        protected override void Start ()
        {
            base.Start();

            if (string.IsNullOrEmpty(startScriptName))
                UIComponent.interactable = false;
        }

        protected override async void OnButtonClick ()
        {
            if (string.IsNullOrEmpty(startScriptName))
            {
                Engine.Err("Can't start new game: specify start script name in scripts configuration.");
                return;
            }

            await audioManager.PlaySfxAsync("SFX/Button/apply_audiostock_146290");  // TODO: この辺のリソース名は定数にする?

            await PlayTitleNewGame();
            TitleUI titleUI = uiManager.GetUI<TitleUI>();
            titleUI.Hide();
            stateManager.ResetStateAsync(excludeFromReset,
                () => scriptPlayer.PreloadAndPlayAsync(startScriptName)).Forget();
        }

        protected virtual async UniTask PlayTitleNewGame ()
        {
            const string label = "OnNewGame";
            var scriptName = scriptManager.Configuration.TitleScript;
            if (!scriptManager.TryGetScript(scriptName, out var script) || !script.LabelExists(label)) return;
            scriptPlayer.ResetService();

            // NewGame開始時にはロード元番号をクリアする
            (new LoadSource(Engine.GetService<CustomVariableManager>())).ClearLoadSourceSaveSlot();

            // 思い出シナリオ再生中情報をクリア
            // CustomVariableManager customVariableManager = Engine.GetService<CustomVariableManager>();
            customVariableManager.SetVariableValue(CustomVariableReservedNameConstants.PlayingMemoryScenarioId,"");

            await scriptPlayer.PreloadAndPlayAsync(scriptName, label: label);
            await UniTask.WaitWhile(() => scriptPlayer.Playing);
        }
    }
}

これの何が嬉しいのかというと……UIの処理を記述する際に本編シナリオを再生する部分と完全に分けてしまえるところです。

タイトル画面そのもののコードと、それぞれのボタンを押した時のコードが容易に分けられるのが良いです。複雑なことをやりやすいのも良いですね。

処理の責務毎に別のファイルにしたり、独立した形にできるところが好きです。

今回例示したタイトル画面は比較的シンプルな形ですが、ゲーム内で凝ったUIを作りたい場合は柔軟性が欲しいところでした。

2. CSharpでありオブジェクト指向的言語なので、継承しながら使うことができる

オブジェクト指向についてこの資料がまとまっていました

www.docswell.com

ゲームエンジンやアセット自体の機能弄らずに拡張することができるのがすごく良いですね。

すでに社会人として働いていたので、 前作『ぎゃるちぇん』はおよそ2年ほど、今回『レイチェルの思い出』も年単位の時間をかけて開発しています。

数週間程度で作れるものなら良いのですが、 開発期間中にゲームエンジンやアセット側のアップデートが入ってしまうと、ゲームエンジン自体を書き換えてしまっている場合はアップデートがかなり大変です。

その点ゲームエンジン・アセット自体を書き換えずにカスタマイズすることが可能なので、自分が書いたコードを追従させるだけなので、そこが良いなと思っています。

制作に何年もかければ作品のクォリティが高くなるという保証はどこにもありません。3日で作ったゲームに劣ることも、もしかしたらあるでしょう。

でも、逆もまた然りであるかというとまた違うのが面白いところです。制作者としての人生を考えた時に、長期的にじっくり腰を据えて制作した経験というのは生きてくるはずです。

3. 型

ちょっとプログラミング的な話ですが、型の存在する言語でプログラミングをする方が嬉しいことが多いです。

ja.wikipedia.org

  • エラーの防止
  • コードが読みやすくなる
  • 自動補完とエラーチェック
  • パフォーマンスがよくなる
  • 処理を切り出す時に安全に使うことができる

なんてところがメリットとして挙げることができますが……こればかりは実際にプログラミングしてみないと、ありがたみがわからないかも。

4. ポストエフェクト

詳しい内容についてはここで書くよりも解説記事がたくさん出ているので、それらを読んでみてもらうのが良いでしょう。

docs.unity3d.com

qiita.com

『レイチェルの思い出』では立ち絵に対して発光処理(ブルーム)をかけるのに使っています。

他にもいろいろなエフェクトを画面にかけることができるので、興味があれば調べてみてください。

5. シェーダ

シェーダについてもここでは省略。これらの資料を軽く見てみてください。

docs.unity3d.com

www.youtube.com

『レイチェルの思い出』では、思い出を選ぶ際に背景・立ち絵をぼかすためにシェーダを使っています。

もっと使い倒そうとすると色々留意点はありますが、ノベルゲームの画面効果程度であれば充分でしょう。

その他

他にもゲーム機で動かせるようになるとか、Unityの知見が活かせるなどありますが、個人的にUnityを使う理由としてはこんなところでしょうか。

ゲーム機で動かすのはまだ私はやったことがないので、いつかやりたい!

やっぱり自分の作ったゲームをコンソール機で動かして、ストアで販売するというのは夢がありますね。ただUnityとはあんまり関係なくなってしまうのと、ゲーム機用のチューニングや実装が必要になってくるので、今回の話からは外しました。

ノベルゲームを作るためにプログラミングをするということ

あまりにツールが発達しすぎてなかなか意識しにくい人が多いかもしれない。

プログラミングなんてできてなくても作れるゲームジャンルと思われているフシもあり、それはむしろ良いことなのかもしれない。

でも、ノベルゲームを作ろうという人にも、プログラミングをするという選択肢を取ってみて欲しいです!

前述のように、表現の幅もできることも格段に広がるし、なによりプログラミング自体が楽しいことなのですから。

Unityのアセットを使おう

さすがに0からC#のコードを書いて……というのはかなり辛いです。

そこでUnityのアセットを使いましょう!

私はNaninovelというアセットを使っています。 naninovel.com

Naninovelが良いのは、なんといってもNaninovel自体を書き換えずに開発できること!

あとはDiscordで開発者の方に質問できるのが良いですね。基本的に英語でのやりとりですが、DeepLで今のところ困っていません。

アセットは他にも……

Unityでノベルゲーム用のアセットというと、他には『宴』が有名ですね。 むしろ『宴』の方が日本人ユーザ多いかも。

madnesslabo.net

同じように使っている人が同じ言語で多いというのはなかなか強いものです。 このアドベントカレンダーの執筆者にも何人かユーザの方がいますね。

汎用ゲームエンジンでのゲーム開発を検討してみよう

ノベルゲームを何作か作ったな〜という方は、ぜひ次回作はUnityのような汎用ゲームエンジンで作ってみることを検討してみてください。

こういうアドベントカレンダーを読もうという方は、多かれ少なかれ、

自分の作りたいものを作るんだ!

という気概の方が多いでしょう。

確かに手軽さを求める人には向かないと思いますが、じっくりそれなりの規模を作ってみたい人はおすすめです。

最初は手軽さを重視しても良いとは思います。

でも最初は時間がかかったとしてもできることが増えるので、Unityみたいな汎用ゲームエンジンで作ってみるのをおすすめしたい!

ノーコードでできるツールというのが世の中にはいくつかありますが、個人的には懐疑的です。

ノーコードでできることというのはそのツールでできる範囲内のことしかできないのですから。自分の作りたいものがそれでできなかったら、結局自分でコードを書く羽目になります。それか自分の気持ちを諦めるか…

初心者のうちは手軽さを求めても良いと思います。むしろそれによりゲーム開発者が増えるのは大変良いことでしょう。

でも慣れてきたなら、手軽さよりも自由度を取る選択をしても良いと思います。

確かにハードルは高いし勉強することも多いですが、それを乗り越えた時に得られるものというのは必ずあります。

できることが増えていくとやっぱり楽しいものだから。

おわりに

ここまで読んでいただきありがとうございました!

「現時点での自分でもできそうか」ではなく、「自分の作りたいものを作る」という観点でツールを選んでみてください。

また、現時点での自分ではできないと思うことがあっても、自分自身がスキルを身につけてやるんだという気持ちを持つことが大事だと思います。

明日はracamyyさんによる『地方からコミケに参加し続けているお話』という記事です!