通过命令模式解耦游戏代码,在时间机器上调试它

嗨! 我撰写有关游戏开发中软件体系结构的文章。 在本文中,我想显示命令模式。 它是绝对灵活的,可以以多种方式应用。 但我将向您展示我最喜欢的技巧-用于调试游戏状态突变的时间机器。

当我搜索错误源并尝试重现它们时,这种方法为我节省了很多时间。 它允许我保存游戏状态的“快照”,突变的历史记录,并逐步应用这些更改。

初级开发人员将熟悉这种模式,经验丰富的开发人员,也许会发现此技巧很有帮助。

想知道你是怎么做到的? 欢迎!


“命令”一词是什么意思? 这有点像订单。 一种是在命令的帮助下,表示他们需要执行某些动作 。 动作与命令密不可分。

命令模式-是一种在面向对象编程(OOP)世界中表示Action的方式。 而且只有借助多态性才有可能。

模式背后的想法是所有命令在系统中都是统一的。 就OOP而言,所有命令都具有相同的接口。 系统可以透明地执行它们中的任何一个。 这意味着命令必须绝对独立,封装执行该命令所需的所有数据。

到目前为止,描述还很抽象,对吗? 让我们看看具体的例子。

这是所有命令的基本界面:

 公共接口ICommand 
{
无效Execute();
}

这是该命令的特定实现的示例:

 公共类WriteToConsoleCommand:ICommand 
{
公用字符串消息{get; 私人套装; }
公共无效Execute(){
Console.WriteLine(Message);
}
}

就像使用命令的传统“ Hello world”一样。 但是如何执行呢? 让我们编写一个简单的命令执行系统。

 公共接口IGameSystem 
{
无效执行(ICommand cmd);
}

公共类LoggableGameSystem:IGameSystem
{
公共LoggableGameSystem(ILogger日志)
{
_log =日志;
}

公共无效执行(ICommand cmd){
_log.Debug(string.Format(“执行命令:{1}”,cmd.GetType(),cmd);
cmd.Execute();
}

私有ILogger _log;
}

现在,我们可以记录命令的任何执行以进行调试。 有帮助,对吧? 但是命令应该适合调试输出。 让我们添加ToString()方法。

 公共类WriteToConsoleCommand:ICommand 
{
公用字符串消息{get; 私人套装; }
公共无效Execute(){
Console.WriteLine(Message);
}

公共重写字符串ToString()
{
返回消息;
}
}

让我们看看它是如何工作的:

 班级计划 
{
静态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);
}
}

这是一个非常基本的例子。 当然,调试输出是非常有用的事情,但是仍然不清楚您可以从这种方法中获得什么好处。

在我的项目中,由于以下原因,我始终使用此模式:

  • 命令封装(存储)其执行所需的所有内容。 它实际上是不可变的对象。 因此,很容易通过网络传递此对象,并在客户端和服务器之间完全相同地执行。 当然,只有在给定相同的输入参数的情况下,客户端和服务器才能产生相同的结果。
  • 命令代表很小的业务逻辑。 易于编写,理解和调试。 由于命令是不可变的,并且不包含任何其他依赖项,因此为命令编写单元测试就像小菜一碟。
  • 可以通过一组简单命令来表达复杂的业务逻辑。 命令易于组合和重用。
  • 如果愿意,命令可以表示检查点或事务。 如果仅通过命令完成状态更改,则可简化调试和对程序流程的理解。 如果出现问题,您始终可以跟踪哪个命令引入了该错误。 有用的是-您可以查看已执行所有参数的命令。
  • 命令的执行可以被延迟/异步。 典型示例-您将命令发送到服务器。 当用户在游戏中启动任何动作时,将创建命令并将其附加到队列中以供执行。 实际上,只有在服务器确认命令并成功响应后,命令才会执行。
  • 使用Command方法编写的代码与传统的“调用函数”方法略有不同。 当开发人员创建命令实例时,他们表达了执行某些操作的“意图”,并不关心何时以及如何执行它。 它允许人们做出非常有趣的事情。

关于最后一点的更多细节。 例如,您具有同步功能,该功能应变为异步功能。 为此,您必须更改其签名,并重写逻辑以以回调,协程或async / await的形式处理异步结果(如果已切换到.net 4.6运行时)。 而且,您必须为每个需要更改的功能执行此操作。

命令方法使您可以从执行机制中抽象出来。 因此,如果命令之前已经同步过,则可以很容易地将其更改为异步。 它甚至可以在运行时完成(使用传统方法是不可能的)。

让我们看看具体的例子。 某些游戏应支持“部分”离线模式。 如果当前无法使用Internet连接,则会将命令附加到队列中,并在连接再次可用时将其传送到服务器以执行。 如果连接正常,将立即发送命令。

那“单向游戏状态修改”到底是什么? 这个想法是借鉴了Facebook描述的Flux方法。 同样的想法也出现在像Redux这样的现代库中。

在传统的MV *方法中,View以双向方式与模型交互。

在Unity中,情况甚至更糟。 传统的MVC绝对不适合这里,并且数据是直接从View中更改的(我将在下面显示一个示例)。 在复杂的应用程序中,这些依赖项的数量是疯狂的,在另一个更新中错过了更新,所有东西都弄乱了,并且您得到了意大利面条。

让我们尝试一下,并提出一个与Redux类似的系统,但要使用Unity。 Redux背后的主要思想是应用程序状态由单个对象表示。 换句话说,在单个模型中。

在这一点上,有些人可能会感到恐惧。 但是游戏状态的序列化通常归结为单个对象的序列化。 这是游戏的自然方式。

第二个想法是使用动作来改变游戏状态 实际上,动作与命令相同。 视图不能直接修改状态,只能使用命令。

第三个想法-前一个想法的自然延续。 View Only读取游戏状态并订阅其更新。

这是Flux中的样子:

在我们的情况下:

  • 商店=游戏状态
  • 行动=命令
  • 分派器=执行命令的东西

这种方法将为您带来很多好处。 由于只有一个游戏状态对象,并且仅使用命令对其进行了突变,因此创建游戏状态更新的单个事件很自然

然后,可以轻松地将UI切换为反应性方法。 换句话说,当游戏状态改变时,会自动更新UI组件(嗨,UniRx!我们将在另一篇文章中讨论)。

这种方法还允许您以相同的方式从服务器端启动游戏状态突变:使用命令。 由于游戏状态更新事件是相同的,因此UI绝对无关紧要。

另一个很大的好处-调试功能很棒。 由于View只能发送命令,因此您可以像跟踪应用程序一样跟踪应用程序产生的所有更改。

详细的日志记录,命令历史记录,错误重现等-由于这种方法,所有这些都是可能的。

首先,让我们决定游戏状态。 可能是以下课程:

  [System.Serializable] 
公共类GameState
{
公共int硬币;
}

让我们将游戏状态序列化添加到JSON文件中。 我将为此介绍一个专门的经理班。

 公共接口IGameStateManager 
{
GameState GameState { 组; }
void Load();
void Save();
}公共类LocalGameStateManager:IGameStateManager
{
公共GameState GameState {get; 组; } public void Load()
{
如果(!File.Exists(GAME_STATE_PATH))
{
返回;
}
GameState = JsonUtility.FromJson (File.ReadAllText(GAME_STATE_PATH));
} public void Save()
{
File.WriteAllText(GAME_STATE_PATH,JsonUtility.ToJson(GameState));
}私有静态只读字符串GAME_STATE_PATH = Path.Combine(Application.persistentDataPath,“ gameState.json”); }

让我们使用依赖注入库Zenject明智地管理我们的依赖。 它的安装和设置非常简单,并且有据可查。

我声明了IGameStateManager的绑定。

根据文档,我创建了我的MonoInstaller类BindingsInstaller ,并将其添加到场景中。

 公共类BindingsInstaller:MonoInstaller  
{
公共重写void InstallBindings()
{
Container.Bind ()。To ()。AsSingle();
Container.Bind ()。FromNewComponentOnNewGameObject()。NonLazy();
}

另外,我为Loader组件添加了绑定,它将处理加载并退出游戏。

 公共类加载器:MonoBehaviour {[注入] 
公共无效Init(IGameStateManager gameStateManager)
{
_gameStateManager = gameStateManager;
}私有无效Awake()
{
Debug.Log(“开始加载”);
_gameStateManager.Load();
}私有无效OnApplicationQuit()
{
Debug.Log(“退出应用程序”);
_gameStateManager.Save();
} private IGameStateManager _gameStateManager;
}

脚本加载器开始游戏中的第一个。 我将其用作起点和脚本来处理游戏状态的加载/保存。

现在,我为UI创建最简单的视图。

 公共类CoinsView:MonoBehaviour 
{
public Text currencyText; [注入]
公共无效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 =“硬币:” + _gameStateManager.GameState.coins;
} private IGameStateManager _gameStateManager;
}

