在Unity实现游戏命令模式

作者:Najmm Shora Unity官方平台 2019-09-19
本文由开发者Najmm Shora介绍在Unity中通过使用命令模式实现回放功能,撤销功能和重做功能。我们可以使用该方法来强化自己的策略类游戏。

你是否想知道《超级食肉男孩》(Super Meat Boy)等游戏是如何实现回放功能的?其中一种方法是完全按照玩家发出的命令执行输入,这意味着输入需要以某种方式存储。

命令模式可用于执行此操作和其他操作。如果你希望在策略游戏里实现撤销和重做功能,命令模式也非常实用。


在本教程中,我们将使用C#实现命令模式,然后使用命令模式来遍历3D迷宫中的机器人角色。

我们会学习到以下内容:

  • 命令模式的基础知识。
  • 实现命令模式的方法。
  • 对输入命令进行排队,并推迟执行。
  • 在执行前,撤销和重做已发出的命令。


本教程使用Unity 2019.1和C# 7,学习本文你需要熟悉Unity的使用,并且对C#有一定的了解。

学习准备

本教程将为你提供项目文件和素材,请发送[命令模式]到微信后台,获取下载地址。

下载完成项目素材后,请解压文件,并在Unity中打开Starter项目。然后打开RW/Scenes文件夹,打开主场景。

如下图所示,场景中有一个迷宫和机器人,左侧有一个显示指令的终端UI。地面的是一个网格,当玩家在迷宫中移动机器人时,这些网格将有助于玩家进行观察。


场景中最有趣的部分是Bot对象,它代表游戏中的机器人,我们在层级窗口单击选中该对象。


在检视窗口查看该对象,可以看见它带有Bot组件,我们将在发出输入命令时使用该组件。


理解Bot的逻辑

我们打开RW/Scripts文件夹,在代码编辑器打开Bot脚本。我们不必了解Bot脚本的作用,但要了解其中的Move方法和Shoot方法的使用。

我们发现,Move方法会接收一个类型为CardinalDirection的输入参数。CardinalDirection是一个枚举,类型为CardinalDirection的枚举对象可以为Up,Down,Right或Left。

根据所选的CardinalDirection不同,机器人会在网格上朝着对应方向移动一个网格。


Shoot方法可以让机器人发射炮弹,摧毁黄色的墙体,但对其它墙体毫无作用。


现在查看ResetToLastCheckpoint方法,我们对迷宫进行观察。在迷宫中,有一些点被称为检查点。为了通过迷宫,机器人应该到达绿色检查点。


在机器人穿过新检查点时,该点会成为机器人的最后检查点。ResetToLastCheckpoint方法会重置机器人的位置到最后检查点。


什么是命令设计模式

命令模式是《设计模式:可复用面向对象软件的基础》(Design Patterns: Elements of Reusable Object-Oriented Software)一书中介绍的23种设计模式之一。

书中写道:命令模式把请求封装为对象,从而允许我们使用不同的请求,队列或日志请求,来参数化处理其它对象,并支持可撤销的操作。

这么表达或许难以理解,下面我们详细讲解一下。

封装:方法调用封装为对象的过程。


参数化其它对象:封装的方法可以根据输入参数来处理多个对象。

请求的队列:得到的“命令”可以在执行前和其它命令一起存储。

命令队列

“Undoable”(可撤销)在此不是指无法实现的东西,而是指可以通过撤销功能恢复的操作。那么这些内容怎么用代码表示呢?

简单来说,Command类会有Execute方法,该方法可以接收一个名为Receiver的对象作为输入参数。因此,Execute方法会由Command类进行封装。

Command类的多个实例可以作为常规对象来传递,这表示它们可以存储在数据结构中,例如:队列,栈等。

为了执行命令,Execute方法需要进行调用。触发执行过程的类叫作Invoker。

我们的项目中已包含一个名叫BotCommand的空类。下面我们将完成要求,让Bot对象可以使用命令模式执行动作。


移动机器人Bot对象


实现命令模式

首先,打开RW/Scripts文件夹,在编辑器打开BotCommand脚本,并加入下面的代码。
  1. //1
  2.     private readonly string commandName;

  3.     //2

  4.     public BotCommand(ExecuteCallback executeMethod, string name)

  5.     {

  6.         Execute = executeMethod;

  7.         commandName = name;
复制代码

代码解读:

  • commandName变量用于存储用户可以理解的命令名称。
  • BotCommand构造函数会接收一个函数和一个字符串,它帮助我们设置Command对象的Execute方法和名称。
  • ExecuteCallback委托会定义封装方法的类型。封装方法会返回void类型,接收类型为Bot对象作为输入参数。
  • Execute属性会引用封装方法,我们要使用它来调用封装方法。
  • ToString方法会被重写,返回commandName字符串,该方法主要在UI中使用。


保存改动,现在我们已经实现了命令模式。

创建命令

我们从RW/Scripts文件夹中打开BotInputHandler脚本。

我们将创建BotCommand的5个实例,这些实例会分别封装方法,从而让Bot对象向上、下、左、右移动,以及让机器人发射炮弹。

添加下列代码到BotCommand类中。
  1.    //1

  2.     private static readonly BotCommand MoveUp =

  3.         new BotCommand(delegate (Bot bot) { bot.Move(CardinalDirection.Up); }, "moveUp");


  4.     //2

  5.     private static readonly BotCommand MoveDown =

  6.         new BotCommand(delegate (Bot bot) { bot.Move(CardinalDirection.Down); }, "moveDown");

  7.     //3

  8.     private static readonly BotCommand MoveLeft =

  9.         new BotCommand(delegate (Bot bot) { bot.Move(CardinalDirection.Left); }, "moveLeft");

  10.     //4

  11.     private static readonly BotCommand MoveRight =

  12.         new BotCommand(delegate (Bot bot) { bot.Move(CardinalDirection.Right); }, "moveRight");

  13.     //5

  14.     private static readonly BotCommand Shoot =

  15.         new BotCommand(delegate (Bot bot) { bot.Shoot(); }, "shoot");
复制代码

在每个实例中,都有一个匿名方法传到构造函数。该匿名方法会封装在相应命令对象之中,每个匿名方法的签名都符合ExecuteCallback委托设置的要求。

此外,构造函数的第二个参数是一个字符串,表示用于指代命令的名称。该名称会通过命令实例的ToString方法返回,它会在后面为UI使用。

在前4个实例中,匿名方法会在Bot对象上调用Move方法。

对于MoveUp、MoveDown、MoveLeft和MoveRight命令,传入Move方法的参数分别是CardinalDirection.Up,CardinalDirection.Down,CardinalDirection.Left和CardinalDirection.Right,这些参数对应着Bot对象的不同移动方向。

在第5个实例上,匿名方法在Bot对象调用Shoot方法。这将在执行该命令时,让机器人发射炮弹。

现在我们创建了命令,这些命令需要在用户发出输入时进行访问。请将下面的代码添加到BotInputHandler中。

  1. public static BotCommand HandleInput()

  2.     {

  3.         if (Input.GetKeyDown(KeyCode.W))

  4.         {

  5.             return MoveUp;

  6.         }

  7.         else if (Input.GetKeyDown(KeyCode.S))

  8.         {

  9.             return MoveDown;

  10.         }

  11.         else if (Input.GetKeyDown(KeyCode.D))

  12.         {

  13.             return MoveRight;

  14.         }

  15.         else if (Input.GetKeyDown(KeyCode.A))

  16.         {

  17.             return MoveLeft;

  18.         }

  19.         else if (Input.GetKeyDown(KeyCode.F))

  20.         {

  21.             return Shoot;

  22.         }



  23.         return null;

  24.     }

复制代码

HandleInput方法会根据用户的按键,返回单个命令实例。继续下一步前,保存改动内容。

使用命令

现在我们要使用创建好的命令。打开RW/Scripts文件夹,在代码编辑器打开SceneManager脚本。在该类中,我们会发现有UIManager类型的uiManager变量的引用。

UIManager类为场景中的终端UI提供了实用的功能性方法。此外,Bot变量引用了附加到Bot对象的Bot组件。

我们将下面的代码添加给SceneManager类,替换代码注释//1的已有代码。

  1. //1

  2.     private List<BotCommand> botCommands = new List<BotCommand>();

  3.     private Coroutine executeRoutine;


  4.     //2

  5.     private void Update()

  6.     {

  7.         if (Input.GetKeyDown(KeyCode.Return))

  8.         {

  9.             ExecuteCommands();

  10.         }

  11.         else

  12.         {

  13.             CheckForBotCommands();

  14.         }         

  15.     }


  16.     //3

  17.     private void CheckForBotCommands()

  18.     {

  19.         var botCommand = BotInputHandler.HandleInput();

  20.         if (botCommand != null && executeRoutine == null)

  21.         {

  22.             AddToCommands(botCommand);

  23.         }

  24.     }


  25.     //4

  26.     private void AddToCommands(BotCommand botCommand)

  27.     {

  28.         botCommands.Add(botCommand);

  29.         //5

  30.         uiManager.InsertNewText(botCommand.ToString());

  31.     }


  32.     //6

  33.     private void ExecuteCommands()

  34.     {

  35.         if (executeRoutine != null)

  36.         {

  37.             return;

  38.         }


  39.         executeRoutine = StartCoroutine(ExecuteCommandsRoutine());

  40.     }


  41.     private IEnumerator ExecuteCommandsRoutine()

  42.     {

  43.         Debug.Log("Executing...");

  44.         //7

  45.         uiManager.ResetScrollToTop();


  46.         //8

  47.         for (int i = 0, count = botCommands.Count; i < count; i++)

  48.         {

  49.             var command = botCommands[i];

  50.             command.Execute(bot);

  51.             //9

  52.             uiManager.RemoveFirstTextLine();

  53.             yield return new WaitForSeconds(CommandPauseTime);

  54.         }


  55.         //10

  56.         botCommands.Clear();


  57.         bot.ResetToLastCheckpoint();


  58.         executeRoutine = null;

  59.     }

复制代码

保存代码,通过使用这些代码,我们可以在游戏视图正常运行项目。

运行游戏并测试命令模式
现在要构建所有内容,在Unity编辑器按下Play按钮。

我们可以使用W,A,S,D按键输入方向命令。输入射击模式时,使用F键。最后按下回车键执行命令。


现在观察代码添加到终端UI的方式。命令会通过它们在UI中的名称表示,该效果通过commandName变量实现。

在执行前,UI会滚动到顶部,执行后的代码行会被移除。

详解命令代码

现在我们详解在使用命令部分添加的代码。

botCommands列表存储了BotCommand实例的引用。考虑到内存,我们只可以创建5个命令实例,但有多个引用指向相同的命令。此外,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声明后添加以下变量声明。

  1. private Stack <BotCommand> undoStack = new Stack <BotCommand>();
复制代码

undoStack变量属于来自Collections命名空间的Stack类,它会存储撤销的命令引用。

现在,我们要分别为撤销和重做添加UndoCommandEntry和RedoCommandEntry两个方法。在SceneManager类中,添加下面代码到ExecuteCommandsRoutine后。

  1. private void UndoCommandEntry()

  2.     {

  3.         //1

  4.         if (executeRoutine != null || botCommands.Count == 0)

  5.         {

  6.             return;

  7.         }


  8.         undoStack.Push(botCommands[botCommands.Count - 1]);

  9.         botCommands.RemoveAt(botCommands.Count - 1);


  10.         //2

  11.         uiManager.RemoveLastTextLine();

  12.      }


  13.     private void RedoCommandEntry()

  14.     {

  15.         //3`

  16.         if (undoStack.Count == 0)

  17.         {

  18.             return;

  19.         }


  20.         var botCommand = undoStack.Pop();

  21.         AddToCommands(botCommand);

  22.     }

复制代码

解读这部分代码:

  • 如果命令正在执行,或botCommands列表是空的,UndoCommandEntry方法不执行任何操作。否则,它会把最后输入的命令引用推送到undoStack上。这部分代码也会从botCommands列表移除命令引用。
  • UIManager类的RemoveLastTextLine方法会移除终端UI的最后一行文字,这样在发生撤销时,终端UI内容符合botCommands的内容。
  • 如果undoStack为空,RedoCommandEntry不执行任何操作。否则,它会把最后的命令从undoStack移出,然后通过AddToCommands把命令添加到botCommands列表。


现在我们添加键盘输入来使用这些方法。在SceneManager类中,把Update方法的主体替换为下列代码。

  1. if (Input.GetKeyDown(KeyCode.Return))

  2.     {

  3.         ExecuteCommands();

  4.     }

  5.     else if (Input.GetKeyDown(KeyCode.U)) //1

  6.     {

  7.         UndoCommandEntry();

  8.     }

  9.     else if (Input.GetKeyDown(KeyCode.R)) //2

  10.     {

  11.         RedoCommandEntry();

  12.     }

  13.     else

  14.     {

  15.         CheckForBotCommands();

  16.     }
复制代码

现在按下U键会调用UndoCommandEntry方法,按下R键会调用RedoCommandEntry方法。

处理边缘情况

现在我们快要完成该教程了,在完成前,我们要确定二件事:

  • 输入新命令时,undoStack应该被清空。
  • 执行命令前,undoStack应该被清空。


首先,我们给SceneManager添加一个新方法。添加下面的方法到CheckForBotCommands之后。

  1. private void AddNewCommand(BotCommand botCommand)

  2.     {

  3.         undoStack.Clear();

  4.         AddToCommands(botCommand);

  5.     }
复制代码

该方法会清空undoStack,然后调用AddToCommands方法。

现在把CheckForBotCommands内的AddToCommands调用替换为下列代码:
  1. AddNewCommand(botCommand);
复制代码

最后,复制粘贴下列代码到ExecuteCommands方法内的if语句中,从而在执行前清空undoStack。

  1. undoStack.Clear();
复制代码

现在项目终于完成了,我们保存并构建项目。在Unity编辑器单击Play按钮。输入命令,按下U键撤销命令,按下R键恢复被撤销的命令。

下图展示了让机器人到达绿色检查点。


学习资源

希望了解更多游戏编程中的设计模式,请访问Robert Nystrom的游戏编程模式网站:
http://gameprogrammingpatterns.com/

了解更多高级C#方法,请访问《C# Collections, Lambdas, and LINQ》课程:
https://www.raywenderlich.com/604358-c-collections-lambdas-and-linq

小结


在Unity中通过使用命令模式实现回放功能,撤销功能和重做功能为大家介绍到这里。

作者:Najmm Shora  
来源:Unity官方平台
原地址:https://mp.weixin.qq.com/s/3dbta9vSvY-nERUUH5IDyg

最新评论
暂无评论
参与评论

商务合作 查看更多

编辑推荐 查看更多