好多年没有见到这种游戏场景了,还记得 “超级玛丽” 吗? 当年的红白机都带有这类游戏。
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;
}
最后一题比较复杂,留待以后慢慢看。