3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】スクリプト言語ベースのADVシステムとスクリプト中のセーブ&ロード、ホットリロードの実装例【LuaCSharp】

Last updated at Posted at 2025-12-14

この記事は、サイバーエージェント26卒内定者 Advent Calendarの14日目の記事です。

はじめに

Unityでアドベンチャーパートを作る際、Naninovelや宴といったビジュアルノベル制作ツールを使って制作したり、独自で定義したDSLやスプレッドシート、ScriptableObjectなどからデータを読み出して再生する機構を自力で実装したりといったことが多いと思います。

それに加えて、最近では、LuaCSharpや、VitalRouter.mrubyなどの、async/awaitに対応したスクリプト言語ランタイムが複数リリースされており、これにより、スクリプト言語を利用してアドベンチャーパートを実装することも選択肢に入りやすくなってきました。0から実装するよりも簡単ながら、カスタマイズ性も高く、手軽にコード補完ができる、アドベンチャーパート以外のことをするのにも使えるなどのメリットがあります。

例えば、Luaでは以下のようにスクリプトを書くことができます。

sample.lua
-- LuaでAdvパートのスクリプトを書く例
Camera:setZoom(2.0)
Camera:setPosition(Vector2.new(0, -8))

Adv:talk("a", "赤い部屋にようこそ!")
Camera:animateTo({position = Vector2.new(0, 3), duration = 3, ease = Ease.InOutSine, wait = false})
for count = 1, 4 do
    local insertion = string.sub("赤い部屋", 1, count)
    local answer = Adv:question("b", "あなたは" .. insertion .. "は好きですか?", {"はい", "いいえ"})
    if answer == 1 then
        Adv:talk("a", "山田太郎 山田花子 山田二郎 山田花代" .. Store:getString("user_name"))
        Store:setBool("likes_red_room", true)
        break;
    end
end

今回は、そんなスクリプト言語を利用した際に、スクリプトの途中で保存する機能 及び、それを活用したホットリロードの実現方法について考察、解説していきます。

なぜ保存するのが難しいのか

大抵のノベルゲームには、会話イベントの途中でセーブする機能が実装されています。
選択肢の前でセーブしたり、スチルの前でセーブしたり。
必須とも言えるスクリプト中の保存機能ですが、実現するのが微妙に面倒くさいです。

なぜかというと、コードの途中から実行するということができないからです。
実行コンテキストを丸ごと保存する方法もなければ、コードをスキップして途中から再生する方法も、大抵の場合提供されていません。
スクリプトに連動して動くカメラやウィンドウの状態も全て復元する必要があります。

では、どうするのかというと、以下の3つを保存することで解決します。

  • 初期状態
  • 実行インデックス
  • 決定キュー

やっていることは非常に単純で、途中の状態を復元するために、以前の状態に戻るまで前の操作を復元するだけです。
ここで、初期状態は、スクリプトを実行する直前の状態を意味します。

以降、LuaCSharpUniTaskR3VitalRouter、を使用したサンプルコードを見ながら説明していきます。
LuaCSharpはLua言語の実行、UniTaskは非同期処理、R3はイベント通知、VitalRouterはコマンドの発行(Pub/Subパターン)に使用しています。
それぞれ知らなくてもそこまで問題はないと思います。

1. 状態を定義する (AdventureSnapshot)

まずは、保存データの核となるデータ構造です。
ここでは簡易的にするため、シリアライズの処理は書いていません。シナリオ部分以外のセーブデータと共にシリアライズ専用のクラスを作成してシリアライズするようにすると良いと思います。
ここでのポイントは、「どのスクリプトの」「どこまで進んで」「どんな選択をしたか」 を記録することです。

AdventureSnapshot.cs
// 決定(Decision)のマーカーインターフェース
// 選択肢の選択結果など、分岐に関わるユーザー入力を記録します
public interface IAdventureDecision
{
}

// 保存データ(Snapshot)
// これさえあれば、特定時点の状態を完全に再現できるデータ構造です
public class AdventureSnapshot {
    public AdventureSnapshot(
        string ScriptSource,           // 実行していたスクリプト名
        int StepIndex,                 // どこまで進んだか(インデックス)
        IAdventureStoreSnapshot InitialStoreSnapshot, // 開始時の変数の状態
        IAdventureDecision[] Decisions // ユーザーが下した決定の履歴
    ) { /* ... */ }
}

この AdventureSnapshot があれば、ロード処理が可能になります。

2. 進行モードを伝える(AdventureContext)

