本文介绍如何在 Unity 通过使用命令模式实现回放功能,撤销功能和重做功能,你可以使用该方法来强化自己的策略类游戏。
原博地址:https://www.raywenderlich.com/3067863-implementing-the-command-pattern-in-unity
原文链接:https://connect.unity.com/p/zai-unityshi-xian-you-xi-ming-ling-mo-shi?app=true
作者:Najmm Shora 预计阅读时间:20 分钟
Unity 版本:Unity 2019.1
你是否想知道《超级食肉男孩》(Super Meat Boy)等游戏是如何实现回放功能的?其中一种方法是执行和玩家完全相同的输入操作,这样意味着输入需要保存起来。命令模式可以实现该功能,以及更多其它功能。
如果希望在策略游戏里实现撤销和重做功能,命令模式也非常实用。
在本教程中,我们会使用 C#实现命令模式,然后使用命令模式来遍历 3D 迷宫中的机器人( bot, 文中 bot,机器人交替出现,请整理一下)角色。在这个过程中,我们会学习到以下内容:
-
命令模式的基础知识。
-
实现命令模式的方法。
-
对输入命令进行排队,推迟这些命令的执行。
-
在执行前,撤销和重做发出的命令。
备注:阅读本文需要熟悉 Unity 的使用,并且拥有对 C#有一定的了解。本教程使用 Unity 2019.1 和 C# 7。
准备过程
跟随本教程进行学习时,请下载文末链接的项目素材文件。解压文件,在 Unity 中打开 Starter 项目。
打开 RW/Scenes 文件夹,打开 Main 场景。我们会注意到,场景中有一个迷宫和机器人,旁边有终端 UI 显示指令。地面上有网格,当玩家在迷宫中移动机器人时,这些网格有助于玩家进行观察。
单击 Play 按钮后,我们发现指令不会进行工作,这是因为我们还没添加该功能,我们将在教程中添加功能。 场景中最有趣的部分是 Bot 游戏对象,在层级窗口单击选中该对象。
在检视窗口查看该对象,我们看到它带有 Bot 组件。我们会在发出输入命令时使用该组件。
了解 Bot 对象的逻辑
打开 RW/Scripts 文件夹,在代码编辑器打开 Bot 脚本。我们不必了解 Bot 脚本会做什么,但要注意其中的 Move 方法和 Shoot 方法。我们也不用知道二个方法中的代码作用,但需要了解如何使用二个方法。
我们发现,Move 方法会接收一个类型为 CardinalDirection 的输入参数。CardinalDirection 是一个枚举,类型为 CardinalDirection 的枚举对象可以为 Up,Down,Right 或 Left。根据所选的 CardinalDirection 不同,机器人会在网格上朝着对应方向移动一个网格。
Shoot 方法可以让机器人发射炮弹,摧毁黄色的墙体,但对其它墙体毫无作用。
现在查看 ResetToLastCheckpoint 方法,为了了解它的功能,我们要观察迷宫。在迷宫中,有一些点被称为检查点。为了通过迷宫,机器人应该到达绿色检查点。
在机器人穿过新检查点时,该点会成为机器人的最后检查点。ResetToLastCheckpoint 方法会重置机器人的位置到最后检查点。
我们目前无法使用这些方法,但我们很快就会用到了。首先,我们要介绍命令设计模式。
命令设计模式介绍
命令模式是 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 编写的《设计模式:可复用面向对象软件的基础》( Design Patterns: Elements of Reusable Object-Oriented Software )
书中介绍的 23 种设计模式之一。 书中写道:命令模式把请求封装为对象,从而允许我们使用不同的请求,队列或日志请求,来参数化处理其它对象,并支持可撤销的操作。
这个定义确实对读者来说并不友好,我们下面详细讲解一下。 封装是指方法调用封装为对象的过程。
参数化其它对象指的是:封装的方法可以根据输入参数来处理多个对象。 请求的队列指的是:得到的“命令”可以在执行前和其它命令一起存储。
“undoable”(可撤销)在此不是指无法实现的东西,而是指可以通过撤销功能恢复的操作。
那么这些内容怎么用代码表示呢?
简单来说,Command 类会有 Execute 方法,该方法可以接收命令处理的对象,该对象叫 Receiver,用作输入参数。因此,Execute 方法会由 Command 类进行封装。
最后,为了执行命令,Execute 方法需要进行调用。触发执行过程的类叫作 Invoker。
现在,该项目包含名叫 BotCommand 的空类。在下个部分,我们会完成要求,实现之前的功能,让 Bot 对象可以使用命令模式执行动作。
移动 Bot 对象
实现命令模式
在这部分,我们会实现命令模式。实现该模式有多种方法。本教程会介绍其中一种方法。
首先打开 RW/Scripts 文件夹,在编辑器打开 BotCommand 脚本。BotCommand 类此时应该是空白的,我们会给它加入代码。
在该类中粘贴下列代码:
//1 private readonly string commandName; //2 public BotCommand(ExecuteCallback executeMethod, string name) { Execute = executeMethod; commandName = name; } //3 public delegate void ExecuteCallback(Bot bot); //4 public ExecuteCallback Execute { get; private set; } //5 public override string ToString() { return commandName; } 下面讲解这些代码。
-
commandName 变量用于存储用户可以理解的命令名称。它对于该模式并不重要,但是我们会在后面需要到它。
-
BotCommand 构造函数会接收一个函数和一个字符串。它会帮助我们设置 Command 对象的 Execute 方法和名称。
-
ExecuteCallback 委托会定义封装方法的类型。封装方法会返回 void 类型,接收类型为 Bot (即带有 Bot 组件)的对象作为输入参数。
-
Execute 属性会引用封装方法。我们要使用它来调用封装方法。
-
ToString 方法会被重写,返回 commandName 字符串,该方法主要在 UI 中使用。
保存改动,现在我们已经实现了命令模式。
接下来要使用命令模式。
创建命令
从 RW/Scripts 文件夹打开 BotInputHandler 脚本。
我们会在此创建 BotCommand 的五个实例。这些实例会分别封装方法,从而让 Bot 对象向上,下,左,右移动,还可以让机器人发射炮弹。
复制粘贴下列代码到 BotCommand 类中:
//1 private static readonly BotCommand MoveUp = new BotCommand(delegate (Bot bot) { bot.Move(CardinalDirection.Up); }, "moveUp"); //2 private static readonly BotCommand MoveDown = new BotCommand(delegate (Bot bot) { bot.Move(CardinalDirection.Down); }, "moveDown"); //3 private static readonly BotCommand MoveLeft = new BotCommand(delegate (Bot bot) { bot.Move(CardinalDirection.Left); }, "moveLeft"); //4 private static readonly BotCommand MoveRight = new BotCommand(delegate (Bot bot) { bot.Move(CardinalDirection.Right); }, "moveRight"); //5 private static readonly BotCommand Shoot = new BotCommand(delegate (Bot bot) { bot.Shoot(); }, "shoot"); 在每个实例中,都有一个匿名方法传到构造函数。该匿名方法会封装在相应命令对象之中。我们发现,每个匿名方法的签名都符合 ExecuteCallback 委托设置的要求。
此外,构造函数的第二个参数是一个字符串,表示用于指代命令的名称。该名称会通过命令实例的 ToString 方法返回,它会在后面为 UI 使用。
在前四个实例中,匿名方法会在 Bot 对象上调用 Move 方法。该方法有多种参数。
对于 MoveUp、MoveDown、MoveLeft 和 MoveRight 命令,传入 Move 方法的参数分别是 CardinalDirection.Up ,CardinalDirection.Down,CardinalDirection.Left 和 CardinalDirection.Right。
这些参数对应着 Bot 对象的不同移动方向,这在命令设计模式部分介绍部分中提到过。
最后在第五个实例上,匿名方法在 Bot 对象调用 Shoot 方法。这会在执行该命令时,让机器人发射炮弹。
现在我们创建了命令,这些命令需要在用户发出输入时进行访问。
为此,我们要把下列代码复制粘贴到 BotInputHandler 中,它的位置在命令实例下方:
public static BotCommand HandleInput() { if (Input.GetKeyDown(KeyCode.W)) { return MoveUp; } else if (Input.GetKeyDown(KeyCode.S)) { return MoveDown; } else if (Input.GetKeyDown(KeyCode.D)) { return MoveRight; } else if (Input.GetKeyDown(KeyCode.A)) { return MoveLeft; } else if (Input.GetKeyDown(KeyCode.F)) { return Shoot; } return null; } HandleInput 方法会根据用户的按键,返回单个命令实例。继续下一步前,保存改动内容。
使用命令
现在我们要使用创建好的命令。打开 RW/Scripts 文件夹,在代码编辑器打开 SceneManager 脚本。在该类中,我们会发现有 UIManager 类型的 uiManager 变量的引用。
UIManager 类为场景中的终端 UI 提供了实用的功能性方法。在 UIManager 类的方法使用时,我们会介绍方法的用途,但在本文中,我们不必知道它内部的工作方式。
此外,bot 变量引用了附加到 Bot 对象的 Bot 组件。
现在把下列代码添加给 SceneManager 类,替换代码注释 //1 的已有代码:
//1 private List<BotCommand> botCommands = new List<BotCommand>(); private Coroutine executeRoutine; //2 private void Update() { if (Input.GetKeyDown(KeyCode.Return)) { ExecuteCommands(); } else { CheckForBotCommands(); } } //3 private void CheckForBotCommands() { var botCommand = BotInputHandler.HandleInput(); if (botCommand != null && executeRoutine == null) { AddToCommands(botCommand); } } //4 private void AddToCommands(BotCommand botCommand) { botCommands.Add(botCommand); //5 uiManager.InsertNewText(botCommand.ToString()); } //6 private void ExecuteCommands() { if (executeRoutine != null) { return; } executeRoutine = StartCoroutine(ExecuteCommandsRoutine()); } private IEnumerator ExecuteCommandsRoutine() { Debug.Log("Executing..."); //7 uiManager.ResetScrollToTop(); //8 for (int i = 0, count = botCommands.Count; i < count; i++) { var command = botCommands[i]; command.Execute(bot); //9 uiManager.RemoveFirstTextLine(); yield return new WaitForSeconds(CommandPauseTime); } //10 botCommands.Clear(); bot.ResetToLastCheckpoint(); executeRoutine = null; } 这里的代码很多,通过使用这些代码,我们可以在游戏视图正常运行项目。
之后我们会讲解这些代码,现在先保存改动。
运行游戏,测试命令模式
现在要构建所有内容,在 Unity 编辑器按下 Play 按钮。
我们可以使用 WASD 按键输入方向命令。输入射击模式时,使用 F 键。最后,按下回车键执行命令。
备注:在执行过程结束前,我们无法输入更多命令。
现在观察代码添加到终端 UI 的方式。命令会通过它们在 UI 中的名称表示,该效果通过 commandName 变量实现。
我们还会注意到,在执行前,UI 会滚动到顶部,执行后的代码行会被移除。
详细讲解命令
现在我们讲解在使用命令部分添加的代码:
-
botCommands 列表存储了 BotCommand 实例的引用。考虑到内存,我们只可以创建五个命令实例,但有多个引用指向相同的命令。此外,executeCoroutine 变量引用了 ExecuteCommandsRoutine,后者会处理命令的执行过程。
-
如果用户按下回车键,更新检查结果,此时它会调用 ExecuteCommands,否则会调用 CheckForBotCommands。
-
CheckForBotCommands 使用来自 BotInputHandler 的 HandleInput 静态方法,检查用户是否发出输入信息,此时会返回命令。返回的命令会传递到 AddToCommands。然而,如果命令被执行的话,即如果 executeRoutine 不是空的话,它会直接返回,不把任何内容传递给 AddToCommands。因此,用户必须等待执行过程完成。
-
AddToCommands 给返回的命令实例添加了新引用,返回到 botCommands。
-
UIManager 类的 InsertNewText 方法会给终端 UI 添加新一行文字。该行文字是作为输入参数传给方法的字符串。我们会在此给它传入 commandName。
-
ExecuteCommands 方法会启动 ExecuteCommandsRoutine。
-
UIManager 类的 ResetScrollToTop 会向上滚动终端 UI。它会在执行过程开始前完成。
-
ExecuteCommandsRoutine 拥有 for 循环,它会迭代 botCommands 列表内的命令,通过把 Bot 对象传给 Execute 属性返回的方法,逐个执行这些命令。在每次执行后,我们会添加 CommandPauseTimeseconds 时长的暂停。
-
UIManager 类的 RemoveFirstTextLine 方法会移除终端 UI 里的第一行文字,只要那里仍有文字。因此,每个命令执行后,它的相应名称会从终端 UI 移除。
-
执行所有命令后,botCommands 会清空,机器人会使用 ResetToLastCheckpoint,重置到最后检查点。接着,executeRoutine 会设为 null,用户可以继续发出更多输入信息。
实现撤销和重做功能
再运行一次场景,尝试到达绿色检查点。
我们会注意到,我们现在无法撤销输入的命令,这意味着如果犯了错,我们无法后退,除非执行完所有命令。我们可以通过添加撤销功能和重做功能来解决该问题。
回到 SceneManager.cs 脚本,在 botCommands 的 List 声明后添加以下变量声明:
private Stack<BotCommand> undoStack = new Stack<BotCommand>(); undoStack 变量属于来自 Collections 命名空间的Stack类,它会存储撤销的命令引用。
现在,我们要分别为撤销和重做添加 UndoCommandEntry 和 RedoCommandEntry 二个方法。在 SceneManager 类中,复制粘贴下列代码到 ExecuteCommandsRoutine 之后:
private void UndoCommandEntry() { //1 if (executeRoutine != null || botCommands.Count == 0) { return; } undoStack.Push(botCommands[botCommands.Count - 1]); botCommands.RemoveAt(botCommands.Count - 1); //2 uiManager.RemoveLastTextLine(); } private void RedoCommandEntry() { //3 if (undoStack.Count == 0) { return; } var botCommand = undoStack.Pop(); AddToCommands(botCommand); } 现在讲解这部分代码:
-
如果命令正在执行,或 botCommands 列表是空的,UndoCommandEntry 方法不执行任何操作。否则,它会把最后输入的命令引用推送到 undoStack 上。这部分代码也会从 botCommands 列表移除命令引用。
-
UIManager 类的 RemoveLastTextLine 方法会移除终端 UI 的最后一行文字,这样在发生撤销时,终端 UI 内容符合 botCommands 的内容。
-
如果 undoStack 为空,RedoCommandEntry 不执行任何操作。否则,它会把最后的命令从 undoStack 移出,然后通过 AddToCommands 把命令添加到 botCommands 列表。
现在我们添加键盘输入来使用这些方法。在 SceneManager 类中,把 Update 方法的主体替换为下列代码:
if (Input.GetKeyDown(KeyCode.Return)) { ExecuteCommands(); } else if (Input.GetKeyDown(KeyCode.U)) //1 { UndoCommandEntry(); } else if (Input.GetKeyDown(KeyCode.R)) //2 { RedoCommandEntry(); } else { CheckForBotCommands(); } -
按下 U 键会调用 UndoCommandEntry 方法。
-
按下 R 键会调用 RedoCommandEntry 方法。
处理边缘情况
现在我们快要完成该教程了,在完成前,我们要确定二件事:
-
输入新命令时,undoStack 应该被清空。
-
执行命令前,undoStack 应该被清空。
为此,我们首先给 SceneManager 添加新的方法。复制粘贴下面的方法到 CheckForBotCommands 之后:
private void AddNewCommand(BotCommand botCommand) { undoStack.Clear(); AddToCommands(botCommand); } 该方法会清空 undoStack,然后调用 AddToCommands 方法。 现在把 CheckForBotCommands 内的 AddToCommands 调用替换为下列代码:
AddNewCommand(botCommand); 最后,复制粘贴下列代码到 ExecuteCommands 方法内的 if 语句中,从而在执行前清空 undoStack:
undoStack.Clear(); 现在项目终于完成了!
保存项目。构建项目,然后在 Unity 编辑器单击 Play 按钮。输入命令,按下 U 键撤销命令,按下 R 键恢复被撤销的命令。
尝试让机器人到达绿色检查点。
后续学习
如果想要了解更多游戏编程中的设计模式,建议查看 Robert Nystrom 的游戏编程模式网站。
如果想了解更多高级 C#方法,可以查看C# Collections,Lambdas,and LINQ课程。
挑战
小挑战:尝试达到迷宫终点的绿色检查点。如果遇到困难,我在下面提供了解决方法,这是多个解决方法之一。
解决方法:
-
moveUp × 2
-
moveRight × 3
-
moveUp × 2
-
moveLeft
-
shoot
-
moveLeft × 2
-
moveUp × 2
-
moveLeft × 2
-
moveDown × 5
-
moveLeft
-
shoot
-
moveLeft
-
moveUp × 3
-
shoot × 2
-
moveUp × 5
-
moveRight × 3
本文到此结束,感谢阅读。希望你喜欢这篇教程,如果有问题或评论,请在评论区讨论。 特别感谢艺术家 Lee Barkovich、Jesús Lastra 和 sunburn 提供本项目的资源。
原文链接: https://connect.unity.com/p/zai-unityshi-xian-you-xi-ming-ling-mo-shi?app=true
欢迎大家戳上方链接,下载 Unity 官方 app,技术社区互动答疑,干货资源学不停!
