游戏编程模式:(一)设计模式【typescript】
- 抽象和解耦能使程序开发变得更快更简单,但不要浪费时间来做这件事,除非你确信存在问题的代码需要这种灵活性。
- 在你的开发周期中要对性能进行思考与设计,但是要推迟那些降低灵活性的、底层的、详尽的优化,能晚则晚。
- 尽快地探索你的游戏的设计空间,但是不要走的太快留下一堆烂摊子。
- 如果你将要删除代码,那么不要浪费时间将它整理的很整洁。
- 最重要的是,若要做一些有趣的玩意,那就乐在其中地做吧。
【游戏编程模式】在线书籍
1. 命令模式:
-
a. 定义
将一个请求封装成一个对象,从而允许你使用不同的请求、队列或日志将客户端参数化,同事支持请求操作的撤销与恢复。
命令是具现化的方法调用,一个对象,可以存储在变量中传递给函数,意思是封装在对象中一个方法的调用。
命令模式是面向对象化的回调缺点:可能会浪费内存
-
b. 示例
游戏中读取键盘,执行对应的行为,比如
键盘X-->jump();//跳跃
键盘Y-->fireGun();//开火
键盘B-->lurch();//倾斜
键盘A-->swapWeapon();//切换武器 -
一个简单的实现
function handleInput(){//处理输入 if("X") jump(); else if("Y") fireGun(); else ... }将用户的输入和程序行为硬编码在一起时,这代码是可以正常工作的,但是许多游戏允许玩家配置按键功能。为了支持这点,我们将直接调用转化成可变换的东西。进入命令模式
-
命令模式实现
/** 行为命令接口 */ export interface Command { execute(): void; } /** 跳跃子类实现 */ export class JumpCommand implements Command { execute() { jump();//执行跳跃 } } /** 开火子类实现 */ export class FireCommand implements Command { execute() { fireGun();//执行开火 } } /** 输入类 绑定键盘按钮 */ export class InputHander { cmdX: Command; cmdY: Command; cmdB: Command; cmdA: Command; constructor() { let itself = this; itself.cmdX= new JumpCommand(); itself.cmdY= new FireCommand(); //其他绑定方法 } /** 键盘输入处理,通过命令执行方法 */ handleInput() { let itself = this; if ("X") itself.cmdX.execute(); else if ("Y") itself.cmdY.execute(); //... } }以前每个输入直接调用一个函数,现在则增加了一个间接调用。但是,这个命令类却还是有局限性的。这隐式的知道了玩家游戏实体进行木偶般操控,限制了使用范围,命令只能作用于玩家。
让我们放宽限制,传入一个想控制的对象:/** 一个游戏对象 */ export class GameActor { jump() { }; fireGun() { }; } /** 行为命令接口 */ export interface Command { execute(actor: GameActor): void;//执行方法传入游戏对象 } /** 跳跃子类实现 */ export class JumpCommand implements Command { execute(actor: GameActor) { actor.jump();//执行跳跃 } } /** 开火子类实现 */ export class FireCommand implements Command { execute(actor: GameActor) { actor.fireGun();//执行开火 } } //这里我们修改一下方法,执行命令改为返回对应的命令。 /** 输入类 绑定键盘按钮 */ export class InputHander { cmdX: Command; cmdY: Command; //绑定.... /** 键盘输入处理 这里改为返回命令 */ handleInput() { let itself = this; if ("X") return itself.cmdX; else if ("Y") return itself.cmdY; //... } } //现在它不能立即执行,因为它不知道该传入哪个角色对象。这里我们利用的是命令即具体化的函数调用。 //我们接收命令、象征着玩家的角色执行命令: let actor = new GameActor();//假设这个一个游戏对象 let command = new InputHander().handleInput(); if (command) { command.execute(actor); }但是对于游戏中的非玩家角色呢?它们由游戏AI来驱动。我们可以照搬上面的命令模式来作为AI引擎和角色之间的接口;
-
撤销和重做
如果没有命令模式,撤销和重做是很困难的(书上说的,我也还没有其他想法emmm)。
假设一个回合制游戏,想让玩家能撤销一些行动,因为是抽象输入,所以角色的每个行动都要封装起来,比如移动一个单位:/** 单位类 */ export class Unit { x: number; y: number; /** 移动 */ moveTo(x: number, y: number) { }; } /** 移动单位类 */ export class MoveUnitCommand implements Command { _unit: Unit; _x: number; _y: number; constructor(unit: Unit, x: number, y: number) { let itself = this; itself._unit = unit; itself._x = x; itself._y = y; } execute() { let itself = this; itself._unit.moveTo(itself._x, itself._y); } } //注意这里和前面的命令不太相同,前面我们想要从被操控的角色中抽象出命令,以便于和命令解耦。 //这里,我们特别希望将命令绑定到被移动的单位上。 //这个命令的实例不是一般性质的”移动某些物体”,在游戏回合次序中,它是一个特定的具体移动 //前面一个命令代表一个可重用的对象,表示一件可完成的事,这个例子的命令更加具体,表示特定时间点完成的事,这意味着每次玩家选择一个动作,都会创建一个命令实例。 function getSelecedUnit() {//假设是选择一个单位的获取方法 return new Unit(); } /** 输入处理变成了这样 */ export class InputHander { /** 键盘输入 这里改为返回命令 */ handleInput() {// let unit = getSelecedUnit(); if ("Up") {//向上移动unit 1个单位 let destY = unit.y - 1; return new MoveUnitCommand(unit, unit.x, destY); } else if ("down") { let destY = unit.y + 1; return new MoveUnitCommand(unit, unit.x, destY); }//... return null; } }为了使命令变得可撤销,我们定义一个操作,每个命令类都实现它:
export interface Command { execute(): void; undo(): void;//undo方法会反转由对应的execute方法改变游戏状态。 } export class MoveUnitCommand implements Command { _unit: Unit; _x: number; _y: number; _xBefore: number; _yBefore: number; constructor(unit: Unit, x: number, y: number) { let itself = this; itself._unit = unit; itself._x = x; itself._y = y; itself._xBefore = itself._yBefore = 0; } execute() { let itself = this; //在移动前记住位置 itself._xBefore = itself._unit.x; itself._yBefore = itself._unit.y; itself._unit.moveTo(itself._x, itself._y); } undo() { let itself = this; itself._unit.moveTo(itself._xBefore, itself._yBefore); } }对于多次撤销,我们可以用一个命令列表+当前index。当玩家执行一个命令,将命令添加到列表,index++。
最后,在某些方面,命令模式对于没有闭包的语言来说是模拟闭包的一种方式
2. 享元模式:(看起来像类型对象模式,但背后的意图是不同的)
在命令模式的结尾,作者说道,拥有不止一个这样的命令类的实例会浪费内存,享元模式就是用来解决这个问题的。
-
a. 定义
一般说来当你有太多对象并考虑对其进行轻量化时便可派上用场。
通过将对象数据切分成两种类型来解决问题:一种是能共享的数据(内在的或者上下文无关的),一种是对对象是唯一的数据。 -
b. 示例
假设一个游戏世界,有数以千计的树木,每棵树又包含成千上万的多边形。每棵树都有着与之关联的数据:一个多边形:树干、树枝、树叶
树皮和树叶的纹理
树的位置和朝向
调节参数:大小、颜色等。如果用代码表示,将得到这样的结构:
export class Tree{ _mesh: Mesh;//网格 _bark: Texture;//树皮 _leaves: Texture;//树叶 _position: Vector;//位置 _height: number; _thickness: number;//大小,厚度? _barkTint: Color;//树皮颜色 _leafTint: Color;//树叶颜色 }这里的数据量很大,尤其是网格和纹理。想要将包含整个森林的对象数据在1帧内传给GPU几乎不可能。
但是,它们大部分是相识的,比如相同的网格和纹理。
![]()
我们可以将对象分成两个独立类:通用的数据类TreeModel和Treeexport class TreeModel { _mesh: Mesh;//网格 _bark: Texture;//树皮 _leaves: Texture;//树叶 } export class Tree { _model: TreeModel;//通用数据 _position: Vector;//位置 _height: number; _thickness: number;//大小,厚度? _barkTint: Color;//树皮颜色 _leafTint: Color;//树叶颜色 }![]()
游戏世界中的每一棵树的示例都指向TreeModel的引用(c++示例用的是TreeModel* _model,这里注意一下怎么使用引用而不是新的实例)。 -
c. 再来一个栗子:游戏世界的地面
地面的地形有草地、泥土、丘陵、湖泊、河流等地形。我们使用基于Tile瓦片的技术来构建地面,由许多个小瓦片组成巨大的网格。
每一种地形都有一些影响着游戏玩法的属性移动开销:决定角色通过此地形的速度
地形标志:比如是否是水域
纹理:渲染地形通常做法:用一个枚举表示地形类型
export enum Terrain{ terrain_grass,//草地 terrain_hill,//丘陵 terrain_river,//河流 //... } /** 游戏世界包含大量的瓦片对象 */ export class World{ tiles: Terrain[][];//格子,行列或者宽高 //获得瓦片数据的实现 /** 移动成本 */ getMovementCost(x: number, y: number) { let itself = this; switch (itself.tiles[x][y]) { case Terrain.terrain_grass: return 1; case Terrain.terrain_hill: return 2; case Terrain.terrain_river: return 3; //... } } /** 是否湿地 */ isWater(x: number, y: number) { let itself = this; switch (itself.tiles[x][y]) { case Terrain.terrain_grass: return false; case Terrain.terrain_hill: return false; case Terrain.terrain_river: return true; //... } } }如你所见,可以运行,但是比较简陋。把移动开销和湿地当作地形数据,但这里却被散落在代码中,单一的地形数据被一堆方法拆开了。
将所有的数据封装在一起会更好。像下面这样实现,是非常值得肯定的。export class Terrain { _moveCost: number; _isWater: boolean; _texture: Texture; constructor(movementCost: number, isWater: boolean, texture: Texture) { let itself = this; itself._moveCost = movementCost; itself._isWater = isWater; itself._texture = texture; } getMoveCost() { return this._moveCost; } isWater() { return this._isWater; } getTexture() { return this._texture; } }但是我们并不希望为每个瓦片构建地形实例付出成本。这个瓦片类中并没有标识其位置的特殊代码。在享元术语中,地形的所有状态都是”内在的“或者“上下文无关的“。
因此我们没有理由构建多个同种地形类型。我们不使用枚举或者地形对象网格,而是使用指向地形对象的网格指针。//每一个使用相同地形的瓦片将会指向相同的地形实例 export class World { tiles: Terrain[][]; _grassTerrain: Terrain; _hillTerrain: Terrain; _riverTerrain: Terrain; constructor() { let itself = this; itself._grassTerrain = new Terrain(1, false, Grass_Texture); itself._hillTerrain = new Terrain(3, false, Hill_Texture); itself._riverTerrain = new Terrain(2, true, River_Texture); } /** 生成地形 */ generateTerrain() { let itself = this; //填充草地 for (let x = 0; x < WIDTH; x++) { for (let y = 0; y < HEIGHT; y++) { //随机一些丘陵 if (Math.random() < 0.1) { itself.tiles[x][y] = itself._hillTerrain; } else { itself.tiles[x][y] = itself._grassTerrain; } } } //放置一些河流 let x = Math.floor(Math.random() * WIDTH); for (let y = 0; y < HEIGHT; y++) { itself.tiles[x][y] = itself._riverTerrain; } } //暴露地形对象 getTile(x: number, y: number) { return this.tiles[x][y]; } } //如果想得到砖块的一些属性:这里只是模拟 let cost = new World().getTile(2, 3).getMoveCost();性能表现需要将指针和枚举的性能做比较。
3. 观察者模式
比如MVC架构,其根本,是因为观察者模式。
- a. 栗子:
假设成就系统,有一个成就:“从桥上掉落”,这和物理引擎相关,我们并不希望游戏中的处理撞击代码时有个unlockFallOffBridge()的调用不是吗?
我们喜欢的是让关于游戏一部分的所有代码集成到一块,但是不同层面的代码怎么解耦成就系统和其他部分呢?这就需要观察者模式了。
这让代码宣称有趣的事情发生了,而不必关心到底是谁接受了通知。
成就系统注册他自己为观察者,这样无论何时发送通知,成就系统都能收到。实现代码:export class Physics{ updateEntity(entity: Entity) { entity.accelerate(GRAVITY);//重力加速 entity.update(); if (wasOnSurface && !entity.isOnSurface()) { notify(entity, EVENT_START_FALL);//通知entity:掉落事件。这代表:“额,我不知道有谁感兴趣,但是这个东西刚刚掉下去了,做你想做的事吧” } } }
这里将notify()实现为Subject内的保护方法,这样只有派生类的物理引擎类可以调用并发送通知,外部的代码不行。export class Entity {//构建一个实体 isHero() { return true; } } export enum Event_Observer {//定义几个事件 /** 掉落事件 */ Entiy_Fall, } export enum Achievement {//定义几个成就 /** 掉落在桥上 */ ACHIEVEMENT_FELL_OFF_BRIDGE, } //观察者: //这是一个需要知道别的对象做了什么事的类 export interface Observer { onNotify(entity: Entity, event: Event_Observer): void;//监听通知 } //实现这个类成为观察者。比如成就系统 export class Achievements implements Observer { heroIsOnBridge: boolean = true; unlock(achievement: Achievement) { // 如果还没有解锁,那就解锁成就…… console.log("掉落在了桥上", achievement); } onNotify(entity: Entity, event: Event_Observer) { switch (event) { case Event_Observer.Entiy_Fall: { if (entity.isHero() && this.heroIsOnBridge) { this.unlock(Achievement.ACHIEVEMENT_FELL_OFF_BRIDGE)//解锁成就 } } break; //处理其他事件,更新heroIsOnBridge_变量…… } } } //被观察者: //拥有通知的方法函数。它有一个列表,保存等通知的观察者, export class Subject { private observers_: Observer[] = []; private numObservers_: number = 0; //被观察者暴露 公开的 API来修改这个列表 addOberver(observer: Observer) { let itself = this; itself.observers_.push(observer);//添加到数组中 itself.numObservers_++; } removeObserver(observer: Observer) { let itself = this; let index = itself.observers_.indexOf(observer); if (index >= 0) { itself.observers_.splice(index, 1);//从数组中移除 itself.numObservers_--; } } //发送通知 notify(entity: Entity, event: Event_Observer) { let itself = this; for (let i = 0; i < itself.numObservers_; i++) { itself.observers_[i].onNotify(entity, event); } } } //物理系统与成就系统 export class Physics extends Subject { updateEntity(entity: Entity) { let itself = this; itself.notify(entity, Event_Observer.Entiy_Fall); } } //验证: let entity = new Entity(); let observer = new Achievements(); let physics = new Physics(); physics.addOberver(observer); physics.updateEntity(entity);
值得注意的是,被观察者直接调用了观察者,这意味着直到观察者的通知方法返回后,被观察者才会进行下一步。具体请看原文解释。
这里每次加入都做了内存的动态分配,下面是一种无需动态分配的方式:链式观察者
如果我们愿意在Observer中放一些状态,我们可以将观察者的列表,分布到观察者自己中,来解决动态分配问题。
缺点是:一个观察者,一次只能观察一个被观察者。解决这个问题可以使用链表节点池。//这里仅仅贴了修改的代码 export interface Observer { next_: Observer; } export class Achievements implements Observer { next_: Observer = null; } export class Subject { /** 一个指向观察者的指针或者地址 */ private head_: Observer; addOberver(observer: Observer) { let itself = this; observer.next_ = itself.head_; itself.head_ = observer; } removeObserver(observer: Observer) { if (!observer) { return; } let itself = this; if (itself.head_ == observer) { itself.head_ = observer.next_; observer.next_ = null; return; } let curr = itself.head_; while (curr != null) { if (curr.next_ == observer) { curr.next_ = observer.next_; observer.next_ = null; return; } curr = curr.next_; } } //发送通知 notify(entity: Entity, event: Event_Observer) { let itself = this; let observer = itself.head_; while (observer != null) { observer.onNotify(entity, event); observer = observer.next_; } } }
简答来说是:被观察者的head指向的不是观察者,而是一个个节点node,每个节点node指向对应观察者。
4. 原型模式
一个对象可以生成与自身相似的其他对象。
- a. 栗子
我们为游戏里的3中怪物类型(幽灵、恶魔、术士)分别设计了3个类,
并为每种怪物设计一个生成器类
除非薪资以代码行数来计算,否者这显然不是一个好设计:太多类,太多样板,太多冗余,太多重复代码....export interface Monster {//怪物基类 //stuff } export class Ghost implements Monster { }//幽灵类 export class Demon implements Monster { }//恶魔类 export class Sorcerer implements Monster { }//术士类 export interface Spawner {//生成器基类 spawnMonst(): Monster; } export class GhostSpawner implements Spawner {//幽灵生成类 spawnMonst() { return new Ghost(); } } export class DemonSpawner implements Spawner {//恶魔生成类 spawnMonst() { return new Demon(); } } export class SorcererSpawner implements Spawner {//术士生成类 spawnMonst() { return new Sorcerer(); } }
原型模式:如果你有一个幽灵,则你可以通过这个幽灵制作出更多幽灵。设计基类Monster他有一个抽象方法clone()。
比较优雅的是,不仅克隆了原型类,还克隆了状态。但是,实际上这与之前的代码量差不多。如果一个魔王拿着叉子,那么克隆出来的也需要拿着叉子么?export interface Monster {//怪物基类 clone(): Monster; } export class Ghost implements Monster { //幽灵类 private health_: number;//血量 private speed_: number constructor(health: number, speed: number) { let itself = this; itself.health_ = health; itself.speed_ = speed; } clone() { let itself = this; return new Ghost(itself.health_, itself.speed_); } } export class Spawner {//生成器类 prototype_: Monster; constructor(prototype: Monster) { this.prototype_ = prototype; } spawnMonst(): Monster { return this.prototype_.clone(); }; } //创建一个幽灵生成器,先创建一个幽灵原型实例 let ghostPrototype = new Ghost(15, 3); let ghostSpawner = new Spawner(ghostPrototype);
我们定义



浙公网安备 33010602011771号