1
0

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エンジニアのための設計入門② 複雑な分岐にState, 要求と実行の分離にCommand

Last updated at Posted at 2025-06-09

シリーズ目次

Unityエンジニアのための設計入門① 設計を学ぶ意義
Unityエンジニアのための設計入門② 複雑な分岐にState, 要求と実行の分離にCommand

今回の目的

  • 主にジュニアエンジニア向けに使いやすいデザインパターンを2つ共有する
  • デザインパターンを導入することで、機能追加・変更しやすいコードになる体験を得てもらえれば目標達成

デザインパターンとは何か

ソフトウェア開発の長い歴史の中で状況に応じた設計の型が蓄積されてきました。
デザインパターンはその設計の型を共通言語化したものです。

  • 真似するだけで保守しやすい構造になる
  • レビューで「ここはStateで切ろう」と言えば意図が通じる
  • 自分の書いたコードについて、どこに何を書くべきか迷子になりにくい

今回は状態爆発を抑えるStateパターン、
要求と実行を切り離すCommandパターンを取り上げます。

実戦的なデザインパターンの例を共有

Stateパターン

目的

オブジェクトが複数の状態を持ち、それぞれで挙動が変わるときに、

  • if–elseがどんどん増えて複雑化する
  • 新しい処理を追加すると既存コードを壊しそうで怖い

という問題を避けるためのパターンです。

このパターンには

  • 状態ごとにクラスを分けることで、処理のまとまりがはっきりする
  • 新しい状態を作るときはクラスを1つ追加するだけなので、既存コードに手を入れずに拡張できる
  • 各状態クラスは単体テストしやすく、バグを早く発見できる

メリットがあります。

実装例

保存UIの実装を題材として、Stateパターンの実装例を説明します。

Before - if-elseが複雑化した例
  • スクリプトの中に不具合の原因が混ざっていますが、if-elseが複雑化しているために気付きにくくなっています
  • 仮に気付くことが出来ても、処理の追加は難しくなっていきます
public class SaveButtonController : MonoBehaviour
{
    [SerializeField] private Button saveBtn;
    [SerializeField] private GameObject spinner;
    [SerializeField] private GameObject errorDialog;

    private bool  _isSaving;
    private bool  _isError;
    private float _errorStart;
    private int   _retry;

    private void Awake() => saveBtn.onClick.AddListener(OnSaveClicked);

    private void Update()
    {
        // エラー表示を3秒で閉じたい
        if (_isError && Time.time - _errorStart > 3f)
        {
            _isError = false;
            errorDialog.SetActive(false);
        }
    }

    private async void OnSaveClicked()
    {
        // 二重押し・エラー中は無視
        if (_isSaving || _isError) return;

        _isSaving = true;
        spinner.SetActive(true);

        var ok = await FakeServer.SaveAsync();
        if (ok)
        {
            Toast.Show("保存成功");
            // ⭐︎⭐︎⭐︎不具合1: ここで_isErrorをfalseにしていないので
            // 一度失敗した後3秒以内に成功した時、エラーダイアログがすぐに閉じられない
            _retry = 0;
        }
        else
        {
            if (_retry < 2)
            {
                _retry++;
                await UniTask.Delay(500);
                // ⭐︎⭐︎⭐︎不具合2: ここで_isSavingをfalseにしていないので
                // 下記のOnSaveClickedで最初の早期リターンが行われてretryに失敗する
                OnSaveClicked();
                return;
            }

            _isError    = true;
            _errorStart = Time.time;
            errorDialog.SetActive(true);
            _retry = 0;
        }

        _isSaving = false;
        spinner.SetActive(false);
    }
}
After - State管理で改善
まずは状態遷移図を作成
  1. 要件, 振る舞いを箇条書きで全部書き出す
    まずはUIUXや内部挙動に関わるすべてを漏れなく列挙します
    • ボタン押下時 → 保存処理開始
    • 保存中はスピナー表示 → 二重押下を禁止
    • サーバー保存成功 → トースト表示 → 通常状態に戻る
    • 保存失敗 → 最大2回リトライ
    • 最終失敗後 → エラーダイアログ表示 → 3秒後に自動で通常状態に戻る

  1. UIUXや内部挙動が変わる瞬間を洗い出す
    列挙した項目を見ながら、要件と変化するものの対応表を作成します
    要件 変化するもの
    ①保存処理開始 スピナーが出る
    ②二重押下禁止 ボタンが押せなくなる
    ③成功時 トーストを出す
    ④リトライ (内部的な挙動なのでUIは変わらず)
    ⑤リトライも失敗 → ダイアログ表示 ダイアログが出る
    ⑥3秒後 ダイアログが消える

  1. 変化するものを基にグルーピング
    同時に発生するUIや内部挙動の主要な変化をグルーピングすると、どんな状態を作成すべきかが理解できます
    • 何もしていないとき(Idle)
      • スピナー消えている
      • ダイアログ消えている
      • ボタンが押せる
    • 保存中(Saving)
      • スピナー表示
      • ダイアログ消えている
      • ボタンが押せない
    • リトライ失敗後(Error)
      • スピナー消えている
      • エラーダイアログ表示
      • ボタンが押せない

  1. 状態遷移図を作成する
    状態が遷移する方向と条件を図示化すると、状態遷移図が完成します

    ※字が汚いのはお許しください。子供の頃から書くのが面倒で、念写を渇望してきました
