前言
上文中我們實現了炸彈人顯示和左右移動。本文開始監聽鍵盤事件,使玩家能控制炸彈人移動。然后會在重構的過程中會引入狀態模式。大家會看到我是如何在開發的過程中通過重構來提出設計模式,而不是在初步設計階段提出設計模式的。
本文目的
實現“使用鍵盤控制玩家移動”
完善炸彈人移動,增加上下方向的移動
本文主要內容
回顧上文更新后的領域模型
首先進行性能優化,使用雙緩沖技術顯示地圖。接着考慮到“增加上下移動”的功能與上文實現的“左右移動”功能類似,實現起來沒有難度,因此優先實現“使用鍵盤控制玩家移動”,再實現“增加上下移動”。
性能優化
雙緩沖
什么是雙緩沖
為什么要用雙緩沖
因為顯示地圖是這樣顯示的:假設地圖大小為40*40,每個單元格是一個bitmap,則有40*40個bitmap。使用canvas的drawImage繪制每個bitmap,則要繪制40*40次才能繪制完一張完整的地圖,開銷很大。
那么應該如何優化呢?
- 每次只繪制地圖中變化的部分。
- 當變化的范圍也很大時(涉及到多個bitmap),則可用雙緩沖,減小頁面抖動的現象。
因此,使用“分層渲染”可以實現第1個優化,而使用“雙緩沖”則可實現第2個優化。
實現
在MapLayer中創建一個緩沖畫布,在繪制地圖時先在緩沖畫布上繪制,繪制完成后再將緩沖畫布拷貝到地圖畫布中。
MapLayer
(function () { var MapLayer = YYC.Class(Layer, { Init: function () { //*雙緩沖 //創建緩沖canvas this.___createCanvasBuffer(); //獲得緩沖context this.___getContextBuffer(); }, Private: { ___canvasBuffer: null, ___contextBuffer: null, ___createCanvasBuffer: function () { this.___canvasBuffer = $("<canvas/>", { width: bomberConfig.canvas.WIDTH.toString(), height: bomberConfig.canvas.HEIGHT.toString() })[0]; }, ___getContextBuffer: function () { this.___contextBuffer = this.___canvasBuffer.getContext("2d"); }, ___drawBuffer: function (img) { this.___contextBuffer.drawImage(img.img, img.x, img.y, img.width, img.height); } }, Protected: { P__createCanvas: function () { var canvas = $("<canvas/>", { width: bomberConfig.canvas.WIDTH.toString(), height: bomberConfig.canvas.HEIGHT.toString(), css: { "position": "absolute", "top": bomberConfig.canvas.TOP, "left": bomberConfig.canvas.LEFT, "border": "1px solid blue", "z-index": 0 } }); $("body").append(canvas); this.P__canvas = canvas[0]; } }, Public: { draw: function () { var i = 0, len = 0, imgs = null; imgs = this.getChilds(); for (i = 0, len = imgs.length; i < len; i++) { this.___drawBuffer(imgs[i]); } this.P__context.drawImage(this.___canvasBuffer, 0, 0); }, clear: function () { this.___contextBuffer.clearRect(0, 0, bomberConfig.canvas.WIDTH, bomberConfig.canvas.HEIGHT); this.base(); }, render: function () { if (this.P__isChange()) { this.clear(); this.draw(); this.P__setStateNormal(); } } } }); window.MapLayer = MapLayer; }());
控制炸彈人移動
現在,讓我們來實現“使用鍵盤控制炸彈人家移動” 。
分離出KeyEventManager類
因為玩家是通過鍵盤事件來控制炸彈人的,所以考慮提出一個專門處理事件的KeyEventManager類,它負責鍵盤事件的綁定與移除。
提出按鍵枚舉值
因為控制炸彈人移動的方向鍵可以為W、S、A、D,也可以為上、下、左、右方向鍵。也就是說,具體的方向鍵可能根據個人喜好變化,可以提供幾套方向鍵方案,讓玩家自己選擇。
為了實現上述需求,需要使用枚舉值KeyCodeMap來代替具體的方向鍵。這樣有以下好處:
- 使用抽象隔離具體變化。當具體的方向鍵變化時,只要改變枚舉值對應的value即可,而枚舉值不會變化
- 增加可讀性。枚舉值如Up一看就知道表示向上走,而87(W鍵的keycode)則看不出來是什么意思。
增加keystate
如果在KeyEventManager綁定的鍵盤事件中直接操作PlayerSprite:
- 耦合太重。PlayerSprite變化時也會影響到KeyEventManager
- 不夠靈活。如果以后增加多個玩家的需求,那么就需要修改KeyEventManager,使其直接操作多個玩家精靈類,這樣耦合會更中,第一點的情況也會更嚴重。
因此,我增加按鍵狀態keyState。這是一個空類,用於存儲當前的按鍵狀態。
當觸發鍵盤事件時,KeyEventManager類改變keyState。然后在需要處理炸彈人移動的地方(如PlayerSprite),判斷keyState,就可以知道當前按下的是哪個鍵,進而控制炸彈人進行相應方向的移動。
領域模型
相關代碼
KeyCodeMap
var keyCodeMap = { Left: 65, // A鍵 Right: 68, // D鍵 Down: 83, // S鍵 Up: 87 // W鍵 };
KeyEventManager、KeyState
(function () { //枚舉值 var keyCodeMap = { Left: 65, // A鍵 Right: 68, // D鍵 Down: 83, // S鍵 Up: 87 // W鍵 }; //按鍵狀態 var keyState = {}; var KeyEventManager = YYC.Class({ Private: { _keyDown: function () { }, _keyUp: function () { }, _clearKeyState: function () { window.keyState = {}; } }, Public: { addKeyDown: function () { var self = this; this._keyDown = YYC.Tool.event.bindEvent(this, function (e) { self._clearKeyState(); window.keyState[e.keyCode] = true; }); YYC.Tool.event.addEvent(document, "keydown", this._keyDown); }, removeKeyDown: function(){ YYC.Tool.event.removeEvent(document, "keydown", this._keyDown); }, addKeyUp: function () { var self = this; this._keyUp = YYC.Tool.event.bindEvent(this, function (e) { self._clearKeyState(); window.keyState[e.keyCode] = false; }); YYC.Tool.event.addEvent(document, "keyup", this._keyUp); }, removeKeyUp: function () { YYC.Tool.event.removeEvent(document, "keyup", this._keyUp); }, } }); window.keyCodeMap = keyCodeMap; window.keyState = keyState; window.keyEventManager = new KeyEventManager(); }());
PlayerSprite
handleNext: function () { if (window.keyState[keyCodeMap.A] === true) { this.speedX = -this.speedX; this.setAnim("walk_left"); } else if (window.keyState[keyCodeMap.D] === true) { this.speedX = this.speedX; this.setAnim("walk_right"); } else { this.speedX = 0; this.setAnim("stand_right"); } }
在游戲初始化時綁定事件:
Game
_initEvent: function () { keyEventManager.addKeyDown(); keyEventManager.addKeyUp(); } ... init: function () { ... this._initEvent(); },
引入狀態模式
發現“炸彈人移動”中,存在不同狀態,且狀態可以轉換的現象
在上一篇博文中,我實現了顯示和移動炸彈人,炸彈人可以在畫布上左右走動。
我發現在游戲中,炸彈人是處於不同的狀態的:站立、走動。又可以將狀態具體為:左站、右站、左走、右走。
炸彈人處於不同狀態時,它的行為是不一樣的(如處於左走狀態時,炸彈人移動方向為向左;處於右走狀態時,炸彈人移動方向為向右),且不同狀態之間可以轉換。
狀態圖
根據上面的分析,讓我萌生了可以使用狀態模式的想法。 狀態模式介紹詳見Javascript設計模式之我見:狀態模式。
為什么在此處用狀態模式
其實此處炸彈人的狀態數並不多,且每個狀態的邏輯也不復雜,完全可以直接在PlayerState中使用if else來實現狀態的邏輯和狀態切換。
那為什么我要用狀態模式了?
1、做這個游戲是為了學習,狀態模式我之前沒有實際應用過,因此可以在此處練手
2、此處也符合狀態模式的應用場景:一個對象的行為取決於它的狀態, 並且它必須在運行時刻根據狀態改變它的行為
3、擴展方便。目前實現了炸彈人左右移動,后面還會實現炸彈人上下移動。如果用狀態模式的話,只需要增加四個狀態:上走、上站、下走、下站,再對應修改Context和客戶端即可。
應用狀態模式的領域模型
狀態模式具體實現
因為有右走、右站、左走、左站四個狀態類,因此就要創建4個具體狀態類,分別對應這四個狀態類。
PlayerSprite

(function () { var PlayerSprite = YYC.Class(Sprite, { Init: function (data) { this.x = data.x; this.speedX = data.speedX; this.walkSpeed = data.walkSpeed; this.minX = data.minX; this.maxX = data.maxX; this.defaultAnimId = data.defaultAnimId; this.anims = data.anims; this.setAnim(this.defaultAnimId); this.__context = new Context(this); this.__context.setPlayerState(this.__getCurrentState()); }, Private: { __context: null, _getCurrentState: function () { var currentState = null; switch (this.defaultAnimId) { case "stand_right": currentState = Context.standRightState; break; case "stand_left": currentState = Context.standLeftState; break; case "walk_right": currentState = Context.walkRightState; break; case "walk_left": currentState = Context.walkLeftState; break; default: throw new Error("未知的狀態"); break; } } }, Public: { //精靈的速度 speedX: 0, speedY: 0, //定義sprite走路速度的絕對值 walkSpeed: 0, // 更新精靈當前狀態 update: function (deltaTime) { //每次循環,改變一下繪制的坐標 this.__setCoordinate(deltaTime); this.base(deltaTime); }, draw: function (context) { var frame = null; if (this.currentAnim) { frame = this.currentAnim.getCurrentFrame(); context.drawImage(this.currentAnim.getImg(), frame.x, frame.y, frame.width, frame.height, this.x, this.y, frame.imgWidth, frame.imgHeight); } }, clear: function (context) { var frame = null; if (this.currentAnim) { frame = this.currentAnim.getCurrentFrame(); //要加上圖片的寬度/高度 context.clearRect(0, 0, this.maxX + frame.imgWidth, this.maxY + frame.imgHeight); } }, handleNext: function () { this.__context.walkLeft(); this.__context.walkRight(); this.__context.stand(); } } }); window.PlayerSprite = PlayerSprite; }());
Context

(function () { var Context = YYC.Class({ Init: function (sprite) { this.sprite = sprite; }, Private: { _state: null }, Public: { sprite: null, setPlayerState: function (state) { this._state = state; //把當前的上下文通知到當前狀態類對象中 this._state.setContext(this); }, walkLeft: function () { this._state.walkLeft(); }, walkRight: function () { this._state.walkRight(); }, stand: function () { this._state.stand(); } }, Static: { walkLeftState: new WalkLeftState(), walkRightState: new WalkRightState(), standLeftState: new StandLeftState(), standRightState: new StandRightState() } }); window.Context = Context; }());
PlayerState

(function () { var PlayerState = YYC.AClass({ Protected: { P_context: null }, Public: { setContext: function (context) { this.P_context = context; } }, Abstract: { stand: function () { }, walkLeft: function () { }, walkRight: function () { } } }); window.PlayerState = PlayerState; }());
WalkLeftState

(function () { var WalkLeftState = YYC.Class(PlayerState, { Public: { stand: function () { if (window.keyState[keyCodeMap.A] === false) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.standLeftState); } }, walkLeft: function () { var sprite = null; if (window.keyState[keyCodeMap.A] === true) { sprite = this.P_context.sprite; sprite.speedX = -sprite.walkSpeed; sprite.speedY = 0; sprite.setAnim("walk_left"); } }, walkRight: function () { } } }); window.WalkLeftState = WalkLeftState; }());
StandLeftState

(function () { var StandLeftState = YYC.Class(PlayerState, { Public: { stand: function () { var sprite = null; if (window.keyState[keyCodeMap.A] === false) { sprite = this.P_context.sprite; sprite.speedX = 0; sprite.setAnim("stand_left"); } }, walkLeft: function () { if (window.keyState[keyCodeMap.A] === true) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.walkLeftState); } }, walkRight: function () { if (window.keyState[keyCodeMap.D] === true) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.walkRightState); } } } }); window.StandLeftState = StandLeftState; }());
WalkRightState

(function () { var WalkRightState = YYC.Class(PlayerState, { Public: { stand: function () { if (window.keyState[keyCodeMap.D] === false) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.standRightState); } }, walkLeft: function () { }, walkRight: function () { var sprite = null; if (window.keyState[keyCodeMap.D] === true) { sprite = this.P_context.sprite; sprite.speedX = sprite.walkSpeed; sprite.speedY = 0; sprite.setAnim("walk_right"); } } } }); window.WalkRightState = WalkRightState; }());
StandRightState

(function () { var StandRightState = YYC.Class(PlayerState, { Public: { stand: function () { var sprite = null; if (window.keyState[keyCodeMap.D] === false) { sprite = this.P_context.sprite; sprite.speedX = 0; sprite.setAnim("stand_right"); } }, walkLeft: function () { if (window.keyState[keyCodeMap.A] === true) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.walkLeftState); } }, walkRight: function () { if (window.keyState[keyCodeMap.D] === true) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.walkRightState); } } } }); window.StandRightState = StandRightState; }());
重構PlayerSprite
PlayerSprite重構前相關代碼
Init: function (data) { this.x = data.x; this.speedX = data.speedX; this.walkSpeed = data.walkSpeed; this.minX = data.minX; this.maxX = data.maxX; this.defaultAnimId = data.defaultAnimId; this.anims = data.anims;
this.setAnim(this.defaultAnimId); this.__context = new Context(this);
this.__context.setPlayerState(this.__getCurrentState()); },
從構造函數中分離出init
現在構造函數Init看起來有4個職責:
- 讀取參數
- 設置默認動畫
- 創建Context實例,且因為狀態類需要獲得PlayerSprite類的成員,因此在創建Context實例時,將PlayerSprite的實例注入到Context中。
- 設置當前默認狀態。
在測試PlayerSprite時,發現難以測試。這是因為構造函數職責太多,造成了互相的干擾。
從較高的層面來看,現在構造函數做了兩件事:
- 讀取參數
- 初始化
因此,我將“初始化”提出來,形成init方法。
構造函數保留“創建Context實例”職責
這里比較難決定的是“創建Context實例”這個職責應該放到哪里。
考慮到PlayerSprite與Context屬於組合關系,Context只屬於PlayerSprite,它應該在創建PlayerSprite時而創建。因此,將“創建Context實例”保留在PlayerSprite的構造函數中。
重構后的PlayerSprite
Init: function (data) { this.x = data.x; this.speedX = data.speedX; this.walkSpeed = data.walkSpeed; this.minX = data.minX; this.maxX = data.maxX; this.defaultAnimId = data.defaultAnimId; this.anims = data.anims; this._context = new Context(this); }, ... init: function () { this._context.setPlayerState(this._getCurrentState()); this.setAnim(this.defaultAnimId); }, ...
增加炸彈人上下方向的移動
增加狀態類
增加WalkUpState、WalkDownState、StandUpState、StandDownState類,並對應修改Context即可。
關於“為什么要有四個方向的Stand狀態類”的思考
看到這里,有朋友可能會說,為什么用這么多的Stand狀態類,直接用一個StandState類豈不是更簡潔?
原因在於,上站、下站、左站、右站的行為是不一樣的,這具體體現在顯示的動畫不一樣(炸彈人站立的方向不一樣)。
領域模型
相關代碼
WalkUpState

(function () { var WalkUpState = YYC.Class(PlayerState, { Public: { stand: function () { if (window.keyState[keyCodeMap.W] === false) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.standUpState); } }, walkLeft: function () { }, walkRight: function () { }, walkUp: function () { var sprite = null; if (window.keyState[keyCodeMap.W] === true) { sprite = this.P_context.sprite; sprite.speedX = 0; sprite.speedY = -sprite.walkSpeed; sprite.setAnim("walk_up"); } }, walkDown: function () { } } }); window.WalkUpState = WalkUpState; }());
WalkDownState

(function () { var WalkDownState = YYC.Class(PlayerState, { Public: { stand: function () { if (window.keyState[keyCodeMap.S] === false) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.standDownState); } }, walkLeft: function () { }, walkRight: function () { }, walkUp: function () { }, walkDown: function () { var sprite = null; if (window.keyState[keyCodeMap.S] === true) { sprite = this.P_context.sprite; sprite.speedX = 0; sprite.speedY = sprite.walkSpeed; sprite.setAnim("walk_down"); } } } }); window.WalkDownState = WalkDownState; }());
StandUpState

(function () { var StandUpState = YYC.Class(PlayerState, { Public: { stand: function () { var sprite = null; if (window.keyState[keyCodeMap.W] === false) { sprite = this.P_context.sprite; sprite.speedY = 0; sprite.setAnim("stand_up"); } }, walkLeft: function () { if (window.keyState[keyCodeMap.A] === true) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.walkLeftState); } }, walkRight: function () { if (window.keyState[keyCodeMap.D] === true) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.walkRightState); } }, walkUp: function () { if (window.keyState[keyCodeMap.W] === true) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.walkUpState); } }, walkDown: function () { if (window.keyState[keyCodeMap.S] === true) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.walkDownState); } } } }); window.StandUpState = StandUpState; }());
StandDownState

(function () { var StandDownState = YYC.Class(PlayerState, { Public: { stand: function () { var sprite = null; if (window.keyState[keyCodeMap.S] === false) { sprite = this.P_context.sprite; sprite.speedY = 0; sprite.setAnim("stand_down"); } }, walkLeft: function () { if (window.keyState[keyCodeMap.A] === true) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.walkLeftState); } }, walkRight: function () { if (window.keyState[keyCodeMap.D] === true) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.walkRightState); } }, walkUp: function () { if (window.keyState[keyCodeMap.W] === true) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.walkUpState); } }, walkDown: function () { if (window.keyState[keyCodeMap.S] === true) { this.P_context.sprite.resetCurrentFrame(0); this.P_context.setPlayerState(Context.walkDownState); } } } }); window.StandDownState = StandDownState; }());
Context
walkUp: function () { this._state.walkUp(); }, walkDown: function () { this._state.walkDown(); }, ... Static: { walkUpState: new WalkUpState(), walkDownState: new WalkDownState(), ... standUpState: new StandUpState(), standDownState: new StandDownState() }
解決問題
解決“drawImage中的dx、dy和clearRect中的x、y按比例縮放”
現在我需要解決在第3篇博文中提到的問題。
問題描述
如果把PlayerSprite.js -> draw -> drawImage:
context.drawImage(this.currentAnim.getImg(), frame.x, frame.y, frame.width, frame.height, this.x, this.y, frame.imgWidth, frame.imgHeight);
中的this.x、this.y設定成260、120:
context.drawImage(this.currentAnim.getImg(), frame.x, frame.y, frame.width, frame.height, 260, 120, frame.imgWidth, frame.imgHeight);
則不管畫布canvas的width、height如何設置,玩家人物都固定在畫布的右下角!!!
照理說,坐標應該為一個固定值,不應該隨畫布的變化而變化。即如果canvas.width = 300, drawImage的dx=300,則圖片應該在畫布右側邊界處;如果canvas.width 變為600,則圖片應該在畫布中間!而不應該還在畫布右側邊界處!
問題分析
這是因為我在PlayerLayer的創建canvas時,使用了css設置畫布的大小,因此導致了畫布按比例縮放的問題。
PlayerLayer
P__createCanvas: function () { var canvas = $("<canvas/>", { //id: id, width: bomberConfig.canvas.WIDTH.toString(), height: bomberConfig.canvas.HEIGHT.toString(), css: { "position": "absolute", "top": bomberConfig.canvas.TOP, "left": bomberConfig.canvas.LEFT, "border": "1px solid red", "z-index": 1 } }); $("body").append(canvas); this.P__canvas = canvas[0]; }
解決方案
通過HTML創建canvas,並在Html中設置它的width和height:
<canvas width="500" height="500"> </canvas>
本文最終領域模型
高層划分
新增包
- 事件管理包
KeyState、KeyEventManager
分析
狀態類應該放到哪個包?
狀態類與玩家精靈類PlayerSprite互相依賴且共同重用,因此應該都放到“精靈”這個包中。
本文層、包
對應領域模型
- 輔助操作層
- 控件包
PreLoadImg - 配置包
Config
- 控件包
- 用戶交互層
- 入口包
Main
- 入口包
- 業務邏輯層
- 輔助邏輯
- 工廠包
BitmapFactory、LayerFactory、SpriteFactory - 事件管理包
KeyState、KeyEventManager
- 工廠包
- 游戲主邏輯
- 主邏輯包
Game
- 主邏輯包
- 層管理
- 層管理實現包
PlayerLayerManager、MapLayerManager - 層管理抽象包
- LayerManager
- 層管理實現包
- 層
- 層實現包
PlayerLayer、MapLayer - 層抽象包
Layer - 集合包
Collection
- 層實現包
- 精靈
- 精靈包
PlayerSprite、Context、PlayerState、WalkLeftState、WalkRightState、WalkUpState、WalkDownState、StandLeftState、StandRightState、StandUpState、StandDownState - 動畫包
Animation、GetSpriteData、SpriteData、GetFrames、FrameData
- 精靈包
- 輔助邏輯
- 數據操作層
- 地圖數據操作包
MapDataOperate - 路徑數據操作包
GetPath - 圖片數據操作包
Bitmap
- 地圖數據操作包
- 數據層
- 地圖包
MapData - 圖片路徑包
ImgPathData
- 地圖包
本文參考資料
完全分享,共同進步——我開發的第一款HTML5游戲《驢子跳》