1
0
mirror of https://github.com/apachecn/eloquent-js-3e-zh.git synced 2025-05-28 07:02:20 +00:00
This commit is contained in:
wizardforcel 2018-05-31 22:15:28 +08:00
parent e0ce9aa55b
commit 506b7d92eb

536
16.md
View File

@ -87,9 +87,9 @@ class Level {
因此,`rows`包含字符数组、平面图的行。我们可以从中得出水平宽度和高度。但是我们仍然必须将可移动元素与背景网格分开。我们将其称为角色Actor。它们将存储在一个对象数组中。背景将是字符串的数组的数组持有字段类型`"empty"``"wall"`,或`"lava"`
为了创建这些数组,我们在行上映射,然后在它们的内容上进行映射。请记住,`map`将数组索引作为第二个参数传递给映射函数,它告诉我们给定字符的`x``y`坐标。游戏中的位置将存储为一对坐标,左上角为`0, 0`,并且每个背景正方形为 1 单位高和宽。
为了创建这些数组,我们在行上映射,然后在它们的内容上进行映射。请记住,`map`将数组索引作为第二个参数传递给映射函数,它告诉我们给定字符的`x``y`坐标。游戏中的位置将存储为一对坐标,左上角为`0, 0`,并且每个背景方块为 1 单位高和宽。
为了解释平面图中的字符,`Level`构造函数使用`levelChars`对象,它将背景元素映射为字符串,角色字符映射为类。当`type`是一个角色类时,它的`create`静态方法用于创建一个对象,该对象被添加到`startActors`,映射函数为这个背景正方形返回`"empty"`
为了解释平面图中的字符,`Level`构造函数使用`levelChars`对象,它将背景元素映射为字符串,角色字符映射为类。当`type`是一个角色类时,它的`create`静态方法用于创建一个对象,该对象被添加到`startActors`,映射函数为这个背景方块返回`"empty"`
角色的位置存储为一个`Vec`对象,它是二维向量,一个具有`x``y`属性的对象,像第六章一样。
@ -292,7 +292,7 @@ class DOMDisplay {
由于关卡的背景网格不会改变因此只需要绘制一次即可。角色则需要在每次刷新显示时进行重绘。drawFame需要使用actorLayer属性来跟踪保存角色的动作因此我们可以轻松移除或替换这些角色。
我们的坐标和尺寸以网格单元为单位跟踪也就是说尺寸或距离中的1单元表示一个单元格。在设置像素级尺寸时我们需要将坐标按比例放大如果游戏中的所有元素只占据一个方格中的一个像素那将是多么可笑。而scale变量会给出一个单元格在屏幕上实际占据的像素数目。
我们的坐标和尺寸以网格单元为单位跟踪也就是说尺寸或距离中的1单元表示一个单元格。在设置像素级尺寸时我们需要将坐标按比例放大如果游戏中的所有元素只占据一个方格中的一个像素那将是多么可笑。而scale绑定会给出一个单元格在屏幕上实际占据的像素数目。
```js
const scale = 20;
@ -371,9 +371,9 @@ DOMDisplay.prototype.setState = function(state) {
}
```
在遇到熔岩之后,玩家的颜色应该变成深红色,暗示着角色被烧焦了。当玩家收集完最后一枚硬币时,我们使用两个模糊的白色盒装阴影来创建白色的光环效果,其中一个在左上角,一个在右上角。
在遇到熔岩之后,玩家的颜色应该变成深红色,暗示着角色被烧焦了。当玩家收集完最后一枚硬币时,我们添加两个模糊的白色阴影来创建白色的光环效果,其中一个在左上角,一个在右上角。
我们无法假定关卡总是符合视口尺寸。所以我们需要调用scrollPlayerIntoVirw来确保如果关卡在视口范围之外我们可以滚动视口确保玩家靠近视口的中央位置。下面的CSS样式为包装器的DOM元素设置了一个最大尺寸以确保任何超出视口的元素都是不可见的。我们可以将外部元素的position设置为relative因此该元素中的角色总是相对于关卡的左上角进行定位。
我们无法假定关卡总是符合视口尺寸,它是我们在其中绘制游戏的元素。所以我们需要调用scrollPlayerIntoVirw来确保如果关卡在视口范围之外我们可以滚动视口确保玩家靠近视口的中央位置。下面的CSS样式为包装器的DOM元素设置了一个最大尺寸以确保任何超出视口的元素都是不可见的。我们可以将外部元素的position设置为relative因此该元素中的角色总是相对于关卡的左上角进行定位。
```css
.game {
@ -387,52 +387,47 @@ DOMDisplay.prototype.setState = function(state) {
在scrollPlayerIntoView方法中我们找出玩家的位置并更新其包装器元素的滚动坐标。我们可以通过操作元素的scrollLeft和scrollTop属性当玩家接近视口边界时修改滚动坐标。
```js
DOMDisplay.prototype.scrollPlayerIntoView = function() {
var width = this.wrap.clientWidth;
var height = this.wrap.clientHeight;
var margin = width / 3;
DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
let width = this.dom.clientWidth;
let height = this.dom.clientHeight;
let margin = width / 3;
// The viewport
var left = this.wrap.scrollLeft, right = left + width;
var top = this.wrap.scrollTop, bottom = top + height;
let left = this.dom.scrollLeft, right = left + width;
let top = this.dom.scrollTop, bottom = top + height;
var player = this.level.player;
var center = player.pos.plus(player.size.times(0.5))
.times(scale);
let player = state.player;
let 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;
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;
}
};
```
找出玩家中心位置的代码展示了我们如何使用Vector类型来写出可读性较好的计算代码。为了找出玩家的中心位置,我们需要将左上角位置坐标加上其尺寸的一半。计算结果就是关卡坐标的中心位置。但是我们需要将结果向量乘以显示比例,以将坐标转换成像素级坐标。
找出玩家中心位置的代码展示了我们如何使用Vec类型来写出相对可读的计算代码。为了找出玩家的中心位置,我们需要将左上角位置坐标加上其尺寸的一半。计算结果就是关卡坐标的中心位置。但是我们需要将结果向量乘以显示比例,以将坐标转换成像素级坐标。
接下来我们对玩家的坐标进行一系列检测确保其位置不会超出合法范围。这里需要注意的是这段代码有时候依然会设置无意义的滚动坐标比如小于0的值或超出元素滚动区域的值。这是没问题的。DOM会将其修改为合理的值。如果我们将scrollLeft设置为10DOM会将其修改为0。
接下来我们对玩家的坐标进行一系列检测确保其位置不会超出合法范围。这里需要注意的是这段代码有时候依然会设置无意义的滚动坐标比如小于0的值或超出元素滚动区域的值。这是没问题的。DOM会将其修改为可接受的值。如果我们将scrollLeft设置为10DOM会将其修改为0。
最简单的做法是每次重绘时都滚动视口,确保玩家总是在视口中央。但这种做法会导致画面剧烈晃动,当你跳跃时,视图会不断上下移动。比较合理的做法是在屏幕中央设置一个“中央区域”,玩家在这个区域内部移动时我们不会滚动视口。
最后我们需要一个方法来清除显示的关卡。在游戏进入下一关卡或当前关卡重新开始时需要使用该方法。
```js
DOMDisplay.prototype.clear = function() {
this.wrap.parentNode.removeChild(this.wrap);
};
```
我们现在能够显示小型关卡。
```html
<link rel="stylesheet" href="css/game.css">
<script>
var simpleLevel = new Level(simpleLevelPlan);
var display = new DOMDisplay(document.body, simpleLevel);
let simpleLevel = new Level(simpleLevelPlan);
let display = new DOMDisplay(document.body, simpleLevel);
display.setState(State.start(simpleLevel));
</script>
```
@ -440,312 +435,263 @@ DOMDisplay.prototype.clear = function() {
### 15.8 动作与冲突
现在我们是时候来添加一些动作了。这是游戏中最令人着迷的一部分。实现动作的最基本的方案(也是大多数游戏采用的)是将时间划分为一个个时间段,根据角色的每一步速度(每秒的移动距离)和时间长度(以秒为单位)将元素移动一段距离
现在我们是时候来添加一些动作了。这是游戏中最令人着迷的一部分。实现动作的最基本的方案(也是大多数游戏采用的)是将时间划分为一个个时间段,根据角色的每一步速度和时间长度,将元素移动一段距离。我们将以秒为单位测量时间,所以速度以单元每秒来表示
这其实非常简单。比较困难的一部分是处理元素之间的相互作用。当玩家撞到墙壁或者地板时,不可能简单地直接穿越过去。游戏必须注意特定的动作会导致两个对象产生碰撞,并需要采取相应措施。如果玩家遇到墙壁,则必须停下来,如果遇到硬币则必须将其收集起来。
移动东西非常简单。比较困难的一部分是处理元素之间的相互作用。当玩家撞到墙壁或者地板时,不可能简单地直接穿越过去。游戏必须注意特定的动作会导致两个对象产生碰撞,并需要采取相应措施。如果玩家遇到墙壁,则必须停下来,如果遇到硬币则必须将其收集起来。
想要解决通常情况下的碰撞问题是件艰巨任务。你可以找到一些我们称之为物理引擎的库,这些库会在二维或三维空间中模拟物理对象的相互作用。我们在本章中采用更合适的方案:只处理矩形物体之间的碰撞,并采用最简单的方案进行处理。
在移动角色或熔岩块时,我们需要测试该动作是否会将元素移动到背景不为空的部分。如果背景确实非空,我们只要取消整个动作即可。而对动作的反应则取决于移动元素类型。如果是玩家则停下来,如果是熔岩块则反弹回去。
在移动角色或熔岩块时,我们需要测试元素是否会移动到墙里面。如果会的话,我们只要取消整个动作即可。而对动作的反应则取决于移动元素类型。如果是玩家则停下来,如果是熔岩块则反弹回去。
这种方法需要保证每一步之间的时间间隔足够短,确保能够在对象实际碰撞之前取消动作。如果时间间隔太大,玩家最后会悬浮在离地面很高的地方。另一种方法明显更好但更加复杂,即寻找到精确的碰撞点并将元素移动到那个位置。我们会采取最简单的方案,并确保减少动画之间的时间间隔,以掩盖其问题。
该方法用于判断某个矩形(通过位置与尺寸限定)是否会覆盖在背景网格的非空白区域中
该方法用于判断某个矩形(通过位置与尺寸限定)是否会碰到给定类型的网格
```js
Level.prototype.obstacleAt = function(pos, size) {
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);
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;
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单元。我们获得元素矩形的边长就可以获得元素矩形占据的背景网格范围。
该方法通过对坐标使用`Math.floor``Math.ceil`,来计算与身体重叠的网格方块集合。记住网格方块的大小是`1x1`个单位。通过将盒子的边上下颠倒,我们得到盒子接触的背景方块的范围。
![](../Images/00465.jpeg)
![]()
如果元素处于关卡地图之外且触碰到左右边界或上边界我们返回“wall”如果触碰到下底则返回“lava”。这可以确保玩家掉出世界时死亡。当元素完全在网格中时我们循环扫描其覆盖的网格并返回第一个找到的非空白方格
我们通过查找坐标遍历网格方块,并在找到匹配的方块时返回`true`。关卡之外的方块总是被当作`"wall"`,来确保玩家不能离开这个世界,并且我们不会意外地尝试,在我们的“`rows`数组的边界之外读取
若玩家和其他动态元素(硬币或移动的熔岩块)产生碰撞,需要在玩家移动后进行处理。当玩家的动作导致玩家与其他角色相撞,游戏会触发相应的动作,即收集硬币或死亡。
该方法的参数是一个角色,负责扫描角色数组并找出与参数中的角色重叠的角色。
状态的`update`方法使用`touches`来判断玩家是否接触熔岩。
```js
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;
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");
}
};
```
### 15.9 角色与动作
Level方法中的animate方法让关卡中的每个角色都有一次移动的机会。其step参数为时间间隔以秒为单位。keys对象包含了玩家按下的所有方向键的信息。
```js
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;
}
};
```
如果关卡的status属性非空表示玩家胜利或失败我们必须在finishDelay属性上开始倒计时该属性保存了玩家取胜或失败之后游戏继续展示关卡的时间。
while循环将动画划分为合理的一段段动画。其确保每一步的时间间隔不大于maxStep。比如如果step为0.12秒我们会将其划分为3段动画前两段都是0.05秒第三段为0.02秒。
活动对象有一个act方法该方法有三个参数分别为时间间隔、level对象和keys对象。这里给出Lava角色类型的act方法该方法忽略了keys对象。
```js
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确保熔岩块开始向另一个方向移动。
硬币则使用其act方法来实现摇晃。该方法可以忽略碰撞因为硬币只会在自己的方格中晃动而与玩家的冲突则由玩家的act方法处理。
```js
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));
};
```
我们更新wobble属性来跟踪时间并将其作为Math.sin函数的参数来创建波形然后使用波形来计算出硬币的新位置。
现在只剩下玩家自己了。我们需要分别根据X和Y轴来处理玩家的动作因为如果触碰到了地面玩家会停止水平动作而如果触碰到墙壁玩家会停止下落或跳跃动作。下面代码实现了对水平部分的处理。
```js
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;
};
```
我们根据左右方向键的状态来处理水平动作。当动作导致玩家会触碰到某些元素时调用关卡的playerTouched方法处理玩家被熔岩烧死或收集硬币等事件。否则直接更新玩家对象的位置。
垂直动作部分实现原理类似,但需要处理跳跃和重力。
```js
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;
}
};
```
在方法开始处,我们根据重力加快玩家在垂直方向的速度。本游戏中的重力、跳跃速度和其他常数都是根据反复试验的结果设定的。我不断测试不同的值,直到找到让我满意的参数组合为止。
接下来我们再次检查障碍物。如果我们触碰到障碍物则会得到两种可能结果。当玩家按下上方向键且我们在向下移动意味着障碍物在下方我们将玩家速度设置为比较大的负数。这会使得玩家开始跳跃。如果不是这种情况我们只要停在某个元素上并将速度重置为0即可。
实际的act方法如下所示。
```js
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;
}
};
```
在移动之后该方法检查玩家是否与其他角色碰撞当找到产生碰撞的元素时再次调用playerTouched。此时我们将活动对象作为第二个参数因为如果另一个角色是硬币playerTouched需要知道玩家应该收集那枚硬币。
最后,当玩家死亡(碰到熔岩),我们开始一段简短的动画,通过减少玩家对象的高度来实现玩家缩小下沉的动画效果。
```js
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;
for (let actor of actors) {
if (actor != player && overlap(actor, player)) {
newState = actor.collide(newState);
}
}
return newState;
};
```
当玩家碰到熔岩我们将游戏状态设置为“lost”。当遇到硬币时我们将该硬币从角色数组中移除如果移除的是最后一枚硬币则将游戏状态设置为“won”
它接受时间步长和一个数据结构,告诉它按下了哪些键。它所做的第一件事是调用所有角色的`update`方法,生成一组更新后的角色。角色也得到时间步长,按键,和状态,以便他们可以根据这些来更新。只有玩家才会读取按键,因为这是唯一由键盘控制的角色。
这些代码实现了一个确实可以产生动画的关卡。现在唯一缺少的就是驱动这些动画的代码
如果游戏已经结束,就不需要再做任何处理(游戏不能在输之后赢,反之亦然)。否则,该方法测试玩家是否接触背景熔岩。如果是这样的话,游戏就输了,我们就完了。最后,如果游戏实际上还在继续,它会查看其他玩家是否与玩家重叠。
### 15.10 跟踪按键
对于像这样的游戏,我们不希望玩家每次按下按键时都产生效果。而是根据其按键时间长度来判断是否继续产生效果(移动玩家角色)。
我们需要设置一个键盘处理器来存储左、右、上键的当前状态。我们调用preventDefault防止按键产生页面滚动。
下面的函数需要一个对象其属性名是按键代码属性值为按键名。并注册“keydown”和“keyup”事件当事件对应的按键代码存在于其存储的按键代码集合中时就更新对象。
`overlap`函数检测角色之间的重叠。它需要两个角色对象,当它们触碰时返回`true`,当它们沿`X`轴和`Y`轴重叠时,就是这种情况。
```js
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;
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;
}
```
需要注意的是两种事件类型都使用了相同的处理函数。该处理函数根据事件对象的type属性来确定是将按键状态修改为true“keydown”还是false“keyup”
如果任何角色重叠了,它的`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`)首先考虑重力而加速。
我们再次检查墙壁。如果我们不碰到任何一个,使用新的位置。如果存在一面墙,就有两种可能的结果。当按下向上的箭头,并且我们向下移动时(意味着我们碰到的东西在我们下面),将速度设置成一个相对大的负值。这导致玩家跳跃。否则,玩家只是撞到某物上,速度就被设定为零。
重力、跳跃速度和几乎所有其他常数,在游戏中都是通过反复试验来设定的。我测试了值,直到我找到了我喜欢的组合。
### 15.10 跟踪按键
对于这样的游戏,我们不希望按键在每次按下时生效。相反,我们希望只要按下了它们,他们的效果(移动球员的数字)就一直有效。
我们需要设置一个键盘处理器来存储左、右、上键的当前状态。我们调用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”
### 15.11 运行游戏
我们在第13章中看到的requestAnimationFrames函数是一种产生游戏动画的好方法。但该函数的接口有点过于原始。该函数要求我们跟踪上次调用函数的时间并在每一帧后再次调用requestAnimationFrame方法。
我们在第十四章中看到的requestAnimationFrames函数是一种产生游戏动画的好方法。但该函数的接口有点过于原始。该函数要求我们跟踪上次调用函数的时间并在每一帧后再次调用requestAnimationFrame方法。
我们这里定义一个辅助函数来将这部分烦人的代码包装到一个名为runAnimation的简单接口中我们只需向其传递一个函数即可该函数的参数是一个时间间隔并用于绘制一帧图像。当帧函数返回false时整个动画停止。
```js
function runAnimation(frameFunc) {
var lastTime = null;
let lastTime = null;
function frame(time) {
var stop = false;
let stop = false;
if (lastTime != null) {
var timeStep = Math.min(time - lastTime, 100) / 1000;
stop = frameFunc(timeStep) === false;
let timeStep = Math.min(time - lastTime, 100) / 1000;
if (frameFunc(timeStep) === false) return;
}
lastTime = time;
if (!stop)
requestAnimationFrame(frame);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
```
我们将每帧之间的最大时间间隔设置为100毫秒十分之一秒。当浏览器标签页或窗口隐藏时requestAnimationFrame调用会自动暂停并在标签页或窗口再次显示时重新开始绘制动画。在本例中lastTime和time之差是隐藏页面的整个时间。如果以页面隐藏时间为一步的时间间隔那么据此产生的动画会非常突兀而且需要进行进一步处理记得我们在animate方法中会划分时间段
我们将每帧之间的最大时间间隔设置为100毫秒十分之一秒。当浏览器标签页或窗口隐藏时requestAnimationFrame调用会自动暂停并在标签页或窗口再次显示时重新开始绘制动画。在本例中lastTime和time之差是隐藏页面的整个时间。一步一步地推进游戏看起来很傻,可能会造成奇怪的副作用,比如玩家从地板上掉下去
该函数也会将时间单位转换成秒,相比于毫秒大家会更熟悉秒。
runLevel函数的参数是一个Level对象显示对象的构造函数和一个可选的函数参数。runLevel函数显示关卡在document.body中并使得用户通过该节点操作游戏。当关卡结束时或胜或负runLevel会清除关卡并停止动画如果我们指定了andThen函数则runLevel会以关卡状态为参数调用该函数。
runLevel函数的接受Level对象和显示对象的构造函数并返回一个`Promise`。runLevel函数显示关卡在document.body中并使得用户通过该节点操作游戏。当关卡结束时或胜或负runLevel会多等一秒(让用户看看发生了什么),清除关卡并停止动画如果我们指定了andThen函数则runLevel会以关卡状态为参数调用该函数。
```js
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;
}
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
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;
}
});
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!");
}
```
这些函数展示了一种特殊的程序设计风格。runAnimation函数和runLevel函数都是高阶函数但其代码风格却不是第5章中介绍的那样。这些函数中有些参数类型为函数这些参数用于在未来的某个时间处理一些事件而且这些函数并不会返回任何有用的信息。这些函数的任务在某种程度上类似于调度动作。我们可以将这些动作包装在函数中并在正确的时刻调用这些动作。
因为我们使`runLevel`返回`Promise``runGame`可以使用`async`函数编写,如第十一章中所见。它返回另一个`Promise`,当玩家完成游戏时得到解析。
我们常常将这种程序设计风格称为异步编程。事件处理也是这种编程风格的一个实例而且我们在处理一些耗时较长的任务时常常会见到这种编程风格比如第17章中的网络请求还有第20章中的通用输入输出。
GAME_LEVELS变量中存储了一些可用的关卡平面图可以从[http://eloquentjavascript.net/code#15](http://eloquentjavascript.net/code#15)下载。下面这个网页用于调用runGame并启动实际游戏。
在[本章的沙盒](https://eloquentjavascript.net/code#16)的`GAME_LEVELS`绑定中,有一组可用的关卡平面图。这个页面将它们提供给`runGame`,启动实际的游戏:
```html
<link rel="stylesheet" href="css/game.css">
@ -763,7 +709,7 @@ GAME_LEVELS变量中存储了一些可用的关卡平面图可以从[http://e
按照惯例,平台游戏中玩家一开始会有有限数量的生命,每死亡一次就扣去一条生命。当玩家生命耗尽时,游戏就从头开始了。
调整runGame来实现生命机制。玩家一开始会有3条生命。
调整runGame来实现生命机制。玩家一开始会有3条生命。每次启动时输出当前生命数量(使用`console.log`)。
```html
<link rel="stylesheet" href="css/game.css">
@ -771,18 +717,13 @@ GAME_LEVELS变量中存储了一些可用的关卡平面图可以从[http://e
<body>
<script>
// The old runGame function. Modify it...
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!");
});
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++;
}
startLevel(0);
console.log("You've won!");
}
runGame(GAME_LEVELS, DOMDisplay);
</script>
@ -797,7 +738,7 @@ GAME_LEVELS变量中存储了一些可用的关卡平面图可以从[http://e
乍看起来runAnimation无法完成该任务但如果我们使用runLevel来重新安排调度策略也是可以实现的。
当你完成该功能后可以尝试加入另一个功能。我们现在注册键盘事件处理器的方法多少有点问题。现在arrows对象是一个全局变量即使游戏没有运行时事件处理器也是有效的。我们称之为系统泄露。请扩展tracKeys提供一种方法来注销事件处理器接着修改runLevel在启动游戏时注册事件处理器并在游戏结束后注销事件处理器。
当你完成该功能后可以尝试加入另一个功能。我们现在注册键盘事件处理器的方法多少有点问题。现在arrows对象是一个全局绑定即使游戏没有运行时事件处理器也是有效的。我们称之为系统泄露。请扩展tracKeys提供一种方法来注销事件处理器接着修改runLevel在启动游戏时注册事件处理器并在游戏结束后注销事件处理器。
```html
<link rel="stylesheet" href="css/game.css">
@ -805,20 +746,31 @@ GAME_LEVELS变量中存储了一些可用的关卡平面图可以从[http://e
<body>
<script>
// The old runLevel function. Modify this...
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;
}
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;
}
});
});
}
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
```
### 怪物