状態遷移図をもとにStateパターンを実装
  • 状態遷移図で整理が出来たら、各状態をクラスで表現するStateパターンを実装していきます
  • このパターンを採用することで、行われる処理が状態ごとに区切られます
    • 結果として不具合が起こらなくなっています
    • また、if-elseの複雑化防止が出来ていて、状態の区切りを踏まえて処理を追加しやすくなっています
    • どの状態にも該当しない処理が必要なときは、新しく状態を追加すれば良いです
public class SaveButtonContext : MonoBehaviour
{
    [SerializeField] private SaveButtonView view;

    private ISaveProcessState _idleState;
    private ISaveProcessState _savingState;
    private ISaveProcessState _errorState;

    private ISaveProcessState _currentState;

    private void Awake()
    {
        // 各Stateを構築
        _idleState = new IdleState(view);

        // Saving: 成功したらIdle状態に、失敗したらError状態に遷移
        _savingState = new SavingState(
            view,
            onSuccess: () => ChangeState(_idleState),
            onFailure: () => ChangeState(_errorState)
        );

        // Error: onCompleteで(=3秒後に)Idle状態に遷移
        _errorState = new ErrorState(
            view,
            onComplete: () => ChangeState(_idleState)
        );

        // 初期状態をIdleに
        ChangeState(_idleState);

        view.SaveClicked += HandleSaveClicked;
    }

    private void OnDestroy()
    {
        view.SaveClicked -= HandleSaveClicked;
    }

    private void HandleSaveClicked()
    {
        // Idleのときだけ保存開始が可能
        if (_currentState == _idleState)
        {
            ChangeState(_savingState);
        }
    }

    private void ChangeState(ISaveProcessState next)
    {
        _currentState?.Exit();
        _currentState = next;
        _currentState.Enter();
    }

    public interface ISaveProcessState
    {
        // 状態に入った時に実行
        void Enter();

        // 状態を離脱する時に実行
        void Exit();
    }

    // IdleState : 通常状態
    private class IdleState : ISaveProcessState
    {
        private readonly SaveButtonView _view;
        
        public IdleState(SaveButtonView view) => _view = view;

        public void Enter()
        {
            _view.ShowSpinner(false);
            _view.ShowError(false);
        }

        public void Exit()
        {
            // 特になし
        }
    }

    // SavingState : 非同期で保存中
    private class SavingState : ISaveProcessState
    {
        private readonly SaveButtonView _view;
        private readonly Action _onSuccess;
        private readonly Action _onFailure;
        private CancellationTokenSource _cts;

        public SavingState(SaveButtonView view, Action onSuccess, Action onFailure)
        {
            _view = view;
            _onSuccess = onSuccess;
            _onFailure = onFailure;
        }

        public void Enter()
        {
            _view.ShowSpinner(true);
            
            _cts = new CancellationTokenSource();
            RunSaveLoopAsync(_cts.Token).Forget();
        }

        public void Exit()
        {
            _cts?.Cancel();
            _cts?.Dispose();
            _cts = null;
            
            _view.ShowSpinner(false);
        }

        private async UniTaskVoid RunSaveLoopAsync(CancellationToken ct)
        {
            const int MaxRetry = 2;
            for (int retry = 0; retry <= MaxRetry; retry++)
            {
                if (ct.IsCancellationRequested) return;

                bool ok = await FakeServer.SaveAsync().AttachExternalCancellation(ct);
                if (ok)
                {
                    Toast.Show("保存成功");
                    _onSuccess();
                    return;
                }
            }
            _onFailure();
        }
    }

    // ErrorState : リトライ失敗時
    private class ErrorState : ISaveProcessState
    {
        private readonly SaveButtonView _view;
        private readonly Action _onComplete;

        public ErrorState(SaveButtonView view, Action onComplete)
        {
            _view = view;
            _onComplete = onComplete;
        }

        public void Enter()
        {
            _view.ShowError(true);
            ReturnToIdleAsync().Forget();
        }

        public void Exit()
        {
            _view.ShowError(false);
        }

        private async UniTaskVoid ReturnToIdleAsync()
        {
            await UniTask.Delay(TimeSpan.FromSeconds(3));
            _onComplete();
        }
    }
}

// 保存UI(View)
public class SaveButtonView : MonoBehaviour
{
    [SerializeField] private Button saveBtn;
    [SerializeField] private GameObject spinner;
    [SerializeField] private GameObject errorDialog;

    public event Action SaveClicked;

    private void Awake()
    {
        saveBtn.onClick.AddListener(() => SaveClicked?.Invoke());
    }

    public void ShowSpinner(bool on)
    {
        spinner.SetActive(on);
    }

    public void ShowError(bool on)
    {
        errorDialog.SetActive(on);
    }

    private void OnDestroy()
    {
        saveBtn.onClick.RemoveAllListeners();
    }
}

例として分かりやすい要件を扱いましたが、実務でここまで設計を徹底すると要件の割に冗長かもしれませんね。
ただ、Stateパターンの便利さは共有出来ていると嬉しいです。

Commandパターン

目的

コマンドパターンは何をしたいかを「コマンドオブジェクト」として切り出し、要求箇所と実行箇所を分離する設計手法です。

これにより

  • 入力経路や呼び出し側はコマンドを渡すだけでよく、処理の詳細を知らなくてよい
  • 処理の実行タイミングをキューに入れたり、ログに残したり、まとめて再生したり、と柔軟に制御できる
  • 新しい操作を追加するときはコマンドクラスを追加するだけで済み、既存コードへの影響を最小化できる

というメリットがあります。

実装例

UI、VR、音声、ネット同期、キーボードの5つの経路から入力を受け、順番にオブジェクトを表示する実装です。

Before - 要求と実行を抱え込んだ場合
  • Commandパターンのメリットをより実感して頂くために、Undo/Redoやマクロ機能を追加しています
  • 入力経路を増やしたり、処理の種類を増やす度に複雑化していくのが想像できます
public class ProcedureManager : MonoBehaviour
{
    [SerializeField] private Button addBtn;
    [SerializeField] private Button removeBtn;
    [SerializeField] private Button saveMacroBtn;
    [SerializeField] private Button loadMacroBtn;

    [SerializeField] private GameObject stepPrefab;
    [SerializeField] private Transform hand;
    [SerializeField] private Camera mainCamera;

    // 実際にシーン上に置かれたステップオブジェクト一覧
    private readonly List<GameObject> steps = new List<GameObject>();

    // 追加・削除の履歴を表す型
    private enum ActionKind { Add, Remove }
    private struct StepAction
    {
        public ActionKind Kind;
        public StepData Data;
        public StepAction(ActionKind kind, StepData data)
        {
            Kind = kind;
            Data = data;
        }
    }

    // 過去と未来の操作履歴スタック
    private readonly Stack<StepAction> pastActions = new Stack<StepAction>();
    private readonly Stack<StepAction> futureActions = new Stack<StepAction>();

    private void Awake()
    {
        // UIボタンクリックを登録
        addBtn.onClick.AddListener(OnAddButton);
        removeBtn.onClick.AddListener(OnRemoveButton);
        saveMacroBtn.onClick.AddListener(OnSaveMacroButton);
        loadMacroBtn.onClick.AddListener(OnLoadMacroButton);
    }

    private void OnAddButton()    => RecordAddAtCenter();
    private void OnRemoveButton() => RecordRemove();
    private void OnSaveMacroButton() => SaveMacro();
    private void OnLoadMacroButton() => LoadMacro();

    private void Update()
    {
        // VRコントローラ入力
        if (OVRInput.GetDown(OVRInput.Button.One)) AddAtHand(); // 手の位置に追加
        if (OVRInput.GetDown(OVRInput.Button.Two)) RemoveLast();

        // 音声認識
        string voice = VoiceRecognizer.LastCommand;
        if (voice == "add step")      AddAtCenter(); // カメラ前方に追加
        if (voice == "remove step")   RemoveLast();
        if (voice == "undo")          Undo();
        if (voice == "redo")          Redo();
        if (voice == "save macro")    SaveMacro();
        if (voice == "load macro")    LoadMacro();

        // キーボード
        if (Input.GetKeyDown(KeyCode.Z)) Undo();
        if (Input.GetKeyDown(KeyCode.Y)) Redo();
        if (Input.GetKeyDown(KeyCode.S)) SaveMacro();
        if (Input.GetKeyDown(KeyCode.L)) LoadMacro();

        // ネットワーク受信
        if (Network.HasMessage())
        {
            HandleNetwork(Network.Receive());
        }
    }

