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: