牧师&恶魔:Unity游戏之面向对象的设计模式

160 阅读11分钟

Unity:牧师&恶魔(Priest and Devil)

面向对象编程

C#语言是一门面向对象的现代编程语言,其丰富的类库和开发工具,以及良好的兼容性与移植性,使其编程效率极高。游戏开发者在制作游戏时,往往会创建非常多的不同的游戏对象,然而这些对象却不适合用继承的方法进行管理,在很多时候这样的操作反而会使得程序的耦合程度上升,且不利于处理游戏对象的动态变化。同样地,在Unity中编写C#脚本时,遇到一个复杂的游戏逻辑时,自然而然的就会想到通过职责来对程序进行划分,用不同的类对应不同的角色,每个部分各司其职,通过协作实现游戏的逻辑。当然,没有一种万能的编程方案,只有最合适的,这一点十分重要,这在下面的例子中将会得到印证。

前排提醒:内容较硬,建议先学习继承多态以及接口等相关知识后再阅读

游戏设计模式

将游戏想象成一场可交互的戏剧表演或者一部剧情发展取决于玩家抉择的影片,那么游戏内容可以被划分为不同的场景,不同的场景下有或相同或不同的交互逻辑,交互对象也不尽相同。以现实世界为例,一部影片的制作需要包括但不限于以下角色的参与:制作人、导演、编辑、道具组、特效组、主要演员和群众演员等,参考这些基于职责划分的角色,映射到游戏逻辑中,可以将游戏逻辑基于职责划分为总导演、执行导演和玩家交互处理等,用UML图表示:

• StarUML (UNREGISTERED) 2023_10_20 9_19_21 (2).png

图中分为3类角色:接受玩家交互输入的角色(UserGUI)、执行导演或者场景控制器(SceneController)以及总导演(Director),容易看出,最为核心的部分是执行导演,它需要实现场景资源的加载与控制(实现SceneControllerInterface接口),并且处理来自玩家的输入事件(实现UserInteractionInterface接口),所有游戏的核心逻辑都需要在此类中实现。在不同场景下需要使用不同的场景控制时,只需实现不同的SceneController即可,下面给出各个部分的代码模板:

//SceneControllerInterface.cs
public interface ISceneController {
    void BeforeLoadResources();
    void LoadResources();
    void Init();
}
//UserInteractionInterface.cs
interface IUserInteraction {
    bool IsWin();
    bool IsOver();
    bool IsRunning();
    void PauseSwitch();  //游戏继续/暂停
    void Restart();      //(重新)开始游戏
}
//Director.cs
public class Director : System.Object {
    private static Director _director;
    public ISceneController CurrentController {get; set;}
    //获取总导演
    public static Director GetDirector() {
        _director ??= new Director();
        return _director;
    }
}
//一种SceneController.cs示例
using UnityEngine;

public class FirstSceneController : MonoBehaviour, ISceneController, IUserInteraction {
    void Awake() {
        Director director = Director.GetDirector();
        director.CurrentController = this;    //将当前场景控制器挂载到总导演
        //依次执行场景控制器的资源预加载、资源加载与初始化
        director.CurrentController.BeforeLoadResources();
        director.CurrentController.LoadResources();
        director.CurrentController.Init();
    }
    void BeforeLoadResources() {}
    void LoadResources() {}
    void Init() {}
    bool IsWin() {}
    bool IsOver() {}
    bool IsRunning() {}
    void PauseSwitch() {}
    void Restart() {}
}
//UserGUI.cs
using UnityEngine;

public class UserGUI : MonoBehaviour {
    private IUserInteraction processor;
    void Awake() {
        processor = Director.GetDirector().CurrentController as IUserAction;
        processor.Restart();    //开始游戏
    }
    //这里使用IMGUI作为游戏GUI
    void OnGUI() {
        if(processor.IsRunning()) {
            //TODO
        }
        if(processor.IsOver()) {
            // 游戏失败
            GUI.Label(new Rect(Screen.width/2-50, Screen.height/2-45, 100, 30), "你失败了!");
        } else if(processor.IsWin()) {
            // 游戏成功
            GUI.Label(new Rect(Screen.width/2-50, Screen.height/2-45, 100, 30), "你赢了!");
        }
    }
}

