本章介绍了如何实现一个简单的平台游戏。在这个二维游戏中,控制玩家(红色方块)前进、后退、跳跃来收集所有金币(黄色方块),并且避免碰到熔浆(红色方块)。使用浏览器的DOM来展示游戏界面,通过处理按键事件来读取用户输入,游戏的固定场景用table存储,可移动的物体position:absolute
。最后面学习第十六章的内容使用了<canvas>
标签来绘制图像直接处理形状和像素。
1.关卡
类似于第七章,我们使用数组和字符串来描述一张二维网格,二维数组中用不同的字符定义元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| /* @ = Player start position o = Coins x = Solid surfaces ! = Non-moving lava = = Vertical moving lava v = Dripping lava | = Horizontal moving lava */ var simpleLevelPlan = [ " ", " ", " x = x ", " x o o x ", " x @ xxxxx x ", " xxxxx x ", " x!!!!!!!!!!!!x ", " xxxxxxxxxxxxxx ", " " ];
|
这个游戏总共设置了三关,后面两关用一个二维数组定义,只有通过一个关卡才进入下一个管球。
2.读取关卡
用一个构造函数来构造Level对象,参数是关卡数组。里面保存了地图宽度、高度、表示网格的数组和表示活动元素的数组。活动元素数组负责保存对象。使用filter方法查找player,完成某关卡后调用finishDelay方法来显示一个过渡动画进入下一关。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| // Assumes the plan is valid (add validation for production) function Level(plan) { this.width = plan[0].length; this.height = plan.length; // Array of arrays, each position containing null or a character this.grid = []; // Contains all of the dynamic objects (lava, coin or player), // along with their position and state this.actors = []; for (var y = 0; y < this.height; y++) { var line = plan[y], gridLine = []; for (var x = 0; x < this.width; x++) { var ch = line[x], fieldType = null; var Actor = actorChars[ch]; if (Actor) // This constructs the referenced moving object in // actorChars and pushes it to the actors array this.actors.push(new Actor(new Vector(x, y), ch)); else if (ch == 'x') // Wall fieldType = 'wall'; else if (ch == '!') // Stationary lava fieldType = 'lava'; gridLine.push(fieldType); } this.grid.push(gridLine); } // Find the Player actor this.player = this.actors.filter(function(actor) { return actor.type == 'player'; })[0]; // Track whether the player has won or lost; // finishDelay keeps the level active for a brief period of time this.status = this.finishDelay = null; } // Figure out if the level is finished Level.prototype.isFinished = function() { return this.status != null && this.finishDelay < 0; };
|
Level构造函数里面用到了未定义的actorChars,Actor,Vector,这些都是用来构建 actors,在下一节中定义。
3.读取元素:各个对象的构造函数
Vector:
1 2 3 4 5 6 7 8 9 10
| // Vector stores the position and size of an actor function Vector(x, y) { this.x = x; this.y = y; } Vector.prototype.plus = function(other) { return new Vector(this.x + other.x, this.y + other.y); } Vector.prototype.times = function(factor) { return new Vector(this.x * factor, this.y * factor); }
|
times属性用于计算特定元素移动距离:speed * interval
actorChars
该对象将字符和构造函数关联起来
1 2 3 4 5
| var actorChars = { '@': Player, 'o': Coin, '=': Lava, '|': Lava, 'v': Lava };
|
Player
1 2 3 4 5 6
| function Player(pos) { this.pos = pos.plus(new Vector(0, -0.5)); this.size = new Vector(0.8, 1.5); this.speed = new Vector(0, 0); } Player.prototype.type = 'player';
|
因为玩家的高度是1.5个格子,所以初始位置要比字符@出现的位置高半个格子。在构造的时候要减去0.5,因为屏幕的坐标pos是向下延伸的。
地图上的坐标不是以px为单位,而是table的一个单元格。所以与位置有关的坐标都是以单元格的为单位的,比如Vector,pos,speed…
Lava
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function Lava(pos, ch) { this.pos = pos; this.size = new Vector(1, 1); if (ch == "=") { // I'm guessing that speed will be added to this.pos // in a moving method added later this.speed = new Vector(2, 0); } else if (ch == '|') { this.speed = new Vector(0, 2); } else if (ch == 'v') { this.speed = new Vector(0, 3); this.repeatPos = pos; } } Lava.prototype.type = 'lava';
|
Coin
1 2 3 4 5 6 7
| function Coin(pos) { this.basePos = this.pos = pos.plus(new Vector(0.2, 0.1)); this.size = new Vector(0.6, 0.6); this.wobble = Math.random() * Math.PI * 2; } Coin.prototype.type = 'coin'; var wobbleSpeed = 8, wobbleDist = 0.07;
|
金币会在原地抖动,增加wobble属性。
以上就是所有表示关卡信息的代码。
4.绘图
下面定义一个“显示器”对象DOMDisplay来封装绘图代码。
首先有一个工具函数,创建元素并且赋予class。
1 2 3 4 5
| function elt(name, className) { var elt = document.createElement(name); if (className) elt.className = className; return elt; }
|
创建显示器对象需要指定父元素和一个关卡对象。
背景只需绘制一次,其他活动元素每次刷新都要重绘。
1 2 3 4 5 6 7 8 9 10
| function DOMDisplay(parent, level) { this.wrap = parent.appendChild(elt('div', 'game')); this.level = level; // Background is drawn only once this.wrap.appendChild(this.drawBackground()); // The actorLayer is animated in the drawFrame() method this.actorLayer = null; this.drawFrame(); }
|
然后将坐标按比例放大,创建的DOM元素的坐标尺寸的值都要与scale相乘,以转换成像素。
再添加CSS使background上的不同fieldType显示出来。(CSS代码略)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| // Set the scale of 1 grid unit var scale = 20; // Draw the background DOMDisplay.prototype.drawBackground = function() { var table = elt('table', 'background'); table.style.width = this.level.width * scale + 'px'; this.level.grid.forEach(function(row) { var rowElt = table.appendChild(elt('tr')); rowElt.style.height = scale + 'px'; row.forEach(function(type) { rowElt.appendChild(elt('td', type)); }); }); return table; }; // Draw the actors DOMDisplay.prototype.drawActors = function() { var wrap = elt('div'); this.level.actors.forEach(function(actor) { var rect = wrap.appendChild(elt('div', 'actor ' + actor.type)); rect.style.width = actor.size.x * scale + 'px'; rect.style.height = actor.size.y * scale + 'px'; rect.style.left = actor.pos.x * scale + 'px'; rect.style.top = actor.pos.y * scale + 'px'; }); return wrap; };
|
每次重绘活动元素时,首先移除旧有的活动元素removeChild()
。
1 2 3 4 5 6 7 8 9
| DOMDisplay.prototype.drawFrame = function() { if (this.actorLayer) this.wrap.removeChild(this.actorLayer); this.actorLayer = this.wrap.appendChild(this.drawActors()); // The status class is used to style the player based on // the state of the game (won or lost) this.wrap.className = 'game ' + (this.level.status || ''); this.scrollPlayerIntoView(); };
|
scrollPlayerIntoView()
来此确保关卡在视口范围之内,让player处于屏幕的中心区域。总屏幕中央设置一个中央区域,玩家在这个区域内移动时不会滚动视口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| DOMDisplay.prototype.scrollPlayerIntoView = function() { var width = this.wrap.clientWidth; var height = this.wrap.clientHeight; var margin = width / 3; // The viewport var left = this.wrap.scrollLeft, right = left + width; var top = this.wrap.scrollTop, bottom = top + height; // center makes use of the Vector methods defined earlier var player = this.level.player; var center = player.pos.plus(player.size.times(0.5)) .times(scale); if (center.x < left + margin) this.wrap.scrollLeft = center.x - margin; else if (center.x > right - margin) this.wrap.scrollLeft = center.x + margin - width; if (center.y < top + margin) this.wrap.scrollTop = center.y - margin; else if (center.y > bottom - margin) this.wrap.scrollTop = center.y + margin - height; }
|
最后需要一个方法来清除已经显示的关卡已进入下一关。
1 2 3
| DOMDisplay.prototype.clear = function() { this.wrap.parentNode.removeChild(this.wrap); };
|
5.动作与冲突
Motion: 每个actor都有speed属性,speed * duration 就是它的下一个位置。为了显示的平滑流畅,需要每隔一小段时间(例如:20ms)就让它移动一次(重绘)。
Collision: 碰撞检测。player会碰到墙、lava、coin,移动的lava也会碰到墙。碰撞之后,每种actor有自己特定的行为(停止、收集coin、弹回等)。类似于Motion,Collision检测也要每隔一小段时间就做一次,这样可以让动画更平滑。
Figure out the collision area of an actor:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| Level.prototype.obstacleAt = function(pos, size) { var xStart = Math.floor(pos.x); var xEnd = Math.ceil(pos.x + size.x); var yStart = Math.floor(pos.y); var yEnd = Math.ceil(pos.y + size.y); if (xStart < 0 || xEnd > this.width || yStart < 0) return 'wall'; if (yEnd > this.height) return 'lava'; for (var y = yStart; y < yEnd; y++) { for (var x = xStart; x < xEnd; x++) { var fieldType = this.grid[y][x]; // returns 'wall' or 'lava' if (fieldType) return fieldType; } } };
|
第一个if判断玩家是否跑到地图左、右、上边界,是的话视为撞墙;第二个if判断是否跑到地图下边界,是的话视为碰到熔浆。for循环里判断是否与其他object相撞,返回对应的type,再作不同的反映处理。
1 2 3 4 5 6 7 8 9 10 11
| Level.prototype.actorAt = function(actor) { for (var i = 0; i < this.actors.length; i++) { var other = this.actors[i]; if (other != actor && actor.pos.x + actor.size.x > other.pos.x && actor.pos.x < other.pos.x + other.size.x && actor.pos.y + actor.size.y > other.pos.y && actor.pos.y < other.pos.y + other.size.y) return other; } };
|
这里判断是否碰到其他actor
6.活动元素与动作
定义level的animate方法,传入的参数为时间间隔,创建key对象来保存玩家的按键信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| var maxStep = 0.05; Level.prototype.animate = function(step, keys) { // Used for the delay at the end of a game if (this.status != null) this.finishDelay -= step; while (step > 0) { var thisStep = Math.min(step, maxStep); this.actors.forEach(function(actor) { actor.act(thisStep, this, keys); }, this); step -= thisStep; } };
|
animate(step, keys) 方法会被定期调用,在这个函数中,每个actor都有机会执行自己的动作。
step是个时间值(两次调用animate函数的时间间隔),在while循环中,我们把step切分成小的时间片,就是maxStep,0.05秒,也就是说,actor的每个动作时长最大是50ms。
第一个if,是判断当前关卡是否结束:won or lost。 如果结束了,actors也会继续执行动作,直到 finishDelay 时间耗尽。
keys 是键盘事件,由actor自行处理,只有player需要处理keys,改变运动方向。
Lava ‘s action
1 2 3 4 5 6 7 8 9
| Lava.prototype.act = function(step, level) { var newPos = this.pos.plus(this.speed.times(step)); if (!level.obstacleAt(newPos, this.size)) this.pos = newPos; else if (this.repeatPos) this.pos = this.repeatPos; else this.speed = this.speed.times(-1); };
|
移动没有遇到障碍物就移动到新的位置,如果是包含repeatPos属性的垂直下落熔岩碰到障碍物要回到起始位置,弹跳型熔岩速度逆转(* -1)
Coin ‘s action
硬币通过act方法来实现摇晃,但是与玩家的碰撞是通过玩家的act方法来处理的。
1 2 3 4 5 6 7
| var wobbleSpeed = 8, wobbleDist = 0.07; Coin.prototype.act = function(step) { this.wobble += step * wobbleSpeed; var wobblePos = Math.sin(this.wobble) * wobbleDist; this.pos = this.basePos.plus(new Vector(0, wobblePos)); };
|
Player ‘s action
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| Player.prototype.act = function(step, level, keys) { this.moveX(step, level, keys); this.moveY(step, level, keys); var otherActor = level.actorAt(this); if (otherActor) level.playerTouched(otherActor.type, otherActor); // Losing animation if (level.status == 'lost') { this.pos.y += step; this.size.y -=step; } };
|
水平方向移动 moveX();
垂直方向移动 moveY();
碰撞检测 Level.playerTouched() ;
游戏失败处理 lost 。
在一个平面上的移动,都可以分成两步:先水平移动,再垂直移动。 因为每次移动的时间片很短(50ms),距离也就会很短,从视觉效果看不出来分成了两步。
moveX()
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| var playerXSpeed = 7; Player.prototype.moveX = function(step, level, keys) { this.speed.x = 0; if (keys.left) this.speed.x -= playerXSpeed; if (keys.right) this.speed.x += playerXSpeed; var motion = new Vector(this.speed.x * step, 0); var newPos = this.pos.plus(motion); var obstacle = level.obstacleAt(newPos, this.size); if (obstacle) level.playerTouched(obstacle); else this.pos = newPos; };
|
左键右键调整水平方向的speed,移动到新位置,碰撞检测。
moveY()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| var gravity = 30; var jumpSpeed = 17; Player.prototype.moveY = function(step, level, keys) { this.speed.y += step * gravity; var motion = new Vector(0, this.speed.y * step); var newPos = this.pos.plus(motion); var obstacle = level.obstacleAt(newPos, this.size); if (obstacle) { level.playerTouched(obstacle); if (keys.up && this.speed.y > 0) this.speed.y = -jumpSpeed; else this.speed.y = 0; } else { this.pos = newPos; } };
|
垂直方向上需要考虑重力加速度,如果没有发生碰撞,speed += step * gravity
Level.playerTouched()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| Level.prototype.playerTouched = function(type, actor) { // Lava's been touched if (type == 'lava' && this.status == null) { this.status = 'lost'; this.finishDelay = 1; } else if (type == 'coin') { // a coin's been touched // Remove the coin that's been collected from actors array this.actors = this.actors.filter(function(other) { return other != actor; }); // No more coins = you've won if (!this.actors.some(function(actor) { return actor.type == 'coin'; })) { this.status = 'won'; this.finishDelay = 1; } } }
|
如果碰到lava,游戏结束。如果碰到coin,从actors中删除这个coin,然后判断是否还有未收集的coin,如果没有了,则过关。
7.跟踪按键
跟踪键盘事件,绑定三个按键的event。
因为游戏是定时刷新的,所以不需要在每次收到键盘事件的时候就重绘场景,而是把键盘的状态记录下来,一定时间之后再处理该event。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| var arrowCodes = {37: 'left', 38: 'up', 39: 'right'}; function trackKeys(codes) { var pressed = Object.create(null); function handler(event) { if (codes.hasOwnProperty(event.keyCode)) { var down = event.type == 'keydown'; pressed[codes[event.keyCode]] = down; event.preventDefault(); } } addEventListener('keydown', handler); addEventListener('keyup', handler); pressed.unregister = function() { removeEventListener('keydown', handler); removeEventListener('keyup', handler); }; return pressed; }
|
pressed这个Objtect保存按键状态。
8.运行游戏
游戏场景定时重绘,不是用timeinterval或者setTimeout,而是第十三章的requestAnimationFrames函数,该函数要求我们跟踪上次调用函数的事件,并在每一帧后再次调用requestAnimationFrame方法。在这里定义一个辅助函数把代码包装到runAnimation的简单接口里,用于组织 requestAnimationFrame() 的执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function runAnimation(frameFunc) { var lastTime = null; function frame(time) { var stop = false; if (lastTime != null) { var timeStep = Math.min(time - lastTime, 100) / 1000; stop = frameFunc(timeStep) === false; } lastTime = time; if (!stop) requestAnimationFrame(frame); } requestAnimationFrame(frame); }
|
该函数的参数是一个时间间隔,用于重绘每一帧的图像,当帧函数返回false的时候,整个动画停止。
- 参数frameFunc,是真正的游戏场景刷新函数;
- 内部的fame函数有个time参数,它是由系统传入的,是当前系统时间;
- 注意 var timeStep,它是当前时间与上次刷新时间的间隔,问什么要给它取个最大值(100ms)呢? 因为,如果浏览器窗口 (或tab) 被隐藏了,系统就会停止刷新该窗口,直到该窗口重新显示出来。 这可以起到暂停游戏的作用。
Run the game
实现生命机制,给玩家限定5条命。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| function runGame(plans, Display) { var lives = 5; var livesSpan = document.getElementById('lives'); var gameStatus = document.getElementById('status'); function startLevel(n) { livesSpan.textContent = lives; runLevel(new Level(plans[n]), Display, function(status) { if (status == 'lost') { lives--; if (lives == 0) { gameStatus.textContent = 'Game Over'; console.log('Game over'); } else startLevel(n); } else if (n < plans.length - 1) startLevel(n + 1); else console.log('You win!'); }); } startLevel(0); }
|
runLevel()
的参数是一个Level对象,显示关卡并且是用户通过节点开始游戏。在这里还使用可一个键盘事件处理器增加了一个功能,假如用户按下Esc键的时候就停止运行游戏。另外按照习题的要求,避免系统泄露(游戏没有运行时,事件处理器却是有效的),在启动游戏的时候注册游戏处理器,结束后销毁。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| var arrows = trackKeys(arrowCodes); function runLevel(level, Display, andThen) { var display = new Display(document.body, level); var running = "yes"; function handleKey(event) { if (event.keyCode == 27) { if (running == "no") { running = "yes"; runAnimation(animation); } else if (running == "pausing") { running = "yes"; } else if (running == "yes") { running = "pausing"; } } } addEventListener("keydown", handleKey); var arrows = trackKeys(arrowCodes); function animation(step) { if (running == "pausing") { running = "no"; return false; } level.animate(step, arrows); display.drawFrame(step); if (level.isFinished()) { display.clear(); removeEventListener("keydown", handleKey); arrows.unregister(); if (andThen) andThen(level.status); return false; } } runAnimation(animation); }
|
以上就是第十五章编写platform game的基本过程,玩一玩demo。完整代码详见->我的github。