mirror of
https://github.com/apachecn/eloquent-js-3e-zh.git
synced 2025-05-28 07:02:20 +00:00
16.
This commit is contained in:
parent
e0ce9aa55b
commit
506b7d92eb
536
16.md
536
16.md
@ -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设置为–10,DOM会将其修改为0。
|
||||
接下来,我们对玩家的坐标进行一系列检测,确保其位置不会超出合法范围。这里需要注意的是这段代码有时候依然会设置无意义的滚动坐标,比如小于0的值或超出元素滚动区域的值。这是没问题的。DOM会将其修改为可接受的值。如果我们将scrollLeft设置为–10,DOM会将其修改为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`个单位。通过将盒子的边上下颠倒,我们得到盒子接触的背景方块的范围。
|
||||
|
||||

|
||||
![]()
|
||||
|
||||
如果元素处于关卡地图之外且触碰到左右边界或上边界我们返回“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>
|
||||
```
|
||||
|
||||
### 怪物
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user