炸弹人游戏开发系列(5):控制炸弹人移动,引入状态模式
前言
上文中我们实现了炸弹人显示和左右移动。本文开始监听键盘事件,使玩家能控制炸弹人移动。然后会在重构的过程中会引入状态模式。大家会看到我是如何在开发的过程中通过重构来提出设计模式,而不是在初步设计阶段提出设计模式的。
本文目的
实现“使用键盘控制玩家移动”
完善炸弹人移动,增加上下方向的移动
本文主要内容
回顾上文更新后的领域模型
首先进行性能优化,使用双缓冲技术显示地图。接着考虑到“增加上下移动”的功能与上文实现的“左右移动”功能类似,实现起来没有难度,因此优先实现“使用键盘控制玩家移动”,再实现“增加上下移动”。
性能优化
双缓冲
什么是双缓冲
为什么要用双缓冲
因为显示地图是这样显示的:假设地图大小为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游戏《驴子跳》