《游戏设计模式》阅读与实现

原书地址:英文 http://gameprogrammingpatterns.com/contents.html
     中文 https://gpp.tkchu.me/command.html

 

  阅读此书的目的是面试以及扩展一下自己的知识储备。
  话不多说,现在开始。暂时就先不实现了,有点费时间和精力。

  设计模式:命令模式

  将多种命令抽象出一个基类,并实例化成对象。将其与操作对象解耦,更易拓展更灵活。
  场景:
    游戏内有多个可控操纵的对象,使用selectedItem存储当前选中对象。
    玩家可按WASD对选中对象进行上下左右的移动。
    这种移动将其抽象出一个基类Commond,有方法excute(item),并衍生出对应子类UpCommond、DownCommond、LeftCommond和RightCommond,实现各自的excute(item方法)。在游戏脚本中,每帧检测按下的按钮,如果是W,则执行upCommond(selectedItem),依次类推。
    如果希望提供撤销功能,则Commond中可再提供一个undo(actor),做出还原的操作,并在游戏脚本中有一个队列去记录进行过的操作和对象。

  设计模式:享元模式

  原书中称之为flyweight,翻译为蝇级、轻量级、享元。我更乐意称之为共享单元模式。
  场景:
    渲染一大片森林的时候,需要许多棵树。
    每棵树需要有以下属性,树皮纹理、树叶纹理、位置、高度、粗细。
    可想而知,在游戏世界中树皮和树叶不可能每棵树不一样,应该只有少数几种,在简单的情况下,只有一种。所以可以把树皮和树叶纹理单独抽出作为一个对象,所有的树保持这个对象的引用即可,他们的位置、高度、粗细进行个性化处理。这样在内存上是有一定优势的,在渲染上也会有优势,你只需要告诉一遍CPU纹理,然后传入不同的位置等参数让它不断的渲染实例即可,现在的各种GL也支持这种操作。
    在之前我看到的共享节点,也是和这里的思想类似。

  设计模式:观察者模式

  观察者是一种很常见也很重要的设计模式,常用于2个从设计上没有关系,但在某种条件下希望产生交互的情况。比如书中说希望英雄落下桥的时候触发“落桥”成就,那么在物理系统中发生掉落的时候可以发布一个事件通知观察者,也就是成就系统。收到事件的成就系统则检查掉落物体是否是英雄,如果是则完成“落桥”成就。

  实现:
    我比较印象深刻的是Laya中实现的观察者模式,我觉得也实现得挺好的。
    首先提供一个handler类和对应的对象池,它的实例根据调用对象、方法、参数列表进行初始化。
    观察者则是持有一个Map + Array的数据结构来存储handler,Map的key是事件名,value则是由handler组成的数组。并提供一些常规的方法,添加、移除等。
    

export class LayaHandler {

    caller: any;
    method: Function;
    args: any[];
    once: boolean;
    _id: number;    // 唯一标识

    static _gid: number = 1;
    static _pool: LayaHandler[] = [];

    constructor(caller, method, args, once) {
        /**表示是否只执行一次。如果为true,回调后执行recover()进行回收,回收后会被再利用,默认为false 。*/
        this.once = false;
        this._id = 0;
        (once === void 0) && (once = false);
        this.setTo(caller, method, args, once);
    }

    /**
    *设置此对象的指定属性值。
    */
    setTo(caller, method, args, once) {
        this._id = LayaHandler._gid++;
        this.caller = caller;
        this.method = method;
        this.args = args;
        this.once = once;
        return this;
    }

    /**
     * 执行处理器
     */
    run() {
        if(this.method == null || (this.caller && this.caller.isValid === false)) {
            return null;
        }
        let id = this._id;
        let result = this.method.apply(this.caller, this.args);
        if(this._id === id && this.once) {
            this.recover();    
        }
        return result;
    }

    /**
    *执行处理器,并携带额外数据。
    *@param data 附加的回调数据,可以是单数据或者Array(作为多参)。
    */
    runWith(data: any) {
        if(this.method == null || (this.caller && this.caller.isValid === false)) {
            return
        }
        let id = this._id;
        let result = null;
        if(data == null) {
            result = this.method.apply(this.caller, this.args);
        } else if(!this.args && !data.unshift) {
            result = this.method.call(this.caller, data);
        } else if(this.args) {
            result = this.method.apply(this.caller, this.args.concat(data));
        } else {
            result = this.method.apply(this.caller, data);
        }

        if(this._id === id && this.once) {
            this.recover();
        }
        return result;
    }