注:需要按照以下顺序依次对脚本进行加载(可以在Unity中设置),否则会报空指针错误。

  1. SceneController.cs
  2. UserGUI.cs

本文将在此基础上进行游戏的设计,值得一提的是,如果你的游戏体量够小,那么任何精巧的设计模式都是徒劳的,甚至可能反而增大代码的编写难度,因此对游戏的代码量以及系统复杂程度提前进行估计是十分重要的。

游戏实现

游戏介绍: Priests and Devils

Priests and Devils is a puzzle game in which you will help the Priests and Devils to cross the river within the time limit. There are 3 priests and 3 devils at one side of the river. They all want to get to the other side of this river, but there is only one boat and this boat can only carry two persons each time. And there must be one person steering the boat from one side to the other side. In the flash game, you can click on them to move them and click the go button to move the boat to the other direction. If the priests are out numbered by the devils on either side of the river, they get killed and the game is over. You can try it in many ways. Keep all priests alive! Good luck!

翻译:

Priests and Devils(牧师与魔鬼)是一个谜题游戏,在游戏中,你将帮助牧师与魔鬼在规定的时间内渡过河流。在河的一侧有3个牧师和3个魔鬼。它们都想过到河的另一侧,但是只有一艘船,每次只能运送两人。而且必须有一个人在船的一侧操船,将其从一侧划到另一侧。在游戏中,你可以点击角色将他们移动,点击"Go"按钮将船移动到对岸。如果河的任一侧的魔鬼数量多于牧师数量,牧师就会被杀,游戏结束。你可以用多种方式尝试。保证所有的牧师都能活着渡过河流!祝你好运!

按照软件设计的顺序来,首先对需求进行分析,识别出描述中的对象:“牧师”、“恶魔”、“河”、“船”以及“岸”。然后是识别出各个对象在不同输入下的动作,确认好游戏逻辑:

动作牧师恶魔游戏系统
游戏开始实例化实例化实例化实例化实例化变量初始化
交换乘客移动移动///更新变量
渡河移动移动/移动/判断输赢/更新变量
重新开始移动移动/移动/变量重置

注:此处的游戏对象是通过脚本进行实例化的,本文所有内容都将在脚本中完成。

场景控制器

由于游戏中对象的位置是在不断变化的,且船在进行移动前需要先确认哪些对象已经上船,因此需要一种结构,能够十分方便地对内部的成员进行索引以及交换,数组泛型List<>想必十分合适:

  • Objects的下标0 ~ 2表示左岸的对象,3 ~ 5表示右岸的对象,6与7表示船上的乘客
  • ObjectTypes[i]表示Objects[i]的对象是什么(0表示空,1表示牧师,2表示恶魔)
using UnityEngine;
using System.Collections.Generic;

public class FirstSceneController : MonoBehaviour, ISceneController, IUserInteraction {
    public static List<GameObject> priests, devils;
    private static int[] ObjectTypes = new int[8];
    public static bool isRunning, isOver;
    
    public IActionManager ActionManager {get; set;}
    public List<GameObject> Objects {get; set;}
    public bool Shipping {get; set;}

    public GameObject water;

