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 倒回去運動。