    /**
     * 清理对象引用
     */
    clear() {
        this.caller = null;
        this.method = null;
        this.args = null;
        return this;
    }

    /**
     * 清理并回到 Handler 对象池内
     */
    recover() {
        if(this._id > 0) {
            this._id = 0;
            LayaHandler._pool.push(this.clear())
        }
    }

    /**
     * 尝试从_pool中获取Handler
     *@param caller 执行域(this)。
     *@param method 回调方法。
     *@param args 携带的参数。
     *@param once 是否只执行一次,默认为true,执行后执行recover()进行回收。
     *@return 返回 handler 本身。
     */
    static create(caller: any, method: Function, args?: any[], once?: boolean) {
        if(once === void 0) {
            once = true;    // 默认执行一次就回收
        }

        if(LayaHandler._pool.length > 0) {
            let handler = LayaHandler._pool.pop()
            handler.setTo(caller, method, args, once)
            return handler
        } else {
            let handler = new LayaHandler(caller, method, args, once)
            return handler
        }
    }
}

/**
 * 这个类的意义是供EventDispatcher生成handler,另外缓存
 */
class LayaEventHandler extends LayaHandler {
    static _pool: LayaEventHandler[] = [];

    constructor(caller, method, args, once) {
        super(caller, method, args, once)
    }

    // 重载recover() 和 create(),将其缓存到EventDispatcher._pool中
    recover() {
        if(this._id > 0) {
            this._id = 0;
            LayaEventHandler._pool.push(this.clear());
        }
    }

    static create(caller, method, args, once) {
        if(once === void 0) {
            once = true;
        }
        if(LayaEventHandler._pool.length > 0) {
            let handler = LayaEventHandler._pool.pop();
            handler.setTo(caller, method, args, once);          // 调用setTo(),还是会根据Handler._gid递增设置_id,唯一标识嘛
            return handler;
        } else {
            let handler = new LayaEventHandler(caller, method, args, once);
            return handler;
        }
    }
}

export class LayaEventDispatcher {
    private _events: {[key: string]: any};  // value 为LayaEventHandler or LayaEventHandler[]

    constructor() {
        this._events = null;
    }

    /**
    *检查 EventDispatcher 对象是否为特定事件类型注册了任何侦听器。
    *@param type 事件的类型。
    *@return 如果指定类型的侦听器已注册,则值为 true;否则,值为 false。
    */
    hasListener(type: string) {
        let hasListener = false;
        if(this._events != null && this._events[type] != null) {
            hasListener = true;
        }

        return hasListener;
    }

    /**
    *派发事件。
    *@param type 事件类型。
    *@param data (可选)回调数据。<b>注意:</b>如果是需要传递多个参数 p1,p2,p3,...可以使用数组结构如:[p1,p2,p3,...] ;如果需要回调单个参数 p ,且 p 是一个数组,则需要使用结构如:[p],其他的单个参数 p ,可以直接传入参数 p。
    *@return 此事件类型是否有侦听者,如果有侦听者则值为 true,否则值为 false。
    */
    event(type: string, data?: any) {
        if(!this._events || !this._events[type]) {
            return false;
        }
        let listeners = this._events[type];
        if(listeners.run) {         // 这样判断它是不是数组,怪怪的,万一以后数组添加了一个run的方法,就会是BUG了,不过本人语言不熟,翻译就完事了
            if(listeners.once) {
                delete this._events[type];
            }
            (data != null) ? listeners.runWith(data) : listeners.run();
        } else {
            let n = listeners.length;
            for(let i = 0; i < n; i++) {
                let listener = listeners[i];
                if(listener) {
                    (data != null) ? listener.runWith(data) : listener.run();
                }
                if(!listener || listener.once) {
                    listeners.splice(i, 1);
                    i--;
                    n--;
                }
            }
            if(listeners.length === 0) {
                delete this._events[type]
            }
        }

        return true
    }

    /**
    *使用 EventDispatcher 对象注册指定类型的事件侦听器对象,以使侦听器能够接收事件通知。
    *@param type 事件的类型。
    *@param caller 事件侦听函数的执行域。
    *@param listener 事件侦听函数。
    *@param args (可选)事件侦听函数的回调参数。
    *@return 此 EventDispatcher 对象。
    */
    on(type: string, caller: any, listener: Function, args?: any) {
        return this._createListener(type, caller, listener, args, false);
    }

