Cruyun's Blog


Talk is cheap, show you my code


Eloquent JavaScript学习笔记 A Platform Game

本章介绍了如何实现一个简单的平台游戏。在这个二维游戏中,控制玩家(红色方块)前进、后退、跳跃来收集所有金币(黄色方块),并且避免碰到熔浆(红色方块)。使用浏览器的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;
}
};
  1. 水平方向移动 moveX();

  2. 垂直方向移动 moveY();

  3. 碰撞检测 Level.playerTouched() ;

  4. 游戏失败处理 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的时候,整个动画停止。

  1. 参数frameFunc,是真正的游戏场景刷新函数;
  2. 内部的fame函数有个time参数,它是由系统传入的,是当前系统时间;
  3. 注意 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