    private void AddAtCenter()
    {
        var pos = mainCamera.transform.position + mainCamera.transform.forward * 2f;
        var rot = Quaternion.LookRotation(mainCamera.transform.forward);
        RecordAdd(pos, rot);
    }

    private void AddAtHand()
    {
        RecordAdd(hand.position, hand.rotation);
    }

    private void HandleNetwork(string msg)
    {
        if (msg.StartsWith("Add:"))
        {
            var parts = msg.Substring(4).Split(',');
            var pos = new Vector3(
                float.Parse(parts[0]),
                float.Parse(parts[1]),
                float.Parse(parts[2])
            );
            RecordAdd(pos, Quaternion.identity);
        }
        else if (msg == "Remove")
        {
            RecordRemove();
        }
        else if (msg == "Undo")   Undo();
        else if (msg == "Redo")   Redo();
        else if (msg == "SaveMacro")  SaveMacro();
        else if (msg == "LoadMacro")  LoadMacro();
    }
    
    // 履歴に追加を記録して、ステップを作成
    private void RecordAdd(Vector3 pos, Quaternion rot)
    {
        var data = new StepData(pos, rot);
        
        pastActions.Push(new StepAction(ActionKind.Add, data));
        futureActions.Clear();
        
        CreateStep(pos, rot);
    }

    // 履歴に削除を記録して、最後のステップを消去
    private void RemoveLast()
    {
        if (steps.Count == 0) return;
        
        var last = steps[steps.Count - 1];
        var data = new StepData(last.transform.position, last.transform.rotation);
        
        pastActions.Push(new StepAction(ActionKind.Remove, data));
        futureActions.Clear();
        
        DeleteLastStep();
    }

    // 新しいステップオブジェクトを生成してリストに追加
    private void CreateStep(Vector3 pos, Quaternion rot)
    {
        var go = Instantiate(stepPrefab, pos, rot);
        
        steps.Add(go);
        TimelineUI.Rebuild(steps);
    }

    // 最後のステップオブジェクトを破棄してリストから削除
    private void DeleteLastStep()
    {
        if (steps.Count == 0) return;
        var go = steps[steps.Count - 1];
        
        steps.RemoveAt(steps.Count - 1);
        
        Destroy(go);
        TimelineUI.Rebuild(steps);
    }

    // 直前の操作を取り消す
    public void Undo()
    {
        if (pastActions.Count == 0) return;
        var action = pastActions.Pop();

        if (action.Kind == ActionKind.Add)
        {
            DeleteLastStep();
        }
        else
        {
            CreateStep(action.Data.Position, action.Data.Rotation);
        }

        futureActions.Push(action);
    }

    // 取り消しを再実行する
    public void Redo()
    {
        if (futureActions.Count == 0) return;
        var action = futureActions.Pop();

        if (action.Kind == ActionKind.Add)
        {
            CreateStep(action.Data.Position, action.Data.Rotation);
        }
        else
        {
            DeleteLastStep();
        }

        pastActions.Push(action);
    }

    // 現在のステップ列をJSONに保存
    private void SaveMacro()
    {
        string path = Path.Combine(Application.persistentDataPath, "macro.json");
        
        var macro = new MacroData();
        foreach (var go in steps)
        {
            macro.steps.Add(new StepData(go.transform.position, go.transform.rotation));
        }

        File.WriteAllText(path, JsonUtility.ToJson(macro, true));
        Debug.Log($"Macro saved: {path}");
    }

    // JSONからステップ列を読み込み、履歴をクリア
    private void LoadMacro()
    {
        string path = Path.Combine(Application.persistentDataPath, "macro.json");
        if (!File.Exists(path))
        {
            Debug.LogWarning($"File not found: {path}");
            return;
        }

        var macro = JsonUtility.FromJson<MacroData>(File.ReadAllText(path));
        // 既存ステップをすべて削除
        foreach (var go in steps) Destroy(go);
        steps.Clear();
        TimelineUI.Rebuild(steps);

        // 読み込んだ順で再生成
        foreach (var data in macro.steps)
        {
            CreateStep(data.Position, data.Rotation);
        }

        pastActions.Clear();
        futureActions.Clear();
    }
}
After - 要求と実行を分離した場合
変更点とメリット