    void Awake() {
        Director director = Director.GetDirector();
        director.CurrentController = this;
        director.CurrentController.BeforeLoadResources();
        director.CurrentController.LoadResources();
        director.CurrentController.Init();
    }
    bool DoFailed() {
        // 判断是否失败
        int i, priestsCount, devilsCount;
        for (i = 0, priestsCount = 0, devilsCount = 0; i < 3; i++)
        {
            if(ObjectTypes[i] == 1)
                priestsCount += 1;
            else if(ObjectTypes[i] == -1)
                devilsCount += 1;
        }
        if(devilsCount>priestsCount && priestsCount>0)
            return true;
        for (i = 3, priestsCount = 0, devilsCount = 0; i < 6; i++)
        {
            if(ObjectTypes[i] == 1)
                priestsCount += 1;
            if(ObjectTypes[i] == -1)
                devilsCount += 1;
        }
        if(devilsCount>priestsCount && priestsCount>0)
            return true;
        return false;
    }
    bool DoWin() {
        int i, priestsCount, devilsCount;
        for (i = 0, devilsCount = 0; i < 3; i++)
        {
            if(ObjectTypes[i] == -1)
                devilsCount += 1;
        }
        if(devilsCount != 3)
            return false;
        for (i = 3, priestsCount = 0; i < 6; i++)
        {
            if(ObjectTypes[i] == 1)
                priestsCount += 1;
        }
        if(devilsCount!=3 || priestsCount!=3)
            return false;
        return true;
    }
    public bool IsWin() {
        isRunning = !DoWin();
        return !isRunning;
    }
    public bool IsOver() {
        // Game Loop
        if(isRunning) {
            isRunning = !isOver;
        }
        return isOver;
    }
    public bool IsRunning() { return isRunning; }
    public bool IsShipping() { return Shipping; }
    public void Restart() {
        Init();
        ActionManager?.Restart();
    }
    public bool StartShipping() {
        //将船移动到另一侧
        if(Objects[6]==null && Objects[7]==null)
            return false;
        isOver = DoFailed();
        isRunning = !isOver;
        if(!isOver) {
            Shipping = true;
            ActionManager.MoveBoat();
        }
        return true;
    }
    public void PauseSwitch() { isRunning = !isRunning; }
    public void SwapObject(int a, int b) {
        //交换岸上的牧师/恶魔与船上的乘客
        (Objects[b], Objects[a]) = (Objects[a], Objects[b]);
        (ObjectTypes[b], ObjectTypes[a]) = (ObjectTypes[a], ObjectTypes[b]);
        //执行交换动作
        ActionManager.ExecuteAction();
    }
    public void Init() {
        isRunning = true;
        isOver = false;
        Shipping = false;
        
        Objects.Clear();
        for (int i = 0; i < 3; i++)
        {
            Objects.Add(priests[i]);
            ObjectTypes[i] = 1;
        }
        for (int i = 0; i < 3; i++)
        {
            Objects.Add(devils[i]);
            ObjectTypes[i+3] = -1;
        }
        // 添加两个空位(船位)
        Objects.Add(null);
        Objects.Add(null);
        ObjectTypes[6] = 0;
        ObjectTypes[7] = 0;
    }
    public void LoadResources() {
        // 载入预制件
        for (int i = 0; i < 3; i++)
        {
            // 载入牧师与恶魔
            priests.Add(Instantiate(Resources.Load<GameObject>("Prefabs/Priest"), Vector3.zero, Quaternion.identity));
            devils.Add(Instantiate(Resources.Load<GameObject>("Prefabs/Devil"), Vector3.zero, Quaternion.identity));
        }
        // 载入平台
        Instantiate(Resources.Load<GameObject>("Prefabs/Platform"), new Vector3(-18, -10, 0), Quaternion.identity);
        Instantiate(Resources.Load<GameObject>("Prefabs/Platform"), new Vector3(18, -10, 0), Quaternion.identity);
        // 载入光照
        Instantiate(Resources.Load<GameObject>("Prefabs/Directional Light"), new Vector3(-18, -10, 0), Quaternion.identity);
        Instantiate(Resources.Load<GameObject>("Prefabs/Global Volume"), new Vector3(18, -10, 0), Quaternion.identity);
        // 载入水面
        Instantiate(water, new Vector3(0, -10, 0), Quaternion.identity);
    }
    public void BeforeLoadResources() {
        Objects = new List<GameObject>();
        priests = new List<GameObject>();
        devils = new List<GameObject>();
    }
}

动作管理器

