Unity的C#编程教程_65_行为范式 Command Pattern 详解及应用练习

目录:

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 倒回去运动。

Tags: