一、前言
在上一篇MVC模式的基础上进行修改,使得动作管理从场景控制中分离出来,简化场景控制器的设计。
上一篇博客:
牧师与魔鬼Priests and Devils--unity小游戏-CSDN博客
游戏视频:
牧师与魔鬼Priests and Devils--动作分离版--unity小游戏
二、动作分离和集成
1. 代码框架的改变
在上个基于MVC模式的设计中,场景控制器管理了太多的事情,不仅要实现资源加载、动作控制,还有处理游戏规则(即判断游戏是否结束等)和处理用户交互事件。因此将动作控制从场景控制器中分离出来,专门设计一个动作管理器来管理动作的进行。除此之外,还额外设计一个裁判类,用来判断游戏是否结束。
2. 动作分离和集成的原理
动作分离:将游戏对象的不同行为逻辑分离到独立的脚本或组件中,每个脚本或组件负责处理特定的行为。通过将行为逻辑分离,可以使代码更加模块化和可维护。每个行为脚本可以独立编写、测试和调试,降低了代码的复杂度,并且可以更容易地修改、扩展或重用特定的行为。
动作集成:将不同的行为逻辑整合到游戏对象中,以实现完整的功能。将这些不同行为动作的脚本添加到同一个游戏对象中,可以使其能够同时按照需求执行不同的行为。
动作分离和集成的可以提高代码的可维护性、可读性和可扩展性。通过将行为逻辑分离到独立的脚本或组件中,可以更好地组织和管理代码。同时,通过集成这些行为逻辑,游戏对象可以实现复杂的行为交互和功能组合,使其具备丰富的行为表现和玩法。
3. 动作管理器的设计UML图
设计思路如下:
- 通过门面模式(控制器模式)输出组合好的几个动作,共原来程序调用。
- 好处,动作如何组合变成动作模块内部的事务
- 这个门面就是 CCActionManager
- 通过组合模式实现动作组合,按组合模式设计方法
- 必须有一个抽象事物表示该类事物的共性,例如 SSAction,表示动作,不管是基本动作或是组合后动作
- 基本动作,用户设计的基本动作类。 例如:CCMoveToAction
- 组合动作,由(基本或组合)动作组合的类。例如:CCSequenceAction
- 接口回调(函数回调)实现管理者与被管理者解耦
- 如组合对象实现一个事件抽象接口(ISSCallback),作为监听器(listener)监听子动作的事件
- 被组合对象使用监听器传递消息给管理者。至于管理者如何处理就是实现这个监听器的人说了算了
- 例如:每个学生做完作业通过邮箱发消息给学委,学委是谁,如何处理,学生就不用操心了
- 通过模板方法,让使用者减少对动作管理过程细节的要求
- SSActionManager 作为 CCActionManager 基类
三、具体实现
1. 动作管理器
将原本的动作控制移出Controllers目录,并创建一个新的目录Actions用于存储与动作管理器实现相关的脚本:
Actions目录下脚本文件如下:
(1)SSAction
SSAction是动作的基类,所有的单个动作以及组合动作都是继承于此。SSAction定义了一个enable变量判断动作是否可以进行,一个destroy变量判断是否需要摧毁某个动作(一般来说执行完成的动作就需要被摧毁)。同时定义了动作所绑定的游戏对象gameobject以及游戏对象的Transform组件用来控制位置移动,并且定义了一个callback变量用来实现消息通知。在SSAction中,所有的方法都是虚方法,需要子类(即具体的动作)进行重写实现多态。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 动作基类
public class SSAction : ScriptableObject { // 不需要绑定 GameObject 对象的可编程基类
public bool enable = true; // 动作可以进行
public bool destory = false; // 是否需要摧毁该动作
public GameObject gameobject { get; set; } // 该动作作用的游戏对象
public Transform transform { get; set; } // 该动作作用的游戏对象的 Transform 组件
public ISSActionCallback callback { get; set; } // 实现消息通知,避免与动作管理者直接依赖
protected SSAction () {} // 防止用户自己 new 抽象的对象
// Use this for initialization
public virtual void Start () { // 申明虚方法,通过重写实现多态
throw new System.NotImplementedException ();
}
// Update is called once per frame
public virtual void Update () {
throw new System.NotImplementedException ();
}
}
(2)CCMoveToAction
CCMoveToAction继承自动作基类SSAction。在该游戏中的动作只有移动,因此CCMoveToAction定义了一个目的地位置的变量target和移动的速度。该类还实现了一个返回动作类型的函数GetSSAction来获取当前的动作并返回动作对象。正如上文所言,CCMoveToAction需要对SSAction声明的虚方法进行重写,在Update中持续执行移动的动作直到到达目的地。到达目的地后需要摧毁该动作(因为该动作已经完成),并且通过回调函数通知动作管理器该动作已经完成的消息。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 某个具体的动作实现
public class CCMoveToAction : SSAction
{
public float speed; // 移动速度
public Vector3 target; // 目的地的位置(坐标)
public static CCMoveToAction GetSSAction(Vector3 target, float speed){ // 创建一个新的动作对象
CCMoveToAction action = ScriptableObject.CreateInstance<CCMoveToAction> (); // 使用 CreateInstance 方法可以创建一个新的 ScriptableObject 实例并返回
action.target = target;
action.speed = speed;
return action; // 返回动作对象
}
public override void Update () //用override声明重写
{
this.transform.localPosition = Vector3.MoveTowards(this.transform.localPosition, target, speed * Time.deltaTime); // 移动到目的地
if (this.transform.localPosition == target) { // 如果已经到达目的地
//waiting for destroy
this.destory = true; // 动作执行完成,需要销毁
this.callback.SSActionEvent (this) ; // 通知动作管理者动作完成
return;
}
}
public override void Start () {
}
}
(3)CCSequenceAction
CCSequenceAction是一个动作组合序列,也是继承自SSAction,具有一个SSAction类型的列表用于存储一组动作,一个变量repeat用于存储动作序列重复执行的次数(如果为-1则不断重复执行0,一个变量start用于存储当前执行的动作在列表中的下标/索引值)。CCSequenceAction同样有个返回当前动作的函数GetSSAction,不过返回的不是单个动作而是整个动作序列,包含一个start可以标明当前执行的动作。该类也同样重写了SSAction的函数,在start函数中依次执行动作序列中每个动作的start函数,在update函数中根据循环重复的次数repeat以及当前的动作执行相应的update函数。
与单个动作不同的是,CCSequenceAction还需要实现ISSActionCallback接口,重写SSActionEvent函数来传递动作是否执行完毕的信息。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 动作组合序列
public class CCSequenceAction : SSAction, ISSActionCallback
{
public List<SSAction> sequence; // 列表存储动作序列
public int repeat = -1; //repeat forever 动作序列重复执行的次数
public int start = 0; // 当前执行的动作序列的下标
public static CCSequenceAction GetSSAction(int repeat, int start , List<SSAction> sequence){
CCSequenceAction action = ScriptableObject.CreateInstance<CCSequenceAction> ();
action.repeat = repeat; // 动作序列多一个参数,重复次数
action.sequence= sequence;
action.start = start;
return action;
}
public override void Update ()
{
if (sequence.Count == 0) return; //动作序列为空,执行结束
if (start < sequence.Count) {
sequence [start].Update (); // 执行当前动作
}
}
public void SSActionEvent (SSAction source, SSActionEventType events = SSActionEventType.Competeted, int intParam = 0, string strParam = null, Object objectParam = null)
{
source.destory = false;
this.start++;
if (this.start >= sequence.Count) { // 执行整个动作序列的所有动作
this.start = 0;
if (repeat > 0) repeat--; // 开始下一次循环
if (repeat == 0) { this.destory = true; this.callback.SSActionEvent (this); } // 所有序列动作执行完成,准备摧毁动作,回调
}
}
// Use this for initialization
public override void Start () {
foreach (SSAction action in sequence) { // 依次执行动作序列中的每个动作
action.gameobject = this.gameobject;
action.transform = this.transform;
action.callback = this;
action.Start ();
}
}
void OnDestory() {
//TODO: something
}
}
(4)SSActionManager
SSActionManager是动作对象管理器的基类,实现了对于所有动作的基本管理。有一个动作字典actions用来添加所有需要执行的动作,一个waitingAdd列表存储等待加入到字典的动作,一个waitingDelete列表存储等待摧毁的动作对象。在update函数中先是遍历waiting列表,将需要执行的动作加入到字典中;然后遍历字典中每一个工作,判断动作是否可以执行还是执行完成需要摧毁;最后遍历waitingDelete列表中的每个动作并将它们摧毁。同时定义了一个函数RunAction来创建动作、把游戏对象和动作绑定,并绑定该动作实现的消息接收者,最后添加到列表中并执行。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 动作管理器基类
public class SSActionManager : MonoBehaviour {
private Dictionary <int, SSAction> actions = new Dictionary <int, SSAction> (); // 使用字典管理动作
private List <SSAction> waitingAdd = new List<SSAction> ();
private List<int> waitingDelete = new List<int> ();
// Update is called once per frame
protected void Update () {
foreach (SSAction ac in waitingAdd) actions [ac.GetInstanceID ()] = ac;
waitingAdd.Clear ();
foreach (KeyValuePair <int, SSAction> kv in actions) {
SSAction ac = kv.Value;
if (ac.destory) {
waitingDelete.Add(ac.GetInstanceID()); // 添加到等到删除的列表
} else if (ac.enable) {
ac.Update (); // update action
}
}
foreach (int key in waitingDelete) { // 等待删除的动作
SSAction ac = actions[key];
actions.Remove(key); // 从字典中删除动作
Object.Destroy(ac); // 销毁动作
}
waitingDelete.Clear (); // 等到所有动作执行完毕后,清空等待删除的动作列表
}
// 执行动作
public void RunAction(GameObject gameobject, SSAction action, ISSActionCallback manager) {
action.gameobject = gameobject; // 设置动作的游戏对象
action.transform = gameobject.transform; // 设置动作的游戏对象的 Transform 组件
action.callback = manager; // 设置动作的回调接口
waitingAdd.Add (action); // 添加到等待执行的动作列表中
action.Start (); // 开始执行动作
}
// Use this for initialization
protected void Start () {
}
}
(5)CCActionManager
动作管理器的具体实现,继承自基类SSActionManager,同时还需要实现ISSActionCallback的回调接口。由于该游戏包含移动船的动作和移动角色的动作,因此创建一个单动作对象moveBoat,动作序列moveRole(因为移动角色分两步进行,先移到中间位置,再移动到终点)。由于移动过程中无法再操控对象,因此需要一个变量isMoving判断是否在移动。在初始化函数start中,需要先从场景控制器(FirstController)中获取场景对象,并将该动作管理器赋值给场景控制器(以便实现动作控制和信息交互)。而update直接覆盖基类的方法即可。然后在两个动作函数MoveBoat和MoveRole中创建动作并执行。同时还要重载函数SSActionEvent进行消息通信,返回动作是否在执行(即是否在移动)的信息。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 某个具体动作的动作管理器
public class CCActionManager : SSActionManager, ISSActionCallback {
private FirstController sceneController; // 场景控制器(第一控制器)
private CCMoveToAction moveBoat; // 移动船的动作
private CCSequenceAction moveRole; // 移动角色的动作(移动角色的动作分为两步,因此是一个组合动作)
bool isMoving = false; // 动作是否在进行
protected new void Start() { // 开始运行,初始化
sceneController = (FirstController)SSDirector.GetInstance ().CurrentSceneController; // 当前的场景取出来
sceneController.actionManager = this;
}
protected new void Update ()
{
base.Update (); // Update 是方法覆盖,提醒编程人员重新该方法不会多态,且要用 base 调用原方法
}
public bool IsMoving() { // 判断是否正在移动
return isMoving;
}
//移动船
public void MoveBoat(GameObject boat, Vector3 target, float speed)
{
if (isMoving) return; // 正在移动时无法再次移动
isMoving = true;
moveBoat = CCMoveToAction.GetSSAction(target, speed); // 定义动作
this.RunAction(boat, moveBoat, this); // 执行动作
}
//移动人
public void MoveRole(GameObject role, Vector3 mid_destination, Vector3 destination, float speed)
{
if (isMoving) return; // 正在移动时无法再次移动
isMoving = true;
// 组合动作,先向中间位置移动,再向目的地移动
moveRole = CCSequenceAction.GetSSAction(0, 0, new List<SSAction> { CCMoveToAction.GetSSAction(mid_destination, speed), CCMoveToAction.GetSSAction(destination, speed) });
this.RunAction(role, moveRole, this); // 执行组合动作
}
#region ISSActionCallback implementation
public void SSActionEvent (SSAction source, SSActionEventType events = SSActionEventType.Competeted, int intParam = 0, string strParam = null, Object objectParam = null)
{
isMoving = false; // 动作执行完成,回调
}
#endregion
}
(6)ISSActionCallback
回调函数接口,该接口作为接收通知对象的抽象类型,包含一个枚举类型SSActionEventType来判断动作事件的类型。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public enum SSActionEventType:int { Started, Competeted }
// 回调接口
public interface ISSActionCallback
{
void SSActionEvent(SSAction source,
SSActionEventType events = SSActionEventType.Competeted,
int intParam = 0 ,
string strParam = null,
Object objectParam = null);
}
2. 裁判类
裁判类被放置到了Controllers目录中
Referee
将判断游戏是否结束的函数从场景控制器中分离出来,实现了一个裁判类Referee。该类需要获得场景控制器对象,以及游戏对象中的左河岸、右河岸、船(这三个不是必须的,可以通过访问FirstController中的对象直接获取,这里写了主要是为了减少代码长度)。在start函数中获取场景控制器对象以及三个游戏对象。然后在update中不断判断游戏是否结束,判断逻辑与之前FirstController中的check函数一致,唯一不同的是需要通过回调函数Callback将游戏结束的信息返回给场景控制器。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Referee : MonoBehaviour
{
public FirstController firstCtrl;
public Shore leftShore;
public Shore rightShore;
public Boat boat;
void Start()
{
firstCtrl = (FirstController)SSDirector.GetInstance().CurrentSceneController; // 当前的场景取出来
this.leftShore= firstCtrl.leftShoreController.GetShore();
this.rightShore = firstCtrl.rightShoreController.GetShore();
this.boat = firstCtrl.boatController.GetBoatModel();
}
void Update() // 将FirstController中的Update方法移动到这里
{
firstCtrl = (FirstController)SSDirector.GetInstance().CurrentSceneController; // 当前的场景取出来
if (!firstCtrl.isRunning) return; // 如果游戏结束,那么无需再检查
if (firstCtrl.time <= 0) {
firstCtrl.Callback(false, "时间到! 游戏失败!");
return;
}
firstCtrl.Callback(true, ""); // 游戏信息置空
//判断是否已经胜利
if (rightShore.priestCount == 3) { // 如果右岸的牧师数量为3,游戏胜利
firstCtrl.Callback(false, "牧师成功过岸,游戏胜利!");
}
else{
int leftPriestCount, rightPriestCount, leftDevilCount, rightDevilCount; // 两岸和船上的牧师和魔鬼数量
leftPriestCount = leftShore.priestCount + (boat.isRight ? 0 : boat.priestCount);
rightPriestCount = rightShore.priestCount + (boat.isRight ? boat.priestCount : 0);
leftDevilCount = leftShore.devilCount + (boat.isRight ? 0 : boat.devilCount);
rightDevilCount = rightShore.devilCount + (boat.isRight ? boat.devilCount : 0);
if ((leftPriestCount != 0 && leftPriestCount < leftDevilCount) || (rightPriestCount != 0 && rightPriestCount < rightDevilCount)) { // 如果任意一边的牧师数量不为0且小于魔鬼数量,游戏失败
firstCtrl.Callback(false, "牧师被魔鬼杀死,游戏结束!");
}
}
}
}
3. 场景控制器
将动作和判断游戏结束的代码从场景控制器中移除之后,FirstController中的代码也要进行一些修改:
FirstController
首先要额外定义一个动作管理器对象actionManager。在动作函数部分MoveBoat和MoveRole不再执行动作,而是根据当前游戏对象的相对位置来确定移动的目的地,然后创建一个动作交给动作管理器actionManage来执行。判断游戏结束不再需要check函数了,而是通过回调函数Callback从裁判类Referee中获取游戏是否结束以及需要显示在游戏界面上的信息。当然还需要在awake函数中给游戏对象添加动作管理器组件和裁判类组件。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 场记
// 具体类型:FirstController
public class FirstController : MonoBehaviour, ISceneController, IUserAction { // 对于ISceneController和IUserAction接口的具体实现
public ShoreCtrl leftShoreController, rightShoreController; // 左岸和右岸的控制器
River river; // 河流对象
BackGround backGround; // 背景对象
public BoatCtrl boatController; // 船的控制器
RoleCtrl[] roleControllers; // 人物的控制器,每个人物分别对应一个控制器
public bool isRunning; // 游戏是否在运行
public float time; // 游戏倒计时
SSDirector director; // 导演实例
public CCActionManager actionManager; // 新增一个动作管理器***
public void LoadResources() { // 对于ISceneController的具体实现,加载资源,初始化游戏
//角色部分role
roleControllers = new RoleCtrl[6]; // 数组存储6个角色/控制器
for (int i = 0; i < 6; ++i) {
roleControllers[i] = new RoleCtrl();
roleControllers[i].CreateRole(Position.role_shore[i], i < 3 ? true : false, i); // 创建角色,传入角色在左岸的位置,依次填写角色的id,前三个是牧师,后三个是魔鬼
}
//河岸部分Shore
leftShoreController = new ShoreCtrl();
leftShoreController.CreateShore(Position.left_shore);
leftShoreController.GetShore().shore.name = "left_shore";
rightShoreController = new ShoreCtrl();
rightShoreController.CreateShore(Position.right_shore);
rightShoreController.GetShore().shore.name = "right_shore";
//将人物添加并定位至左岸
foreach (RoleCtrl roleController in roleControllers) // 遍历角色控制器
{
roleController.GetRoleModel().role.transform.localPosition = leftShoreController.AddRole(roleController.GetRoleModel()); // 设置每个角色在左岸的位置
}
//船部分boat
boatController = new BoatCtrl();
boatController.CreateBoat(Position.left_boat);
//河流对象river
river = new River(Position.river);
//背景对象backGround
backGround = new BackGround(Position.background);
isRunning = true; // 游戏开始运行
time = 60; // 游戏倒计时60s
}
public void destroyResourse(){
Object.DestroyImmediate(leftShoreController.GetShore().shore);
Object.DestroyImmediate(rightShoreController.GetShore().shore);
Object.DestroyImmediate(boatController.GetBoatModel().boat);
Object.DestroyImmediate(river.river);
Object.DestroyImmediate(backGround.bg);
foreach (RoleCtrl roleController in roleControllers) {
Object.DestroyImmediate(roleController.GetRoleModel().role);
}
}
public void MoveBoat() { // 对于IUserAction的具体实现,移动船
if(isRunning==false || boatController.isEmpty()) return; // 如果游戏结束或船上没人,那么无法再移动船
Vector3 destination = boatController.GetBoatModel().isRight ? Position.left_boat : Position.right_boat;
actionManager.MoveBoat(boatController.GetBoatModel().boat, destination, 5); // 动作交给动作管理器处理
boatController.GetBoatModel().isRight = !boatController.GetBoatModel().isRight; // 每次移动之后,方向取反
}
public void MoveRole(Role roleModel) { // 对于IUserAction的具体实现,移动角色
if (isRunning == false || actionManager.IsMoving()) return; // 如果游戏结束或者船正在移动,那么无法再移动角色
Vector3 destination, mid_destination; // 目的地和中间位置
if (roleModel.inBoat) { // 如果角色在船上
if (boatController.GetBoatModel().isRight) { // 如果船在右岸
destination = rightShoreController.AddRole(roleModel); // 设置角色的目的地为右岸
}
else { // 如果船在左岸
destination = leftShoreController.AddRole(roleModel); // 设置角色的目的地为左岸
}
if (roleModel.role.transform.localPosition.y > destination.y) { // 如果角色的y坐标大于目的地的y坐标,说明是从岸上到船上,那么就先水平移动到中间位置,然后再竖直移动到目的地
mid_destination = new Vector3(destination.x, roleModel.role.transform.localPosition.y, destination.z);
}
else { // 如果角色的y坐标小于目的地的y坐标,说明是从船上到岸上,那么就先竖直向上移动到中间位置,然后再水平移动到目的地
mid_destination = new Vector3(roleModel.role.transform.localPosition.x, destination.y, destination.z);
}
actionManager.MoveRole(roleModel.role, mid_destination, destination, 5); // 动作交给动作管理器处理
roleModel.onRight = boatController.GetBoatModel().isRight; // 每次移动之后,角色的方向取决于船的方向
boatController.RemoveRole(roleModel); // 将角色从船上移动到岸上
}
else { // 如果角色在岸上
if (boatController.GetBoatModel().isRight == roleModel.onRight) { // 如果角色和船在同一边
if (roleModel.onRight) { // 如果角色在右岸
rightShoreController.RemoveRole(roleModel); // 将角色从右岸移动到船上
}
else { // 如果角色在左岸
leftShoreController.RemoveRole(roleModel); // 将角色从左岸移动到船上
}
destination = boatController.AddRole(roleModel); // 设置角色的目的地为船上
if (roleModel.role.transform.localPosition.y > destination.y)
mid_destination = new Vector3(destination.x, roleModel.role.transform.localPosition.y, destination.z);
else
mid_destination = new Vector3(roleModel.role.transform.localPosition.x, destination.y, destination.z);
actionManager.MoveRole(roleModel.role, mid_destination, destination, 5); // 动作交给动作管理器处理
}
}
}
public void Check() { // 对于IUserAction的具体实现,检查游戏是否结束
// 由裁判类Referee判断是否结束
}
public void Restart(){ // 重新开始游戏
// director.CurrentSceneController.destroyResourse();
// this.gameObject.GetComponent<UserGUI>().gameMessage = ""; // 游戏信息置空
// director.CurrentSceneController.LoadResources();
// 由于动作管理器部分需要位置信息,不能直接摧毁游戏对象,所以该部分暂时去除
}
void Awake() { // 唤醒函数,在游戏开始前调用一次
director = SSDirector.GetInstance(); // 获取导演实例
director.CurrentSceneController = this; // 设置当前场景控制器为本对象
director.CurrentSceneController.LoadResources(); // 调用上面的LoadResources()函数,加载资源,初始化游戏
this.gameObject.AddComponent<UserGUI>(); // 添加UserGUI脚本,显示用户交互界面
this.gameObject.AddComponent<CCActionManager>(); // 添加动作管理器
this.gameObject.AddComponent<Referee>(); // 添加裁判类
}
void Update() { // 每帧调用一次
if (isRunning) {
time -= Time.deltaTime; // 游戏剩余时间减少
this.gameObject.GetComponent<UserGUI>().time = (int)time; // 设置游戏剩余时间
}
}
// 回调函数
public void Callback(bool isRuning, string message)
{
this.isRunning = isRuning; // 游戏结束信息
this.gameObject.GetComponent<UserGUI>().gameMessage = message;
}
}
四、代码链接
除了上述提及的新增的和修改的代码外,其他的代码与上个项目一致,就不再赘述。代码链接如下: