Unity的C#编程教程_65_行为范式 Command Pattern 详解及应用练习
目录:
1. Command Pattern
2. When to use the Command Pattern
Command Pattern – Setup and Implementation
1. Scene Setup for Command Pattern
2. Implementing the Command Pattern
Challenge: The Command Manager
Command Pattern – Practicals
1. Command Pattern Implementation – Practical
2. Rewind and Play Command – Practical
Command Pattern – Getting Started
1. Command Pattern
-
Command Pattern 就是行为范式,记录各种操作,或者游戏中的事件
-
隔离功能到各自的 class 中
-
例如,我们可以用 Player class 去检测用户输入,并运行对应的动作,对应动作的执行会隔离在对应的 class 中,以便于后期维护和检查,包括游戏回滚操作等。
-
在战术和策略游戏 tactical and strategy game 中用的很多,比如选定的不同角色,有不同的动作执行方式等。
-
任务说明:
- 有 3 个 cube
- 点击每一个 cube,会改变其颜色
- 记录玩家的每一个动作
- 比如点击第一个1次,第三个2次,第二个3次这样的动作
- 点击 Done 按钮完成录制
- 点击 Play 按钮重播之前的录制
- 点击 Rewind 按钮倒着播放之前的录制
- 点击 Reset 重新开始录制
这样的任务就可以使用 Command Pattern 来完成。
2. When to use the Command Pattern
- 什么时候使用 Command Pattern
- 现在很多企业都会使用 Command Pattern
- 用于代码去耦 decouple code
- 可建立灵活,可扩展的软件
- 在游戏中,我们希望建立 rewind 系统(回放系统)的时候可以使用,这是一种记录玩家行为的简单方式
- 在策略游戏中,我们会面临很多节点,这个时候玩家要做出选择(比如一个对话,我们有很多种回答的选项),选择以后会影响后面游戏的发展,这个时候也可以利用这个 Command Pattern
- Command Pattern 的优点:
- 代码去耦,各种功能被隔离,和主程序的运行互不干扰,改变一种功能不会影响其他功能
- Command Pattern 缺点:
- 增加了项目的复杂度
- 需要额外的 class 文件
Command Pattern – Setup and Implementation
1. Scene Setup for Command Pattern
- 创建场景来实现 Command Pattern
首先创建 3 个 cube,并设置 Tag 为 Cube。
创建 4 个 Button:Play,Done,Rewind,Reset,放置在 4 个角上。
2. Implementing the Command Pattern
- 实施 Command Pattern
- 即实施 Command Interface 指令接口,包含两个方法
- 一个是执行 execute
- 另一个是额外 undue
- 所以我们要为 command 定一个契约 contract
在脚本文件夹 Scripts 下面建立一个文件夹命名为 Interfaces,创建脚本 ICommand:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public interface ICommand
{
void Execute();
void Undue();
}
在我们创建具体的 command 之前,我们要搞清楚用户想要实现的具体功能,这里我们需要点击一个 cube,随机设定一个颜色。
创建用户操作脚本 UserInput,挂载到 Main Camera 上:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class UserInput : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
// 运行逻辑:
// 1. 鼠标点击
// 2. 生成一个从摄像机到鼠标位置方向的 ray
// 3. 在这个 ray 路径上检测到 cube
// 4. 该 cube 随机改变颜色
// 以上功能我们希望通过 command interface 实现,以做到程序隔离
// 先看下一般的实现方法:
if (Input.GetMouseButtonDown(0)) // 点击鼠标左键
{
Ray ranOrigin = Camera.main.ScreenPointToRay(Input.mousePosition);
// Camera.main 表示第一个被激活的摄像机,并且该摄像机 tag 为 MainCamera
// 从这个相机射出一条射线,指向鼠标所在位置
RaycastHit hitInfo;
// 该变量用于从射线投射中获得信息
if(Physics.Raycast(ranOrigin,out hitInfo)) // 从初始位置投射出去的ray遇到什么就返回什么信息
{
if(hitInfo.collider.tag == "Cube") // 如果射线碰撞到了一个 tag 为 cube 的游戏对象
{
hitInfo.collider.GetComponent<MeshRenderer>().material.color = new Color(Random.value, Random.value, Random.value);
// 把这个碰撞到的对象设定随机颜色
}
}
}
}
}
这种做法的弊端在于,执行这个动作的代码和 UserInput 深度绑定了。
我们希望隔离这部分代码。
在 Scripts 文件夹下面创建 Commands 文件夹,创建 ClickCommand 文件:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ClickCommand : ICommand
{
private GameObject _cube;
private Color _color;
private Color _previousColor;
public ClickCommand(GameObject cube, Color color) // ClickCommand 这个类的构造函数
{
this._cube = cube;
this._color = color;
}
public void Execute()
{
// 首先要存储之前的颜色
_previousColor = _cube.gameObject.GetComponent<MeshRenderer>().material.color;
// 在这里我们要实现的功能是:点击 cube,随机改变颜色
_cube.gameObject.GetComponent<MeshRenderer>().material.color = _color;
}
public void Undo()
{
// 设置回到之前的颜色
_cube.gameObject.GetComponent<MeshRenderer>().material.color = _previousColor;
}
}
每个 command 都需要实现 Execute 和 Undo 方法,但是每个的实现方法可以不同,可自定义。
然后我们修改 UserInput 文件:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class UserInput : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
// 运行逻辑:
// 1. 鼠标点击
// 2. 生成一个从摄像机到鼠标位置方向的 ray
// 3. 在这个 ray 路径上检测到 cube
// 4. 该 cube 随机改变颜色
// 以上功能我们希望通过 command interface 实现,以做到程序隔离
if (Input.GetMouseButtonDown(0)) // 点击鼠标左键
{
Ray ranOrigin = Camera.main.ScreenPointToRay(Input.mousePosition);
// Camera.main 表示第一个被激活的摄像机,并且该摄像机 tag 为 MainCamera
// 从这个相机射出一条射线,指向鼠标所在位置
RaycastHit hitInfo;
// 该变量用于从射线投射中获得信息
if(Physics.Raycast(ranOrigin,out hitInfo)) // 从初始位置投射出去的ray遇到什么就返回什么信息
{
if(hitInfo.collider.tag == "Cube") // 如果射线碰撞到了一个 tag 为 cube 的游戏对象
{
// hitInfo.collider.GetComponent<MeshRenderer>().material.color = new Color(Random.value, Random.value, Random.value);
// 把这个碰撞到的对象设定随机颜色
// 采用新的 ICommand 方式:
Color color = new Color(Random.value, Random.value, Random.value);
ClickCommand onClick = new ClickCommand(hitInfo.collider.gameObject, color);
// ICommand onClick = new ClickCommand(hitInfo.collider.gameObject, color); 也可以
onClick.Execute();
}
}
}
}
}
这里我们把具体的改变 cube 颜色的操作进行了隔离。
如果需要,可以创建许多不同的功能,然后在 UserInput 中访问对应的 class 即可。
Challenge: The Command Manager
- 如何录制游戏运行过程
首先我们需要一个 manager class。
在 scripts 文件夹下面创建 Managers 文件夹,并创建 CommandManager 脚本,挂载到同名的游戏对象上:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CommandManager : MonoBehaviour
{
// 创建 singleton:
private static CommandManager _instance;
public static CommandManager Instance
{
get
{
if (_instance == null)
{
Debug.Log("no instance");
}
return _instance;
}
}
private void Awake()
{
_instance = this;
}
// 我希望建立一个 List,用于存储所有的 command:
private List<ICommand> _commandPool = new List<ICommand>(); // List需要初始化,避免报错
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
}
- 任务说明:
- 建立 3 个方法
- 一个为 command 列表添加新的 command
- 建立一个 play routine,重新运行一遍所有执行过的 command,每个 command 间隔 1 秒钟
- 建立一个 play method 激活 play routine
- 建立一个 rewind routine,倒着运行一遍所有执行过的 command,每个 command 间隔 1 秒钟
- 建立一个 rewind method 激活 rewind routine
- Done 按钮,停止运行,把所有 cube 变为白色
- Reset 按钮,清空 CommandPool
实现方法:
对于 ICommand 的定义不变:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public interface ICommand
{
void Execute();
void Undo();
}
对于 ClickCommand 的定义不变:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ClickCommand : ICommand
{
private GameObject _cube;
private Color _color;
private Color _previousColor;
public ClickCommand(GameObject cube, Color color) // ClickCommand 这个类的构造函数
{
this._cube = cube;
this._color = color;
}
public void Execute()
{
// 首先要存储之前的颜色
_previousColor = _cube.gameObject.GetComponent<MeshRenderer>().material.color;
// 在这里我们要实现的功能是:点击 cube,随机改变颜色
_cube.gameObject.GetComponent<MeshRenderer>().material.color = _color;
}
public void Undo()
{
// 设置回到之前的颜色
_cube.gameObject.GetComponent<MeshRenderer>().material.color = _previousColor;
}
}
编写 CommandManager 脚本:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CommandManager : MonoBehaviour
{
// 创建 singleton:
private static CommandManager _instance;
public static CommandManager Instance
{
get
{
if (_instance == null)
{
Debug.Log("no instance");
}
return _instance;
}
}
private void Awake()
{
_instance = this;
}
// 我希望建立一个 List,用于存储所有的 command:
private List<ICommand> _commandPool = new List<ICommand>(); // List需要初始化,避免报错
public void Command(ICommand command)
{
_commandPool.Add(command);
}
public void Play()
{
StartCoroutine(PlayRoutine());
}
public void Rewind()
{
StartCoroutine(RewindRoutine());
}
public void Done()
{
foreach(var cube in GameObject.FindGameObjectsWithTag("Cube"))
{
cube.GetComponent<MeshRenderer>().material.color = Color.white;
}
}
public void GetReset()
{
_commandPool.Clear();
}
IEnumerator PlayRoutine()
{
for (int i = 0; i < _commandPool.Count; i++)
{
yield return new WaitForSeconds(1);
_commandPool[i].Execute();
}
}
IEnumerator RewindRoutine()
{
for (int i = _commandPool.Count-1; i >=0; i--)
{
yield return new WaitForSeconds(1);
_commandPool[i].Undo();
}
}
}
将 CommandManager 游戏对象,通过拖拽赋值,关联到各个按钮上,并选择对应的方法,比如 Play 按钮选择 Play 方法(按下按钮后运行 CommandManager 中的 Play 方法)。
修改 UserInput:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class UserInput : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
// 运行逻辑:
// 1. 鼠标点击
// 2. 生成一个从摄像机到鼠标位置方向的 ray
// 3. 在这个 ray 路径上检测到 cube
// 4. 该 cube 随机改变颜色
// 以上功能我们希望通过 command interface 实现,以做到程序隔离
// 先看下一般的实现方法:
if (Input.GetMouseButtonDown(0)) // 点击鼠标左键
{
Ray ranOrigin = Camera.main.ScreenPointToRay(Input.mousePosition);
// Camera.main 表示第一个被激活的摄像机,并且该摄像机 tag 为 MainCamera
// 从这个相机射出一条射线,指向鼠标所在位置
RaycastHit hitInfo;
// 该变量用于从射线投射中获得信息
if(Physics.Raycast(ranOrigin,out hitInfo)) // 从初始位置投射出去的ray遇到什么就返回什么信息
{
if(hitInfo.collider.tag == "Cube") // 如果射线碰撞到了一个 tag 为 cube 的游戏对象
{
// hitInfo.collider.GetComponent<MeshRenderer>().material.color = new Color(Random.value, Random.value, Random.value);
// 把这个碰撞到的对象设定随机颜色
// 采用新的 ICommand 方式:
Color color = new Color(Random.value, Random.value, Random.value);
ICommand onClick = new ClickCommand(hitInfo.collider.gameObject, color);
onClick.Execute();
CommandManager.Instance.Command(onClick); // 把执行的动作进行记录
}
}
}
}
}
点击运行游戏,即可开始录制和回放了!
Command Pattern – Practicals
1. Command Pattern Implementation – Practical
- 什么时候使用 Command Pattern
创建一个 Player 游戏对象,如果我们想要记录 Player 做的所有动作,那我们就需要利用 Command Pattern 了。
首先需要一个 Player 脚本,挂载在 Player 游戏对象上:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : MonoBehaviour
{
ICommand moveUp, moveDown, moveLeft, moveRight;
[SerializeField] // 在 inspector 中可见和修改
private float _speed = 3.0f; // 定义移动速度
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
if (Input.GetKey(KeyCode.W))
{
moveUp = new MoveUpCommand(this.transform, _speed);
moveUp.Excute();
}
else if (Input.GetKey(KeyCode.S))
{
moveDown = new MoveDownCommand(this.transform, _speed);
moveDown.Excute();
}
else if (Input.GetKey(KeyCode.A))
{
moveLeft = new MoveLeftCommand(this.transform, _speed);
moveLeft.Excute();
}
else if (Input.GetKey(KeyCode.D))
{
moveRight = new MoveRightCommand(this.transform, _speed);
moveRight.Excute();
}
}
}
这个脚本用于玩家控制游戏角色,以及进行各种动作指令的输入。
第二部分,我们需要有一个东西记录我们所有的动作过程,比如先往左走,再往前走,最后往右走。
创建 Interface 文件夹,创建 ICommand 接口脚本:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public interface ICommand
{
void Excute();
void Undo();
}
这个接口文件里面规定了 Excute 方法和 Undo 方法。
创建一个 Commands 文件夹,并创建 MoveUpCommand,MoveDownCommand,MoveLeftCommand,MoveRightCommand 这 4 个脚本文件,用于执行游戏角色的动作:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MoveLeftCommand : ICommand
{
private Transform _player;
private float _speed;
public MoveLeftCommand(Transform player,float speed) // 构造函数
{
_player = player;
_speed = speed;
}
public void Excute()
{
_player.Translate(Vector3.left * _speed * Time.deltaTime);
// 让player进行移动
// 方向*速度*时间修正
}
public void Undo()
{
throw new System.NotImplementedException();
}
}
其他 3 个脚本与这个类似。
运行以后就可以控制 player 的移动了。
下一节介绍如何记录移动动作。
2. Rewind and Play Command – Practical
- 记录动作,回放动作
ICommand 脚本不变;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public interface ICommand
{
void Excute();
void Undo();
}
创建 CommandManager 游戏对象并挂载同名脚本,该脚本设置在 Managers 文件夹下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
public class CommandManager : MonoBehaviour
{
// 设置为 singleton:
private static CommandManager _instance;
public static CommandManager Instance
{
get
{
if(_instance == null)
{
Debug.LogError("no instance");
}
return _instance;
}
}
private void Awake()
{
_instance = this;
}
private List<ICommand> _commandPool = new List<ICommand>();
// 建立命令列表并初始化
public void AddCommand(ICommand command)
{
_commandPool.Add(command);
}
public void Rewind()
{
StartCoroutine(RewindRoutine());
}
IEnumerator RewindRoutine()
{
Debug.Log("Start rewind");
foreach(var command in Enumerable.Reverse(_commandPool))
{
command.Undo();
yield return new WaitForEndOfFrame();
}
Debug.Log("Finish rewind");
}
}
Player:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : MonoBehaviour
{
ICommand moveUp, moveDown, moveLeft, moveRight;
[SerializeField] // 在 inspector 中可见和修改
private float _speed = 3.0f; // 定义移动速度
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
if (Input.GetKey(KeyCode.W))
{
moveUp = new MoveUpCommand(this.transform, _speed);
moveUp.Excute();
CommandManager.Instance.AddCommand(moveUp);
}
else if (Input.GetKey(KeyCode.S))
{
moveDown = new MoveDownCommand(this.transform, _speed);
moveDown.Excute();
CommandManager.Instance.AddCommand(moveDown);
}
else if (Input.GetKey(KeyCode.A))
{
moveLeft = new MoveLeftCommand(this.transform, _speed);
moveLeft.Excute();
CommandManager.Instance.AddCommand(moveLeft);
}
else if (Input.GetKey(KeyCode.D))
{
moveRight = new MoveRightCommand(this.transform, _speed);
moveRight.Excute();
CommandManager.Instance.AddCommand(moveRight);
}
}
}
MoveUpCommand:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MoveUpCommand : ICommand
{
private Transform _player;
private float _speed;
public MoveUpCommand(Transform player, float speed) // 构造函数
{
_player = player;
_speed = speed;
}
public void Excute()
{
_player.Translate(Vector3.up * _speed * Time.deltaTime);
// 让player进行移动
// 方向*速度*时间修正
}
public void Undo()
{
_player.Translate(Vector3.down * _speed * Time.deltaTime);
}
}
其他 3个脚本类似。
创建一个按钮,把 CommandManager 拖拽进去,选择 Rewind 方法。
运行,控制移动 player。
点击 Rewind 按钮,player 倒回去运动。