去年9月份,微信小游戏《羊了个羊》火爆全网,由于同时在线玩家过多,开发商服务器2天之内竟然出现了3次宕机。这在云开发时代是极少出现的,若不是火爆程度大大超出了预期,程序员怎么可能来不及扩容服务器呢?
微信小游戏开发整体来讲简单、独立、易上手,即使是一个人,也可以开发,不少程序员还是独立的微信小游戏开发者,仅靠游戏收入就远远超过了一般程序员的上班收入。《羊了个羊》小游戏的火爆,更加刺激了程序员,尤其是前端程序员向这个领域转行。
为什么要在游戏开发中使用设计模式呢?
对于游戏开发,一般人认为这是一个创意行业,不仅要有过硬的技术,更要有新奇的创意。这个认知没有错,但是,创意是不受法律保护的,任何一个创意火爆以后,马上就可能有N个开发商跟风抄袭。在游戏行业的开发史上,已经出现过多次,第一个想出创意的老大被后来居上的老二反超了。
怎么应对这种情况呢?
如果别人跑得快,就要想办法比别人跑得更快,跑得更久。游戏开发和其他所有软件产品的开发一样,并不是一锤子买卖,在第一个版本上线以后,后续根据玩家反馈和竞品功能的升级,需要不断研发和推出新版本。
在版本迭代的过程中,怎么样让新功能更快地开发出来,同时老功能还能更大范围地保持稳定,这是最考验游戏架构师能力的。架构师在项目启动的时候,就要为后续可能的变化预留方案,让后面游戏版本的迭代进行得又快、又稳。这涉及游戏架构师的一项核心能力:渐进式模块化重构与面向对象重构的能力。
软件开发是有成熟的套路的,前辈大牛经过实践总结的设计模式便是套路的结晶,有意识地在游戏开发中运用成熟的设计模式,不仅可以彰显程序员的内功水平,还能在一定程度上保证版本迭代的快速与稳定。
当前的小游戏项目分析
接下来作者分享的,是来自《微信小游戏开发》这本书中的一个小游戏实战案例,项目进行到第11章,基本功能已经开发完了,为了方便读者锤炼渐进式模块化重构与面向对象重构的能力,特意在这个阶段安排了设计模式实战。
在目前的项目中(以《微信小游戏开发》前端篇随书源码第11章/11.1/11.1.1的源码为基础),有两类碰撞检测:一类发生在球与挡板之间;另一类发生在球与屏幕边界之间。在游戏中,碰撞检测是非常常见一种功能,为了应对可能增加的碰撞检测需求,我们使用设计模式将两类碰撞的耦合性降低,方便后续加入新的碰撞与被碰撞对象。
具体从实现上来讲,接下来我们准备应用桥接模式,将发生碰撞的双方,分别定义为两个可以独立变化的抽象对象(HitObjectRectangle与HitedObjectRectangle),然后再让它们的具体实现部分独立变化,以此完成对桥接模式的应用。
目前球(Ball)与挡板(Panel)还没有基类,我们可以让它们继承于新创建的抽象基类,但这样并不是很合理,它们都属于可视化对象,如果要继承,更应该继承于Component基类。在JS中一个类的继承只能实现单继续,不能让一个类同时继承于多个基类,在这种情况下我们怎么实现桥接模式中的抽象部分呢?对象能力的扩展形式,除了继承,还有复合,我们可以将定义好的桥接模式中的具体实现部分,以类属性的方式放在球和挡板对象中。
什么是桥接模式?
在应用桥接模式之前,我们先从概念上简单了解一下什么是桥接模式。
桥接模式是一种结构型设计模式, 可将一系列紧密相关的类拆分为抽象和实现两个独立的层次结构, 从而能在开发时分别使用。
换一个说法,桥接模式将对象的抽象部分与它的具体实现部分分离,使它们都可以独立的变化。在桥接模式中,一般包括两个抽象部分和两个具体实现的部分,一个抽象部分和一个具体实现部分为一组,一共有两组,两组通过中间的抽象部分进行桥接,从而让两组的具体实现部分可以相对独立自由的变化。
为了更好地理解这个模式,我们通过一张图看一个应用示例,如图11-1所示:
图11-1桥接模式示例示意图
在这张图中,中间是一个跨平台开发框架,它为开发者抽离出一套通用接口(抽象部分B),这些接口是通用的、系统无关的,借此开发框架实现了跨平台特性。在开发框架中,具体到每个系统(Mac、Windows和Linux),每个接口及UI有不同的实现(具体实现部分B1、B2、B3)。左边,在应用程序中,开发者在软件中定义了一套抽象部分A,在每个系统上有不同的具体实现(具体实现部分A1、A2、A3)。应用程序面向抽象部分B编程,不必关心开发框架在每个系统下的具体实现;应用程序的具体实现部分A1、A2、A3是基于抽象部分A编程的,它们也不需要知道抽象部分B。抽象部分A与抽象部分B之间仿佛有一个桥连接了起来,这两套抽象部分与其具体实现部分呈现的模式便是桥接模式。
试想一下,如果我们不使用桥接模式,没有中间这一层跨平台开发框架,没有抽象部分B和抽象部分A,这时候我们想实现具体实现部分A1、A2、A3,怎么做呢?直接在各个系统的基础类库上实现呢?让A1与B1耦合、A2与B2耦合、A3与B3耦合吗?每次在应用程序中添加一个新功能,都要在三个地方分别实现。而有了桥接模式之后,B1、B2、B3都不需要关心了,只需要知道抽象部分B就可以了;添加新功能时,只需要在抽象部分A中定义并基于抽象部分B实现核心功能就可以了,在具体实现部分A1、A2、A3中只是UI和交互方式不同而已。这是使用桥接模式的价值。
桥接模式的具体实现
接下来开始实践,我们先定义桥接模式当中的抽象部分,一个是主动撞击对象的抽象部分(HitObjectRectangle),一个是被动撞击对象的抽象部分(HitedObjectRectangle)。由于两个部分的抽象部分具有相似性,我们可以先定义一个抽象部分的基类Rectangle,如代码清单11-7所示:
代码清单11-7矩形基类
1.// JS:src\views\hitTest\rectangle.js 2./** 对象的矩形描述,默认将注册点放在左上角 */ 3.class Rectangle { 4. constructor(x, y, width, height) { 5. this.x = x 6. this.y = y 7. this.width = width 8. this.height = height 9. } 10. 11. /** X坐标 */ 12. x = 0 13. /** Y坐标 */ 14. y = 0 15. /** X轴方向上所占区域 */ 16. width = 0 17. /** Y轴方向上所占区域 */ 18. height = 0 19. 20. /** 顶部边界 */ 21. get top() { 22. return this.y 23. } 24. /** 底部边界 */ 25. get bottom() { 26. return this.y + this.height 27. } 28. /** 左边界 */ 29. get left() { 30. return this.x 31. } 32. /** 右边界 */ 33. get right() { 34. return this.x + this.width 35. } 36.} 37. 38.export default Rectangle
上面代码做了什么事?
❑ 第12行至第18行,这是4个属性,x、y决定注册点,width、height决定尺寸。
❑ 第21行至第35行,这是4个getter访问器,分别代表对象在4个方向上的边界值。这4个属性不是实际存在的,而是通过注册点与尺寸计算出来的。根据注册点位置的不同,这4个getter的值也不同。默认注册点,即(0,0)坐标点在左上角,这时候top等于y;如果注册点在左下角,这时候top则等于y减去height。
Rectangle描述了一个对象的距形范围,关于4个边界属性top、bottom、left、right与注册点的关系,可以参见图11-2:
图11-2注册点与边界值的关系
接下来我们开始定义两个抽象部分:一个是撞击对象的,另一个是受撞击对象的。先看受撞击对象的,它比较简单:
1.// JS:src\views\hitTest\hited_object_rectangle.js 2.import Rectangle from "rectangle.js" 3. 4./** 被碰撞对象的抽象部分,屏幕及左右挡板的注册点默认在左上角 */ 5.class HitedObjectRectangle extends Rectangle{ 6. constructor(x, y, width, height){ 7. super(x, y, width, height) 8. } 9.} 10. 11.export default HitedObjectRectangle
HitedObjectRectangle类它没有新增属性或方法,所有特征都是从基类继承的。它的主要作用是被继承,稍后有3个子类继承它。
再看一下撞击对象的定义,如代码清单11-8所示:
代码清单11-8创建撞击对象基类
1.// JS:src\views\hitTest\hit_object_rectangle.js 2.import Rectangle from "rectangle.js" 3.import LeftPanelRectangle from "left_panel_rectangle.js" 4.import RightPanelRectangle from "right_panel_rectangle.js" 5.import ScreenRectangle from "screen_rectangle.js" 6. 7./** 碰撞对象的抽象部分,球与方块的注册点在中心,不在左上角 */ 8.class HitObjectRectangle extends Rectangle { 9. constructor(width, height) { 10. super(GameGlobal.CANVAS_WIDTH / 2, GameGlobal.CANVAS_HEIGHT / 2, width, height) 11. } 12. 13. get top() { 14. return this.y - this.height / 2 15. } 16. get bottom() { 17. return this.y + this.height / 2 18. } 19. get left() { 20. return this.x - this.width / 2 21. } 22. get right() { 23. return this.x + this.width / 2 24. } 25. 26. /** 与被撞对象的碰撞检测 */ 27. hitTest(hitedObject) { 28. let res = 0 29. if (hitedObject instanceof LeftPanelRectangle) { // 碰撞到左挡板返回1 30. if (this.left < hitedObject.right && this.top > hitedObject.top && this.bottom < hitedObject.bottom) { 31. res = 1 << 0 32. } 33. } else if (hitedObject instanceof RightPanelRectangle) { // 碰撞到右挡板返回2 34. if (this.right > hitedObject.left && this.top > hitedObject.top && this.bottom < hitedObject.bottom) { 35. res = 1 << 1 36. } 37. } else if (hitedObject instanceof ScreenRectangle) { 38. if (this.right > hitedObject.right) { // 触达右边界返回4 39. res = 1 << 2 40. } else if (this.left < hitedObject.left) { // 触达左边界返回8 41. res = 1 << 3 42. } 43. if (this.top < hitedObject.top) { // 触达上边界返回16 44. res = 1 << 4 45. } else if (this.bottom > hitedObject.bottom) { // 触达下边界返回32 46. res = 1 << 5 47. } 48. } 49. return res 50. } 51.} 52. 53.export default HitObjectRectangle
在上面代码中:
❑ HitObjectRectangle也是作为基类存在的,稍后有一个子类继承它。在这个基类中,第13行至第24行,我们通过重写getter访问器属性,将注册点由左上角移到了中心。
❑ 第10行,在构造器函数中我们看到,默认的起始x、y是屏幕中心的坐标。
❑ 第27行至第50行,hitTest方法的实现是核心代码,碰撞到左挡板与碰撞到右挡板返回的数字与之前定义的一样,碰撞四周墙壁返回的数字是4个新增的数字。
❑ 第35行,这行出现的1<<0代表数值的二进制向左移0个位置。移0个位置没有意义,这样书写是为了与下面的第35行、第39行、第41行等保持格式一致。1<<0等于1,1<<1等于2,1<<2等于4,1<<3等于8,这些数值是按2的N次幂递增的。
接下来我们定义ScreenRectangle,它是被撞击部分的具体实现部分:
1.// JS:src\views\hitTest\screen_rectangle.js 2.import HitedObjectRectangle from "hited_object_rectangle.js" 3. 4./** 被碰撞对象屏幕的大小数据 */ 5.class ScreenRectangle extends HitedObjectRectangle { 6. constructor() { 7. super(0, 0, GameGlobal.CANVAS_WIDTH, GameGlobal.CANVAS_HEIGHT) 8. } 9.} 10. export default ScreenRectangle
ScreenRectangle是屏幕的大小、位置数据对象,是一个继承于HitedObjectRectangle的具体实现。ScreenRectangle类作为一个具体的实现类,却没有添加额外的属性或方法,那我们为什么要定义它呢?它存在的意义,是由它本身作为一个对象成立的,参见HitObjectRectangle类中的hitTest方法。
接下来我们再看左挡板的大小、位置数据对象:
1.// JS:src\views\hitTest\left_panel_rectangle.js 2.import HitedObjectRectangle from "hited_object_rectangle.js" 3. 4./** 被碰撞对象左挡板的大小数据 */ 5.class LeftPanelRectangle extends HitedObjectRectangle { 6. constructor() { 7. super(0, (GameGlobal.CANVAS_HEIGHT - GameGlobal.PANEL_HEIGHT) / 2, GameGlobal.PANEL_WIDTH, GameGlobal.PANEL_HEIGHT) 8. } 9.} 10. 11.export default LeftPanelRectangle
LeftPanelRectangle与ScreenRectangle一样,是继承于HitedObjectRectangle的一个具体实现,仍然没有新增属性或方法,所有信息,包括大小和位置,都已经通过构造器参数传递进去了。
再看一下右挡板的大小、位置数据对象:
1.// JS:src\views\hitTest\right_panel_rectangle.js 2.import HitedObjectRectangle from "hited_object_rectangle.js" 3. 4./** 被碰撞对象右挡板的大小数据 */ 5.class RightPanelRectangle extends HitedObjectRectangle { 6. constructor() { 7. super(GameGlobal.CANVAS_WIDTH - GameGlobal.PANEL_WIDTH, (GameGlobal.CANVAS_HEIGHT - GameGlobal.PANEL_HEIGHT) / 2, GameGlobal.PANEL_WIDTH, GameGlobal.PANEL_HEIGHT) 8. } 9.} 10. 11.export default RightPanelRectangle
RightPanelRectangle也是继承于HitedObjectRectangle的一个具体实现,与LeftPanelRectangle不同的只是坐标位置。
接下来我们再看撞击对象这边的具体实现部分,只有一个BallRectangle类:
1.// JS:src\views\hitTest\ball_rectangle.js 2.import HitObjectRectangle from "hit_object_rectangle.js" 3. 4./** 碰撞对象的具体实现部分,球的大小及运动数据对象 */ 5.class BallRectangle extends HitObjectRectangle { 6. constructor() { 7. super(GameGlobal.RADIUS * 2, GameGlobal.RADIUS * 2) 8. } 9.} 10. 11.export default BallRectangle
BallRectangle是描述球的位置、大小的,所有信息在基类中都具备了,所以它不需要添加任何属性或方法了。
以上就是我们为应用桥接模式定义的所有类了,为了进一步明确它们之间的关系,看一张示意图,如图11-3所示:
图11-3桥接模式示例类关系图
第二层的HitObjectRectangle和HitedObjectRectangle是桥接模式中的抽象部分,第三层是具体实现部分。事实上如果我们需要的话,我们在HitObjectRectangle和HitedObjectRectangle两条支线上,还可以定义更多的具体实现类。
在项目中消费桥接模式
接下来看如何使用,先改造原来的Ball类,如代码清单11-9所示:
代码清单11-9改造Ball类
1.// JS:src/views/ball.js 2.import BallRectangle from "hitTest/ball_rectangle.js" 3. 4./** 小球 */ 5.class Ball { 6. ... 7. 8. constructor() { } 9. 10. get x() { 11. // return this.#pos.x 12. return this.rectangle.x 13. } 14. get y() { 15. // return this.#pos.y 16. return this.rectangle.y 17. } 18. /** 小于碰撞检测对象 */ 19. rectangle = new BallRectangle() 20. // #pos // 球的起始位置 21. #speedX = 4 // X方向分速度 22. #speedY = 2 // Y方向分速度 23. 24. /** 初始化 */ 25. init(options) { 26. // this.#pos = options?.ballPos ?? { x: GameGlobal.CANVAS_WIDTH / 2, y: GameGlobal.CANVAS_HEIGHT / 2 } 27. // const defaultPos = { x: this.#pos.x, y: this.#pos.y } 28. // this.reset = () => { 29. // this.#pos.x = defaultPos.x 30. // this.#pos.y = defaultPos.y 31. // } 32. this.rectangle.x = options?.x ?? GameGlobal.CANVAS_WIDTH / 2 33. this.rectangle.y = options?.y ?? GameGlobal.CANVAS_HEIGHT / 2 34. this.#speedX = options?.speedX ?? 4 35. this.#speedY = options?.speedY ?? 2 36. const defaultArgs = Object.assign({}, this.rectangle) 37. this.reset = () => { 38. this.rectangle.x = defaultArgs.x 39. this.rectangle.y = defaultArgs.y 40. this.#speedX = 4 41. this.#speedY = 2 42. } 43. } 44. 45. /** 重设 */ 46. reset() { } 47. 48. /** 渲染 */ 49. render(context) { 50. ... 51. } 52. 53. /** 运行 */ 54. run() { 55. // 小球运动数据计算 56. // this.#pos.x += this.#speedX 57. // this.#pos.y += this.#speedY 58. this.rectangle.x += this.#speedX 59. this.rectangle.y += this.#speedY 60. } 61. 62. /** 小球与墙壁的四周碰撞检查 */ 63. // testHitWall() { 64. // if (this.#pos.x > GameGlobal.CANVAS_WIDTH - GameGlobal.RADIUS) { // 触达右边界 65. // this.#speedX = -this.#speedX 66. // } else if (this.#pos.x < GameGlobal.RADIUS) { // 触达左边界 67. // this.#speedX = -this.#speedX 68. // } 69. // if (this.#pos.y > GameGlobal.CANVAS_HEIGHT - GameGlobal.RADIUS) { // 触达右边界 70. // this.#speedY = -this.#speedY 71. // } else if (this.#pos.y < GameGlobal.RADIUS) { // 触达左边界 72. // this.#speedY = -this.#speedY 73. // } 74. // } 75. testHitWall(hitedObject) { 76. const res = this.rectangle.hitTest(hitedObject) 77. if (res === 4 || res === 8) { 78. this.#speedX = -this.#speedX 79. } else if (res === 16 || res === 32) { 80. this.#speedY = -this.#speedY 81. } 82. } 83. 84. ... 85.} 86. 87.export default Ball.getInstance()
在Ball类中发生了什么变化?
❑ 第19行,我们添加了新的类属性rectangle,它是BallRectangle的实例。所有关于球的位置、大小等信息都移到了rectangle中,所以原来的类属性#pos(第20行)不再需要了,同时原来调用它的代码(例如第58行、第59行)都需要使用rectangle改写。
❑ 第32行至第42行,这是初始化代码,原来#pos是一个坐标,包括x、y两个值,现在将这两个值分别以rectangle中的x、y代替。
❑ 方法testHitWall用于屏幕边缘碰撞检测的,第63行至第74行的是旧代码,第75行至第82行是新代码。hitedObject是新增的参数,它是HitedObjectRectangle子类的实例。
小球属于撞击对象,它的rectangle是一个HitObjectRectangle的子类实例(BallRectangle)。
看一下对Panel类的改造,它是LeftPanel和RightPanel的基类,如代码清单11-10所示:
代码清单11-10改造Panel类
1.// JS:src/views/panel.js 2./** 挡板基类 */ 3.class Panel { 4. constructor() { } 5. 6. // x // 挡板的起点X坐标 7. // y // 挡板的起点Y坐标 8. get x() { 9. return this.rectangle.x 10. } 11. set x(val) { 12. this.rectangle.x = val 13. } 14. get y() { 15. return this.rectangle.y 16. } 17. set y(val) { 18. this.rectangle.y = val 19. } 20. /** 挡板碰撞检测对象 */ 21. rectangle 22. ... 23.} 24. 25.export default Panel
这个基类发生了什么变化?
❑ 第21行,rectangle是新增的HitedObjectRectangle的子类实例,具体是哪个实现,要在子类中决定。
❑ 第6行、第7行将x、y去掉,代之以第8行至第19行的getter访问器和setter设置器,对x、y属性的访问和设置,将转变为对rectangle中x、y的访问和设置。
为什么要在Panel基类中新增一个rectangle属性?因为要在它的子类LeftPanel、RightPanel中新增这个属性,挡板是被撞击对象,rectangle是HitedObjectRectangle的子类实例。与其在子类中分别设置,不如在基类中一个地方统一设置;另外,基类中render方法渲染挡板时要使用x、y属性,x、y属性需要重写,这也要求rectangle必须定义在基类中定义。
看一下对LeftPanel类的改造,如代码清单11-11所示:
代码清单11-11改造LeftPanel类
1.// JS:src/views/left_panel.js 2.... 3.import LeftPanelRectangle from "hitTest/left_panel_rectangle.js" 4. 5./** 左挡板 */ 6.class LeftPanel extends Panel { 7. constructor() { 8. super() 9. this.rectangle = new LeftPanelRectangle() 10. } 11. 12. ... 13. 14. /** 小球碰撞到左挡板返回1 */ 15. testHitBall(ball) { 16. return ball.rectangle.hitTest(this.rectangle) 17. // if (ball.x < GameGlobal.RADIUS + GameGlobal.PANEL_WIDTH) { // 触达左挡板 18. // if (ball.y > this.y && ball.y < (this.y + GameGlobal.PANEL_HEIGHT)) { 19. // return 1 20. // } 21. // } 22. // return 0 23. } 24.} 25. 26.export default new LeftPanel()
上面发生了什么?只有两处改动:
❑ 第9行,这里决定了基类中的rectangle是LeftPanelRectangle实例。LeftPanelRectangle是HitedObjectRectangle的子类。
❑ 第16行,碰撞检测代码修改为:由小球的rectangle与当前对象的rectangle做碰撞测试。
接下来是对RightPanel类的改写,如代码清单11-12所示:
代码清单11-12改造RightPanel类
1.// JS:src/views/right_panel.js 2.... 3.import RightPanelRectangle from "hitTest/right_panel_rectangle.js" 4. 5./** 右挡板 */ 6.class RightPanel extends Panel { 7. constructor() { 8. super() 9. this.rectangle = new RightPanelRectangle() 10. } 11. 12. ... 13. 14. /** 小球碰撞到左挡板返回2 */ 15. testHitBall(ball) { 16. return ball.rectangle.hitTest(this.rectangle) 17. // if (ball.x > (GameGlobal.CANVAS_WIDTH - GameGlobal.RADIUS - GameGlobal.PANEL_WIDTH)) { // 碰撞右挡板 18. // if (ball.y > this.y && ball.y < (this.y + GameGlobal.PANEL_HEIGHT)) { 19. // return 2 20. // } 21. // } 22. // return 0 23. } 24.} 25. 26.export default new RightPanel()
与LeftPanel类似,在这个RightPanel类中也只有两处修改,见第9行与第16行。
最后,我们开始改造GameIndexPage,它是我们应用桥接模式的最后一站了,如代码清单11-13所示:
代码清单11-13改造游戏主页对象
1.// JS:src\views\game_index_page.js 2.... 3.import ScreenRectangle from "hitTest/screen_rectangle.js" 4. 5./** 游戏主页页面 */ 6.class GameIndexPage extends Page { 7. ... 8. /** 墙壁碰撞检测对象 */ 9. #rectangle = new ScreenRectangle() 10. 11. ... 12. 13. /** 运行 */ 14. run() { 15. ... 16. // 小球碰撞检测 17. // ball.testHitWall() 18. ball.testHitWall(this.#rectangle) 19. ... 20. } 21. 22. ... 23.} 24. export default GameIndexPage
在GameIndexPage类中,只有两处修改:
❑ 第9行,添加了一个私有属性#rectangle,它是一个碰撞检测数据对象,是HitedObjectRectangle的子类实例。
❑ 第18行,在调用小球的testHitWall方法,将#rectangle作为参数传递了进去。
现在代码修改完了,重新编译测试,运行效果与之前一致,如下所示。