次に、実行中の状態管理を行う AdventureContext です。
これは、Adv:talkAdv:questionなどの具体的なLuaからの呼び出しに対して処理を行う
CommandHandlerに渡して、現在が 通常プレイ中なのか、復元中なのか を伝える役割を持ちます。AdventurePlayer以外から操作してほしくないものを隠すために、IAdventureContextを介して操作させるとよいでしょう。
合わせて、IAdventureDecisionの管理も行います。

AdventureContext.cs
public enum AdventurePlayMode
{
    Normal, // 通常モード
    Restore, // 復元モード
    FastForward // スキップモード
}
public interface IAdventureContext
{
    public Observable<IAdventureDecision> OnDecisionConfirmed { get; }
    public Observable<Unit> OnPlayCompleted { get; }
    public Observable<Unit> OnPlayCanceled { get; }
    public AdventurePlayMode CurrentPlayMode { get; }

    public void StepNext();
    public void ConfirmDecision(IAdventureDecision decision);
    public void RestoreDecision();
    public bool TryGetLastDecision<TDecision>(out TDecision? decision) where TDecision : IAdventureDecision;
}

public sealed class AdventureContext : IDisposable, IAdventureContext
{
    private string _scriptSource;
    
    // モード管理
    private bool _isFastForwarding = false;
    private bool _isRestoring = false;
    private int _restorationStepIndex;

    // 変数状態管理
    private IAdventureStore _store;
    private IAdventureStoreSnapshot _initialStoreSnapshot;

    // 決定
    private List<IAdventureDecision> _decisions = new();
    private Queue<IAdventureDecision> _restoreDecisionQueue = new();

    // イベント
    private Subject<IAdventureDecision> _onDecisionConfirmed = new();
    private Subject<Unit> _onPlayCompleted = new();
    private Subject<Unit> _onPlayCanceled = new();
    private Subject<AdventurePlayMode> _onPlayModeChanged = new();

    public AdventurePlayMode  CurrentPlayMode
    {
        get
        {
            if (_isRestoring)
            {
                return AdventurePlayMode.Restore;
            }
            return _isFastForwarding
                ? AdventurePlayMode.FastForward
                : AdventurePlayMode.Normal;
        }
    }
    
    // 現在のステップ数(何回目のコマンド実行か)
    public int CurrentStepIndex { get; private set; }

    // 初期状態のsnapshot
    private IAdventureStoreSnapshot _initialStoreSnapshot;

    public AdventureContext(string scriptSource, IAdventureStore store, AdventureSnapshot? restoreSnapshot = null)
    {
        _store = store;
        _scriptSource = scriptSource;
        _restorationStepIndex = -1;
        CurrentPlayMode = AdventurePlayMode.Normal;
        if (restoreSnapshot != null)
        {
            CurrentPlayMode = AdventurePlayMode.Restore;
            _restoreDecisionQueue = new Queue<IAdventureDecision>(restoreSnapshot.Decisions);
            _restorationStepIndex = restoreSnapshot.StepIndex;

            _initialStoreSnapshot = restoreSnapshot.InitialStoreSnapshot;
            _store.RestoreSnapshot(_initialStoreSnapshot);
        }
        else
        {
            _initialStoreSnapshot = store.CreateSnapshot();
        }
    }

    // ステップを進める
    // 復元モードの場合、目標のステップまで到達したら通常モードに切り替えます
    public void StepNext()
    {
        CurrentStepIndex++;
        if (CurrentPlayMode == AdventurePlayMode.Restore && CurrentStepIndex >= _restorationStepIndex)
        {
            CurrentPlayMode = _isFastForwarding
                ? AdventurePlayMode.FastForward
                : AdventurePlayMode.Normal;
            _restoreDecisionQueue.Clear();
        }
    }

    public void SetFastForwarding(bool isFastForwarding)
    {
        if (_isFastForwarding == isFastForwarding)
        {
            return;
        }
        _isFastForwarding = isFastForwarding;
        if (CurrentPlayMode != AdventurePlayMode.Restore)
        {
            _onPlayModeChanged.OnNext(CurrentPlayMode);
        }
    }

    // 決定(Decision)を確定させる(通常プレイ時)
    public void ConfirmDecision(IAdventureDecision decision)
    {
        _decisions.Add(decision);
        _onDecisionConfirmed.OnNext(decision);
    }

    // 決定を復元する(復元モード時)
    public void RestoreDecision()
    {
        if (CurrentPlayMode != AdventurePlayMode.Restore) 
            throw new InvalidOperationException("復元モードではありません");
            
        var decision = _restoreDecisionQueue.Dequeue();
        ConfirmDecision(decision); // 履歴に追加し直す
    }

    // 確定したDecisionを読み出す
    public bool TryGetLastDecision<TDecision>(out TDecision? decision) where TDecision : IAdventureDecision
    {
        decision = default;
        if (_decisions.Count == 0)
        {
            return false;
        }
        if (_decisions[^1] is TDecision lastDecision)
        {
            decision = lastDecision;
            return true;
        }
        return false;
    }
    // ... スナップショット作成処理など ...
}

