導語 | 以《羊了個羊》爲代表的微信小遊戲在去年多次刷屏,引爆全網。近期又有幾款微信小遊戲成爲熱門,一度讓“微信小遊戲”熱度指數上漲 20% 以上。微信小遊戲市場一直都充滿着希望與競爭,開發者如何在爆品爭霸中脫穎而出呢?在小遊戲開發中有哪些傳統開發經驗可以借鑑與學習呢?我們特邀騰訊雲 TVP、計算機作家/講師 李藝老師,在他新書《微信小遊戲開發》的基礎上帶我們看看在微信小遊戲項目開發中,從架構師角度如何應用面向對象和軟件設計思想和設計模式。
作者簡介
李藝,騰訊雲 TVP、日行一課聯合創始人兼 CTO,極客時間視頻專欄《微信小程序全棧開發實戰》講師,一汽大衆等知名企業內訓培訓講師。具有近 20 年互聯網軟件研發經驗,參與研發的音視頻直播產品曾在騰訊 QQ 上線,爲數千萬人使用。是國內早期閃客之一,曾自定義課件標準並完成全平臺教育課件產品研發,官方評定爲 Adobe 中國十五位社區管理員之一。同時,還是中國人工智能學會會員,在北京協同創新研究院負責過人工智能項目的研發。業餘喜歡寫作,在微信公衆號/視頻號“藝述論”分享技術經驗,著有《微信小遊戲開發》、《小程序從 0 到 1:微信全棧工程師一本通》等計算機圖書。
引言
去年 9 月,微信小遊戲《羊了個羊》火爆全網,用戶訪問量驟增時甚至出現過多次宕機,其火爆程度遠超預期。其實,微信小遊戲開發整體而言簡單、獨立、易上手,即使單人也可以完成開發,不少程序員都是獨立的微信小遊戲開發者。《羊了個羊》微信小遊戲的火熱,吸引了很多前端開發者向這個領域轉行。
爲什麼要在遊戲開發中使用設計模式呢?
一般而言,遊戲開發作爲創意行業,不僅要有過硬的技術,更要有新奇的想法。尤其當任何一個創意火爆後,馬上就會引發衆多開發廠商快速跟進。這在遊戲行業的開發史上,已經出現過多次後來者居上的案例了。
那麼我們該怎麼應對這種情況呢?如果別人跑得快,就要想辦法比別人跑得更快,跑得更久。遊戲開發和其他所有軟件產品的開發一樣,並不是一錘子買賣,在第一個版本上線以後,後續根據玩家反饋和競品功能的升級,需要不斷研發和推出新版本。
在版本迭代的過程中,怎麼樣讓新功能更快地開發出來,同時老功能還能更大範圍地保持穩定,這是最考驗遊戲架構師能力的。架構師在項目啓動的時候,就要爲後續可能的變化預留方案,讓後面遊戲版本的迭代進行得又快、又穩。這涉及遊戲架構師的一項核心能力:漸進式模塊化重構與面向對象重構的能力。
軟件開發是有成熟的套路的,前輩大牛經過實踐總結的設計模式便是套路的結晶,有意識地在遊戲開發中運用成熟的設計模式,不僅可以彰顯程序員的內功水平,還能在一定程度上保證版本迭代的快速與穩定。
小遊戲實戰項目介紹
接下來分享的,是來自《微信小遊戲開發》這本書中的一個小遊戲實戰案例,項目在基本功能開發完後,爲了方便讀者錘鍊漸進式模塊化重構與面向對象重構的能力,特意在這個階段安排了設計模式實戰。
在目前的項目中,有兩類碰撞檢測:一類發生在球與擋板之間;另一類發生在球與屏幕邊界之間。在遊戲中,碰撞檢測是非常常見一種功能,爲了應對可能增加的碰撞檢測需求,我們使用設計模式將兩類碰撞的耦合性降低,方便後續加入的碰撞與被碰撞對象。
具體從實現上來講,我們準備應用橋接模式,將發生碰撞的雙方,分別定義爲兩個可以獨立變化的抽象對象(HitObjectRectangle與HitedObjectRectangle),然後再讓它們的具體實現部分獨立變化,以此完成對橋接模式的應用。
目前球(Ball)與擋板(Panel)還沒有基類,我們可以讓它們繼承於新創建的抽象基類,但這樣並不是很合理,它們都屬於可視化對象,如果要繼承,更應該繼承於 Component 基類。在 JS 中一個類的繼承只能實現單繼承,不能讓一個類同時繼承於多個基類,在這種情況下我們怎麼實現橋接模式中的抽象部分呢?對象能力的擴展形式,除了繼承,還有複合,我們可以將定義好的橋接模式中的具體實現部分,以類屬性的方式放在球和擋板對象中。
模式應用之橋接模式
在應用橋接模式之前,我們首先需要把握它的概念,從定義入手。其實,橋接模式是一種結構型設計模式,可將一系列緊密相關的類拆分爲抽象和實現兩個獨立的層次結構,從而能在開發時分別使用。
換言之,橋接模式將對象的抽象部分與它的具體實現部分分離,使它們都可以獨立的變化。在橋接模式中,一般包括兩個抽象部分和兩個具體實現的部分,一個抽象部分和一個具體實現部分爲一組,一共有兩組,兩組通過中間的抽象部分進行橋接,從而讓兩組的具體實現部分可以相對獨立自由的變化。
爲了更好地理解這個模式,我們通過一張圖看一個應用示例,如圖 1 所示:
圖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:
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 與註冊點的關係,可以參見圖 2:
圖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 個子類繼承它。
再看一下撞擊對象的定義:
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.
11. 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 是描述球的位置、大小的,所有信息在基類中都具備了,所以它不需要添加任何屬性或方法了。
以上就是我們爲應用橋接模式定義的所有類了,爲了進一步明確它們之間的關係,看一張示意圖,如圖 3 所示:
圖3,橋接模式示例類關係圖
第二層的 HitObjectRectangle 和 HitedObjectRectangle 是橋接模式中的抽象部分,第三層是具體實現部分。事實上如果我們需要的話,我們在 HitObjectRectangle 和 HitedObjectRectangle 兩條支線上,還可以定義更多的具體實現類。
在項目中消費橋接模式
接下來看如何使用,先改造原來的 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 的基類:
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 類的改造:
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 類的改寫:
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,它是我們應用橋接模式的最後一站了:
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.
25. export default GameIndexPage
在 GameIndexPage 類中,只有以下兩處修改:
- 第 9 行,添加了一個私有屬性 #rectangle,它是一個碰撞檢測數據對象,是 HitedObjectRectangle 的子類實例。
- 第 18 行,在調用小球的 testHitWall 方法,將 #rectangle 作爲參數傳遞了進去。
現在代碼修改完了,重新編譯測試,運行效果與之前一致,如下所示:
圖4,運行效果圖
使用橋接模式的意義
我們思考一下,我們在碰撞檢測這一塊應用橋接模式,創建了許多新類,除了把項目變複雜了,到底有什麼積極作用?我們將碰撞測試元素拆分爲兩個抽象對象(HitObjectRectangle 和 HitedObjectRectangle)的意義在哪裏?
看一張結構圖,如圖 5 所示:
圖5,待擴展的橋接模式示意圖
HitObjectRectangle 代表碰撞對象的碰撞檢測數據對象,HitedObjectRectangle 代表被碰撞對象的碰撞檢測數據對象,後者有三個具體實現的子類:ScreenRectangle、LeftPanelRectangle 和 RightPanelRectangle,這三個子類代表三類被撞擊的類型。
如果遊戲中出現一個四周需要被碰撞檢測的對象,它的檢測數據對象可以繼承於 ScreenRectangle;如果出現一個右側需要碰撞檢測的對象,它的檢測數據對象可以繼承於 RightPanelRectangle,以此類推左側出現的,它的數據對象可以繼承於 LeftPanelRectangle。而如果出現一個撞擊對象,它的檢測數據對象可以繼承於 BallRectangle。
目前我們這個小遊戲項目太過簡單,不足夠顯示橋接模式的作用。接下來我們做一個人爲拓展,新增一個紅色立方體代替小球:
1. // JS:src\views\cube.js
2. import { Ball } from "ball.js"
3. import CubeRectangle from "hitTest/cube_rectangle.js"
4.
5. /** 紅色立方塊 */
6. class Cube extends Ball {
7. constructor() {
8. super()
9. this.rectangle = new CubeRectangle()
10. }
11.
12. /** 渲染 */
13. render(context) {
14. context.fillStyle = "red"
15. context.beginPath()
16. context.rect(this.rectangle.left, this.rectangle.top, this.rectangle.width, this.rectangle.height)
17. context.fill()
18. }
19. }
20.
21. export default new Cube()
Cube 類的代碼與 Ball 是類似的,只有 render 代碼略有不同,讓它繼承於 Ball 是最簡單的實現方法。第 9 行,rectangle 設置爲 CubeRectangle 的實例,這個類尚不存在,稍後我們創建,它是 BallRectangle 的子類。
在 cube.js 文件中引入的 Ball(第 2 行)現在還沒有導出,我們需要修改一下 ball.js 文件,如下所示:
1. // JS:src/views/ball.js
2. ...
3.
4. /** 小球 */
5. // class Ball {
6. export class Ball {
7. ...
8. }
9. ...
第 6 行,使用 export 關鍵字添加了常規導出,其它不會修改。
現在看一下新增的 CubeRectangle 類,如下所示:
1. // JS:src\views\hitTest\ball_rectangle.js
2. import BallRectangle from "ball_rectangle.js"
3.
4. /** 碰撞對象的具體實現部分,立方體的大小及運動數據對象 */
5. class CubeRectangle extends BallRectangle { }
6.
7. export default CubeRectangle
CubeRectangle 是立方塊的檢測數據對象。CubeRectangle 可以繼承於HitObjectRectangle 實現,但因爲立方體與小球特徵很像,所以讓它繼承於 BallRectangle 更容易實現。事實上它像一個“富二代”,只需要繼承(第 5 行),什麼也不用做。
接下來開始使用立方塊。爲了使測試代碼簡單,我們將 game.js 文件中的頁面創建代碼修改一下,如下所示:
1. // JS:disc\第11章\11.1\11.1.2\game.js
2. ...
3. // import PageBuildDirector from "src/views/page_build_director.js" // 引入頁面建造指揮者
4. import PageFactory from "src/views/page_factory.js" // 引入頁面工廠
5.
6. /** 遊戲對象 */
7. class Game extends EventDispatcher {
8. ...
9.
10. /** 遊戲換頁 */
11. turnToPage(pageName) {
12. ...
13. // this.#currentPage = PageBuildDirector.buildPage(pageName, { game: this, context: this.#context })
14. this.#currentPage = PageFactory.createPage(pageName, this, this.#context)
15. ...
16. }
17.
18. ...
19. }
20. ...
只有兩處改動,第 4 行和第 14 行,繼承使用 PageBuildDirector 不利於代碼測試,使用 PageFactory 代碼會更簡單。這一步改動與本小節的橋接模式沒有直接關係。
最後修改 game_index_page.js 文件,使用立方塊,代碼如下:
1. // JS:src\views\game_index_page.js
2. ...
3. // import ball from "ball.js" // 引入小球單例
4. import ball from "cube.js" // 引入立方塊實例
5. ...
只有第 4 行引入地址變了,其他不會改變。代碼擴展完了,重新編譯測試,遊戲的運行效果如圖 6 所示:
圖6,小球變成了紅色方塊
改動後,白色的小球變成了紅色的方塊。此處,項目的可擴展性非常好,在應用了橋接模式以後,當我們把小球擴展爲方塊時,只需要少量的變動就可以做到了。現在,將 CubeRectangle 納入結構圖,如圖 7所示:
圖7,擴展後的橋接模式示意圖
第四層添加了一個 CubeRectangle,我們的 HitObjectRectangle 修改了嗎?沒有。雖然在 HitObjectRectangle 的 hitTest 方法中,我們使用 instanceof 進行了類型判斷,如下所示:
1. /** 與被撞對象的碰撞檢測 */
2. hitTest(hitedObject) {
3. let res = 0
4. if (hitedObject instanceof LeftPanelRectangle) {
5. ...
6. } else if (hitedObject instanceof RightPanelRectangle) {
7. ...
8. } else if (hitedObject instanceof ScreenRectangle) {
9. ...
10. }
11. return res
12. }
但判斷的是基本類型,在第四層添加子類型不會影響代碼的執行。我們添加的CubeRectangle 繼承於 BallRectangle,屬於 HitObjectRectangle 一支,如果添加一個新類繼承於 HitedObjectRectangle 的子類(即 ScreenRectangle、LeftPanelRectangle 和 RightPanelRectangle),結果是一樣的,代碼不用修改仍然有效。HitObjectRectangle 和 HitedObjectRectangle 作爲抽象部分,是我們實現的橋接模式中的重要組成部分,它們幫助具體實現部分屏蔽了變化的複雜性。
注意:如果我們添加了新的碰撞檢測類型,不同於 ScreenRectangle、LeftPanelRectangle 和 RightPanelRectangle 中的任何一個,代碼應該如何拓展?這時候就需要修改 HitObjectRectangle 類的 hitTest 方法啦,需要添加 else if 分支。
橋接模式用法總結
綜上所述,在橋接模式中,是有兩部分對象分別實現抽象部分與具體部分,然後這兩部分對象相對獨立自由的變化。在本小節示例中,我們主要應用橋接模式實現了碰撞檢測。小球和立方塊是撞擊對象,左右擋板及屏幕是被撞擊對象,通過相同的方式定義它們的大小、位置數據,然後以一種相對優雅的方式實現了碰撞檢測。
對比重構前後的代碼,我們不難發現,在應用橋接模式之前,我們的碰撞檢測代碼是與 GameIndexPage、Ball、LeftPanel 和 RightPanel 耦合在一起的,並且不方便進行新的碰撞對象擴展;在重構以後,我們碰撞檢測的代碼變成了只有 top、bottom、left 和 right 屬性數值的對比,變得非常清晰。
所有面向對象重構中使用的設計模式,橋接模式是最複雜的,在大型跨平臺 GUI 軟件中,橋接模式基本也是必出現的。
模式應用之訪問者模式
在應用了橋接模式以後,相信大家對設計模式的作用會有更深的瞭解,也有意識地運用設計模式,它可以幫助我們更大限度地應對需求變化的複雜性,從而保證版本迭代的穩定與快捷。
訪問者模式則是微信小遊戲開發中另一應用設計,以下內容屬於《微信小遊戲開發》前端篇內容,我們嘗試在源碼基礎之上,嘗試應用訪問者模式,目的仍然是有針對性地錘鍊學習者漸進性模塊化重構和麪向對象重構思維的能力。
應用模式之前的項目狀態
目前我們在實現碰撞檢測功能的時候,在 HitObjectRectangle 類中有一個很重要的方法:
1. // JS:src\views\hitTest\hit_object_rectangle.js
2. ...
3.
4. /** 碰撞對象的抽象部分,球與方塊的註冊點在中心,不在左上角 */
5. class HitObjectRectangle extends Rectangle {
6. ...
7.
8. /** 與被撞對象的碰撞檢測 */
9. hitTest(hitedObject) {
10. let res = 0
11. if (hitedObject instanceof LeftPanelRectangle) { // 碰撞到左擋板返回1
12. ...
13. } else if (hitedObject instanceof RightPanelRectangle) { // 碰撞到右擋板返回2
14. ...
15. } else if (hitedObject instanceof ScreenRectangle) {
16. ...
17. }
18. return res
19. }
20. }
21.
22. export default HitObjectRectangle
正是 hitTest 這個方法實現了碰撞檢測,它根據不同的被撞擊的對象,分別做了不同的邊界檢測。
但是這個方法它存在缺陷,其內部有 if else,並且這個 if else 是會隨着被檢測對象的類型增長而增加的。那麼在實踐中該怎麼優化它呢?我們可以使用訪問者模式重構。在訪問者模式中,可以根據不同的對象分別作不同的處理,這裏多個被撞擊的對象,恰好是定義中所說的不同的對象。
什麼是訪問者模式
訪問者模式是一種行爲設計模式, 它能將算法與算法所作用的對象隔離開來。換言之,訪問者模式根據訪問者不同,展示不同的行爲或做不同的處理。使用訪問者模式,一般意味着調用反轉,本來是 A 調用 B,結果該調用最終反赤來是通過 B 調用 A 完成的。
在這個模式中一般有兩個方面,我們可以拿軟件外包市場中的甲方乙方類比一下,甲方是發包方,乙方是接包方,本來需要甲方到乙方公司系統闡明需求,由乙方根據不同需求安排不同的項目進行開發;現在則是與之相反。
訪問者模式的實現與應用
接下來開始訪問者模式的實踐,我們先給 LeftPanelRectangle、RightPanelRectangle 和 ScreenRectangle 都添加一個相同的方法 accept,第一個 LeftPanelRectangle 的改動是這樣的:
1. // JS:src\views\hitTest\left_panel_rectangle.js
2. ...
3.
4. /** 被碰撞對象左擋板的大小數據 */
5. class LeftPanelRectangle extends HitedObjectRectangle {
6. ...
7.
8. visit(hitObject) {
9. if (hitObject.left < this.right && hitObject.top > this.top && hitObject.bottom < this.bottom) {
10. return 1 << 0
11. }
12. return 0
13. }
14. }
15.
16. export default LeftPanelRectangle
第 8 行至第 13 行,在這個新增的 visit 方法中,代碼是從原來 HitObjectRectangle 類中摘取一段並稍加修改完成的,這裏碰撞檢測只涉及兩個對象的邊界,沒有 if else,邏輯上便會更加簡潔清晰。
第二個 RightPanelRectangle 類的改動是這樣的:
1. // JS:src\views\hitTest\right_panel_rectangle.js
2. ...
3.
4. /** 被碰撞對象右擋板的大小數據 */
5. class RightPanelRectangle extends HitedObjectRectangle {
6. ...
7.
8. visit(hitObject) {
9. if (hitObject.right > this.left && hitObject.top > this.top && hitObject.bottom < this.bottom) {
10. return 1 << 1
11. }
12. return 0
13. }
14. }
15.
16. export default RightPanelRectangle
第 8 行至第 13 行,這個 visit 方法的實現,與 LeftPanelRectangle 中 visit 方法的實現如出一轍。
第 3 個是 ScreenRectangle 類的改動:
1. // JS:src\views\hitTest\screen_rectangle.js
2. ...
3.
4. /** 被碰撞對象屏幕的大小數據 */
5. class ScreenRectangle extends HitedObjectRectangle {
6. ...
7.
8. visit(hitObject) {
9. let res = 0
10. if (hitObject.right > this.right) { // 觸達右邊界返回4
11. res = 1 << 2
12. } else if (hitObject.left < this.left) { // 觸達左邊界返回8
13. res = 1 << 3
14. }
15. if (hitObject.top < this.top) { // 觸達上邊界返回16
16. res = 1 << 4
17. } else if (hitObject.bottom > this.bottom) { // 觸達下邊界返回32
18. res = 1 << 5
19. }
20. return res
21. }
22. }
23.
24. export default ScreenRectangle
第 8 行至第 21 行,是新增的 visit 方法。所有返回值,與原來均是一樣的,代碼的邏輯結構也是一樣的,只是從哪個對象上取值上進行比較做了變化。
上面這 3 個類都是 HitedObjectRectangle 的子類,爲了讓基類的定義更加完整,我們也修改一下 hited_object_rectangle.js 文件,如下所示:
1. // JS:src\views\hitTest\hited_object_rectangle.js
2. ...
3.
4. /** 被碰撞對象的抽象部分,屏幕及左右擋板的註冊點默認在左上角 */
5. class HitedObjectRectangle extends Rectangle {
6. ...
7.
8. visit(hitObject) { }
9. }
10.
11. export default HitedObjectRectangle
僅是第 8 行添加了一個空方法 visite,這個改動可以讓所有 HitedObjectRectangle 對象都有一個默認的 visite方法,在某些情況下可以避免代碼出錯。
最後我們再看一下 HitObjectRectangle 類的改動,這也是訪問者模式中的核心部分:
1. // JS:src\views\hitTest\hit_object_rectangle.js
2. ...
3.
4. /** 碰撞對象的抽象部分,球與方塊的註冊點在中心,不在左上角 */
5. class HitObjectRectangle extends Rectangle {
6. ...
7.
8. /** 與被撞對象的碰撞檢測 */
9. hitTest(hitedObject) {
10. // let res = 0
11. // if (hitedObject instanceof LeftPanelRectangle) { // 碰撞到左擋板返回1
12. // if (this.left < hitedObject.right && this.top > hitedObject.top && this.bottom < hitedObject.bottom) {
13. // res = 1 << 0
14. // }
15. // } else if (hitedObject instanceof RightPanelRectangle) { // 碰撞到右擋板返回2
16. // if (this.right > hitedObject.left && this.top > hitedObject.top && this.bottom < hitedObject.bottom) {
17. // res = 1 << 1
18. // }
19. // } else if (hitedObject instanceof ScreenRectangle) {
20. // if (this.right > hitedObject.right) { // 觸達右邊界返回4
21. // res = 1 << 2
22. // } else if (this.left < hitedObject.left) { // 觸達左邊界返回8
23. // res = 1 << 3
24. // }
25. // if (this.top < hitedObject.top) { // 觸達上邊界返回16
26. // res = 1 << 4
27. // } else if (this.bottom > hitedObject.bottom) { // 觸達下邊界返回32
28. // res = 1 << 5
29. // }
30. // }
31. // return res
32. return hitedObject.visit(this)
33. }
34. }
35.
36. export default HitObjectRectangle
第 10 行至第 31 行,是 hitTest 方法中被註釋掉的舊代碼,原來複雜的 if else 邏輯沒有了,只留下簡短的一句話(第 32 行)。這就是設計模式的力量,不僅現在簡單,後續如果我們要添加其他碰撞對象與被碰撞對象,這裏也不需要變動,足以證明代碼的可擴展性。
這樣我們在增加新的碰撞檢測對象時,只需要創建新類,沒有 if else 邏輯需要添加,也不影響舊代碼。第 9 行,這裏的 hitTest 方法,相當於一般訪問者模式中的 accept 方法。
當我們將訪問者模式和橋接模式完成結合應用時,代碼便變得異常簡潔清晰。小遊戲的運行效果與之前是一致的,如下所示:
圖7,運行效果示意圖
訪問者模式用法總結
綜上,訪問者模式特別擅長將擁有多個 if else 邏輯或 switch 分支邏輯的代碼,以一種反向調用的方式,轉化爲兩類對象之間一對一的邏輯關係進行處理。這是一個應用十分普遍的設計模式,當遇到複雜的 if else 代碼時,可以考慮使用該模式重構。
總結
橋接模式與訪問者模式是通用的,不僅可以應用於小遊戲開發中,而且可以用在其他前端項目中,甚至在其他編程語言中也可以發揮作用。設計模式本質上是一種組織軟件功能、架構代碼模塊的面向對象思想,這種思想貌似讓我們在開始寫代碼的時候多幹了一些活,但幹這些活的精力是值得投入的,它讓我們可以把其他的活幹得更快、更穩、更好。
只有走得穩,纔可以走得更遠、更快。設計模式在項目開發中的作用一目瞭然,但也有一些反駁的聲音認爲,項目着急上線時根本沒有仔細分析需求與架構的時間,如何應用設計模式?
其實,快速上線是沒有問題的,時間就是產品的生命;但在第一版本上線之後,程序員可以進行漸進式重構,重構並不發生在項目之初,對設計模式的應用也是在基本功能塵埃落定之後進行的。
只有走得穩,纔可以走得更遠、更快,而設計模式與漸進式面向對象重構思想便可以幫助我們實現。
本篇內容摘自騰訊雲 TVP 李藝著、機械工業出版社出版的《微信小遊戲開發》,該書已在京東上架,想要進一步深入瞭解微信小遊戲開發的朋友們可以自行前往購買,文中涉及的所有設計模式源碼在隨書源碼中都可以找到。