変更点1. 「入力」「命令の制御」「実際に処理を行う」責務をそれぞれ別のクラスに分けます。

  • その結果、処理の追加や変更を行いやすくなります
    • 新しい操作方法を増やしたい → 入力クラスを1つ追加すればOK
    • 入力方法ごとにできることを変えたい → 入力クラス内の処理を書き直せばOK
    • 命令の共通処理を変えたい・命令の種類を増やしたい → コマンドクラスを追加・修正すればOK

変更点2. 取り消し・やり直しの仕組みはCommandInvokerにまとめる

  • 各コマンドは「何をするか」だけを書けばよいので、履歴の仕組みを気にせず、命令の中身に集中できます

まずは全体制御クラスの説明です。
この実装は先ほどより薄いクラスになっています。

// ProcedureManager: 全体制御
public class ProcedureManager : MonoBehaviour
{
    [SerializeField] private Button addBtn;
    [SerializeField] private Button removeBtn;
    [SerializeField] private Button saveBtn;
    [SerializeField] private Button loadBtn;
    [SerializeField] private GameObject stepPrefab;

    private CommandInvoker _invoker;
    private List<GameObject> _steps;
    private List<IInputHandler> _handlers;

    private void Awake()
    {
        // 初期化
        _invoker = new CommandInvoker();
        _steps = new List<GameObject>();

        // 入力ハンドラーをまとめて登録
        _handlers = new List<IInputHandler>
        {
            new UIInputHandler(addBtn, removeBtn, saveBtn, loadBtn, _invoker, _steps, stepPrefab),
            new VRInputHandler(_invoker, _steps, stepPrefab),
            new VoiceInputHandler(_invoker, _steps, stepPrefab),
            new KeyboardInputHandler(_invoker, _steps, stepPrefab),
            new NetworkInputHandler(_invoker, _steps, stepPrefab)
        };
    }

    private void Update()
    {
        // 各ハンドラーの更新呼び出し
        foreach (var handler in _handlers)
        {
            handler.HandleUpdate();
        }
    }

    private void OnDestroy()
    {
        // 各ハンドラーの後始末
        foreach (var handler in _handlers)
        {
            handler.Cleanup();
        }
    }
}

次に入力経路の実装です。

  • 新しい操作方法を増やしたい → 入力クラスを1つ追加すればOK
  • 入力方法ごとにできることを変えたい → 入力クラス内の処理を書き直せばOK
// 入力ハンドラー共通インターフェース
public interface IInputHandler
{
    void HandleUpdate();
    void Cleanup();
}

// ①UI入力ハンドラー
public class UIInputHandler : IInputHandler
{
    private readonly Button _addBtn;
    private readonly Button _removeBtn;
    private readonly Button _saveBtn;
    private readonly Button _loadBtn;
    private readonly CommandInvoker _invoker;
    private readonly List<GameObject> _steps;
    private readonly GameObject _prefab;
    private readonly string _macroPath;

    public UIInputHandler(
        Button addBtn, Button removeBtn,
        Button saveBtn, Button loadBtn,
        CommandInvoker invoker,
        List<GameObject> steps,
        GameObject prefab)
    {
        _addBtn = addBtn;
        _removeBtn = removeBtn;
        _saveBtn = saveBtn;
        _loadBtn = loadBtn;
        _invoker = invoker;
        _steps = steps;
        _prefab = prefab;
        _macroPath = Path.Combine(Application.persistentDataPath, "macro.json");

        _addBtn.onClick.AddListener(AddStep);
        _removeBtn.onClick.AddListener(RemoveStep);
        _saveBtn.onClick.AddListener(SaveMacro);
        _loadBtn.onClick.AddListener(LoadMacro);
    }

    public void HandleUpdate() { /* UIはイベント駆動 */ }

    public void Cleanup()
    {
        _addBtn.onClick.RemoveListener(AddStep);
        _removeBtn.onClick.RemoveListener(RemoveStep);
        _saveBtn.onClick.RemoveListener(SaveMacro);
        _loadBtn.onClick.RemoveListener(LoadMacro);
    }

    private void AddStep()
    {
        var cam = Camera.main;
        var data = new StepData(
            cam.transform.position + cam.transform.forward * 2f,
            Quaternion.LookRotation(cam.transform.forward)
        );
        _invoker.ExecuteCommand(new AddStepCommand(_steps, _prefab, data));
    }

    private void RemoveStep()
    {
        _invoker.ExecuteCommand(new RemoveStepCommand(_steps, _prefab));
    }

    private void SaveMacro()
    {
        _invoker.ExecuteCommand(new SaveMacroCommand(_steps, _macroPath));
    }