    /**
    *使用 EventDispatcher 对象注册指定类型的事件侦听器对象,以使侦听器能够接收事件通知,此侦听事件响应一次后自动移除。
    *@param type 事件的类型。
    *@param caller 事件侦听函数的执行域。
    *@param listener 事件侦听函数。
    *@param args (可选)事件侦听函数的回调参数。
    *@return 此 EventDispatcher 对象。
    */
    once(type: string, caller: any, listener: Function, args?: any) {
        return this._createListener(type, caller, listener, args, true);
    }

    private _createListener(type: string, caller: any, listener: Function, args: any, once: boolean, offBefore?: boolean) {
        // 默认移除上一个监听
        if(offBefore === void 0) {      
            offBefore = true;
        }
        if(offBefore) {
            this.off(type, caller, listener, once);
        }

        let handler = LayaEventHandler.create(caller || this, listener, args, once);
        if(!this._events) {
            this._events = {};
        }
        if(!this._events[type]) {
            this._events[type] = handler;
        } else {
            if(!this._events[type].run) {
                this._events[type].push(handler);
            } else {
                let preHadnler = this._events[type];
                this._events[type] = [preHadnler, handler];
            }
        }

        return this;
    }

    /**
    *从 EventDispatcher 对象中删除侦听器。
    *@param type 事件的类型。
    *@param caller 事件侦听函数的执行域。
    *@param listener 事件侦听函数。
    *@param onceOnly (可选)如果值为 true ,则只移除通过 once 方法添加的侦听器。
    *@return 此 EventDispatcher 对象。
    */
    off(type: string, caller: any, listener: Function, onceOnly?: boolean) {
        if(onceOnly === void 0) {
            onceOnly = false;
        }
        if(!this._events || !this._events[type]) {
            return this;
        }
        let listeners = this._events[type];
        if(listeners.run) {
            if((!caller || caller === listeners.caller) && (!listener || listener === listeners.method) && (!onceOnly || listeners.once)) {
                delete this._events[type];
                listeners.recover();
            }
        } else {
            let offCount = 0;
            let n = listeners.length;
            for(let i = 0; i < n; i++) {
                let item = listeners[i];
                if(!item) {
                    offCount = offCount + 1;
                } else {
                    if((!caller || caller === item.caller) && (!listener || listener === item.method) && (!onceOnly || item.once)) {
                        offCount = offCount + 1;
                        listeners[i] = null;
                        item.recover();
                    }
                }
            }

            if(offCount == n) {
                delete this._events[type];
            }
        }

        return this;
    }

    /**
    *从 EventDispatcher 对象中删除指定事件类型的所有侦听器。
    *@param type (可选)事件类型,如果值为 null,则移除本对象所有类型的侦听器。
    *@return 此 EventDispatcher 对象。
    */
    offAll(type?: string) {
        if(!this._events) { 
            return; 
        }
        if(type) {
            this._recoverHandlers(this._events[type]);
            delete this._events[type];
        } else {
            for(let key in this._events) {
                this._recoverHandlers(this._events[key])
            }
            this._events = null;
        }

        return this;
    }

    /**
    *移除caller为target的所有事件监听
    *@param caller caller对象
    */
    offAllCaller(caller: any) {
        if(caller && this._events) {
            for(let key in this._events) {
                this.off(key, caller, null);
            }
        }

        return this;
    }

    private _recoverHandlers(arr) {
        if(!arr) {
            return;
        }

        if(arr.run) {
            arr.recover()
        } else {
            let n = arr.length
            for(let i = 0; i < n; i++) {
                if(arr[i]){
                    arr[i].recover()
                    arr[i] = null;
                }
            }
        }
    }

    static init() {
        Object.defineProperty(LayaEventDispatcher.prototype, "_events", {
            enumerable: false,
            writable: true
        });
    }
}

