Eloquent JavaScript 笔记 十五:A Platform Game
来源:互联网 发布:mplayerx mac 编辑:程序博客网 时间:2024/05/22 00:53
好多年没有见到这种游戏场景了,还记得 “超级玛丽” 吗? 当年的红白机都带有这类游戏。
1. 游戏介绍:
1. 黑色方块代表player,可以用键盘控制它前进、后退、跳跃。
2. 红色代表岩浆,碰到就game over。
3. 黄色方块是金币,收集到所有的金币就过关。
2. The Technology
1. 使用DOM显示游戏,通过key event获得用户的键盘输入。
2. 游戏的固定场景用table存储。
3. 可移动的物体放在场景之上,用absolute position的element。
3. Levels
var simpleLevelPlan = [ " ", " ", " x = x ", " x o o x ", " x @ xxxxx x ", " xxxxx x ", " x!!!!!!!!!!!!x ", " xxxxxxxxxxxxxx ", " "];像第七章的grid,本章的地图场景也使用类似的二维数组构建。固定的grid和移动的elements都包含在plan中。
1. x : 墙
2. 空格 : 空白区域
3. ! : 不可移动的岩浆
4. @ : 游戏开始时,player所处位置
5. o : 金币
6. = : 水平往复移动的岩浆
7. | : 垂直移动的岩浆
8. v : 滴落的岩浆
整个游戏包含多个level。收集到所有的金币,当前level结束,进入下一level。碰到岩浆,本level重新开始。
4. Reading a Level
Level 是游戏的一关,包含所有的场景信息,主要包括:
width: 地图宽度
height: 地图高度
grid: 即地图,一个二维数组,每个数组元素中包含一种类型的 “静物” (null 空白, x 墙, ! 不动的lava)
actors: 可以移动的物体 (player, coin, 可移动的lava)
player: 玩家
status: 游戏状态,失败还是过关
finishDelay: 过关之后,游戏的场景保留一段时间,游戏的动画继续显示。这样的话,切换到下一关就不那么突兀。
function Level(plan) { this.width = plan[0].length; this.height = plan.length; this.grid = []; 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.actors.push(new Actor(new Vector(x, y), ch)); else if (ch == "x") fieldType = "wall"; else if (ch == "!") fieldType = "lava"; gridLine.push(fieldType); } this.grid.push(gridLine); } this.player = this.actors.filter(function(actor) { return actor.type == "player"; })[0]; this.status = this.finishDelay = null;}Level.prototype.isFinished = function() { return this.status != null && this.finishDelay < 0;};Level的构造函数中用到了几个未定义的东西:
actorChars,Actor,Vector,这些都是用来构建 actors 的,都在下一节定义。
5. Actors
5.1. Vector
先看Vector,第六章和第七章都用到过:
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(factor) ,后面会用到speed vector,times方法可以方便的计算运动距离( speed x interval )。
5.2. actorChars
var actorChars = { "@": Player, "o": Coin, "=": Lava, "|": Lava, "v": Lava};可以移动的物体都是actors。注意,在actorChars这个对象中,每个property的值都是构造函数。下面一个一个分别定义:
5.3. Player
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";
地图上的坐标不是用pixel为单位的,而是自定义的一个方块,后面会讲。所以,与位置相关的都是以方块为单位,如:Vector, size, speed, pos 等。
size: player 宽度为0.8块,高度为1.5块
speed: 用来模拟运动和重力
pos: 坐标。注意,屏幕坐标是向下延伸的。player 的高度是1.5,那么,它的bottom没有和grid对齐(左图),所以,在构造时,给它的y减去0.5(右图)。
5.4. Lava
function Lava(pos, ch) { this.pos = pos; this.size = new Vector(1, 1); if (ch == "=") { 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";5.5. Coin
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";金币会在本地抖动,所以有个wobble属性。
好了,Level需要的所有元素都齐备了。
var simpleLevel = new Level(simpleLevelPlan); console.log(simpleLevel.width, "by", simpleLevel.height);
6. Encapsulation as a Burden
7. Drawing
7.1. DOMDisplay
Level如何绘制到屏幕上? 我们需要定义一个display对象,这里,用DOM元素显示,所以,该对象叫做 DOMDisplay。
function DOMDisplay(parent, level) { this.wrap = parent.appendChild(elt("div", "game")); this.level = level; this.wrap.appendChild(this.drawBackground()); this.actorLayer = null; this.drawFrame();}function elt(name, className) { var elt = document.createElement(name); if (className) elt.className = className; return elt;}分两层绘制,background是静止不动的场景,只需要绘制一次,使用drawBackground()绘制。actors是活动的,需要频繁重绘,放在actorLayer上,使用drawFrame() 绘制。
7.2. drawBackground()
var scale = 20;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;};scale 是一个grid的大小,就是上面说的块大小。
background是个<table>,grid中每一行是个<tr>,每一格是个<td>。
需要添加CSS,使background上的不同fieldType显示出来:
.background { background: rgb(52, 166, 251); table-layout: fixed; border-spacing: 0; }.background td { padding: 0; }.lava { background: rgb(255, 100, 100); }.wall { background: white; }
7.3. drawActors()
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;};
.actor { position: absolute; }.coin { background: rgb(241, 229, 89); }.player { background: rgb(64, 64, 64); }
每个actor是个<div>,它的position是absolute。
7.4. drawFrame()
DOMDisplay.prototype.drawFrame = function() { if (this.actorLayer) this.wrap.removeChild(this.actorLayer); this.actorLayer = this.wrap.appendChild(this.drawActors()); this.wrap.className = "game " + (this.level.status || ""); this.scrollPlayerIntoView();};每次清空所有的actors,重绘。
胜利或失败时,player的样式稍有变化:
.lost .player { background: rgb(160, 64, 64);}.won .player { box-shadow: -4px -7px 8px white, 4px -7px 8px white;}游戏的场景通常会超出viewport的大小,所以,需要scrollPlayerIntoView(),让player处于屏幕的中心区域。先定义游戏区域的大小:
.game { overflow: hidden; max-width: 600px; max-height: 450px; position: relative;}
7.5. scrollPlayerIntoView()
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; 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;};
7.6. 进入下一关,删除当前场景
DOMDisplay.prototype.clear = function() { this.wrap.parentNode.removeChild(this.wrap);};好了,可以显示了:
var simpleLevel = new Level(simpleLevelPlan);var display = new DOMDisplay(document.body, simpleLevel);
8. Motion and Collision
Motion: 每个actor都有speed属性,speed * duration 就是它的下一个位置。为了显示的平滑流畅,需要每隔一小段时间(例如:20ms)就让它移动一次(重绘)。
Collision: 碰撞检测。player会碰到墙、lava、coin,移动的lava也会碰到墙。碰撞之后,每种actor有自己特定的行为(停止、收集coin、弹回等)。类似于Motion,Collision检测也要每隔一小段时间就做一次,这样可以让动画更平滑。
8.1. 背景物体的碰撞检测方法:(只有地图上的静态物体,不包括 actors)
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]; if (fieldType) return fieldType; } }};第一个 if,判断是否跑到地图外面(左、右、上 三个方向),如果在这三个方向上超出地图范围,认为撞墙了。
第二个 if,判断是否跑到地图下边界,如果超出下边界,视同碰到lava。
在 for 循环中,判断是否与其他object(wall,lava)重叠。
8.2. 判断是否碰撞其他actors
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; }};
9. Actors and Actions
9.1. Level的animate方法
var maxStep = 0.05;Level.prototype.animate = function(step, keys) { 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,改变运动方向。
9.2. Lava 的 act()
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);};首先,计算移动后的位置;
然后,判断是否碰到别的物体,如果没碰到,移动到下一个位置。
如果碰到了,对于滴落的lava,它有个属性repeatPos(初始位置),直接移动到repeatPos;
没有repeatPos属性的lava,都可以往复运动,撞墙之后弹回,即speed逆转。
9.3. Coin 的 act()
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));};Coin只能在本地抖动,抖动的位置通过一个三角函数计算。
9.4. Player 的act()
act() :
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; }};
主要分成四部分:
1. 水平方向移动 moveX();
2. 垂直方向移动 moveY();
3. 碰撞检测 Level.playerTouched() ;
4. 游戏失败处理 lost 。
在一个平面上的移动,都可以分成两步:先水平移动,再垂直移动。 因为每次移动的时间片很短(50ms),距离也就会很短,从视觉效果看不出来分成了两步。
moveX() :
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;};moveX() 比较简单,只需要考虑水平方向的移动。当用户按下左、右方向键时,调整水平方向的speed。然后,移动到新位置,碰撞检测。
moveY() :
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; }};
moveY() 需要考虑重力加速度,如果没有碰撞,y方向的speed会持续增加 step * gravity 。
Level.playerTouched() :
Level.prototype.playerTouched = function(type, actor) { if (type == "lava" && this.status == null) { this.status = "lost"; this.finishDelay = 1; } else if (type == "coin") { this.actors = this.actors.filter(function(other) { return other != actor; }); if (!this.actors.some(function(actor) { return actor.type == "coin"; })) { this.status = "won"; this.finishDelay = 1; } }};如果碰到lava,游戏结束。如果碰到coin,从actors中删除这个coin,然后判断是否还有未收集的coin,如果没有了,则过关。
10. Tracking Keys
跟踪键盘事件,绑定三个按键的event(左、上、右)。
因为游戏是定时刷新的,所以不需要在每次收到键盘event时直接改变game场景,而是把按键的状态记下来,在下一个时间片时再处理该event。
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); return pressed;}这里用一个名叫pressed的Object保存按键状态。三个keyCode分别作为property name。
11. Running the Game
前文一直多次提到游戏场景定时刷新,这个定时并不是用setInterval或setTimeout这样的定时器,而是用第13章讲过的requestAnimationFrame() 。该函数通知系统需要刷新屏幕,系统会选择合适的时机执行传入的函数。这样可以更好的平衡性能和游戏的流畅性。
我们需要定义一个辅助函数 runAnimation() ,用于组织 requestAnimationFrame() 的执行:
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);}1. 参数frameFunc,是真正的游戏场景刷新函数;
2. 内部的fame函数有个time参数,它是由系统传入的,是当前系统时间;
3. 注意 var timeStep,它是当前时间与上次刷新时间的间隔,问什么要给它取个最大值(100ms)呢? 因为,如果浏览器窗口 (或tab) 被隐藏了,系统就会停止刷新该窗口,直到该窗口重新显示出来。 这可以起到暂停游戏的作用。
游戏的总控函数:
function runGame(plans, Display) { function startLevel(n) { runLevel(new Level(plans[n]), Display, function(status) { if (status == "lost") startLevel(n); else if (n < plans.length - 1) startLevel(n + 1); else console.log("You win!"); }); } startLevel(0);}
var arrows = trackKeys(arrowCodes);function runLevel(level, Display, andThen) { var display = new Display(document.body, level); runAnimation(function(step) { level.animate(step, arrows); display.drawFrame(step); if (level.isFinished()) { display.clear(); if (andThen) andThen(level.status); return false; } });}终于完工了。
12. Exercise: Game Over
现在的player有无数条命,如果在某一关失败了可以重复无数次。给它加个上限:3条命,如果失败3次,则从第一关开始。
function runGame(plans, Display) { var lifeCount = 3; function startLevel(n) { runLevel(new Level(plans[n]), Display, function(status) { if (status == "lost") { lifeCount --; if(lifeCount > 0) { startLevel(n); } else { startLevel(0); } } else if (n < plans.length - 1) startLevel(n + 1); else console.log("You win!"); }); } startLevel(0); }
13. Exercise: Pausing the Game
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(); // Here we remove all our event handlers removeEventListener("keydown", handleKey); arrows.unregister(); // (see change to trackKeys below) if (andThen) andThen(level.status); return false; } } runAnimation(animation); } function trackKeys(codes) { var pressed = Object.create(null); function handler(event) { if (codes.hasOwnProperty(event.keyCode)) { var state = event.type == "keydown"; pressed[codes[event.keyCode]] = state; event.preventDefault(); } } addEventListener("keydown", handler); addEventListener("keyup", handler); // This is new -- it allows runLevel to clean up its handlers pressed.unregister = function() { removeEventListener("keydown", handler); removeEventListener("keyup", handler); }; // End of new code return pressed; }
最后一题比较复杂,留待以后慢慢看。
- Eloquent JavaScript 笔记 十五:A Platform Game
- Eloquent JavaScript 笔记 十一:A Programming Language
- 《Eloquent JavaScript》笔记--函数;
- Eloquent JavaScript 笔记 三: Functions
- Eloquent JavaScript 笔记 十: Modules
- Eloquent JavaScript 笔记 十三:DOM
- Eloquent JavaScript 笔记 十七:HTTP
- 《Eloquent JavaScript》笔记--对象与数组
- 《Eloquent JavaScript》笔记--程序的结构;
- Eloquent JavaScript 笔记 二:Program Structure
- Eloquent JavaScript 笔记 五: High-Order Functions
- Eloquent JavaScript 笔记 四:Objects and Arrays
- Eloquent JavaScript 笔记 七: Electronic Life
- Eloquent JavaScript 笔记 十四:Handling Event
- Eloquent JavaScript 笔记 十六:Drawing on Canvas
- Eloquent JavaScript 笔记 十九:Node.js
- Eloquent JavaScript 笔记 二十:略有遗憾
- Eloquent JavaScript 笔记 十二:Javascript and the Browser
- ConcurrentHashMap 原理
- tomcat的处理过程
- ESP8266 How-to
- 最常用的正则表达式
- 优秀程序员的十个习惯
- Eloquent JavaScript 笔记 十五:A Platform Game
- NumPy-快速处理数据
- 以太坊连载(四):以太坊发展历史回顾
- eCos Build
- Android ADB 启动失败,端口占用问题解决方案
- STM32时钟体系结构
- DM368开发 -- 硬件认识
- nginx 接口单独划分日志
- iOS 工厂设计模式解耦和