    private void LoadMacro()
    {
        _invoker.ExecuteCommand(new LoadMacroCommand(_steps, _prefab, _macroPath));
    }
}

// ②VR入力ハンドラー
public class VRInputHandler : IInputHandler
{
    private readonly CommandInvoker _invoker;
    private readonly List<GameObject> _steps;
    private readonly GameObject _prefab;

    public VRInputHandler(CommandInvoker invoker, List<GameObject> steps, GameObject prefab)
    {
        _invoker = invoker;
        _steps = steps;
        _prefab = prefab;
    }

    public void HandleUpdate()
    {
        if (OVRInput.GetDown(OVRInput.Button.One))
        {
            var cam = Camera.main;
            var data = new StepData(
                cam.transform.position + cam.transform.forward * 2f,
                Quaternion.LookRotation(cam.transform.forward)
            );
            _invoker.ExecuteCommand(new AddStepCommand(_steps, _prefab, data));
        }

        if (OVRInput.GetDown(OVRInput.Button.Two))
        {
            _invoker.ExecuteCommand(new RemoveStepCommand(_steps, _prefab));
        }
    }

    public void Cleanup() { /* イベントリスナ不要 */ }
}

// ③音声入力ハンドラー
public class VoiceInputHandler : IInputHandler
{
    private readonly CommandInvoker _invoker;
    private readonly List<GameObject> _steps;
    private readonly GameObject _prefab;
    private readonly string _macroPath;

    public VoiceInputHandler(CommandInvoker invoker, List<GameObject> steps, GameObject prefab)
    {
        _invoker = invoker;
        _steps = steps;
        _prefab = prefab;
        _macroPath = Path.Combine(Application.persistentDataPath, "macro.json");
    }

    public void HandleUpdate()
    {
        var cmd = VoiceRecognizer.LastCommand;
        switch (cmd)
        {
            case "add step":
                var cam = Camera.main;
                var data = new StepData(
                    cam.transform.position + cam.transform.forward * 2f,
                    Quaternion.LookRotation(cam.transform.forward)
                );
                _invoker.ExecuteCommand(new AddStepCommand(_steps, _prefab, data));
                break;

            case "remove step":
                _invoker.ExecuteCommand(new RemoveStepCommand(_steps, _prefab));
                break;

            case "undo":
                _invoker.Undo();
                break;

            case "redo":
                _invoker.Redo();
                break;

            case "save macro":
                _invoker.ExecuteCommand(new SaveMacroCommand(_steps, _macroPath));
                break;

            case "load macro":
                _invoker.ExecuteCommand(new LoadMacroCommand(_steps, _prefab, _macroPath));
                break;
        }
    }

    public void Cleanup() { /* イベントリスナ不要 */ }
}

// ④キーボード入力ハンドラー
public class KeyboardInputHandler : IInputHandler
{
    private readonly CommandInvoker _invoker;
    private readonly List<GameObject> _steps;
    private readonly GameObject _prefab;
    private readonly string _macroPath;

    public KeyboardInputHandler(CommandInvoker invoker, List<GameObject> steps, GameObject prefab)
    {
        _invoker = invoker;
        _steps = steps;
        _prefab = prefab;
        _macroPath = Path.Combine(Application.persistentDataPath, "macro.json");
    }

    public void HandleUpdate()
    {
        if (Input.GetKeyDown(KeyCode.Z)) { _invoker.Undo(); return; }
        if (Input.GetKeyDown(KeyCode.Y)) { _invoker.Redo(); return; }
        if (Input.GetKeyDown(KeyCode.A))
        {
            var cam = Camera.main;
            var data = new StepData(
                cam.transform.position + cam.transform.forward * 2f,
                Quaternion.LookRotation(cam.transform.forward)
            );
            _invoker.ExecuteCommand(new AddStepCommand(_steps, _prefab, data));
            return;
        }
        if (Input.GetKeyDown(KeyCode.R)) { _invoker.ExecuteCommand(new RemoveStepCommand(_steps, _prefab)); return; }
        if (Input.GetKeyDown(KeyCode.S)) { _invoker.ExecuteCommand(new SaveMacroCommand(_steps, _macroPath)); return; }
        if (Input.GetKeyDown(KeyCode.L)) { _invoker.ExecuteCommand(new LoadMacroCommand(_steps, _prefab, _macroPath)); return; }
    }

    public void Cleanup() { /* イベントリスナ不要 */ }
}

// ネットワーク入力ハンドラー
public class NetworkInputHandler : IInputHandler
{
    private readonly CommandInvoker _invoker;
    private readonly List<GameObject> _steps;
    private readonly GameObject _prefab;
    private readonly string _macroPath;