3. Luaコマンドとの連携 (LuaObject, CommandHandler)

「復元モード(Restore Mode)」の時、スクリプトから呼ばれたコマンド(会話や選択肢)は、「演出をスキップし、保存された決定を自動で適用し、即座に次へ進む」 ことで、即座の復元を可能にします。

Luaから触ることができるようにする[LuaObject]をつけたクラスには、可変引数の対応や型の判定などの処理も入ってくるので、ここでは、VitalRouterを用いてコマンドを発行して、処理を依存関係のないクラスに委譲します。

Lua関数をC#側で実装する際、以下のようなパターンで記述することによりこれを実現します。

LuaAdventure.cs
[LuaObject]
public sealed partial class LuaAdventure : IAdventureInitializer
{
    private ICommandPublisher _commandPublisher;
    private AdventureContext _adventureContext;
    // ... talk は省略 ...
    [LuaMember("question")]
    public async UniTask<int> Question(string message, LuaTable options, CancellationToken cancellationToken)
    {
        var optionLabels = options.GetArraySpan().ToArray()
            .Where(v => v.Type == LuaValueType.String)
            .Select(v => v.Read<string>()).ToArray();
        // VitalRouterでコマンドを発行する
        await _commandPublisher.PublishAsync(new AdventureQuestionCommand(_adventureContext, message, optionLabels.ToArray()), cancellation: cancellationToken);

        // 選択されたか、復元されたIAdventureDecisionを取得する
        return _adventureContext.TryGetLastDecision<AdventureOptionDecision>(out var decision)
            ? decision.SelectedOptionIndex
            : -1;
    }

    // 初期化 IAdventureInitializerを介してAdventurePlayer(後述)から呼び出すことを想定
    public UniTask SetupAsync(IAdventureContext context, LuaState state, CancellationToken cancellationToken)
    {
        _adventureContext = context;
        state.Environment["Adv"] = this;
        return UniTask.CompletedTask;
    }
}

上記のコマンドに対して、質問の表示と選択を設定するクラスはこのようになります。
VitalRouterによって、AdventureQuestionCommandが発行されると、以下のクラスのOnメソッドが呼ばれます。
ここでのポイントは、StepNextによる復元ポイントの作成です。
各モードで復元する際のIndexが変わらないよう、同じ回数だけStepNextを呼ぶ必要があります。

QuestionCommandHandler.cs
[Routes]
public sealed partial class QuestionCommandHandler
{
    private readonly QuestionView _questionView; // 省略、質問文や選択肢を表示する
    private readonly AdventureInputHandler _inputHandler;

    public QuestionCommandHandler(QuestionView questionView, AdventureInputHandler inputHandler)
    {
        _questionView = questionView;
        _inputHandler = inputHandler;
    }

    [Route]
    public async UniTask On(AdventureQuestionCommand command, CancellationToken cancellationToken)
    {
        switch(command.Context.CurrentPlayMode)
        {
            case AdventurePlayMode.Normal:
                var index = await UniTask.WhenAny(
                    _questionView.ShowMessageAsync(command.Speaker.DisplayName, command.Message, cancellationToken),
                    _inputHandler.WaitForConfirmAsync(cancellationToken),
                    // 途中でスキップモードになったら入力や表示完了を待たない
                    command.Context.OnPlayModeChanged.FirstAsync(mode => mode != AdventurePlayMode.Normal).AsUniTask()
                );
                if(index != 0)
                {
                    // スキップされた場合は即座に全文表示
                    _questionView.ShowMessageAll();
                }
                _questionView.ShowOptions(command.Options);
                command.Context.StepNext();
                break;
            case AdventurePlayMode.FastForward:
                // スキップモードのときは選択肢まで飛ばす
                _questionView.ShowMessageAll();
                _questionView.ShowOptions(command.Options);
                command.Context.StepNext();
                break;
            case AdventurePlayMode.Restore: // 復元モードのとき
                command.Context.StepNext(); // 復元ポイントを進める
                if (command.Context.CurrentPlayMode == AdventurePlayMode.Restore)
                {
                    // 既に選択済みの場合は選択肢を表示せずに決定を復元して終了
                    command.Context.RestoreDecision();
                    return;
                }
                // ここで復元が終わったら
                _questionView.ShowMessageAll();
                _questionView.ShowOptions(command.Options);
                break;
        }

        // 選択を待つ
        var selectedIndex = -1;
        try
        {
            selectedIndex = await _questionView.OnOptionSelected.FirstAsync(cancellationToken);
        }
        finally
        {
            _questionView.ClearOptions();
        }

        // 選択肢と質問文を非表示
        _questionView.HideMessage();
        await UniTask.WaitForSeconds(0.1f, cancellationToken: cancellationToken);

        // 決定を確定
        command.Context.ConfirmDecision(new AdventureOptionDecision(selectedIndex + 1));
    
    }
}