LayaEventDispatcher.init()

 

  设计模式:原型模式

  书中讲的原型模式的例子,看得有点迷。他先举了个列子,有基类Monster,有3个衍生子类AMonster、BMonster、CMonster。如果单独写生产者类,则需要写3个。那么如果这Monster提供一个clone()方法来产出自身,则可以写一个生产者类,提供方法指定模板,调用模板的clone()来生产即可。最后又举个例子,写一个生产者,定义泛型,在构造对象的时候提供具体类即可。。。
  他说原型模式的思想就是原型可以提供类似自身的对象。
  我认为他在语言部分举的例子更容易理解。JS中就有原型和原型链。JS中的设计是这样的,每个类生成的实例对象会有一个引用指向原型对象,这个对象也可以作为另一个对象的原型对象。当在一个对象上寻找属性和方法时,如果不存在,则会顺着原型链一直往上寻找。如此便实现了继承的机制,提高了代码的复用率。

  我没感觉这个思想在游戏代码中有合适的场景,他举的例子在我看来有些牵强。

  设计模式:单例模式

  这一章书中讲得比较多,考虑了挺多利弊,以及何时才应该使用单例。
  没太看进去,我觉得是自然地去使用就好了,比如任务系统的数据管理类,它保存了任务的数据,提供一些方法。它就应该只有一个实例。
  实现的话,通常做法就是静态变量提供引用,构造方法私有即可。

  设计模式:状态模式

  关于这个模式,书中举了一个层层递进的例子,很好。

  首先玩家希望在按下B的时候跳跃、按↓的时候趴下、松开↓的时候站立、跳跃的途中按↓斩击。
  很直观的去实现,那么就需要一堆标志变量是否跳跃、是否趴下、是否站立,当handleInput()中检测到输入是B时,因为趴下的时候不能跳跃;当输入是松开↓的时候,你要判断它是趴下状态还是跳跃状态。同时当新添加一个状态,它和其它状态又有冲突的时候,你要添加新的标志变量,并修改有冲突的那部分判断逻辑。
  这显然是很糟糕的,所有的逻辑都堆在一起,且修改起来很麻烦,容易出现漏洞。
  那么可以用一种简单的状态模式来处理,我觉得就是换了一个角度来看,角色有站立、趴下、跳跃3个状态,用3个状态值来表示角色处于某种状态。在handleInput()中,先判断角色处于某种状态,再看该中状态下允许进行什么操作,以及在操作完之后会进入什么新的状态。这样就将逻辑拆分了出来,你只需要关心角色当前的状态,再做对应的处理即可。更进一步用面对对象的思维去考虑,可以抽象一个基类状态,然后每个状态是它的子类的实例。每个状态有enter()和exit()方法,来处理进入和离开该状态的逻辑。
  当你有其它的状态和当前状态可以同时发生时,比如角色有持枪时,在趴下、站立时可以攻击,不要考虑在当前状态机中添加趴下攻击状态、站立攻击状态等,可以考虑使用并发状态机,也就是2个或多个状态机。
  当每个状态中有重复处理的逻辑,比如角色有站立、奔跑、滑铲等状态,当按下B的时候会跳跃,如果在前3种状态中检测输入B,移动到跳跃状态,就很重复。可以考虑使用分层状态机,比如前3种状态有一个父状态,如果在自身中没有处理的操作,都去父状态中尝试处理。
  当希望状态可以回溯,比如玩家本身有站立、趴下、跳跃状态,攻击时会进入特殊的攻击状态,攻击结束之后希望回到之前的状态。这个时候可以下推自动机,就是用一个栈来存储状态,比如进入攻击时,推入攻击状态入栈,完成攻击之后,攻击状态出栈。之前的状态来到栈顶。

  序列模式:双缓冲模式

  当你有一块内存里存储了数据,在写的过程中会去读,但你希望能保证读是在全部写完之后才进行,就可以考虑使用这个模式。
  比如绘制图形是从缓存区里读像素的颜色绘制到屏幕上,就可以用2个缓存区来存储。这一帧在next缓存区里写数据,同时绘制cur缓存区里的内容。写完之后再交换,下一帧继续。

  序列模式:游戏循环

  这是游戏引擎的核心,不需要我们游戏开发人员去关心,了解一下。

  游戏引擎它会有一个update()函数,每帧执行一次,希望进行更新、渲染这两块操作。
  但是如何来控制update()执行的速度呢?这一章给了几个方案。
  方案一:最直接的不控制速度,你CPU、GPU能跑多快就跑多快。
  游戏的时间速度由硬件决定。

  

   方案二:添加一个睡眠时间,处理完时间还没超过预设的MS_PRE_FRAME的时候,等待一段时间,再进行下一次update()
  可限制高性能的帧率。

  

   方案三:在更新时传入真实时间,保证游戏本身的时间速度和真实时间速度一致。
  这里没有限制帧率,我觉得也不是什么好办法。

  

   方案四:追逐时间。
  当性能高时,更新消耗时间小,则每次增加lag,直到lag达到MS_PER_UPDATE,进行一次更新,这样限制了帧率。
  当性能低时,更新消耗时间大,比如lag大于2个MS_PER_UPDATE,则通过循环执行2次更新,来追赶时间。
  前提是更新本身的时间不能大于MS_PER_UPDATE,不然永远追赶不上了。

  

   更进一步可以改为 if 判断lag >=MS_PER_UPDATE ,直接在update()中传入差值,比如lag/MS_PER_UPDATE。
  有机会可以看看cocos里是怎么做的。

  序列模式:更新方法

  这一章大致说的是应该让每个实体或者组件(这取决采用的模式)本身实现update()方法,然后由游戏世界调用。

  行为模式:字节码

  这一设计模式的适用场景,我想到的是技能编辑器。当游戏策划需要自己编辑角色技能效果、数值的时候。
  那么这一部分应该和游戏本身的代码进行隔离,否则会很麻烦。
  理所当然的应该生成配置文件这种数据,书中提供的是生成字节码。

  游戏代码主体里应该有一个虚拟机、解释器,用来解释字节码。
  从设计上来说,每个指令是一个枚举值,改变血量法术、改变智力法术、播放声音、播放粒子。
  还需要一些内部操作指令:查询、推入值、加减乘除等。
  每个方法还需要对应的参数,比如改变血量需要2个参数,增加还是减少、数值。
  那么就需要2个数据结构来存储,比如一个数组存储操作指令,一个栈存储值。
  
  比如策划要自定义一个技能,它的效果是根据当前智力值的一半增加血量。那么需要执行的指令就是。
  推入值、2、推入值、查询当前智力、除法、推入值、查询当前血量、加法、改变血量。
  指令集是一棵树,通过递归的方式去完成,所以将对应的指令按顺序推入数组中去执行。

  如何生成正确的指令,我觉得较好的办法就是用可视化界面通过拖拽去生成。这样限定了规则、也降低了操作门槛,同时不容易出错。

  行为模式:子类沙箱

  它的思想是将众多子类中重复的代码移动到基类中,减少代码冗余,提高代码复用率。

  行为模式:类型对象

  比如有各种怪物都有血量和攻击行为,可以将某些血量和攻击抽出一个类型来,让怪物来引用。避免重复编码。

  解耦模式:组件模式

  将一个类中的行为和属性抽象为某种组件。比如一个节点需要渲染、物理碰撞、播放声音,在实际实现中将这些逻辑都堆在节点中是不可取的,你不可能每个类似的节点都去实现这些重复逻辑。将渲染、物理、声音都抽象为组件,节点只拥有一个它们的实例引用然后进行相关的调用即可。
  游戏引擎更应该这样实现,实际上也是这样做的。根据特性实现了相应的组件,当节点需要对应的特性的时候,就将该组件挂载在节点上即可。

  解耦模式:事件队列

  该模式的名字已经直观明了的表达了这个模式做了什么,它提供了一个事件队列,解耦了请求 和 执行的耦合。让请求和执行可以异步去执行,以及做一些额外的处理。不要混淆,观察者模式是将观察者和被观察者解耦,事件对列是将请求 和 执行解耦。

  解耦模式:服务定位器

  这个模式讲了一些公共的对象,比如音频、日志等不应该直接暴露出来,比如静态和单例。应该提供一层抽象层,通过一个服务定位器来获取到对应的实例,这样在修改的时候会方便一些。同时比如音频如果有正式模式 和 Debug模式,可以使用装饰器模式来处理,用debug来装饰正式模式,在其中加入debug需要的逻辑。当你使用服务定位器时,只需要在抽象层根据条件使用即可,很方便。

  优化模式:数据局部性

  CPU速度很快,但内存的读取相对来说很慢,因此CPU有一个缓存,它每次都从内存中读取一块大的内存,并每次优先从缓存中读取,如果没有再去内存中查找,也就是未命中。
  为了提高命中率,可以将数据尽量紧凑的排列在内存中。
  比较典型的就是游戏引擎的ECS架构,渲染、物理这些组件应该是以数组的形式在内存中,update()的时候遍历对应的系统,对其中的组件数组进行迭代处理。提高了命中率和性能。

  优化模式:脏标识模式

  就是采用一个标志变量来控制逻辑,避免一些重复的运算。

  优化模式:对象池模式

  很常见的模式,用对象池来缓存、复用对象,减少内存的分配和回收。

  优化模式:空间分区

  空间管理,在2D世界中,可以使用四叉树进行空间划分,减少多余的判断,提高性能。

posted @ 2023-05-17 08:43  比格海德  阅读(81)  评论(0)    收藏  举报