From fbd9b88183db60f71270ccb365738f3f94b3e702 Mon Sep 17 00:00:00 2001 From: wizardforcel <562826179@qq.com> Date: Thu, 31 May 2018 17:57:29 +0800 Subject: [PATCH] 16. --- 16.md | 775 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 775 insertions(+) create mode 100644 16.md diff --git a/16.md b/16.md new file mode 100644 index 0000000..1cc70f1 --- /dev/null +++ b/16.md @@ -0,0 +1,775 @@ +# 十五、项目实战:平台游戏 + +> 所有现实都是游戏。 +> +> Iain Banks,《The Player of Games》 + +就像许多小孩一样,我最初之所以对计算机感到着迷,都是始于计算机游戏。我沉迷在那个计算机所模拟出的小小世界中,我可以操纵这个世界,我同时也沉迷在那些尚未展开的故事之中。但我沉迷其中并不是因为游戏实际描述的故事,而是因为我可以充分发挥我的想象力,去构思故事的发展。 + +我并不希望任何人把编写游戏作为自己的事业。就像音乐产业中,那些希望加入这个行业的热忱年轻人与实际的人才需求之间存在巨大的鸿沟,也因此产生了一个极不健康的就业环境。不过,把编写游戏作为乐趣还是相当不错的。 + +本章将会介绍如何实现一个简单的平台游戏。平台游戏(或者叫作“跳爬”游戏)要求玩家操纵一个角色在世界中移动,这种游戏往往是二维的,而且采用单一侧面作为观察视角,玩家需要频繁在世界的物体中来回跳跃。 + +### 15.1 游戏 + +我们游戏大致基于由Thomas Palef开发的Dark Blue([www.lessmilk.com/games/10](http://www.lessmilk.com/games/10))。我之所以选择了这个游戏,是因为这个游戏既有趣又简单,而且不需要编写大量代码。该游戏看起来如下页图所示。 + +黑色的方块表示玩家,玩家任务是收集黄色的方块(硬币),同时避免碰到红色素材(我们取名为“熔浆”)。当玩家收集完所有硬币后就可以过关。 + +玩家可以使用左右方向键移动,并使用上方向键跳跃。跳跃正是这个游戏角色的特长。玩家可以跳跃到数倍于自己身高的地方,也可以在半空中改变方向。虽然这样不切实际,但这有助于玩家感觉自己在直接控制屏幕上那个自己的化身。 + + + +该游戏包含一个固定的背景,使用网格方式进行布局,可移动的元素则覆盖在背景之上。网格中的元素可能是空气、固体或岩浆。可移动的元素是玩家、硬币或者某一块岩浆。不同于第7章中模拟的人造生命,这些元素的位置不必局限在网格中。其坐标可以是小数,这样元素可以在场景中平滑移动。 + +### 15.2 实现技术 + +我们会使用浏览器的DOM来展示游戏界面,我们会通过处理按键事件来读取用户输入。 + +与屏幕和键盘相关的代码只是实现游戏代码中的很小一部分。由于所有元素都只是彩色方块,因此绘制方法并不复杂。我们为每个元素创建对应的DOM元素,并使用样式来为其指定背景颜色、尺寸和位置。 + +由于背景是由不会改变的方块组成的网格,因此我们可以使用表格来展示背景。自由移动的元素可以使用绝对定位元素来覆盖在背景之上。 + +游戏和某些程序需要在不产生明显延迟的情况下绘制动画并响应用户输入,性能是非常重要的。尽管DOM最初并非为高性能绘图而设计,但实际上DOM的性能表现得比我们想象中要好得多。读者已经在第13章中看过一些动画,在现代机器中,即使我们不怎么考虑性能优化,像这种简单的游戏也可以流畅运行。 + +在下一章中,我们会研究另一种浏览器技术——<canvas>标签。该标签提供了一种更为传统的图像绘制方式,直接处理形状和像素而非DOM元素。 + +### 15.3 关卡 + +在第7章中,我们使用数组和字符串来描述一张二维网格。我们这里可以采取同样的做法。这样我们不需要提前构建关卡编辑器就可以直接设计关卡。 + +一个简单的关卡如下所示。 + +```js +var simpleLevelPlan = [ + " ", + " ", + " x = x ", + " x o o x ", + " x @ xxxxx x ", + " xxxxx x ", + " x!!!!!!!!!!!!x ", + " xxxxxxxxxxxxxx ", + " " +]; +``` + +这张平面图中包含了所有的固定网格和可移动元素。字符x表示墙壁,空格表示空气,而感叹号表示固定不会移动的熔岩。 + +字符@定义了玩家的起始位置。每个字符o是一枚硬币,等号(=)代表会在水平方向来回移动的熔岩块。你需要注意,我们将这些位置对应的网格设置为空气,并使用另一个数据结构来跟踪可移动元素的位置。 + +我们支持两种其他类型的可移动熔岩:管道符号(|)表示在垂直方向移动的熔岩块,而v表示下落的熔岩块——这种熔岩块也是垂直移动,但不会来回弹跳,只会向下移动,直到遇到地面才会直接回到其起始位置。 + +整个游戏包含了许多关卡,玩家必须完成所有关卡。每关的过关条件是玩家需要收集所有硬币。如果玩家碰到熔岩,当前关卡会恢复初始状态,而玩家可以再次尝试过关。 + +### 15.4 读取关卡 + +下面的构造函数用于构造Level对象。这个构造函数的参数是一个定义关卡的数组。 + +```js +function Level(plan) { + this.width = plan[0].length; + this.height = plan.length; + this.grid = []; + 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.actors.push(new Actor(new Vector(x, y), ch)); + else if (ch == "x") + fieldType = "wall"; + else if (ch == "!") + fieldType = "lava"; + gridLine.push(fieldType); + } + this.grid.push(gridLine); + } + + this.player = this.actors.filter(function(actor) { + return actor.type == "player"; + })[0]; + this.status = this.finishDelay = null; +} +``` + + +为了简洁明了,代码中并未检查有问题的输入。代码中假定调用者给出的关卡平面图正确无误,且包含玩家的起始位置和其他的关键元素。 + +关卡需要存储的数据有:地图宽度、高度、两个数组,其中一个数组表示网格,另一个数组表示活动元素。我们使用一个数组的数组来表示网格,使用内部数组存储水平方向的元素,方块可以包含null、空方块或一个表示方块类型的字符串(比如“wave”或“lava”)。 + +活动元素数组负责保存对象,并跟踪关卡中动态元素的位置和状态。其中每个元素都包含以下属性:pos属性用于给定元素的当前位置(左上角坐标);size属性用于给定元素的尺寸;type属性用于保存表示元素类型的字符串(lava、coin或player)。 + +在构建完网格后,我们使用filter方法来查找类型为“player”的活动元素,我们将其保存为关卡的属性。status属性负责记录选手的胜负信息。当选手完成某个关卡时,我们调用finishDelay方法来确保关卡在一段时间内处于活动状态,因此我们可以显示简单的动画(如果直接重置或进入下一关会显得太简陋)。我们可以使用该方法判断某个关卡是否结束。 + +```js +Level.prototype.isFinished = function() { + return this.status != null && this.finishDelay < 0; +}; +``` + +### 15.5 活动元素 + +为了存储活动元素的位置和尺寸,我们回顾一下之前介绍的Vector类型,该类型将x坐标和y坐标组合保存在同一个对象中。 + +```js +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方法根据指定的数字来缩放向量。当我们需要计算特定一段时间内元素移动的距离时,可以使用该方法计算一个速度向量与时间间隔的乘积,此时该方法很实用。 + +在上一节中,Level构造函数使用actorChars对象来将字符与其构造函数关联起来。该对象如下所示。 + +```js +var actorChars = { + "@": Player, + "o": Coin, + "=": Lava, "|": Lava, "v": Lava +}; +``` + +我们将三个字符映射到Lava。Level构造函数将活动元素的原始字符作为第二个参数传递给构造函数,而且Lava构造函数使用该字符来调整其行为(水平移动、垂直移动或垂直下落)。 + +我们可以使用下面的构造函数来生成表示玩家的类型。该类型包含一个名为speed的属性,用于存储当前速度,这对模拟动量和重力都非常有用。 + +```js +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"; +``` + +因为玩家高度是一个半格子,因此其初始位置相比于@字符出现的位置要高出半个格子。这样一来,玩家角色的底部就可以和其出现的方格底部对齐。 + +当构建动态Lava对象时,我们需要根据不同字符来初始化对象。动态的熔岩块会根据给定的速度移动,直到遇到障碍物为止。此时,如果Lava对象包含repeatPos属性,该对象会直接跳回起始位置(垂直下落)。如果不包含repeatPos属性,对象会改变速度方向,沿着原来的反方向继续移动(弹回)。构造函数仅仅设置必要属性,随后我们会编写完成实际移动的方法。 + +```js +function Lava(pos, ch) { + this.pos = pos; + this.size = new Vector(1, 1); + if (ch == "=") { + 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对象很简单,大多时候只需要待在原地即可。但为了使游戏更加有趣,我们让硬币轻微摇晃,也就是会在垂直方向上小幅度来回移动。每个硬币对象都存储了其基本位置,同时使用wobble属性跟踪图像跳动幅度。这两个属性同时决定了硬币的实际位置(存储在pos属性中)。 + +```js +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"; +``` + +第13章中,我们知道了Math.sin可以计算出圆形的y坐标。因为我们沿着圆移动,因此y坐标会以平滑的波浪形式来回移动,正弦函数在实现波浪形移动中非常实用。 + +为了避免出现所有硬币同时上下移动,每个硬币的初始阶段都是随机的。由Math.sin产生的波长是2π。我们可以将Math.random的返回值乘以2π,计算出硬币波形轨迹的初始位置。 + +我们现在已经编写了所有表示关卡信息的所需代码。 + +```js +var simpleLevel = new Level(simpleLevelPlan); +console.log(simpleLevel.width, "by", simpleLevel.height); +// → 22 by 9 +``` + +上面一段代码的任务是将特定关卡显示在屏幕上,并构建关卡中的时间与动作。 + +### 15.6 当封装成为负担 + +本章中大多数代码并没有考虑进行封装。首先,封装需要耗费额外精力。封装使得程序变得更加庞大,而且会引入额外的概念和接口。我尽量将程序的体积控制在较小的范围之内,避免读者因为代码过于庞大而走神。 + +其次,游戏中的大量元素是紧密耦合在一起的,如果其中一个元素行为改变,其他的元素很有可能也会发生变化。我们需要根据游戏的工作细节来为元素之间设计大量接口。这使得接口的效果不是很好。每当你改变系统中的某一部分时,由于其他部分的接口可能没有考虑到新的情况,因此你需要关心这一修改是否会影响到其他部分的代码。 + +系统中的某些分割点可以通过严格的接口对系统进行合理的划分,但某些分割点则不是如此。尝试去封装某些本没有合理边界的代码必然会导致浪费大量精力。当你犯下这种大错之际,你就会注意到你的接口变得庞大臃肿,而且随着程序不断演化,你需要频繁修改这些接口。 + +本章我们会封装的一部分代码是绘图子系统。其原因是我们会在下一章中使用另一种方式来展示相同的游戏。通过将绘图代码隐藏在接口之后,我们可以在下一章中使用相同的游戏程序,只需要插入新的显示模块即可。 + +### 15.7 绘图 + +我们通过定义一个“显示器”对象来封装绘图代码,该对象负责显示指定的关卡。本章定义的显示器类型名为DOMDisplay,因为该类型使用简单的DOM元素来显示关卡。 + +我们会使用样式表来设定实际的颜色以及其他构建游戏中所需的固定的属性。创建这些属性时,我们可以直接对元素的style属性进行赋值,但这会使得游戏代码变得冗长。 + +下面的工具函数提供了一种创建元素并赋予class属性的简单方式。 + +```js +function elt(name, className) { + var elt = document.createElement(name); + if (className) elt.className = className; + return elt; +} +``` + +我们创建显示器对象时需要指定其父元素,显示器将会创建在该父元素上,同时还需指定一个关卡对象。 + +```js +function DOMDisplay(parent, level) { + this.wrap = parent.appendChild(elt("div", "game")); + this.level = level; + + this.wrap.appendChild(this.drawBackground()); + this.actorLayer = null; + this.drawFrame(); +} +``` + +由于appendChild会返回新添加的元素,因此我们使用其返回值创建整个游戏的包装器(wrapper)元素,并将其存储在wrap属性中,整个过程只需要一条语句。 + +由于关卡背景不会改变,因此只需要绘制一次即可。活动元素则需要在每次刷新显示时进行重绘。drawFame需要使用actorLayer属性来跟踪保存活动元素的动作,因此我们可以轻松移除或替换这些活动元素。 + +地图坐标和尺寸以一个单元格尺寸为单位,也就是说尺寸或距离中的1单元表示一个单元格。在设置像素级尺寸时,我们需要将坐标按比例放大,如果游戏中的所有元素只占据一个方格中的一个像素,那将是多么可笑。而scale变量会给出一个单元格在屏幕上实际占据的像素数目。 + +```js +var scale = 20; + +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; +}; +``` + +前文提及过,我们使用<table>元素来绘制背景。这非常符合关卡中grid属性的结构。网格中的每一行对应表格中的一行(<tr>元素)。网格中的每个字符串对应表格单元格(<td>)元素的类型名。下面的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 +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; +}; +``` + +为了赋予一个元素多个类别,我们使用空格来分隔类名。在下面展示的CSS代码中,actor类型会赋予活动元素一个绝对坐标。我们将活动元素的类型名称作为额外的CSS类来设置这些元素的颜色。我们并没有再次定义lava类,因为我们可以直接复用前文为熔岩单元格定义的规则。 + +```css +.actor { position: absolute; } +.coin { background: rgb(241, 229, 89); } +.player { background: rgb(64, 64, 64); } +``` + +每次刷新显示时,drawFrame方法首先移除旧有的活动元素,如果需要则将其重绘在新位置。你可能想直接复用活动元素的DOM元素,但我们如果要这么做,需要在显示代码和模拟代码中加入大量的额外信息流。我们需要将活动元素与DOM元素关联起来,而绘图代码需要在活动元素消失时移除对应的DOM元素。由于游戏中活动元素数量不多,因此直接重绘所有元素的代码并不高昂。 + +```js +DOMDisplay.prototype.drawFrame = function() { + if (this.actorLayer) + this.wrap.removeChild(this.actorLayer); + this.actorLayer = this.wrap.appendChild(this.drawActors()); + this.wrap.className = "game " + (this.level.status || ""); + this.scrollPlayerIntoView(); +}; +``` + +我们可以将关卡的当前状态作为类名添加到包装器中,这样可以根据游戏胜负与否来改变玩家角色的样式。我们只需要添加CSS规则,指定祖先节点包含特定类的player元素的样式即可。 + +```css +.lost .player { + background: rgb(160, 64, 64); +} +.won .player { + box-shadow: -4px -7px 8px white, 4px -7px 8px white; +} +``` + +在遇到熔岩之后,玩家的颜色应该变成深红色,暗示着角色被烧焦了。当玩家收集完最后一枚硬币时,我们使用两个模糊的白色盒装阴影来创建白色的光环效果,其中一个在左上角,一个在右上角。 + +我们无法假定关卡总是符合视口尺寸。所以我们需要调用scrollPlayerIntoVirw来确保如果关卡在视口范围之外,我们可以滚动视口,确保玩家靠近视口的中央位置。下面的CSS样式为包装器的DOM元素设置了一个最大尺寸,以确保任何超出视口的元素都是不可见的。我们可以将外部元素的position设置为relative,因此该元素中的活动元素总是相对于关卡的左上角进行定位。 + +```css +.game { + overflow: hidden; + max-width: 600px; + max-height: 450px; + position: relative; +} +``` + +在scrollPlayerIntoView方法中,我们找出玩家的位置并更新其包装器元素的滚动坐标。我们可以通过操作元素的scrollLeft和scrollTop属性,当玩家接近视口边界时修改滚动坐标。 + +```js +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; + + 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; +}; +``` + +找出玩家中心位置的代码展示了我们如何使用Vector类型来写出可读性较好的计算代码。为了找出玩家的中心位置,我们需要将左上角位置坐标加上其尺寸的一半。计算结果就是关卡坐标的中心位置。但是我们需要将结果向量乘以显示比例,以将坐标转换成像素级坐标。 + +接下来,我们对玩家的坐标进行一系列检测,确保其位置不会超出合法范围。这里需要注意的是这段代码有时候依然会设置无意义的滚动坐标,比如小于0的值或超出元素滚动区域的值。这是没问题的。DOM会将其修改为合理的值。如果我们将scrollLeft设置为–10,DOM会将其修改为0。 + +最简单的做法是每次重绘时都滚动视口,确保玩家总是在视口中央。但这种做法会导致画面剧烈晃动,当你跳跃时,视图会不断上下移动。比较合理的做法是在屏幕中央设置一个“中央区域”,玩家在这个区域内部移动时我们不会滚动视口。 + +最后我们需要一个方法来清除显示的关卡。在游戏进入下一关卡或当前关卡重新开始时需要使用该方法。 + +```js +DOMDisplay.prototype.clear = function() { + this.wrap.parentNode.removeChild(this.wrap); +}; +``` + +我们现在能够显示小型关卡。 + +```html + + + +``` + +我们可以在link标签中使用rel="stylesheet",将一个CSS文件加载到页面中。文件game.css包含了我们的游戏所需的样式。 + +### 15.8 动作与冲突 + +现在我们是时候来添加一些动作了。这是游戏中最令人着迷的一部分。实现动作的最基本的方案(也是大多数游戏采用的)是将时间划分为一个个时间段,根据活动元素的每一步速度(每秒的移动距离)和时间长度(以秒为单位)将元素移动一段距离。 + +这其实非常简单。比较困难的一部分是处理元素之间的相互作用。当玩家撞到墙壁或者地板时,不可能简单地直接穿越过去。游戏必须注意特定的动作会导致两个对象产生碰撞,并需要采取相应措施。如果玩家遇到墙壁,则必须停下来,如果遇到硬币则必须将其收集起来。 + +想要解决通常情况下的碰撞问题是件艰巨任务。你可以找到一些我们称之为物理引擎的库,这些库会在二维或三维空间中模拟物理对象的相互作用。我们在本章中采用更合适的方案:只处理矩形物体之间的碰撞,并采用最简单的方案进行处理。 + +在移动角色或熔岩块时,我们需要测试该动作是否会将元素移动到背景不为空的部分。如果背景确实非空,我们只要取消整个动作即可。而对动作的反应则取决于移动元素类型。如果是玩家则停下来,如果是熔岩块则反弹回去。 + +这种方法需要保证每一步之间的时间间隔足够短,确保能够在对象实际碰撞之前取消动作。如果时间间隔太大,玩家最后会悬浮在离地面很高的地方。另一种方法明显更好但更加复杂,即寻找到精确的碰撞点并将元素移动到那个位置。我们会采取最简单的方案,并确保减少动画之间的时间间隔,以掩盖其问题。 + +该方法用于判断某个矩形(通过位置与尺寸限定)是否会覆盖在背景网格的非空白区域中。 + +```js +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]; + if (fieldType) return fieldType; + } + } +}; +``` + +该方法首先使用Math.floor和Math.ceil处理元素的坐标,计算矩形会覆盖的网格。这里需要记住,网格尺寸是1x1单元。我们获得元素矩形的边长,就可以获得元素矩形占据的背景网格范围。 + + + +如果元素处于关卡地图之外且触碰到左右边界或上边界我们返回“wall”,如果触碰到下底则返回“lava”。这可以确保玩家掉出世界时死亡。当元素完全在网格中时,我们循环扫描其覆盖的网格,并返回第一个找到的非空白方格。 + +若玩家和其他动态元素(硬币或移动的熔岩块)产生碰撞,需要在玩家移动后进行处理。当玩家的动作导致玩家与其他活动元素相撞,游戏会触发相应的动作,即收集硬币或死亡。 + +该方法的参数是一个活动元素,负责扫描活动元素数组并找出与参数中的活动元素重叠的活动元素。 + +```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; + } +}; +``` + +### 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; + } + } +}; +``` + +当玩家碰到熔岩,我们将游戏状态设置为“lost”。当遇到硬币时,我们将该硬币从活动元素数组中移除,如果移除的是最后一枚硬币,则将游戏状态设置为“won”。 + +这些代码实现了一个确实可以产生动画的关卡。现在唯一缺少的就是驱动这些动画的代码。 + +### 15.10 跟踪按键 + +对于像这样的游戏,我们不希望玩家每次按下按键时都产生效果。而是根据其按键时间长度来判断是否继续产生效果(移动玩家角色)。 + +我们需要设置一个键盘处理器来存储左、右、上键的当前状态。我们调用preventDefault,防止按键产生页面滚动。 + +下面的函数需要一个对象,其属性名是按键代码,属性值为按键名。并注册“keydown”和“keyup”事件,当事件对应的按键代码存在于其存储的按键代码集合中时,就更新对象。 + +```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; +} +``` + +需要注意的是两种事件类型都使用了相同的处理函数。该处理函数根据事件对象的type属性来确定是将按键状态修改为true(“keydown”)还是false(“keyup”)。 + +### 15.11 运行游戏 + +我们在第13章中看到的requestAnimationFrames函数是一种产生游戏动画的好方法。但该函数的接口有点过于原始。该函数要求我们跟踪上次调用函数的时间,并在每一帧后再次调用requestAnimationFrame方法。 + +我们这里定义一个辅助函数来将这部分烦人的代码包装到一个名为runAnimation的简单接口中,我们只需向其传递一个函数即可,该函数的参数是一个时间间隔,并用于绘制一帧图像。当帧函数返回false时,整个动画停止。 + +```js +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); +} +``` + +我们将每帧之间的最大时间间隔设置为100毫秒(十分之一秒)。当浏览器标签页或窗口隐藏时,requestAnimationFrame调用会自动暂停,并在标签页或窗口再次显示时重新开始绘制动画。在本例中,lastTime和time之差是隐藏页面的整个时间。如果以页面隐藏时间为一步的时间间隔,那么据此产生的动画会非常突兀而且需要进行进一步处理(记得我们在animate方法中会划分时间段)。 + +该函数也会将时间单位转换成秒,相比于毫秒大家会更熟悉秒。 + +runLevel函数的参数是一个Level对象,显示对象的构造函数和一个可选的函数参数。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; + } + }); +} +``` + +一个游戏是一个关卡序列。每当玩家死亡时就重新开始当前关卡。当完成关卡后,我们切换到下一关。我们可以使用下面的函数来完成该任务,该函数的参数为一个关卡平面图(字符串数组)数组和显示对象的构造函数。 + +```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; + } + }); +} +``` + +这些函数展示了一种特殊的程序设计风格。runAnimation函数和runLevel函数都是高阶函数,但其代码风格却不是第5章中介绍的那样。这些函数中有些参数类型为函数,这些参数用于在未来的某个时间处理一些事件,而且这些函数并不会返回任何有用的信息。这些函数的任务在某种程度上类似于调度动作。我们可以将这些动作包装在函数中,并在正确的时刻调用这些动作。 + +我们常常将这种程序设计风格称为异步编程。事件处理也是这种编程风格的一个实例,而且我们在处理一些耗时较长的任务时常常会见到这种编程风格,比如第17章中的网络请求,还有第20章中的通用输入输出。 + +GAME_LEVELS变量中存储了一些可用的关卡平面图(可以从[http://eloquentjavascript.net/code#15](http://eloquentjavascript.net/code#15)下载)。下面这个网页用于调用runGame,并启动实际游戏。 + +```html + + +
+ + +``` + +### 15.12 习题 + +#### 15.12.1 游戏结束 + +按照惯例,平台游戏中玩家一开始会有有限数量的生命,每死亡一次就扣去一条生命。当玩家生命耗尽时,游戏就从头开始了。 + +调整runGame来实现生命机制。玩家一开始会有3条生命。 + +```html + + + + + +``` + +#### 15.12.2 暂停游戏 + +现在实现一个功能——当用户按下ESC键时可以暂停或继续游戏。 + +我们可以修改runLevel函数,使用另一个键盘事件处理器来实现在玩家按下ESC键的时候中断或恢复动画。 + +乍看起来,runAnimation无法完成该任务,但如果我们使用runLevel来重新安排调度策略,也是可以实现的。 + +当你完成该功能后,可以尝试加入另一个功能。我们现在注册键盘事件处理器的方法多少有点问题。现在arrows对象是一个全局变量,即使游戏没有运行时,事件处理器也是有效的。我们称之为系统泄露。请扩展tracKeys,提供一种方法来注销事件处理器,接着修改runLevel在启动游戏时注册事件处理器,并在游戏结束后注销事件处理器。 + +```html + + + + + +```