コマンドパターンを使用してゲームコードを解き、タイムマシンで飛行します。

注意画像:>バグ-10492を再生;時間を遡る







こんにちは ゲーム開発のアーキテクチャに関する記事を書いています。 この記事では、 コマンドパターンを解析します。 多面的であり、さまざまな方法で適用できます。 しかし、ゲームの状態変更をデバッグするためのタイムマシンである、私のお気に入りのトリックを行う方法を紹介します。







このことにより、複雑なバグを見つけて再生する時間が大幅に短縮されました。 ゲームの状態、変更の履歴の「スナップショット」を作成し、それらを段階的に適用できます。







初心者の開発者はこのパターンに慣れるでしょうが、上級の開発者はこのトリックが役立つと思うかもしれません。







これを行う方法を知りたいですか? 猫をお願いします。







既にコマンドパターンに精通している場合は、「単方向の状態変更の作成」セクションに直接進んでください。







コマンドパターン



「チーム」という言葉はどういう意味ですか? これは注文のようなものです。 チームの助けを借りて、人はアクションを実行する必要性を表明します。 アクションはチームから切り離せません。







コマンドパターンは、オブジェクト指向プログラミングの世界でアクションを表現する方法です。 そして、これが可能になるのはポリモーフィズムのおかげです。







パターンの考え方は、システムのすべてのコマンドが同じであるということです。 OOPに関しては、すべてのコマンドに共通のインターフェースがあります。 システムはそれらのいずれかを透過的に実行できます。 これは、チームが完全に独立し、実行に必要なすべてのデータをそれ自体でカプセル化する必要があることを意味します。







説明は非常に抽象的なものです。 詳細に移りましょう。 すべてのコマンドの基本インターフェース:







public interface ICommand { void Execute(); }
      
      





次に、コマンドの具体的な実装の例を示します。







 public class WriteToConsoleCommand : ICommand { public string Message { get; private set; } public void Execute() { Console.WriteLine(Message); } }
      
      





これは、チームの一種の「Hello World」です。 しかし、それらを実行する方法は? 簡単なコマンド処理システムを作成します。







 public interface IGameSystem { void Execute(ICommand cmd); } public class LoggableGameSystem : IGameSystem { public LoggableGameSystem(ILogger log) { _log = log; } public void Execute(ICommand cmd) { _log.Debug(string.Format("Executing command <{0}>: {1}", cmd.GetType(), cmd); cmd.Execute(); } private ILogger _log; }
      
      





これで、デバッグのためにすべての実行可能コマンドを記録できます。 便利ですか? ただし、チームはバッチ出力のために準備する必要があり、ToString()メソッドを追加します。







 public class WriteToConsoleCommand : ICommand { public string Message { get; private set; } public void Execute() { Console.WriteLine(Message); } public override string ToString() { return Message; } }
      
      





仕組みを確認しましょう。







  class Program { static void Main(string[] args) { var gameSystem = new LoggableGameSystem(); var cmd = new WriteToConsoleCommand("Hello world"); var cmd2 = new WriteToConsoleCommand("Hello world2"); gameSystem.Execute(cmd); gameSystem.Execute(cmd2); } }
      
      





これは非常に単純な例です。 もちろん、デビューの結論は有用ですが、このパターンから他に有用なものを引き出すことができるかどうかは明らかではありません。







私のプロジェクトでは、いくつかの理由でこのパターンを常に使用しています。









最後のポイントについてもう少し。 たとえば、非同期になるはずの同期関数がありました。 これを行うには、その署名を変更し、コールバック、コルーチン、または非同期/待機の形式で非同期結果を処理するメカニズムを記述する必要があります(.net 4.6にクロールした場合)。 そして毎回、個々の機能ごとに。







コマンドメカニズムを使用すると、実行メカニズムから抽象化できます。 したがって、コマンドが以前に即座に実行された場合、簡単に非同期にすることができます。 実行時に動的に変更することもできます。







具体例。 ゲームは部分的なオフラインをサポートしています。 ネットワーク接続が現在利用できない場合、コマンドはキューに入れられ、接続が復元されたときに実行されます。 接続がある場合、コマンドは即座に実行されます。







コマンドを使用したロジックからの状態の簡単な分離



理論



このアイテムは、「タイムマシン」の実装には必要ありませんが、UIの反応性はデバッグ中にも役立つため、有用です。







まず、UIをロジックから簡単に切り離すことについてお話ししたかったのです。 UnityはMVVMを含むさまざまなパターンを適用し、これには多くのフレームワークがあります。 しかし、一般的に、これはUIについてではなく、状態自体の変更についてです。







一般的な概念を見て、自分で簡単なシステムを構築してみましょう。







状態のどのような一方向の変更ですか? このアイデアは、Facebookの仲間が説明したFluxアプローチから借用しています。 Reduxのようなすべての新しいライブラリは、同じアプローチで構築されています。







従来のMV *アプローチでは、Viewはモデルと相互に対話します。







Unityでは、状況はしばしばさらに悪化します。 従来のMVCはここでは適切ではなく、データは多くの場合、以下に示すようにビューから直接変更されます。 複雑なアプリケーションでは、接続の数が屋根を通過し、更新で更新が失われ、すべてが混乱し、スパゲッティが発生します。







MV *アーキテクチャのモデルと表現との相互作用







(ソース: medium.com







Reduxに似たシステムを試してみることをお勧めします。 主なアイデアは、Reduxがアプリケーションのすべての状態を1つのオブジェクトに保存することを提供するということです。 それが一つのモデルです。







いくつかはここで恐ろしいです。 しかし、結局のところ、ゲームの状態のシリアル化は、多くの場合、1つのオブジェクトのシリアル化に帰着します。 これは非常に自然なゲームのアプローチです。







2番目のアイデアは、アクションを使用して状態を変更することです。 実際、これは前述のコマンドとまったく同じです。 ビューは状態を直接変更することはできませんが、コマンドを介してのみ変更できます。







3番目のアイデアは自然な継続であり、Viewは状態を読み取り、その更新をサブスクライブすることしかできません。







Fluxイデオロギーでは次のようになります。







フラックデデタフロー







(ソース: medium.com







私たちの場合、ストアはゲームの状態です。 そして、アクションはチームです。 Dispatcherは、それぞれコマンドを実行するものです。







このアプローチは多くの利点をもたらします。 状態オブジェクトは1つしかなく、その変更はコマンドを介してのみ実行されるため、単一の状態更新イベントを簡単に作成できます。







その後、UIを簡単にリアクティブにできます。 つまり、状態を更新するときに自動的にデータを更新します(hello UniRx 、その使用については別の記事で説明します)。







このアプローチでは、ゲームの状態の変更をサーバー側から開始することもできます。 また、チームを通じて。 更新イベントはまったく同じであるため、UIは完全に紫色であり、更新元はどこですか。







もう1つの良い点は、クールなデバッグ機能です。 Viewはチームにしか出産できないので、蒸したカブの状態の変化を追跡しやすくなります。







詳細なログ、コマンド履歴、バグの再現など、このすべてがこのパターンのおかげで可能になります。







実装



まず、ゲームの状態を判断しましょう。 次のクラスにしましょう:







  [System.Serializable] public class GameState { public int coins; }
      
      





ゲームの状態の保存をJSONファイルに追加します。 これを行うには、別のマネージャーを作成します。







 public interface IGameStateManager { GameState GameState { get; set; } void Load(); void Save(); } public class LocalGameStateManager : IGameStateManager { public GameState GameState { get; set; } public void Load() { if (!File.Exists(GAME_STATE_PATH)) { return; } GameState = JsonUtility.FromJson<GameState>(File.ReadAllText(GAME_STATE_PATH)); } public void Save() { File.WriteAllText(GAME_STATE_PATH, JsonUtility.ToJson(GameState)); } private static readonly string GAME_STATE_PATH = Path.Combine(Application.persistentDataPath, "gameState.json"); }
      
      





前回の記事では、依存関係の問題に注目し、Dependency Injection(DI)パターンについて説明しました。 それを使用する時間です。







Unity3dには、シンプルで便利なZenject DIフレームワークがあります。 使用します。 インストールと構成は非常に簡単で、ドキュメントで詳細に説明されています。 したがって、すぐにポイントに。 IGameStateManagerのバインディングを宣言します。







ドキュメントに従って、 BindingsInstaller



というMonoInstaller



インスタンスを作成し、シーンに追加しました。







 public class BindingsInstaller : MonoInstaller<BindingsInstaller> { public override void InstallBindings() { Container.Bind<IGameStateManager>().To<LocalGameStateManager>().AsSingle(); Container.Bind<Loader>().FromNewComponentOnNewGameObject().NonLazy(); }
      
      





また、ゲームの読み込みと終了を監視するローダーコンポーネントのバインディングも追加しました。







 public class Loader : MonoBehaviour { [Inject] public void Init(IGameStateManager gameStateManager) { _gameStateManager = gameStateManager; } private void Awake() { Debug.Log("Loading started"); _gameStateManager.Load(); } private void OnApplicationQuit() { Debug.Log("Quitting application"); _gameStateManager.Save(); } private IGameStateManager _gameStateManager; }
      
      





ローダースクリプトは、ゲームの一番最初に実行されます。 出発点として使用します。 また、ゲームの状態の読み込みと保存を監視するスクリプトとして。







次に、UIの最も単純なビューをまとめます。







 public class CoinsView : MonoBehaviour { public Text currencyText; [Inject] public void Init(IGameStateManager gameStateManager) { _gameStateManager = gameStateManager; UpdateView(); } public void AddCoins() { _gameStateManager.GameState.coins += Random.Range(1,100); UpdateView(); } public void RemoveCoins() { _gameStateManager.GameState.coins -= Random.Range(1,100); UpdateView(); } public void UpdateView() { currencyText.text = "Coins: " + _gameStateManager.GameState.coins; } private IGameStateManager _gameStateManager; }
      
      





ここで、任意の数のコインを追加および削除する2つの方法を追加しました。 コードでよく目にする標準的なアプローチは、ビジネスロジックを直接UIにプッシュすることです。







だからそれをしないでください:)。 しかし、今のところ、小さなプロトタイプが機能することを確認しましょう。







UIスクリーンショットト







ボタンが機能し、起動時に状態が保存および復元されます。







それでは、コードを確認しましょう。







GameStateを変更する別のタイプのチームを作成しましょう。







 public interface ICommand { } public interface IGameStateCommand : ICommand { void Execute(GameState gameState); }
      
      





単一のタイプのコマンドを示すために、共通インターフェースを空にします。 GameStateを変更するコマンドの場合、状態をパラメーターとして受け取るExecuteメソッドを示します。







先ほど示したような状態を変更するコマンドを実行するサービスを作成しましょう。 あらゆるタイプのコマンドに適合するように、インターフェースを汎用化します。







 public interface ICommandsExecutor<TCommand> where TCommand: ICommand { void Execute(TCommand command); } public class GameStateCommandsExecutor : ICommandsExecutor<IGameStateCommand> { public GameStateCommandsExecutor(IGameStateManager gameStateManager) { _gameStateManager = gameStateManager; } public void Execute(IGameStateCommand command) { command.Execute(_gameStateManager.GameState); } private readonly IGameStateManager _gameStateManager; }
      
      





マネージャーをDIに登録します。







 public class BindingsInstaller : MonoInstaller<BindingsInstaller> { public override void InstallBindings() { Container.Bind<IGameStateManager>().To<LocalGameStateManager>().AsSingle(); Container.Bind<Loader>().FromNewComponentOnNewGameObject().AsSingle().NonLazy(); // added this line Container.Bind<ICommandsExecutor<IGameStateCommand>>().To<GameStateCommandsExecutor>().AsSingle(); } }
      
      





次に、チーム自体の実装を行います。







 public class AddCoinsCommand : IGameStateCommand { public AddCoinsCommand(int amount) { _amount = amount; } public void Execute(GameState gameState) { gameState.coins += _amount; } private int _amount; }
      
      





コマンドを使用するようにCoinsViewを変更します。







 public class CoinsView : MonoBehaviour { public Text currencyText; [Inject] public void Init(IGameStateManager gameStateManager, ICommandsExecutor<IGameStateCommand> commandsExecutor) { _gameStateManager = gameStateManager; _commandsExecutor = commandsExecutor; UpdateView(); } public void AddCoins() { var cmd = new AddCoinsCommand(Random.Range(1, 100)); _commandsExecutor.Execute(cmd); UpdateView(); } public void RemoveCoins() { var cmd = new AddCoinsCommand(-Random.Range(1, 100)); _commandsExecutor.Execute(cmd); UpdateView(); } public void UpdateView() { currencyText.text = "Coins: " + _gameStateManager.GameState.coins; } private IGameStateManager _gameStateManager; private ICommandsExecutor<IGameStateCommand> _commandsExecutor; }
      
      





CoinsViewはGameStateを読み取り専用として使用するようになりました。 また、状態の変更はすべてチームを通じて行われます。







ここで写真を台無しにしているのは、UpdateViewの手動呼び出しです。 彼に電話するのを忘れることができます。 または、別のビューからコマンドを送信してステータスを更新できます。







ICommandExecutor



ステータス更新イベントを追加します。 さらに、Executorのゲーム状態コマンド用に別のインターフェイスエイリアスを作成して、ジェネリックの余分なタイプを非表示にします。







 public interface ICommandsExecutor<TState, TCommand> { // added event event System.Action<TState> stateUpdated; void Execute(TCommand command); } public interface IGameStateCommandsExecutor : ICommandsExecutor<GameState, IGameStateCommand> { }
      
      





DIで登録を更新する







 public class BindingsInstaller : MonoInstaller<BindingsInstaller> { public override void InstallBindings() { Container.Bind<IGameStateManager>().To<LocalGameStateManager>().AsSingle(); Container.Bind<Loader>().FromNewComponentOnNewGameObject().AsSingle().NonLazy(); // updated this line Container.Bind<IGameStateCommandsExecutor>() .To<DefaultCommandsExecutor>().AsSingle(); } }
      
      





イベントをDefaultCommandsExecutor



追加します。







 public class DefaultCommandsExecutor : IGameStateCommandsExecutor { // this event added public event Action<GameState> stateUpdated { add { _stateUpdated += value; if (value != null) { value(_gameStateManager.GameState); } } remove { _stateUpdated -= value; } } public DefaultCommandsExecutor(IGameStateManager gameStateManager) { _gameStateManager = gameStateManager; } public void Execute(IGameStateCommand command) { command.Execute(_gameStateManager.GameState); // these lines added if (_stateUpdate != null) { _stateUpdated(_gameStateManager.GameState); } } private readonly IGameStateManager _gameStateManager; // this line added private Action<GameState> _stateUpdated; }
      
      





イベントの実装に注意を払う価値があります。 executorはイベント内でのみ状態をファンブルするため、サブスクライブするときにすぐにプルすることが重要です。







最後に、ビューを更新します。







 public class CoinsView : MonoBehaviour { public Text currencyText; [Inject] public void Init(IGameStateCommandsExecutor commandsExecutor) { _commandsExecutor = commandsExecutor; _commandsExecutor.stateUpdated += UpdateView; } public void AddCoins() { var cmd = new AddCoinsCommand(Random.Range(1, 100)); _commandsExecutor.Execute(cmd); } public void RemoveCoins() { var cmd = new AddCoinsCommand(-Random.Range(1, 100)); _commandsExecutor.Execute(cmd); } public void UpdateView(GameState gameState) { currencyText.text = "Coins: " + gameState.coins; } private void OnDestroy() { _commandsExecutor.stateUpdated -= UpdateView; } private IGameStateCommandsExecutor _commandsExecutor; }
      
      





IGameStateManager



はGameStateをパラメーターとして取得するため、IGameStateManagerはViewに必要なくなりました。 素晴らしい、不要な依存症を取り除きました! UpdateView自体は、 IGameStateCommandsExecutor



イベントにサブスクライブします。 状態が変化すると呼び出されます。 また、OnDestroyのイベントから退会することを忘れないでください。







これがアプローチです。 とてもきれい。 複雑ではありません。 今では、月の特定の段階でのみ再現されるある条件で、ある場所でUpdateViewを呼び出すことを忘れることはできません。







じゃあ 吐き出されて先へ進むと、さらに多くの利点があります。







コマンドの履歴をタイムマシンとして使用して、複雑なロジックをデバッグします



バグをどのようにテストしますか? アプリケーションを起動し、手順に従ってバグを再現します。 多くの場合、これらの手順は手動で実行され、UIの操作、ボタンの突刺し、すべての作業が行われます。







バグが単純な場合、またはバグを再現するための条件を簡単に繰り返すことができれば、すべてが正常です。 しかし、バグがネットワークロジックと時間に関係している場合はどうでしょう。 たとえば、ゲームには10分間続くイベントがあります。 バグはイベントの最後に発生します。







各テストの反復には、少なくとも 10分かかります。 通常、いくつかの反復が必要であり、それらの間で何かを修復する必要があります。







上記のパターンを使用した興味深いトリックを紹介します。これにより、頭痛が軽減されます。







前の段落のコードでは、バグが明らかに入り込んでいます。 結局のところ、コインの数は負になる可能性があります。 もちろん、このケースは最も難しいものとはほど遠いですが、あなたが良い想像力を持っていることを願っています。







ロジックが複雑で、バグを毎回再現するのは面倒だと想像してください。 しかし、ここで私たちは、またはテスターが偶然それを見つけました。 このバグを「保存」できるとしたらどうでしょうか?







トリック自体:ゲームの開始時にあった状態と、ゲームセッション中にコミットしたチームの全履歴を保存しましょう。

このデータは、一瞬で必要な回数だけバグを再現するのに十分です。 この場合、UIを実行する必要さえありません。 結局、壊れた状態のすべての変更は履歴に保存されます。 小さな統合テストケースのようなものです。







実装に移ります。 このソリューションには、インターフェイスのシリアル化など、少し高度なシリアル化が含まれるため、JsonUtilityでは十分ではありません。 したがって、Unity用のJson.Netをアセットから配置します。







まず、ゲームの「初期」状態を別のファイルにコピーするIGameStateManager



デバッグバージョンを作成しましょう。 つまり、ゲームが開始された時点の状態です。







 public class DebugGameStateManager : LocalGameStateManager { public override void Load() { base.Load(); File.WriteAllText(BACKUP_GAMESTATE_PATH, JsonUtility.ToJson(GameState)); } public void SaveBackupAs(string name) { File.Copy( Path.Combine(Application.persistentDataPath, "gameStateBackup.json"), Path.Combine(Application.persistentDataPath, name + ".json"), true); } public void RestoreBackupState(string name) { var path = Path.Combine(Application.persistentDataPath, name + ".json"); Debug.Log("Restoring state from " + path); GameState = JsonUtility.FromJson<GameState>(File.ReadAllText(path)); } private static readonly string BACKUP_GAMESTATE_PATH = Path.Combine(Application.persistentDataPath, "gameStateBackup.json"); }
      
      





舞台裏では、親クラスのメソッドを仮想に変換しました。 これは演習として残しておきます。 他のすべてに、 SaveBackupAs



メソッドがSaveBackupAs



SaveBackupAs



。これは、将来的に特定の名前で「キャスト」を保存できるようにするために必要になります。







次に、コマンドの履歴を保存できるエグゼキューターのデビューバージョンを作成し、通常は「初期状態+チーム」という形式の完全なキャストを保存します。







 public class DebugCommandsExecutor : DefaultCommandsExecutor { public IList<IGameStateCommand> commandsHistory { get { return _commands; } } public DebugCommandsExecutor(DebugGameStateManager gameStateManager) : base(gameStateManager) { _debugGameStateManager = gameStateManager; } public void SaveReplay(string name) { _debugGameStateManager.SaveBackupAs(name); File.WriteAllText(GetReplayFile(name), JsonConvert.SerializeObject(new CommandsHistory { commands = _commands }, _jsonSettings)); } public void LoadReplay(string name) { _debugGameStateManager.RestoreBackupState(name); _commands = JsonConvert.DeserializeObject<CommandsHistory>( File.ReadAllText(GetReplayFile(name)), _jsonSettings ).commands; _stateUpdated(_gameStateManager.GameState); } public void Replay(string name, int toIndex) { _debugGameStateManager.RestoreBackupState(name); LoadReplay(name); var history = _commands; _commands = new List<IGameStateCommand>(); for (int i = 0; i < Math.Min(toIndex, history.Count); ++i) { Execute(history[i]); } _commands = history; } private string GetReplayFile(string name) { return Path.Combine(Application.persistentDataPath, name + "_commands.json"); } public override void Execute(IGameStateCommand command) { _commands.Add(command); base.Execute(command); } private List<IGameStateCommand> _commands = new List<IGameStateCommand>(); public class CommandsHistory { public List<IGameStateCommand> commands; } private readonly JsonSerializerSettings _jsonSettings = new JsonSerializerSettings() { TypeNameHandling = TypeNameHandling.All }; private readonly DebugGameStateManager _debugGameStateManager; }
      
      





ここでは、JsonUtilityの標準機能では不十分であることは明らかです。 シリアル化設定にTypeNameHandling



を設定するTypeNameHandling



ました。これにより、ナゲットをロード/保存するときに、コマンドが型付けされたオブジェクトにシリアル化解除されました。







このエグゼキューターについて他に注目すべきことは何ですか?









リリースプロジェクトでは、履歴がメモリをいっぱいにしたくないため、DEBUG定義がある場合にのみ、このサービスをDIに登録します。







 public class BindingsInstaller : MonoInstaller<BindingsInstaller> { public override void InstallBindings() { Container.Bind<Loader>().FromNewComponentOnNewGameObject().AsSingle().NonLazy(); #if DEBUG Container.Bind<IGameStateManager>().To<DebugGameStateManager>().AsSingle(); Container.Bind<DebugGameStateManager>().AsSingle(); Container.Bind<IGameStateCommandsExecutor>().To<DebugCommandsExecutor>().AsSingle(); #else Container.Bind<IGameStateManager>().To<LocalGameStateManager>().AsSingle(); Container.Bind<IGameStateCommandsExecutor>().To<DefaultCommandsExecutor>().AsSingle(); #endif } }
      
      





そうそう、シリアル化のためにチームを準備する必要があります。







 public class AddCoinsCommand : IGameStateCommand { public AddCoinsCommand(int amount) { _amount = amount; } public void Execute(GameState gameState) { gameState.coins += _amount; } public override string ToString() { return GetType().ToString() + " " + _amount; } [JsonProperty("amount")] private int _amount; }
      
      





JsonProperty, . ToString(), .







, "DEBUG" Player Settings -> Other Settings -> Scripting define symbols.







/ Unity. EditorWindow.







 public class CommandsHistoryWindow : EditorWindow { [MenuItem("Window/CommandsHistoryWindow")] public static CommandsHistoryWindow GetOrCreateWindow() { var window = EditorWindow.GetWindow<CommandsHistoryWindow>(); window.titleContent = new GUIContent("CommandsHistoryWindow"); return window; } public void OnGUI() { // this part is required to get // DI context of the scene var sceneContext = GameObject.FindObjectOfType<SceneContext>(); if (sceneContext == null || sceneContext.Container == null) { return; } // this guard ensures that OnGUI runs only when IGameStateCommandExecutor exists // in other words only in runtime var executor = sceneContext.Container.TryResolve<IGameStateCommandsExecutor>() as DebugCommandsExecutor; if (executor == null) { return; } // general buttons to load and save "snapshot" EditorGUILayout.BeginHorizontal(); _replayName = EditorGUILayout.TextField("Replay name", _replayName); if (GUILayout.Button("Save")) { executor.SaveReplay(_replayName); } if (GUILayout.Button("Load")) { executor.LoadReplay(_replayName); } EditorGUILayout.EndHorizontal(); // and the main block which allows us to walk through commands step by step EditorGUILayout.LabelField("Commands: " + executor.commandsHistory.Count); for (int i = 0; i < executor.commandsHistory.Count; ++i) { var cmd = executor.commandsHistory[i]; EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField(cmd.ToString()); if (GUILayout.Button("Step to")) { executor.Replay(_replayName, i + 1); } EditorGUILayout.EndHorizontal(); } } private string _replayName; }
      
      





. ?







commandHistoryWindowのアニメーションGIF







"initial" , , .

, , , .







version1.







Step to, "" , .







. . " " , . , "negativeCoins" save.







, negativeCoins.json negativeCoins_commands.json, . , negativeCoins, Load . .







, , UI, . .







. , "", , , .







, .







 public class AddCoinsCommand : IGameStateCommand { public AddCoinsCommand(int amount) { _amount = amount; } public void Execute(GameState gameState) { gameState.coins += _amount; // this is the fix if (gameState.coins < 0) { gameState.coins = 0; } } public override string ToString() { return GetType().ToString() + " " + _amount; } [JsonProperty("amount")] private int _amount; }
      
      





version1



, .







CommandsHistoryWindowで再生される修正されたバグのアニメーションGIF







, . !







まとめると



Command. . , .







:









UI, Flux, UI.







, , , , , .







, , , , . , /. =).







. , , UI, , . , GameStateManager'a, UI .







UI — , . , , . .







.







, =), .








All Articles