前言
在总结设计模式之前,我想先声明,有关Unity知识更新的内容,是我对UnityLearn中文站提供的学习路线内容的总结。建议学习Unity的小伙伴也可以看看这个学习路线,尤其是一些XXX框架、XXX系统等通用知识。相较于某个功能的实现,一些框架上的思路更有利于我们培养解决问题的能力。
这期博客主要总结一下博主@机智的游戏开发 在UnityLearn、B站和个人博客等更新的《游戏开发设计模式》。讲得十分通俗易懂,下面贴出UnityLearn地址和博主的个人博客:
游戏设计模式之工厂模式 | Witty (wittykyrie.github.io)
本篇是对上述视频、博客教程的总结思考并形成的知识笔记。
设计模式
设计模式是解决某些软件问题的思路,使用设计模式的目的往往是提高代码的灵活性、复用度、可扩展性,降低耦合度等。实现老生常谈的高内聚、低耦合。设计模式有很多,针对Unity游戏开发,常用的设计模式如下:
- 组合模式
- 单例模式
- 命令模式
- 观察者模式
- 工厂模式
组合模式
我理解的组合模式就是将基类和子类中共同出现的内容或者说内容的并集组件化,在创建类的时候通过组合不同的组件来形成逻辑子类。
首先我们用一般的代码来实现父子类(Unity开发的原因,我常使用C#)
public class People
{
public void 吃();
public void 喝();
public void 拉();
public void 撒();
}
public class 小明 : People
{
public void 唱();
public void 跳();
public void rap();
}
public class 小华 : People
{
public void 篮球();
public void 跳();
public void rap();
}
我们可以看出,作为人的基类,有吃喝拉撒的共同点,而作为人的子类,小明和小华虽然不同,但是他们派生出的新特性无非在唱、跳、rap、篮球的集合之中。很明显,如果一个类能包含吃喝拉撒和唱、跳、rap、篮球中的一个或多个,那么他就是People的派生。
那么,如果我们不去写传统的基类和派生,转而写吃喝拉撒和唱、跳、rap、篮球的类,然后将他们组合起来,在逻辑上,也能形成上文的小明、小华,甚至坤哥 。
在Unity中,组合模式是非常符合直觉的,大家都不知不觉使用了这种模式,因为各类功能都作为component,在游戏物体上绑定各种component来形成我们所需要的对象。
观察inspector面板,我们其实可以认为Camera对象是从一个空物体,添加Camera、 Audio Lisener、Physics Raycaster等组件形成了我们常用的Camera。对应上方的People例子,如果这里的基类我们在逻辑上认为是Camera,MainCamera就是其一种派生,我们可以增删一些组件产生新的Camera派生,但实际上我们并没有写出上文的代码,一切都是逻辑使然。
单例模式
熟悉Unity开发的朋友们一定不会陌生,我真是太爱单例啦,单例模式是提供类在全局的单一实例且能全局访问。
在项目复杂程度不高、功能较为简单时,单例模式会非常的方便快捷,它能很方便的做类之间的信息传递。但是,当项目复杂程度提高、时间周期延长,单例模式使用过多时,会发生程序耦合过高,扩展困难的问题。我个人认为其中较大影响的是,单例模式隐蔽了类与类之间的依赖关系,单例是可以全局访问的,在其他的类中只需要调用就可以了,既不是继承类,也不是依靠参数传递,表面上比较难以察觉类的依赖,实际上所有包含单例调用的类都依赖了单例,单例类内部作出修改时,所有调用的类都应作出修改。
我的经验是,对诸如某某管理类、文件系统类等这种功能比较固定,设计后不会有大量改动的,且在整个项目中需要频繁用到的类使用单例。
下面附上一段Unity中常用的单例代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SingletonClass : MonoBehaviour
{
public static SingletonClass instance { get; private set; }
public int num = 0;
void Awake()
{
if (instance == null)
{
instance = this;
}
else
{
Destroy(gameObject);
}
}
public void Test()
{
//test
}
}
public class OtherClass : MonoBehaviour
{
public void Test()
{
int num = SingletonClass.instance.num;
SingletonClass.instance.Test();
}
}
如果有很多单例类(虽然不推荐),可以写个单例父类,省去每次都写instance和Awake的麻烦。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MySingleton<T> : MonoBehaviour where T : MySingleton<T>
{
public static T Instance { get; private set; }
protected virtual void Awake()
{
if (Instance == null)
{
Instance = (T)this;
}
else
{
Destroy(gameObject);
}
}
}
public class SingletonClass : MySingleton<SingletonClass>
{
//不用再手写了
public int num = 0;
public void Test()
{
//test
}
}
public class OtherClass : MonoBehaviour
{
public void Test()
{
//还是这样调用单例
int num = SingletonClass.instance.num;
SingletonClass.instance.Test();
}
}
命令模式
将一个请求封装成一个对象,从而让你使用不同的请求把客户端参数化,对请求排队或者记录请求日志,可以提供命令的撤销和恢复功能。
总的思路是,首先实现一个命令基类Command,包含两个抽象方法Execute和Undo;派生各种命令子类,完善命令执行方法和Undo(把命令执行反过来);Command管理类,要包括一个存储Command的序列,用来记录Execute的顺序,准备Undo等;最后在需要的位置生明命令并调用。
首先实现CMD基类和前后移动的派生:
using UnityEngine;
//CMD 基类
public abstract class Command
{
public abstract void Execute(GameObject go);//带参数的原因是可以改变命令的对象
public abstract void Undo();
}
public class MoveForward : Command
{
GameObject _go;
public override void Execute(GameObject go)
{
_go = go;
//省略代码了,物体移动的实现
Debug.Log("---------F-----------");
}
public override void Undo()
{
//反着写一遍物体移动
Debug.Log("---------Undo_F-----------");
}
}
public class MoveBackward : Command
{
GameObject _go;
public override void Execute(GameObject go)
{
_go = go;
Debug.Log("---------B-----------");
}
public override void Undo()
{
Debug.Log("---------Undo_B-----------");
}
}
然后,我们写一个CMD的管理器,主要用来记录CMD的调用顺序,为Undo做准备。因为我们会随时在不同的类去调用和记录CMD,根据上文单例的内容,这里比较适合使用单例。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CommandController : MonoBehaviour
{
public static CommandController instance;
//常见的撤回操作是撤回最近一次操作,栈有后进先出的特点,挺合适的
//可以看作是个CMD操作日志
public Stack<Command> commandStack = new();
private void Awake()
{
if (instance == null)
{
instance = this;
}
else
{
Destroy(instance);
}
}
//每次调用CMD的时候,向栈中压入
public void AddCMD(Command c)
{
commandStack.Push(c);
}
public void Undo()
{
//弹栈时记得查空
commandStack.Pop().Undo();
}
}
最后,我们在各种类中声明相对应的命令,就可以使用他们了。记得在每次调用命令时,添加到CMD的栈中。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class InputController : MonoBehaviour
{
public GameObject go;
KeyCode[] _keyCodes = { KeyCode.W, KeyCode.S } ;
readonly MoveForward _moveForward = new();
readonly MoveBackward _moveBackward = new();
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
if (Input.GetKeyDown(_keyCodes[0]))
{
//调用和记录一定要成对出现
_moveForward.Execute(go);
CommandController.instance.AddCMD(_moveForward);
}
if (Input.GetKeyDown(_keyCodes[1]))
{
_moveBackward.Execute(go);
CommandController.instance.AddCMD(_moveBackward);
}
}
}
附赠一个耦合版本的代码,这样读者应该能体会到命令模式的优势
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MoveController : MonoBehaviour
{
public GameObject go;
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
if (Input.GetKeyDown(KeyCode.W))
{
//在在这个代码块直接实现
go.transform.Translate(...)
...
}
if (Input.GetKeyDown(KeyCode.S))
{
go.transform.Translate(...)
...
}
}
}
在上述的代码中,虽然也能实现按下按钮使物体移动的功能,但是在同一个脚本中,我们同时去做了对按键的输入和对物体移动的操作,耦合程度更高了,而且难以做到操作日志这类功能。这样的代码也是我经常会写出来的,今后可以优化优化。
观察者模式
在了解Unity中观察者模式的设计之前,需要先了解C#的委托相关概念:
- Delegate
- Event
- Action
- Func
Delegate
委托(delegate)可以看作是一个函数容器,委托首先定义了一个模板,所有符合模板的函数,都能用+=的方式添加到委托中,并在委托invoke时同步执行。首先看一下无参的大概写法:
//定义委托模板
public delegate void TypeName();
//声明一个委托
public TypeName delegate1;
//符合模板的函数
public void DoSomething1()
{
//
}
public void DoSomething2()
{
//
}
public void DoSomething3()
{
//
}
//添加函数到委托
delegate1 += DoSomething1;
delegate1 += DoSomething2;
delegate1 += DoSomething3;
//执行委托
delegate1?.Invoke();
当函数模板带参数时:
//定义委托模板
public delegate void TypeName(int a);
//声明一个委托
public TypeName delegate1;
//符合模板的函数
public void DoSomething1(int a)
{
//
}
public void DoSomething2(int a)
{
//
}
public void DoSomething3(int a)
{
//
}
//添加函数到委托
delegate1 += DoSomething1;
delegate1 += DoSomething2;
delegate1 += DoSomething3;
//执行委托时传参
delegate1?.Invoke(a);
委托有一个缺点,就是它可以被赋值,也就是说,如果一个保存了大量模板函数的委托还没被执行,这时却被赋值,那之前保存的函数也就丢失了
//定义委托模板
public delegate void TypeName();
//声明一个委托
public TypeName delegate1;
//符合模板的函数
public void DoSomething1()
{
//
}
public void DoSomething2()
{
//
}
public void DoSomething3()
{
//
}
//添加函数到委托,此时delegate1包含如下函数,并未执行
delegate1 += DoSomething1;
delegate1 += DoSomething2;
delegate1 += DoSomething3;
public TypeName delegate2;
//此时delegate1被置空了
delegate1 = delegate2;
//什么都不会发生
delegate1?.Invoke();
Event
而Event是delegate的特殊实例,他不允许等号直接赋值,相当于对delegate的权限进行了限制。使用方法跟delegate很像,只需要在实例化委托时,在委托类型前加上"event"关键字即可。
//定义委托模板
public delegate void TypeName();
//声明一个委托
public event TypeName delegate1;
Action
Action是C#封装好的一种委托类型,使用Action我们可以不预先声明delegate委托,直接实例化Action使用。相当于简写的delegate。Action最多可以包含16个泛型参数,没有返回值。
上文介绍delegate时我们写过一个带参数的委托代码,使用Action可以达到相同的效果:
//无需定义委托模板
//public delegate void TypeName(int a);
//声明一个Action
public Action<int> TypeName delegate1;
//符合模板的函数
public void DoSomething1(int a)
{
//
}
public void DoSomething2(int a)
{
//
}
public void DoSomething3(int a)
{
//
}
//添加函数到委托
delegate1 += DoSomething1;
delegate1 += DoSomething2;
delegate1 += DoSomething3;
//执行委托时传参
delegate1?.Invoke(a);
Func
和Action一样,Func也是C#封装好的委托,其用法几乎和Action相同,不同的是,Action没有返回值,而Func的最后一个泛型参数是返回值类型,也就是说Func支持多个参数和一个返回值
值得注意的是,如果使用+=的形式向Func中添加多个函数,只能获得最后一个函数的返回值
//无需定义委托模板
//public delegate void TypeName(int a);
//声明一个Func,第一个泛型int是传入的参数,最后一个string泛型参数是返回值类型
public Func<int,string> TypeName delegate1;
//符合模板的函数
public string DoSomething1(int a)
{
return a.ToString() + "-1";
}
public string DoSomething2(int a)
{
return a.ToString() + "-2";
}
public string DoSomething3(int a)
{
return a.ToString() + "-3";
}
//添加函数到委托
delegate1 += DoSomething1;
delegate1 += DoSomething2;
delegate1 += DoSomething3;
//执行委托时传参,此时委托的返回值是最后一个函数即DoSomething3的返回值,str = “ 1-3 ”
string str = delegate1?.Invoke(1);
Unity的观察者模式——基于委托的实现方法
举一个形象的例子,当游戏里角色死亡时,我们会有很多系统对此做出响应,例如更新动画、更新世界、更新音效、更新存档等。
我们可以大概这样写
if(Player.hp == 0)
{
var 动画系统 = Find();
var 世界系统 = Find();
var 音效系统 = Find();
var 存档系统 = Find();
动画系统.更新();
世界系统.更新();
音效系统.更新();
存档系统.更新();
}
这样就有个问题,我们在这一段代码就把角色、动画、世界、音效、存档等系统捆绑在一起了。
我们使用委托来进行优化,主体类Player声明一个全局委托,只负责触发委托。
其他相关类负责丰富委托的内容。
public class Player : MonoBehaviour
{
float hp;
public delegate void MyDelegate();
public static event MyDelegate OnPlayerDead;
void Update()
{
if(hp <= 0)
OnPlayerDead?.Invoke();
}
}
public class AnimationSystem : MonoBehaviour
{
void Start()
{
Player.OnPlayerDead += PlayDeadAnimation;
}
public void PlayDeadAnimation()
{
//播放动画
}
}
public class WorldSystem : MonoBehaviour
{
void Start()
{
Player.OnPlayerDead += FreshWorld;
}
public void FreshWorld()
{
//刷新世界
}
}
//其他类同理
本来想把工厂写了,但是这样篇幅有点太长了,正好工厂模式内容也更多些,可以单独整理一篇。
代码几乎都是没经测试的脑测代码,主要还是想理清逻辑,如果有大佬发现错误,还请不吝指教。