diff --git a/16.md b/16.md index 78240a1..d73ca96 100644 --- a/16.md +++ b/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 ``` @@ -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 @@ -763,7 +709,7 @@ GAME_LEVELS变量中存储了一些可用的关卡平面图(可以从[http://e 按照惯例,平台游戏中玩家一开始会有有限数量的生命,每死亡一次就扣去一条生命。当玩家生命耗尽时,游戏就从头开始了。 -调整runGame来实现生命机制。玩家一开始会有3条生命。 +调整runGame来实现生命机制。玩家一开始会有3条生命。每次启动时输出当前生命数量(使用`console.log`)。 ```html @@ -771,18 +717,13 @@ GAME_LEVELS变量中存储了一些可用的关卡平面图(可以从[http://e @@ -797,7 +738,7 @@ GAME_LEVELS变量中存储了一些可用的关卡平面图(可以从[http://e 乍看起来,runAnimation无法完成该任务,但如果我们使用runLevel来重新安排调度策略,也是可以实现的。 -当你完成该功能后,可以尝试加入另一个功能。我们现在注册键盘事件处理器的方法多少有点问题。现在arrows对象是一个全局变量,即使游戏没有运行时,事件处理器也是有效的。我们称之为系统泄露。请扩展tracKeys,提供一种方法来注销事件处理器,接着修改runLevel在启动游戏时注册事件处理器,并在游戏结束后注销事件处理器。 +当你完成该功能后,可以尝试加入另一个功能。我们现在注册键盘事件处理器的方法多少有点问题。现在arrows对象是一个全局绑定,即使游戏没有运行时,事件处理器也是有效的。我们称之为系统泄露。请扩展tracKeys,提供一种方法来注销事件处理器,接着修改runLevel在启动游戏时注册事件处理器,并在游戏结束后注销事件处理器。 ```html @@ -805,20 +746,31 @@ GAME_LEVELS变量中存储了一些可用的关卡平面图(可以从[http://e ``` + +### 怪物 +