このように、コマンド側で IsRestoreMode を見て分岐させることで、スクリプト側(Lua)は何も気にすることなく「ロード機能」に対応できます。

アドベンチャーゲームにありがちな既読スキップ・早送り機能も、AdventurePlayModeの一つとして実装しています。

4. ホットリロードに対応する再生システム(AdventurePlayer)

最後に、これらを統括する AdventurePlayer を紹介します。
ちょっとした演出の修正や、カメラ演出の確認などを簡単に行うために、ホットリロード機能を組み込んでいます。

AdventurePlayer.cs
public sealed class AdventurePlayer
{
    // ... メンバ変数定義 ...
    public async UniTask<LuaValue[]> PlayAsync(string scriptSource, AdventureSnapshot? restoreSnapshot = null, CancellationToken cancellationToken = default)
    {
        _isHotReloading = false;
        _playCts?.Cancel();
        _playCts?.Dispose();
        _playCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        _currentContext = new AdventureContext(scriptSource, _store, restoreSnapshot);

        // ファイルの読み込み
        using var luaAsset = await _resourceLoader.LoadAssetAsync<LuaAsset>(scriptSource, cancellationToken: _playCts.Token);
        if (luaAsset == null)
        {
            throw new System.IO.FileNotFoundException($"`{scriptSource}` の読み込みに失敗しました。");
        }

        var state = CreateState();
        LuaValue[] result;
        try
        {
            // LuaStateのセットアップ
            await UniTask.WhenAll(_adventureInitializers.Select(i => i.SetupAsync(_currentContext, state, _playCts.Token)));
            // Luaスクリプトの実行
            result = await state.DoStringAsync(luaAsset.Asset.Text, cancellationToken: _playCts.Token);
            _currentContext.Complete();
        }
        catch (OperationCanceledException) when (!_currentContext.IsCompleted && _isHotReloading)
        {
            var snapshot = _currentContext.CreateSnapshot();
            snapshot = snapshot with { StepIndex = _hotReloadIndex >= 0 ? _hotReloadIndex : snapshot.StepIndex };
            // ホットリロード
            result = await PlayAsync(scriptSource, snapshot, cancellationToken);
        }
        finally
        {
            _currentContext.Dispose();
            _currentContext = null;
        }
        return result;
    }
    }

    // ホットリロードのトリガー
    public void DispatchHotReload(int hotReloadIndex = -1)
    {
        _isHotReloading = true;
        _hotReloadIndex = hotReloadIndex;
        _playCts?.Cancel(); // 実行中のLuaをキャンセル
    }
}

DispatchHotReload が呼ばれると、実行中の DoStringAsyncOperationCanceledException を投げます。これを catch ブロックで捕まえ、「今の状態をSnapshotとして保存」した上で PlayAsync を呼び直す という流れです。

長くなるためエディター部分は省略しますが、FileSystemWatcherでファイルの変更を検知し、DispatchHotReloadを呼び出します。合わせて、StepIndexを操作するようなボタンを付け、巻き戻して演出等を確認することもできるよう作っています。

スクリーンショット 2025-12-14 213055.png

これにより、開発中にLuaスクリプトを書き換えて保存した瞬間、

  1. 現在の状態を保存
  2. 実行停止
  3. 新しいスクリプトを読み込み
  4. 保存した地点まで高速スキップ(Restoreモード)
  5. 続きから通常再生

という、シームレスなスクリプトのホットリロードが可能になります。

まとめ

今回紹介した「初期状態 + 決定ログ」による復元方式によって、
スクリプト言語ベースのADVシステムを途中のセーブやホットリロードに対応することができました。

この方式の注意点は、全てを決定論的に進める必要がある点です。LuaのScript内で、組み込みのmath.randomなどを使用して分岐するなどしてしまうと壊れてしまうので、
シード値を固定するか、ランダムの結果もIAdventureDecisionとして保存する必要があります。

また、スクリプト変更によるデータ破損にも注意が必要です。
途中で行が追加・削除された場合は、セーブの位置がずれてしまうので、適宜バリデーションを噛ます必要があります。

スクリプト中のセーブ/ロードが必要にない場合でも、ホットリロードを実装したくなる場合もあると思いますが、このような設計を使えば実現できるかと思います。

量産パートを楽にできるよう、スクリプトが書きやすい環境の整備を意識していきたいです。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?