《游戏设计模式》阅读与实现
原书地址:英文 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世界中,可以使用四叉树进行空间划分,减少多余的判断,提高性能。