常用设计模式

本文介绍游戏客户端开发中几个十分朴实但实用的设计模式,是个人在经历不同游戏项目后的经验总结。 每种模式在阐明用途后,均给出参考代码(以Unity引擎为例)。在实践中,需要根据项目需求进行扩展或简化。

单例

单例可能是被使用最多也是被误用最多的一种模式。由于管理游戏对象时势必需要各类“Manager”,且这些Manager类对象通常生命周期比较长,如果用一个个全局单例来实现,不管在哪里都能访问,似乎十分方便。然而,笔者并不鼓励这种依赖多个全局单例的写法,这样会造成生命周期不明确、资源泄漏隐患增大、模块调用关系混乱而难以维护。推荐的方法是通过依赖注入的方式传递给需要访问它的类。

这并不是否认单例写法的使用面,甚至在早期的小规模预研中,全局单例的写法有助于尽快搭建框架进行玩法验证。用Unity引擎进行原型开发时,由于只有主线程能访问GameObject这类Native资源,所以写法三通常已经足够。

//写法一:c# 线程安全的单例(Double-checked locking)
public sealed class Singleton
{
    private static volatile Singleton instance;
    private static object syncRoot = new object();

    private Singleton(){} //防止外界创建该类对象

    public static Singleton Instance
    {
        get
        {
            if (instance == null) //提升性能,不用每次加锁
            {
                lock (syncRoot)
                {
                    if (instance == null)
                    {
                        instance = new Singleton();
                    }
                }
            }

            return instance;
        }
    }
}

//写法二:c# 线程安全的单例,利用了 Lazy<T> 内部已有的多线程处理逻辑。适用 .Net4 以上版本
public class Singleton
{
    private static readonly Lazy<Singleton> instance = new Lazy<Singleton>(() => new Singleton());

    private Singleton() { }

    public static Singleton Instance => instance.Value;
}

//写法三:继承MonoBehaviour以用于Unity引擎,单线程版。
public abstract class USingleton<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T instance;

    public static T Instance
    {
        get
        {
            if (instance == null)
            {
                T[] objs = GameObject.FindObjectsOfType<T>(true);
                if (objs.Length > 0)
                {
                    instance = objs[0];
                    for (int i = 1; i < objs.Length; i++)
                    {
                        GameObject.Destroy(objs[i].gameObject); //去除重复
                    }
                }
                else
                {
                    GameObject newObj = new GameObject(typeof(T).Name); //自动创建 object
                    DontDestroyOnLoad(newObj); //生命周期跨场景
                    instance = newObj.AddComponent<T>();
                }
            }

            return instance;
        }
    }
}
//使用方法
public class GameManager : USingleton<GameManager>
{

}

抽象工厂

对象池

对象池的主要作用是加速对象的分配与回收。游戏中通常会用到大量同类对象(如敌人、子弹、网络协议包),如果每次都调用API去申请内存,不仅更慢,而且更容易产生内存碎片从而降低系统运行效率。很多时候,一个简单的对象池就能在一定程度上缓解这个问题。(相比于内存池,对象池整体也更为简单。内存池的讨论参考这里

🟡TODO: 对象池的设计和注意点

观察者模式

🟡TODO: 以一个简易的消息分发系统为例。

命令模式

🟡TODO: 以一个简易的输入模块为例。

状态模式

当业务逻辑变得越来越复杂,一种常见的处理方法是添加各种标记位或状态变量,配合if-else实现整套逻辑。笔者曾经在一份古老的代码仓库中修改长达几千行的if-else逻辑块,别有一番滋味在心头。诚然,这种代码扎根在运行了二三十年的系统中,进行重构的风险已经太大。如果能在设计之初预见到未来的复杂度,一定会采用其他方式。

有限状态机(FSM)就提供了一种分解复杂逻辑的方案。其有以下内涵:

  • 可划分为有限个状态对象;

  • 不同状态是互斥的,某一时刻仅有一个状态有效;

  • 状态之间具有明确的切换规则;

  • 状态机可以接收外部输入;

  • 下一个状态是由当前状态、外部输入、当前状态的切换规则决定的;

划分状态的好处是:将变量以及依赖的环境包裹在一起,并与别的变量和逻辑隔离开来,极大减少了认知负担。

FSM有多种实现方式,简单如switch-case,但从封装性和灵活性出发,状态模式是更好的实现方式。下面就基于C#给出状态模式的泛型实现,涉及3个实体:状态、状态机、状态机管理器。

状态机管理器的实现

状态机管理器(FSMManager)负责创建、终止状态机,以及驱动其执行每帧更新逻辑。

状态机的实现

状态机(FSM<T,M>)对外暴露启动/终止、暂停/恢复、更新等接口,以及消息驱动和状态转移框架逻辑。这里有四点值得一说的设计:

  • 泛型的使用。对于不同类型的角色对象,其状态种类和可以接受的消息种类应当是相互隔离的。因此,这里用T表示状态机所属的角色类。用M表示该状态机接收的消息类型。

  • 统一的接口。由于使用了泛型类,为了能将不同类型的状态机统一管理,定义了接口IFSM

  • 全局状态。若存在一种状态,可以从其他任何状态转变而来,那么为了避免繁冗,可以将其从状态转换图中独立出来,称为全局状态,也可以理解为角色的第二状态。这在“死亡”逻辑中很有用:不论在何种状态下,一旦血量小于等于0,则执行死亡逻辑。

  • 是否立即状态切换。当触发了状态切换的条件时,除了立即切换状态,也可以等到该逻辑帧结束后下一逻辑帧开始前切换状态。后者的合理之处在于:同一帧内某个对象暴露给外界所有其他对象的状态是相同的。以敌人碰到子弹后死亡为例,如果子弹立即切换状态并销毁,那么轮到敌人处理碰撞逻辑时,将访问不到子弹对象的一些属性。但下一帧切换状态也会让某些逻辑变得不再直观,反而因此掣肘。总之,是否立即进行状态切换需要根据玩法进行权衡。

状态基类的实现

所有状态的基类为BaseState,其中Enter/Exit仅在进入/离开状态时调用一次,Update 每帧调用一次。另外在实际使用中,状态类可以拥有自己的字段。

使用案例

上述状态机可以用来实现敌人AI。首先需要:

  • 定义角色类,例如 Enemy 类,其持有状态机对象FSM

  • 定义消息类,例如 LocalMsg 结构体,包含消息ID(枚举类型),消息携带的数据。

  • 定义若干状态类(例如IdleState),继承BaseState <Enemy, LocalMsg>,内部通过调用 owner.FSM.ChangeState 实现状态切换。

然后便可按如下方式使用:

完整例子见 github

状态模式的不足之处和应对方法

状态模式解决了大量if-else堆砌造成的混乱,但当状态种类到达几十上百后,维护上述状态机也变得棘手。下面提供两种常见解决思路,这里不展开介绍。

  • 层次状态机。即将一组联系紧密的状态归结到一个父状态下。通过这样分层,使得每一层的状态转移图不再那样复杂。

  • 行为树。不严谨地说,行为树将状态和状态转移逻辑分离开了,好处是状态转移逻辑比较集中而不是散落在一个个状态类定义里,且修改状态转移逻辑不会破坏状态本身,便于频繁修改以打磨玩法。

以上方案结合可视化节点编辑器,是一个大型游戏项目中常见的解决方案。

更多资料

  • 《Game Programming Patterns》一书较为系统的介绍了游戏开发中常见的设计模式,以上介绍的几种模式在书中亦有提及。在线阅读地址:http://gameprogrammingpatterns.com/

Last updated