等等,上面的代码里面似乎没有用于移动对象的代码,而且还多了一个ActionManager,这是什么东西?恭喜,你打开了继承与多态的大门!由于场景控制器需要管理的事情非常多:游戏对象的动作控制、变量维护以及资源加载等等。如果不同的场景都需要实现一个不同的场景控制器,那么将十分不利于大型项目的代码编写、复用以及维护,一个很自然的想法就是,将场景控制器的动作管理和资源加载等功能分离出去,用新的类代替场景控制器进行维护,下面给出一种动作控制器的UML设计图以及模板:

• StarUML (UNREGISTERED) 2023_10_20 10_48_42 (2).png

//AbstractAction.cs
using UnityEngine;

public class AbstractAction : ScriptableObject
{
    public bool enable = true;
    public bool destroy = false;

    public GameObject Executor {get; set;}
    public Transform Transform {get; set;}
    public IAbstractActionCallback Callback {get; set;}

    protected AbstractAction() {}

    public virtual void Start() {
        throw new System.NotImplementedException("Function 'Start()' has no implement!");
    }
    //在Update中编写动作的执行逻辑
    public virtual void Update() {
        throw new System.NotImplementedException("Function 'Update()' has no implement!");
    }
}
//AbstractActionCallback.cs
public interface IAbstractActionCallback {
    void BeforeExecution(AbstractAction sourceAction);
    void AfterExecution(AbstractAction sourceAction);
    void BeforeDestruction(AbstractAction sourceAction);
}
//AbstractActionManager.cs
using UnityEngine;
using System.Collections.Generic;

public class AbstractActionManager : MonoBehaviour {
    private Dictionary<int, AbstractAction> ExecuteTable = new();
    private List<AbstractAction> ActionPlan = new();
    private List<int> ReleasePlan = new();
    //首先将待执行动作计划加入执行动作计划表,然后执行动作计划表,最后执行动作计划的销毁
    protected void Update() {
        foreach (AbstractAction Action in ActionPlan)
        {
            Action.Callback.BeforeExecution(Action);
            ExecuteTable[Action.GetInstanceID()] = Action;
        }
        ActionPlan.Clear();
        foreach (KeyValuePair<int, AbstractAction> executor in ExecuteTable)
        {
            if(executor.Value.destroy) {
                ReleasePlan.Add(executor.Key);
            }
            if(executor.Value.enable) {
                //执行动作计划
                executor.Value.Update();
            }
        }
        foreach (int key in ReleasePlan)
        {
            AbstractAction elem = ExecuteTable[key];
            elem.Callback.BeforeDestruction(elem);
            ExecuteTable.Remove(key);
            Destroy(elem);
        }
        ReleasePlan.Clear();
    }
    //添加新的待执行动作计划
    public void AddActionPlan(GameObject gameObject, AbstractAction newPlan, IAbstractActionCallback actionManager) {
        if(gameObject == null)
            return;
        newPlan.Executor = gameObject;
        newPlan.Transform = gameObject.GetComponent<Transform>();
        newPlan.Callback = actionManager;
        newPlan.Start();
        ActionPlan.Add(newPlan);
    }
}

这里的逻辑是:动作管理者在AddActionPlan方法中接受新的执行计划,然后在被调用Update方法时执行动作计划表,在动作的执行前、执行后以及销毁前都会“通知”相应的管理者进行处理,一般而言,这个管理者就是添加了此动作计划的ActionManager。在需要一个新的场景控制器时,仍然可以使用AbstractActionManager来帮助快速搭建一个新的动作管理器。令人兴奋的是,继承了AbstractAction的任何动作能够被任何ActionManager所识别并执行,这无疑提升了代码的复用性!

最后给出游戏中的动作管理器的UML设计图以及代码:

• StarUML (UNREGISTERED) 2023_10_20 11_41_40 (2).png

//MoveAction.cs
using UnityEngine;