我在那里添加了两种方法,可以添加和删除硬币。 我经常看到的标准方法-在视图中放置业务逻辑。

你不应该那样做。 但是现在我们应该确保我们的原型能够正常工作。

按钮在起作用,游戏状态正在保存并在游戏开始时加载。

我们已经使其工作了。 让我们做对。

我为突变GameState的命令创建了单独的类型。

 公共接口ICommand 
{}
公共接口IGameStateCommand:ICommand
{
无效Execute(GameState gameState);
}

公共接口为空,表示命令的单一共享类型。 对于改变游戏状态的特定命令,有一个Execute方法,将GameState作为参数。

现在,我创建了一个服务,该服务将执行IGameStateCommand类型的命令,就像我之前显示的那样。 接口将通用,以适合每种类型的命令。

 公共接口ICommandsExecutor  
其中TCommand:ICommand
{
无效的执行(TCommand命令);
}公共类GameStateCommandsExecutor:ICommandsExecutor
{public GameStateCommandsExecutor(IGameStateManager gameStateManager)
{
_gameStateManager = gameStateManager;
} public void Execute(IGameStateCommand命令)
{
command.Execute(_gameStateManager.GameState);
}私有只读IGameStateManager _gameStateManager;
}

这是我在DI中注册的方式。

 公共类BindingsInstaller:MonoInstaller  
{
公共重写void InstallBindings()
{
Container.Bind ()。To ()。AsSingle();
Container.Bind ()。FromNewComponentOnNewGameObject()。AsSingle()。NonLazy(); //添加了这一行
Container.Bind <ICommandsExecutor >()。To ()。AsSingle();
}
}

现在,我实现了使硬币值变异的具体命令。

 公共类AddCoinsCommand:IGameStateCommand 
{
公共AddCoinsCommand(int数量)
{
_amount =金额;
} public void Execute(GameState gameState)
{
gameState.coins + = _amount;
} private int _amount;
}

更新CoinsView以使用AddCoinsCommand代替直接修改。

 公共类CoinsView:MonoBehaviour 
{
public Text currencyText; [注入]
公共无效Init(IGameStateManager gameStateManager,ICommandsExecutor 命令执行器)
{
_gameStateManager = gameStateManager;
_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 =“硬币:” + _gameStateManager.GameState.coins;
} private IGameStateManager _gameStateManager;
私有ICommandsExecutor _commandsExecutor;
}

现在,CoinsView 仅读取GameState。 所有突变都是使用命令进行的。

这里看起来很脏的是对UpdateView的手动调用。 我们可能会忘记它。 或者可以通过命令从另一个视图更改GameState。

让我们在ICommandExecutor中添加GameState更新事件。 另外,我们还创建一个单独的别名接口IGameStateCommandsExecutor,以隐藏其他类型的通用接口。

 公共接口ICommandsExecutor  
{
//添加事件
事件System.Action stateUpdated;
无效的执行(TCommand命令);
}
公共接口IGameStateCommandsExecutor:ICommandsExecutor
{}

现在,我需要更新DI中的注册。

 公共类BindingsInstaller:MonoInstaller  
{
公共重写void InstallBindings()
{
Container.Bind ()。To ()。AsSingle();
Container.Bind ()。FromNewComponentOnNewGameObject()。AsSingle()。NonLazy();
//更新了这一行
Container.Bind ()
To ()。AsSingle();
}
}

让我们向DefaultCommandsExecutor添加一个事件。

 公共类DefaultCommandsExecutor:IGameStateCommandsExecutor 
{
//此事件已添加
公共事件Action stateUpdated
{

{
_stateUpdated + =值;
如果(值!=空)
{
值(_gameStateManager.GameState);
}
}
去掉
{
_stateUpdated-=值;
}
} public DefaultCommandsExecutor(IGameStateManager gameStateManager)
{
_gameStateManager = gameStateManager;
} public void Execute(IGameStateCommand命令)
{
command.Execute(_gameStateManager.GameState);
//这些行已添加
如果(_stateUpdate!= null)
{
_stateUpdated(_gameStateManager.GameState);
}
}私有只读IGameStateManager _gameStateManager;
//添加此行
私人Action _stateUpdated;}

值得注意事件的实施。 由于执行者仅在事件内部共享游戏状态,因此在订阅后立即调用它非常重要。

最后,更新视图。

 公共类CoinsView:MonoBehaviour 
{
public Text currencyText; [注入]
公共无效Init(IGameStateCommandsExecutor命令执行程序)
{
_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 =“硬币:” + gameState.coins;
}私人无效OnDestroy()
{
_commandsExecutor.stateUpdated-= UpdateView;
}私人IGameStateCommandsExecutor _commandsExecutor;
}

View现在不需要IGameStateManager ,因为UpdateView将GameState作为参数。 太好了,我们摆脱了额外的依赖! UpdateView本身我已经在IGameStateCommandsExecutor订阅了该事件。 每当GameState更改时,它将被调用。 取消订阅OnDestroy中的事件也很重要。

而已。 非常干净直接的方法。 现在,不可能忘记在某些人知道的情况下在某个地方调用UpdateView,该条件仅在月球的特定阶段复制。

好的,您可以休息一下,然后我们继续前进。 还有其他好处。

您如何重现错误? 您启动该应用程序并遵循repro步骤。 这些步骤通常是手动执行的。 从一个屏幕走到另一个屏幕,按下按钮等。

如果错误很简单,或者简化了复制步骤,则可以。 但是,如果该错误取决于网络逻辑和时间,该怎么办。 例如,有一个游戏事件,持续10分钟。 错误在事件结束时发生。

每次测试至少需要10分钟。 通常,您至少需要几次迭代,并且在两次迭代之间您要修复一些问题。

我将展示一种有趣的方法,该方法利用了到目前为止我们已经完成的所有工作,并且可以使您免于头痛。

上一个示例的代码中有一个明显的错误。 硬币数量不能为负。 当然,情况并不是那么困难,但是我希望您有一个很好的想象力。

想象一下,业务逻辑很复杂,而且每次都很难重现错误。 但是在某个时候,您或您的QA伙伴偶然发现了该错误。 如果您可以“保存”该错误该怎么办?

现在的诀窍是:保存游戏开始时的初始游戏状态,并浏览游戏会话期间已应用于该命令的所有历史记录。

该数据足以在您需要的时间内(以毫秒为单位)多次重现该错误。 同时,根本不需要启动UI,因为所有损坏游戏状态的修改都存储在命令历史记录中。 这就像一个小型集成测试用例。

现在,让我们转到实现。 由于这种方法需要比Unity的JsonUtility可能提供的更高级的序列化,因此我将从资产商店为Unity安装Json.Net。

首先,让我们创建IGameStateManager的调试版本,该版本将初始游戏状态克隆到单独的文件中。

 公共类DebugGameStateManager:LocalGameStateManager 
{
公共重写void Load()
{
基本负荷();
File.WriteAllText(BACKUP_GAMESTATE_PATH,JsonUtility.ToJson(GameState));
} public void SaveBackupAs(字符串名称)
{
File.Copy(
Path.Combine(Application.persistentDataPath,“ gameStateBackup.json”),
Path.Combine(Application.persistentDataPath,name +“ .json”),true);
} public void RestoreBackupState(字符串名称)
{
var path = Path.Combine(Application.persistentDataPath,name +“ .json”);
Debug.Log(“从” +路径还原状态);
GameState = JsonUtility.FromJson (File.ReadAllText(path));
}私有静态只读字符串BACKUP_GAMESTATE_PATH
= Path.Combine(Application.persistentDataPath,“ gameStateBackup.json”);}

在幕后,我将父母的方法更改为虚拟方法。 这将是您的一项练习。 还有一个SaveBackupAs方法,稍后我们将使用它,因此我们可以使用特定名称保存“快照”。

现在,让我们创建执行程序的调试版本,该版本可以存储“重播”错误所需的所有内容。

 公共类DebugCommandsExecutor:DefaultCommandsExecutor 
{
公共IList 命令历史记录{获取{return _commands; }}
公共DebugCommandsExecutor(DebugGameStateManager gameStateManager)
:基础(gameStateManager)
{
_debugGameStateManager = gameStateManager;
} public void SaveReplay(字符串名称)
{
_debugGameStateManager.SaveBackupAs(name);
File.WriteAllText(GetReplayFile(name),
JsonConvert.SerializeObject(new CommandsHistory {命令= _commands},
_jsonSettings));
} public void LoadReplay(字符串名称)
{
_debugGameStateManager.RestoreBackupState(name);
_commands = JsonConvert.DeserializeObject (
File.ReadAllText(GetReplayFile(name)),
_jsonSettings
)。命令;
_stateUpdated(_gameStateManager.GameState);
} public void Replay(字符串名称,int toIndex)
{
_debugGameStateManager.RestoreBackupState(name);
LoadReplay(名称);
var history = _commands;
_commands = new List ();
for(int i = 0; i <Math.Min(toIndex,history.Count); ++ i)
{
执行(history [i]);
}
_commands =历史;
}私有字符串GetReplayFile(字符串名称)
{
返回Path.Combine(Application.persistentDataPath,name +“ _commands.json”);
}公共重写void Execute(IGameStateCommand命令)
{
_commands.Add(command);
base.Execute(命令);
} private List _commands = new List (); 公共类CommandsHistory
{
公用List 命令;
}私有只读JsonSerializerSettings _jsonSettings = new JsonSerializerSettings(){
TypeNameHandling = TypeNameHandling.All
};
私有只读DebugGameStateManager _debugGameStateManager;
}

在这里,您可以看到JsonUtility无法处理它。 我必须为序列化设置指定TypeNameHandling,因此在加载/保存快照期间,命令是有类型的对象,因为业务逻辑与类型相关。

关于此执行器还有哪些有趣的观点?

  • 拒绝历史的每条命令
  • 可以保存和恢复命令的历史记录和状态
  • 密钥方法重播-重播所有命令,将它们应用到初始状态,直到到达具有目标索引的命令。

我不想将内存浪费在发布项目中的调试内容上,因此,仅当有#DEBUG定义时,我才注册调试服务。

 公共类BindingsInstaller:MonoInstaller  
{
公共重写void InstallBindings()
{
Container.Bind ()。FromNewComponentOnNewGameObject()。AsSingle()。NonLazy();
#if调试
Container.Bind ()。To ()。AsSingle();
Container.Bind ()。AsSingle();
Container.Bind ()。To ()。AsSingle();
#其他
Container.Bind ()。To ()。AsSingle();
Container.Bind ()。To ()。AsSingle();
#万一
}
}

啊,我们需要准备一个序列化命令:

 公共类AddCoinsCommand:IGameStateCommand 
{public AddCoinsCommand(int数量)
{
_amount =金额;
} public void Execute(GameState gameState)
{
gameState.coins + = _amount;
}公共重写字符串ToString(){
返回GetType()。ToString()+“” + _amount;
} [JsonProperty(“ amount”)]
private int _amount;
}

我在这里添加了JsonProperty,因为_amount属性是私有的,默认情况下不会序列化。 另外,我已经重写了ToString(),因此可以很好地记录该命令。

为了使调试版本正常工作,请不要忘记在播放器设置->其他设置->脚本定义符号中添加“ DEBUG”定义。