    public NetworkInputHandler(CommandInvoker invoker, List<GameObject> steps, GameObject prefab)
    {
        _invoker = invoker;
        _steps = steps;
        _prefab = prefab;
        _macroPath = Path.Combine(Application.persistentDataPath, "macro.json");
    }

    public void HandleUpdate()
    {
        if (!Network.HasMessage()) return;

        var msg = Network.Receive();
        if (msg.StartsWith("Add:"))
        {
            var parts = msg.Substring(4).Split(',');
            var pos = new Vector3(
                float.Parse(parts[0]),
                float.Parse(parts[1]),
                float.Parse(parts[2])
            );
            var data = new StepData(pos, Quaternion.identity);
            _invoker.ExecuteCommand(new AddStepCommand(_steps, _prefab, data));

        }
        else if (msg == "Remove")
        {
            _invoker.ExecuteCommand(new RemoveStepCommand(_steps, _prefab));
        }
        else if (msg == "Undo")
        {
            _invoker.Undo();
        }
        else if (msg == "Redo")
        {
            _invoker.Redo();
        }
        else if (msg == "SaveMacro")
        {
            _invoker.ExecuteCommand(new SaveMacroCommand(_steps, _macroPath));
        }
        else if (msg == "LoadMacro")
        {
            _invoker.ExecuteCommand(new LoadMacroCommand(_steps, _prefab, _macroPath));
        }
    }

    public void Cleanup() { /* イベントリスナ不要 */ }
}

最後にコマンドの実装です。

  • 命令の共通処理を変えたい・命令の種類を増やしたい → コマンドクラスを追加・修正すればOK
// コマンド共通定義
public interface ICommand
{
    void Execute();
    void Undo();
    void Redo();
}

// コマンド制御
public class CommandInvoker
{
    private readonly Stack<ICommand> _undoStack = new Stack<ICommand>();
    private readonly Stack<ICommand> _redoStack = new Stack<ICommand>();

    public void ExecuteCommand(ICommand cmd)
    {
        cmd.Execute();
        _undoStack.Push(cmd);
        _redoStack.Clear();
    }

    public void Undo()
    {
        if (_undoStack.Count == 0) return;
        var cmd = _undoStack.Pop();
        cmd.Undo();
        _redoStack.Push(cmd);
    }

    public void Redo()
    {
        if (_redoStack.Count == 0) return;
        var cmd = _redoStack.Pop();
        cmd.Redo();
        _undoStack.Push(cmd);
    }
}

// 具体コマンド
// ステップ追加コマンド
public class AddStepCommand : ICommand
{
    private readonly List<GameObject> _steps;
    private readonly GameObject _prefab;
    private readonly StepData _data;
    private GameObject _inst;

    public AddStepCommand(List<GameObject> steps, GameObject prefab, StepData data)
    {
        _steps = steps;
        _prefab = prefab;
        _data = data;
    }

    public void Execute()
    {
        _inst = Object.Instantiate(_prefab, _data.Position, _data.Rotation);
        _steps.Add(_inst);
        TimelineUI.Rebuild(_steps);
    }

    public void Undo()
    {
        if (_inst == null) return;
        _steps.Remove(_inst);
        Object.Destroy(_inst);
        TimelineUI.Rebuild(_steps);
    }

    public void Redo() => Execute();
}


// ステップ削除コマンド
public class RemoveStepCommand : ICommand
{
    private readonly List<GameObject> _steps;
    private readonly GameObject _prefab;
    private StepData _oldData;

    public RemoveStepCommand(List<GameObject> steps, GameObject prefab)
    {
        _steps = steps;
        _prefab = prefab;
    }

    public void Execute()
    {
        if (_steps.Count == 0) return;

        // 現在の最後のステップ情報を保存
        var last = _steps[_steps.Count - 1];
        _oldData = new StepData(last.transform.position, last.transform.rotation);

        // 削除
        _steps.RemoveAt(_steps.Count - 1);
        Object.Destroy(last);
        TimelineUI.Rebuild(_steps);
    }

    public void Undo()
    {
        // 元の位置に再生成
        var inst = Object.Instantiate(_prefab, _oldData.Position, _oldData.Rotation);
        _steps.Add(inst);
        TimelineUI.Rebuild(_steps);
    }

    public void Redo() => Execute();
}


// マクロ保存コマンド
public class SaveMacroCommand : ICommand
{
    private readonly List<GameObject> _steps;
    private readonly string _path;

    // 実行前のファイル内容をバックアップ
    private string _oldJson;

    public SaveMacroCommand(List<GameObject> steps, string path)
    {
        _steps = steps;
        _path  = path;
    }