public class MoveAction : AbstractAction {
    private Vector3 TargetPosition;
    private float Speed;
    public static MoveAction CreateMoveAction(Vector3 target, float speed) {
        MoveAction result = CreateInstance<MoveAction>();
        result.TargetPosition = target;
        result.Speed = speed;
        return result;
    }
    public override void Start() {}
    public override void Update() {
        Transform.position = Vector3.MoveTowards(Transform.position, TargetPosition, Speed);
        if(Transform.position == TargetPosition) {
            if(!destroy) {
                destroy = true;
                Callback.AfterExecution(this);
            }
        }
    }
}
//ActionManager.cs
using UnityEngine;
using System.Collections.Generic;

struct PlaceInfo
{
    public float x, y;
    public bool hasObject;
}

public class ActionManager : AbstractActionManager, IAbstractActionCallback, IActionManager {
    private static PlaceInfo[] Pos = new PlaceInfo[8];
    private float boatLeftx, boatRightx;
    private FirstSceneController controller;
    private MoveAction BoatMovement;
    private GameObject BoatInstance;
    public GameObject boat;
    private bool isLeft;
    private float Speed;
    protected void Start() {
        controller = (FirstSceneController)SceneDirector.GetDirector().CurrentController;
        controller.ActionManager = this;
        // 载入船
        BoatInstance = Instantiate(boat);
        Restart();
    }
    public void Restart() {
        BoatInstance.GetComponent<Transform>().position = new Vector3(-7, -10, 0);
        isLeft = true;
        Speed = 0.1f;
        // 左岸
        for (int i = 0; i < 3; i++)
        {
            Pos[i].x = -16 + i*2;
            Pos[i].y = -6.5f;
        }
        // 右岸
        for (int i = 0; i < 3; i++)
        {
            Pos[i+3].x = 12 + i*2;
            Pos[i+3].y = -6.5f;
        }
        // 船上的两个空位
        boatLeftx = -8f;
        boatRightx = -6f;
        Pos[6].x = boatLeftx;
        Pos[6].y = -9f;
        Pos[7].x = boatRightx;
        Pos[7].y = -9f;
        ExecuteAction();
    }
    protected new void Update() {
        //每帧执行一次动作计划表
        base.Update();
    }
    public void ExecuteAction() {
        //更新牧师与恶魔的位置
        for(int i = 0; i < controller.Objects.Count; i++)
        {
            GameObject item = controller.Objects[i];
            if(item != null) {
                AddActionPlan(item, MoveAction.CreateMoveAction(new Vector3(Pos[i].x, Pos[i].y), Speed), this);
            }
        }
    }
    public void MoveBoat() {
        //将船移动到另一侧
        BoatMovement = MoveAction.CreateMoveAction(new Vector3(-7 + (isLeft == true ? 14 : 0), -10, 0), Speed);
        Pos[6].x = isLeft ? boatLeftx + 14 : boatLeftx;
        Pos[7].x = isLeft ? boatRightx + 14 : boatRightx;
        AddActionPlan(BoatInstance, BoatMovement, this);
        AddActionPlan(controller.Objects[6], MoveAction.CreateMoveAction(new Vector3(Pos[6].x, Pos[6].y), Speed), this);
        AddActionPlan(controller.Objects[7], MoveAction.CreateMoveAction(new Vector3(Pos[7].x, Pos[7].y), Speed), this);
    }
    public void SetMoveSpeed(float speed) {
        Speed = speed;
    }
    public void BeforeExecution(AbstractAction sourceAction) {}
    public void AfterExecution(AbstractAction sourceAction) {
        if(sourceAction == BoatMovement) {
            //船移动完成后需要设置船在另一侧,以及通知场景控制器移动结束
            isLeft = !isLeft;
            controller.Shipping = false;
        }
    }
    public void BeforeDestruction(AbstractAction sourceAction) {}
}

系统类图:

202e12b8cb512dfedccaa8a901be6e3c.svg - 个人 - Microsoft​ Edge 2023_10_20 12_57_21 (2).png

除了动作管理器,还可以从SceneController中分出资源管理类……想想就头皮发麻了有没有?为什么会有这样的感觉呢?就是因为这里的游戏体量太小了,既没有多个游戏场景,又没有十分复杂的动作,让这样复杂的设计模式显得十分可笑,不过还是希望看到这里的你能够有所收获吧~

- fin -