然后,我想有一种方法可以直接从Unity的UI保存和加载命令和状态的历史记录。 让我们创建一个自定义的EditorWindow。

 公共类CommandsHistoryWindow:EditorWindow 
{[MenuItem(“ Window / CommandsHistoryWindow”)]
公共静态CommandsHistoryWindow GetOrCreateWindow()
{
var window = EditorWindow.GetWindow ();
window.titleContent = new GUIContent(“ CommandsHistoryWindow”);
返回窗口;
} public void OnGUI()
{//这部分是必需的
//场景的DI上下文
var sceneContext = GameObject.FindObjectOfType ();
如果(sceneContext == null || sceneContext.Container == null)
{
返回;
}
//此保护措施确保OnGUI仅在IGameStateCommandExecutor存在时运行
//换句话说,仅在运行时
var executor = sceneContext.Container.TryResolve ()as DebugCommandsExecutor;
如果(执行者== null)
{
返回;
} //加载和保存“快照”的常规按钮
EditorGUILayout.BeginHorizo​​ntal();
_replayName = EditorGUILayout.TextField(“重放名称”,_replayName);
如果(GUILayout.Button(“ Save”))
{
executor.SaveReplay(_replayName);
}
如果(GUILayout.Button(“ Load”))
{
executor.LoadReplay(_replayName);
}
EditorGUILayout.EndHorizo​​ntal(); //和允许我们逐步浏览命令的主块
EditorGUILayout.LabelField(“ Commands:” + executor.commandsHistory.Count);
for(int i = 0; i <executor.commandsHistory.Count; ++ i)
{
var cmd = executor.commandsHistory [i];
EditorGUILayout.BeginHorizo​​ntal();
EditorGUILayout.LabelField(cmd.ToString());
如果(GUILayout.Button(“ Step to”))
{
executor.Replay(_replayName,i + 1);
}
EditorGUILayout.EndHorizo​​ntal(); }
}私有字符串_replayName;
}

而已。 很简单 它是什么样子的?

我已经保存了空的“初始”状态,因此可以在出现问题的情况下恢复原状。 然后我按了几次按钮,硬币计数器变了。 另外,您可能会看到应用于游戏状态的命令列表。

然后,我用名称“ version1”保存了最终快照。

然后,我使用“移至”按钮“重播”所有突变,直到特定命令为止。

现在,让我们回到硬币值为负的错误。 可能测试人员偶然发现了该错误。 我仅在Unity中共享了“保存快照”按钮,但可以在游戏界面中直接实现。 在这种情况下,测试人员可以指定快照名称“ negativeCoins”,然后单击“保存”按钮。

然后他们浏览到“ saves”文件夹,找到两个文件:negativeCoins.json和negativeCoins_commands.json。

然后他们将其发送给开发人员。 开发人员将这些文件放在他的计算机上的同一文件夹中。 在调试窗口中,他们输入“ negativeCoins”名称,然后单击“加载”按钮,瞧。 他们手中有一个完美的测试用例。

此外,您可以创建一个没有任何UI的空白场景,在其中只能“重播”游戏状态快照。 这样可以大大节省您的时间。

您甚至可以围绕此方法构建集成过程。 例如,保留一份“快照”列表,应在每个构建中对其进行测试。

好吧,让我们停止幻想。 让我们修复该错误。

 公共类AddCoinsCommand:IGameStateCommand 
{public AddCoinsCommand(int数量)
{
_amount =金额;
} public void Execute(GameState gameState)
{
gameState.coins + = _amount;
//这是修复
如果(gameState.coins <0)
{
gameState.coins = 0;
}
}公共重写字符串ToString(){
返回GetType()。ToString()+“” + _amount;
} [JsonProperty(“ amount”)]
private int _amount;
}

让我们在我们之前保存的快照中进行检查。

如您所见,不能再为负。 赢得!

在本文中,我分享了我对Command模式的看法。 我相信有很多方法可以应用它。 我只显示了少数几个我正在使用的。

在下一篇文章中,我计划分享更多使用命令的方式:

  • 使用命令与游戏服务器通信
  • 客户端和服务器之间命令处理的基本逻辑

另外,我还稍微触及了痛苦的UI主题,Flux方法和被动方法。

我已经展示了一种有趣的调试方式,当您像调试器中那样重玩游戏状态突变时,它似乎像是时光机。

结合这些模式,我们得到了一个灵活的体系结构,该体系结构易于支持,重构和调试。 当然,许多事情可能会得到改善。 但这取决于你。

我还要指出,在这种情况下,UI和Command模式中的反应性方法极大地使系统脱钩。 当我添加了executor和GameStateManager的调试版本时,我什么都没做。

UI是一个涉及广泛的主题,为此将有单独的文章。

您可以在此存储库中找到源代码。


如果您喜欢这篇文章,请别忘了拍手展示它,或在评论中写下您的想法🙂

订阅,不要错过新内容。

我的联系方式:

  • 我有关GameDev Architecture的频道(俄语)
  • 电报
  • Github
  • 推特