`元素)。网格中的每个字符串对应表格单元格(``)元素的类型名。扩展(三点)运算符用于将子节点数组作为单独的参数传给`elt`。
下面的 CSS 使表格看起来像我们想要的背景:
```css
.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; }
```
其中某些属性(border-spacing和padding)用于取消一些我们不想保留的表格默认行为。我们不希望在单元格之间或单元格内部填充多余的空白。
其中`background`规则用于设置背景颜色。CSS中可以使用两种方式来指定颜色,一种方法是使用单词(`white`),另一种方法是使用形如`rgb(R,G,B)`的格式,其中`R`表示颜色中的红色成分,`G`表示绿色成分,`B`表示蓝色成分,每个数字范围均为 0 到 255。因此在`rgb(52,166,251)`中,红色成分为 52,绿色为 166,而蓝色是 251。由于蓝色成分数值最大,因此最后的颜色会偏向蓝色。而你可以看到`.lava`规则中,第一个数字(红色)是最大的。
我们绘制每个角色时需要创建其对应的 DOM 元素,并根据角色属性来设置元素坐标与尺寸。这些值都需要与`scale`相乘,以将游戏中的尺寸单位转换为像素。
```js
function drawActors(actors) {
return elt("div", {}, ...actors.map(actor => {
let rect = elt("div", {class: `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 rect;
}));
}
```
为了赋予一个元素多个类别,我们使用空格来分隔类名。在下面展示的 CSS 代码中,`actor`类会赋予角色一个绝对坐标。我们将角色的类型名称作为额外的 CSS 类来设置这些元素的颜色。我们并没有再次定义`lava`类,因为我们可以直接复用前文为岩浆单元格定义的规则。
```css
.actor { position: absolute; }
.coin { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64); }
```
`setState`方法用于使显示器显示给定的状态。它首先删除旧角色的图形,如果有的话,然后在他们的新位置上重新绘制角色。试图将 DOM 元素重用于角色,可能很吸引人,但是为了使它有效,我们需要大量的附加记录,来关联角色和 DOM 元素,并确保在角色消失时删除元素。因为游戏中通常只有少数角色,重新绘制它们开销并不大。
```js
DOMDisplay.prototype.setState = function(state) {
if (this.actorLayer) this.actorLayer.remove();
this.actorLayer = drawActors(state.actors);
this.dom.appendChild(this.actorLayer);
this.dom.className = `game ${state.status}`;
this.scrollPlayerIntoView(state);
};
```
我们可以将关卡的当前状态作为类名添加到包装器中,这样可以根据游戏胜负与否来改变玩家角色的样式。我们只需要添加 CSS 规则,指定祖先节点包含特定类的`player`元素的样式即可。
```css
.lost .player {
background: rgb(160, 64, 64);
}
.won .player {
box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}
```
在遇到岩浆之后,玩家的颜色应该变成深红色,暗示着角色被烧焦了。当玩家收集完最后一枚硬币时,我们添加两个模糊的白色阴影来创建白色的光环效果,其中一个在左上角,一个在右上角。
我们无法假定关卡总是符合视口尺寸,它是我们在其中绘制游戏的元素。所以我们需要调用`scrollPlayerIntoView`来确保如果关卡在视口范围之外,我们可以滚动视口,确保玩家靠近视口的中央位置。下面的 CSS 样式为包装器的DOM元素设置了一个最大尺寸,以确保任何超出视口的元素都是不可见的。我们可以将外部元素的`position`设置为`relative`,因此该元素中的角色总是相对于关卡的左上角进行定位。
```css
.game {
overflow: hidden;
max-width: 600px;
max-height: 450px;
position: relative;
}
```
在`scrollPlayerIntoView`方法中,我们找出玩家的位置并更新其包装器元素的滚动坐标。我们可以通过操作元素的`scrollLeft`和`scrollTop`属性,当玩家接近视口边界时修改滚动坐标。
```js
DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
let width = this.dom.clientWidth;
let height = this.dom.clientHeight;
let margin = width / 3;
// The viewport
let left = this.dom.scrollLeft, right = left + width;
let top = this.dom.scrollTop, bottom = top + height;
let player = state.player;
let center = player.pos.plus(player.size.times(0.5))
.times(scale);
if (center.x < left + margin) {
this.dom.scrollLeft = center.x - margin;
} else if (center.x > right - margin) {
this.dom.scrollLeft = center.x + margin - width;
}
if (center.y < top + margin) {
this.dom.scrollTop = center.y - margin;
} else if (center.y > bottom - margin) {
this.dom.scrollTop = center.y + margin - height;
}
};
```
找出玩家中心位置的代码展示了,我们如何使用`Vec`类型来写出相对可读的计算代码。为了找出玩家的中心位置,我们需要将左上角位置坐标加上其尺寸的一半。计算结果就是关卡坐标的中心位置。但是我们需要将结果向量乘以显示比例,以将坐标转换成像素级坐标。
接下来,我们对玩家的坐标进行一系列检测,确保其位置不会超出合法范围。这里需要注意的是这段代码有时候依然会设置无意义的滚动坐标,比如小于 0 的值或超出元素滚动区域的值。这是没问题的。DOM 会将其修改为可接受的值。如果我们将`scrollLeft`设置为`–10`,DOM 会将其修改为 0。
最简单的做法是每次重绘时都滚动视口,确保玩家总是在视口中央。但这种做法会导致画面剧烈晃动,当你跳跃时,视图会不断上下移动。比较合理的做法是在屏幕中央设置一个“中央区域”,玩家在这个区域内部移动时我们不会滚动视口。
我们现在能够显示小型关卡。
```html
```
我们可以在`link`标签中使用`rel="stylesheet"`,将一个 CSS 文件加载到页面中。文件`game.css`包含了我们的游戏所需的样式。
## 动作与冲突
现在我们是时候来添加一些动作了。这是游戏中最令人着迷的一部分。实现动作的最基本的方案(也是大多数游戏采用的)是将时间划分为一个个时间段,根据角色的每一步速度和时间长度,将元素移动一段距离。我们将以秒为单位测量时间,所以速度以单元每秒来表示。
移动东西非常简单。比较困难的一部分是处理元素之间的相互作用。当玩家撞到墙壁或者地板时,不可能简单地直接穿越过去。游戏必须注意特定的动作会导致两个对象产生碰撞,并需要采取相应措施。如果玩家遇到墙壁,则必须停下来,如果遇到硬币则必须将其收集起来。
想要解决通常情况下的碰撞问题是件艰巨任务。你可以找到一些我们称之为物理引擎的库,这些库会在二维或三维空间中模拟物理对象的相互作用。我们在本章中采用更合适的方案:只处理矩形物体之间的碰撞,并采用最简单的方案进行处理。
在移动角色或岩浆块时,我们需要测试元素是否会移动到墙里面。如果会的话,我们只要取消整个动作即可。而对动作的反应则取决于移动元素类型。如果是玩家则停下来,如果是岩浆块则反弹回去。
这种方法需要保证每一步之间的时间间隔足够短,确保能够在对象实际碰撞之前取消动作。如果时间间隔太大,玩家最后会悬浮在离地面很高的地方。另一种方法明显更好但更加复杂,即寻找到精确的碰撞点并将元素移动到那个位置。我们会采取最简单的方案,并确保减少动画之间的时间间隔,以掩盖其问题。
该方法用于判断某个矩形(通过位置与尺寸限定)是否会碰到给定类型的网格。
```js
Level.prototype.touches = function(pos, size, type) {
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);
for (var y = yStart; y < yEnd; y++) {
for (var x = xStart; x < xEnd; x++) {
let isOutside = x < 0 || x >= this.width ||
y < 0 || y >= this.height;
let here = isOutside ? "wall" : this.rows[y][x];
if (here == type) return true;
}
}
return false;
};
```
该方法通过对坐标使用`Math.floor`和`Math.ceil`,来计算与身体重叠的网格方块集合。记住网格方块的大小是`1x1`个单位。通过将盒子的边上下颠倒,我们得到盒子接触的背景方块的范围。

我们通过查找坐标遍历网格方块,并在找到匹配的方块时返回`true`。关卡之外的方块总是被当作`"wall"`,来确保玩家不能离开这个世界,并且我们不会意外地尝试,在我们的“`rows`数组的边界之外读取。
状态的`update`方法使用`touches`来判断玩家是否接触岩浆。
```js
State.prototype.update = function(time, keys) {
let actors = this.actors
.map(actor => actor.update(time, this, keys));
let newState = new State(this.level, actors, this.status);
if (newState.status != "playing") return newState;
let player = newState.player;
if (this.level.touches(player.pos, player.size, "lava")) {
return new State(this.level, actors, "lost");
}
for (let actor of actors) {
if (actor != player && overlap(actor, player)) {
newState = actor.collide(newState);
}
}
return newState;
};
```
它接受时间步长和一个数据结构,告诉它按下了哪些键。它所做的第一件事是调用所有角色的`update`方法,生成一组更新后的角色。角色也得到时间步长,按键,和状态,以便他们可以根据这些来更新。只有玩家才会读取按键,因为这是唯一由键盘控制的角色。
如果游戏已经结束,就不需要再做任何处理(游戏不能在输之后赢,反之亦然)。否则,该方法测试玩家是否接触背景岩浆。如果是这样的话,游戏就输了,我们就完了。最后,如果游戏实际上还在继续,它会查看其他玩家是否与玩家重叠。
`overlap`函数检测角色之间的重叠。它需要两个角色对象,当它们触碰时返回`true`,当它们沿`X`轴和`Y`轴重叠时,就是这种情况。
```js
function overlap(actor1, actor2) {
return actor1.pos.x + actor1.size.x > actor2.pos.x &&
actor1.pos.x < actor2.pos.x + actor2.size.x &&
actor1.pos.y + actor1.size.y > actor2.pos.y &&
actor1.pos.y < actor2.pos.y + actor2.size.y;
}
```
如果任何角色重叠了,它的`collide`方法有机会更新状态。触碰岩浆角色将游戏状态设置为`"lost"`,当你碰到硬币时,硬币就会消失,当这是最后一枚硬币时,状态就变成了`"won"`。
```js
Lava.prototype.collide = function(state) {
return new State(state.level, state.actors, "lost");
};
Coin.prototype.collide = function(state) {
let filtered = state.actors.filter(a => a != this);
let status = state.status;
if (!filtered.some(a => a.type == "coin")) status = "won";
return new State(state.level, filtered, status);
};
```
## 角色的更新
角色对象的`update`方法接受时间步长、状态对象和`keys`对象作为参数。`Lava`角色类型忽略`keys`对象。
```js
Lava.prototype.update = function(time, state) {
let newPos = this.pos.plus(this.speed.times(time));
if (!state.level.touches(newPos, this.size, "wall")) {
return new Lava(newPos, this.speed, this.reset);
} else if (this.reset) {
return new Lava(this.reset, this.speed, this.reset);
} else {
return new Lava(this.pos, this.speed.times(-1));
}
};
```
它通过将时间步长乘上当前速度,并将其加到其旧位置,来计算新的位置。如果新的位置上没有障碍,它移动到那里。如果有障碍物,其行为取决于岩浆块的类型:滴落岩浆具有`reset`位置,当它碰到某物时,它会跳回去。跳跃岩浆将其速度乘以`-1`,从而开始向相反的方向移动。
硬币使用它们的`act`方法来晃动。他们忽略了网格的碰撞,因为它们只是在它们自己的方块内部晃动。
```js
const wobbleSpeed = 8, wobbleDist = 0.07;
Coin.prototype.update = function(time) {
let wobble = this.wobble + time * wobbleSpeed;
let wobblePos = Math.sin(wobble) * wobbleDist;
return new Coin(this.basePos.plus(new Vec(0, wobblePos)),
this.basePos, wobble);
};
```
递增`wobble`属性来跟踪时间,然后用作`Math.sin`的参数,来找到波上的新位置。然后,根据其基本位置和基于波的偏移,计算硬币的当前位置。
还剩下玩家本身。玩家的运动对于每和轴单独处理,因为碰到地板不应阻止水平运动,碰到墙壁不应停止下降或跳跃运动。
```js
const playerXSpeed = 7;
const gravity = 30;
const jumpSpeed = 17;
Player.prototype.update = function(time, state, keys) {
let xSpeed = 0;
if (keys.ArrowLeft) xSpeed -= playerXSpeed;
if (keys.ArrowRight) xSpeed += playerXSpeed;
let pos = this.pos;
let movedX = pos.plus(new Vec(xSpeed * time, 0));
if (!state.level.touches(movedX, this.size, "wall")) {
pos = movedX;
}
let ySpeed = this.speed.y + time * gravity;
let movedY = pos.plus(new Vec(0, ySpeed * time));
if (!state.level.touches(movedY, this.size, "wall")) {
pos = movedY;
} else if (keys.ArrowUp && ySpeed > 0) {
ySpeed = -jumpSpeed;
} else {
ySpeed = 0;
}
return new Player(pos, new Vec(xSpeed, ySpeed));
};
```
水平运动根据左右箭头键的状态计算。当没有墙壁阻挡由这个运动产生的新位置时,就使用它。否则,保留旧位置。
垂直运动的原理类似,但必须模拟跳跃和重力。玩家的垂直速度(`ySpeed`)首先考虑重力而加速。
我们再次检查墙壁。如果我们不碰到任何一个,使用新的位置。如果存在一面墙,就有两种可能的结果。当按下向上的箭头,并且我们向下移动时(意味着我们碰到的东西在我们下面),将速度设置成一个相对大的负值。这导致玩家跳跃。否则,玩家只是撞到某物上,速度就被设定为零。
重力、跳跃速度和几乎所有其他常数,在游戏中都是通过反复试验来设定的。我测试了值,直到我找到了我喜欢的组合。
## 跟踪按键
对于这样的游戏,我们不希望按键在每次按下时生效。相反,我们希望只要按下了它们,他们的效果(移动球员的数字)就一直有效。
我们需要设置一个键盘处理器来存储左、右、上键的当前状态。我们调用`preventDefault`,防止按键产生页面滚动。
下面的函数接受一个按键名称数组,返回跟踪这些按键的当前位置的对象。并注册`"keydown"`和`"keyup"`事件,当事件对应的按键代码存在于其存储的按键代码集合中时,就更新对象。
```js
function trackKeys(keys) {
let down = Object.create(null);
function track(event) {
if (keys.includes(event.key)) {
down[event.key] = event.type == "keydown";
event.preventDefault();
}
}
window.addEventListener("keydown", track);
window.addEventListener("keyup", track);
return down;
}
const arrowKeys =
trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]);
```
两种事件类型都使用相同的处理程序函数。该处理函数根据事件对象的type属性来确定是将按键状态修改为true(“keydown”)还是false(“keyup”)。
## 运行游戏
我们在第十四章中看到的`requestAnimationFrames`函数是一种产生游戏动画的好方法。但该函数的接口有点过于原始。该函数要求我们跟踪上次调用函数的时间,并在每一帧后再次调用`requestAnimationFrame`方法。
我们这里定义一个辅助函数来将这部分烦人的代码包装到一个名为`runAnimation`的简单接口中,我们只需向其传递一个函数即可,该函数的参数是一个时间间隔,并用于绘制一帧图像。当帧函数返回`false`时,整个动画停止。
```js
function runAnimation(frameFunc) {
let lastTime = null;
function frame(time) {
let stop = false;
if (lastTime != null) {
let timeStep = Math.min(time - lastTime, 100) / 1000;
if (frameFunc(timeStep) === false) return;
}
lastTime = time;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
```
我们将每帧之间的最大时间间隔设置为 100 毫秒(十分之一秒)。当浏览器标签页或窗口隐藏时,`requestAnimationFrame`调用会自动暂停,并在标签页或窗口再次显示时重新开始绘制动画。在本例中,`lastTime`和`time`之差是隐藏页面的整个时间。一步一步地推进游戏看起来很傻,可能会造成奇怪的副作用,比如玩家从地板上掉下去。
该函数也会将时间单位转换成秒,相比于毫秒大家会更熟悉秒。
`runLevel`函数的接受Level对象和显示对象的构造器,并返回一个`Promise`。`runLevel`函数(在`document.body`中)显示关卡,并使得用户通过该节点操作游戏。当关卡结束时(或胜或负),`runLevel`会多等一秒(让用户看看发生了什么),清除关卡,并停止动画,如果我们指定了`andThen`函数,则`runLevel`会以关卡状态为参数调用该函数。
```js
function runLevel(level, Display) {
let display = new Display(document.body, level);
let state = State.start(level);
let ending = 1;
return new Promise(resolve => {
runAnimation(time => {
state = state.update(time, arrowKeys);
display.setState(state);
if (state.status == "playing") {
return true;
} else if (ending > 0) {
ending -= time;
return true;
} else {
display.clear();
resolve(state.status);
return false;
}
});
});
}
```
一个游戏是一个关卡序列。每当玩家死亡时就重新开始当前关卡。当完成关卡后,我们切换到下一关。我们可以使用下面的函数来完成该任务,该函数的参数为一个关卡平面图(字符串)数组和显示对象的构造器。
```js
async function runGame(plans, Display) {
for (let level = 0; level < plans.length;) {
let status = await runLevel(new Level(plans[level]),
Display);
if (status == "won") level++;
}
console.log("You've won!");
}
```
因为我们使`runLevel`返回`Promise`,`runGame`可以使用`async`函数编写,如第十一章中所见。它返回另一个`Promise`,当玩家完成游戏时得到解析。
在[本章的沙盒](https://eloquentjavascript.net/code#16)的`GAME_LEVELS`绑定中,有一组可用的关卡平面图。这个页面将它们提供给`runGame`,启动实际的游戏:
```html
```
## 习题
### 游戏结束
按照惯例,平台游戏中玩家一开始会有有限数量的生命,每死亡一次就扣去一条生命。当玩家生命耗尽时,游戏就从头开始了。
调整`runGame`来实现生命机制。玩家一开始会有 3 条生命。每次启动时输出当前生命数量(使用`console.log`)。
```html
```
### 暂停游戏
现在实现一个功能 —— 当用户按下 ESC 键时可以暂停或继续游戏。
我们可以修改`runLevel`函数,使用另一个键盘事件处理器来实现在玩家按下 ESC 键的时候中断或恢复动画。
乍看起来,`runAnimation`无法完成该任务,但如果我们使用`runLevel`来重新安排调度策略,也是可以实现的。
当你完成该功能后,可以尝试加入另一个功能。我们现在注册键盘事件处理器的方法多少有点问题。现在`arrows`对象是一个全局绑定,即使游戏没有运行时,事件处理器也是有效的。我们称之为系统泄露。请扩展`tracKeys`,提供一种方法来注销事件处理器,接着修改`runLevel`在启动游戏时注册事件处理器,并在游戏结束后注销事件处理器。
```html
```
### 怪物
它是传统的平台游戏,里面有敌人,你可以跳到它顶上来打败它。这个练习要求你把这种角色类型添加到游戏中。
我们称之为怪物。怪物只能水平移动。你可以让它们朝着玩家的方向移动,或者像水平岩浆一样来回跳动,或者拥有你想要的任何运动模式。这个类不必处理掉落,但是它应该确保怪物不会穿过墙壁。
当怪物接触玩家时,效果取决于玩家是否跳到它们顶上。你可以通过检查玩家的底部是否接近怪物的顶部来近似它。如果是这样的话,怪物就消失了。如果没有,游戏就输了。
```html
```
================================================
FILE: 17.md
================================================
## 十七、在画布上绘图
> 原文:[Drawing on Canvas](https://eloquentjavascript.net/17_canvas.html)
>
> 译者:[飞龙](https://github.com/wizardforcel)
>
> 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/)
>
> 自豪地采用[谷歌翻译](https://translate.google.cn/)
>
> 部分参考了[《JavaScript 编程精解(第 2 版)》](https://book.douban.com/subject/26707144/)
> 绘图就是欺骗。
>
> M.C. Escher,由 Bruno Ernst 在《The Magic Mirror of M.C. Escher》中引用

浏览器为我们提供了多种绘图方式。最简单的方式是用样式来规定普通 DOM 对象的位置和颜色。就像在上一章中那个游戏展示的,我们可以使用这种方式实现很多功能。我们可以为节点添加半透明的背景图片,来获得我们希望的节点外观。我们也可以使用`transform`样式来旋转或倾斜节点。
但是,在一些场景中,使用 DOM 并不符合我们的设计初衷。比如我们很难使用普通的 HTML 元素画出任意两点之间的线段这类图形。
这里有两种解决办法。第一种方法基于 DOM,但使用可缩放矢量图形(SVG,Scalable Vector Graphics)代替 HTML。我们可以将 SVG 看成文档标记方言,专用于描述图形而非文字。你可以在 HTML 文档中嵌入 SVG,还可以在` `标签中引用它。
我们将第二种方法称为画布(canvas)。画布是一个能够封装图片的 DOM 元素。它提供了在空白的`html`节点上绘制图形的编程接口。SVG 与画布的最主要区别在于 SVG 保存了对于图像的基本信息的描述,我们可以随时移动或修改图像。
另外,画布在绘制图像的同时会把图像转换成像素(在栅格中的具有颜色的点)并且不会保存这些像素表示的内容。唯一的移动图形的方法就是清空画布(或者围绕着图形的部分画布)并在新的位置重画图形。
## SVG
本书不会深入研究 SVG 的细节,但是我会简单地解释其工作原理。在本章的结尾,我会再次来讨论,对于某个具体的应用来说,我们应该如何权衡利弊选择一种绘图方式。
这是一个带有简单的 SVG 图片的 HTML 文档。
```html
Normal HTML here.
```
`xmlns`属性把一个元素(以及他的子元素)切换到一个不同的 XML 命名空间。这个由`url`定义的命名空间,规定了我们当前使用的语言。在 HTML 中不存在``与``标签,但这些标签在 SVG 中是有意义的,你可以通过这些标签的属性来绘制图像并指定样式与位置。
和 HTML 标签一样,这些标签会创建 DOM 元素,脚本可以和它们交互。例如,下面的代码可以把``元素的颜色替换为青色。
```html
let circle = document.querySelector("circle");
circle.setAttribute("fill", "cyan");
```
## `canvas`元素
我们可以在` |