    public void Execute()
    {
        // バックアップ取得
        if (File.Exists(_path))
        {
            _oldJson = File.ReadAllText(_path);
        }
        else
        {
            _oldJson = null;
        }

        // 現在のステップ情報をシリアライズ
        var macro = new MacroData();
        foreach (var go in _steps)
        {
            macro.steps.Add(new StepData(go.transform.position, go.transform.rotation));
        }

        // ファイル書き出し
        File.WriteAllText(_path, JsonUtility.ToJson(macro, prettyPrint: true));
        Debug.Log($"Macro saved: {_path}");
    }

    public void Undo()
    {
        if (_oldJson != null)
        {
            // 元の内容に戻す
            File.WriteAllText(_path, _oldJson);
            Debug.Log($"Macro restored: {_path}");
        }
        else if (File.Exists(_path))
        {
            // もともと存在しなかったならファイルを削除
            File.Delete(_path);
            Debug.Log($"Macro deleted: {_path}");
        }
    }

    public void Redo()
    {
        Execute();
    }
}

// マクロ読み込みコマンド
public class LoadMacroCommand : ICommand
{
    private readonly List<GameObject> _steps;
    private readonly GameObject _prefab;
    private readonly string _path;

    // 元に戻すための旧状態データ
    private List<StepData> _oldState;

    public LoadMacroCommand(List<GameObject> steps, GameObject prefab, string path)
    {
        _steps  = steps;
        _prefab = prefab;
        _path   = path;
    }

    public void Execute()
    {
        // 旧状態を保存
        _oldState = new List<StepData>();
        foreach (var go in _steps)
        {
            _oldState.Add(new StepData(go.transform.position, go.transform.rotation));
            Object.Destroy(go);
        }
        _steps.Clear();

        // JSON読み込み
        if (!File.Exists(_path))
        {
            Debug.LogWarning($"LoadMacroCommand: file not found at {_path}");
            return;
        }
        var json  = File.ReadAllText(_path);
        var macro = JsonUtility.FromJson<MacroData>(json);

        // 新状態を適用
        foreach (var data in macro.steps)
        {
            var inst = Object.Instantiate(_prefab, data.Position, data.Rotation);
            _steps.Add(inst);
        }
        TimelineUI.Rebuild(_steps);
        Debug.Log($"Macro loaded: {_path}");
    }

    public void Undo()
    {
        // 読み込んだ状態をクリア
        foreach (var go in _steps)
        {
            Object.Destroy(go);
        }
        _steps.Clear();

        // 旧状態を復元
        foreach (var data in _oldState)
        {
            var inst = Object.Instantiate(_prefab, data.Position, data.Rotation);
            _steps.Add(inst);
        }
        TimelineUI.Rebuild(_steps);
        Debug.Log("Macro load undone");
    }

    public void Redo()
    {
        Execute();
    }
}

まとめ

パターンまとめ

  • Stateパターン
    • 状態ごとにクラスを分けるだけで、if–elseのネストが消え、処理のまとまりがはっきりします
    • 新しい状態を追加するときはクラスを1つ作るのみでよく、既存コードに影響を与えずに拡張できます
      • ただし状態を増やしすぎると、状態遷移図が複雑化して保守性を損なう恐れがあるため、状態数は適切に制御しましょう

  • Commandパターン
    • 「入力」「命令の制御」「実際の処理」を担うクラスを分離し、責務をはっきりさせます
    • 操作の履歴(Undo/Redo)やマクロ記録など、命令の制御をInvoker側にまとめられるので、個々のコマンド実装はシンプルなまま
    • 新しい操作を増やすときはコマンドクラスを追加するだけで済み、入力方法や後処理の変更が柔軟に行えます

試すと望ましいこと

  • まずは小さな画面や機能で試してみる
    • 実際に簡単なUIやボタン連打の例などで動かしてみると、パターンの効果を体感しやすいです
  • テストコードを書いてみる
    • Stateパターンなら各状態クラスに、Commandパターンなら個別コマンドに対して、単体テストを書いてみましょう
  • 要件に合わせて導入を検討する
    • 必ずしも全プロジェクトでフル導入が必要というわけではありません
      • 状態が3つ以上あって振る舞いが複雑ならStateパターンを検討
      • 複雑なマルチプレイや複数入力経路、操作の履歴管理やマクロがあるならCommandパターンを検討

デザインパターンは他にもあります。
書籍や記事で知識を増やしていけると良いかなと思います。

次回はMessageBusとDIについてです。
お疲れ様でした。お楽しみに!

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?