1
0
mirror of https://github.com/apachecn/eloquent-js-3e-zh.git synced 2025-05-28 07:02:20 +00:00
wizardforcel e0ce9aa55b 16.
2018-05-31 20:53:08 +08:00

40 KiB
Raw Blame History

十五、项目实战:平台游戏

所有现实都是游戏。

Iain Banks《The Player of Games》

我最初对电脑的痴迷,就像许多小孩一样,与电脑游戏有关。我沉迷在那个计算机所模拟出的小小世界中,我可以操纵这个世界,我同时也沉迷在那些尚未展开的故事之中。但我沉迷其中并不是因为游戏实际描述的故事,而是因为我可以充分发挥我的想象力,去构思故事的发展。

我并不希望任何人把编写游戏作为自己的事业。就像音乐产业中,那些希望加入这个行业的热忱年轻人与实际的人才需求之间存在巨大的鸿沟,也因此产生了一个极不健康的就业环境。不过,把编写游戏作为乐趣还是相当不错的。

本章将会介绍如何实现一个小型平台游戏。平台游戏(或者叫作“跳爬”游戏)要求玩家操纵一个角色在世界中移动,这种游戏往往是二维的,而且采用单一侧面作为观察视角,玩家可以来回跳跃。

15.1 游戏

我们游戏大致基于由Thomas Palef开发的Dark Bluewww.lessmilk.com/games/10)。我之所以选择了这个游戏,是因为这个游戏既有趣又简单,而且不需要编写大量代码。该游戏看起来如下页图所示。

The game Dark Blue

黑色的方块表示玩家,玩家任务是收集黄色的方块(硬币),同时避免碰到红色素材(“岩浆”)。当玩家收集完所有硬币后就可以过关。

玩家可以使用左右方向键移动,并使用上方向键跳跃。跳跃正是这个游戏角色的特长。玩家可以跳跃到数倍于自己身高的地方,也可以在半空中改变方向。虽然这样不切实际,但这有助于玩家感觉自己在直接控制屏幕上那个自己的化身。

该游戏包含一个固定的背景,使用网格方式进行布局,可可移动元素则覆盖在背景之上。网格中的元素可能是空气、固体或岩浆。可可移动元素是玩家、硬币或者某一块岩浆。这些元素的位置不限于网格,它们的坐标可以是分数,允许平滑运动。

15.2 实现技术

我们会使用浏览器的DOM来展示游戏界面我们会通过处理按键事件来读取用户输入。

与屏幕和键盘相关的代码只是实现游戏代码中的很小一部分。由于所有元素都只是彩色方块因此绘制方法并不复杂。我们为每个元素创建对应的DOM元素并使用样式来为其指定背景颜色、尺寸和位置。

由于背景是由不会改变的方块组成的网格,因此我们可以使用表格来展示背景。自由可移动元素可以使用绝对定位元素来覆盖。

游戏和某些程序应该在不产生明显延迟的情况下绘制动画并响应用户输入性能是非常重要的。尽管DOM最初并非为高性能绘图而设计但实际上DOM的性能表现得比我们想象中要好得多。读者已经在第13章中看过一些动画在现代机器中即使我们不怎么考虑性能优化像这种简单的游戏也可以流畅运行。

在下一章中我们会研究另一种浏览器技术——标签。该标签提供了一种更为传统的图像绘制方式直接处理形状和像素而非DOM元素。

15.3 关卡

我们需要一种人类可读的、可编辑的方法来指定关卡。因为一切最开始都可以在网格,所以我们可以使用大型字符串,其中每个字符代表一个元素,要么是背景网格的一部分,要么是可移动元素。

小型关卡的平面图可能是这样的:

var simpleLevelPlan = `
......................
..#................#..
..#..............=.#..
..#.........o.o....#..
..#.@......#####...#..
..#####............#..
......#++++++++++++#..
......##############..
......................`;

句号是空的位置,井号(#)字符是墙,加号是岩浆。玩家的起始位置是 AT 符号(@)。每个O字符都是一枚硬币,等号(=)是一块来回水平移动的熔岩块。

我们支持两种额外的可移动熔岩:管道符号(|)表示垂直移动的熔岩块,而v表示下落的熔岩块——这种熔岩块也是垂直移动,但不会来回弹跳,只会向下移动,直到遇到地面才会直接回到其起始位置。

整个游戏包含了许多关卡,玩家必须完成所有关卡。每关的过关条件是玩家需要收集所有硬币。如果玩家碰到熔岩,当前关卡会恢复初始状态,而玩家可以再次尝试过关。

15.4 读取关卡

下面的类存储了关卡对象。它的参数应该是定义关卡的字符串。

class Level {
  constructor(plan) {
    let rows = plan.trim().split("\n").map(l => [...l]);
    this.height = rows.length;
    this.width = rows[0].length;
    this.startActors = [];
    this.rows = rows.map((row, y) => {
      return row.map((ch, x) => {
        let type = levelChars[ch];
        if (typeof type == "string") return type;
        this.startActors.push(
          type.create(new Vec(x, y), ch));
        return "empty";
      });
    });
  }
}

trim方法用于移除平面图字符串起始和终止处的空白。这允许我们的示例平面图以换行开始,以便所有行都在彼此的正下方。其余的字符串由换行符拆分,每一行扩展到一个数组中,生成了字符数组。

因此,rows包含字符数组、平面图的行。我们可以从中得出水平宽度和高度。但是我们仍然必须将可移动元素与背景网格分开。我们将其称为角色Actor。它们将存储在一个对象数组中。背景将是字符串的数组的数组持有字段类型"empty""wall",或"lava"

为了创建这些数组,我们在行上映射,然后在它们的内容上进行映射。请记住,map将数组索引作为第二个参数传递给映射函数,它告诉我们给定字符的xy坐标。游戏中的位置将存储为一对坐标,左上角为0, 0,并且每个背景正方形为 1 单位高和宽。

为了解释平面图中的字符,Level构造函数使用levelChars对象,它将背景元素映射为字符串,角色字符映射为类。当type是一个角色类时,它的create静态方法用于创建一个对象,该对象被添加到startActors,映射函数为这个背景正方形返回"empty"

角色的位置存储为一个Vec对象,它是二维向量,一个具有xy属性的对象,像第六章一样。

当游戏运行时,角色将停在不同的地方,甚至完全消失(就像硬币被收集时)。我们将使用一个State类来跟踪正在运行的游戏的状态。

class State {
  constructor(level, actors, status) {
    this.level = level;
    this.actors = actors;
    this.status = status;
  }

  static start(level) {
    return new State(level, level.startActors, "playing");
  }

  get player() {
    return this.actors.find(a => a.type == "player");
  }
}

当游戏结束时,status属性将切换为"lost""won"

这又是一个持久性数据结构,更新游戏状态会创建新状态,并使旧状态保持完整。

15.5 角色

角色对象表示,游戏中给定可移动元素的当前位置和状态。所有的角色对象都遵循相同的接口。它们的pos属性保存元素的左上角坐标,它们的size属性保存其大小。

然后,他们有update方法,用于计算给定时间步长之后,他们的新状态和位置。它模拟了角色所做的事情:响应箭头键并且移动,因岩浆而来回弹跳,并返回新的更新后的角色对象。

type属性包含一个字符串,该字符串指定了角色类型:"player""coin"或者"lava"。这在绘制游戏时是有用的,为角色绘制的矩形的外观基于其类型。

角色类有一个静态的create方法,它由Level构造器使用,用于从关卡平面图中的字符中,创建一个角色。它接受字符本身及其坐标,这是必需的,因为Lava类处理几个不同的字符。

这是我们将用于二维值的Vec类,例如角色的位置和大小。

class Vec {
  constructor(x, y) {
    this.x = x; this.y = y;
  }
  plus(other) {
    return new Vec(this.x + other.x, this.y + other.y);
  }
  times(factor) {
    return new Vec(this.x * factor, this.y * factor);
  }
}

times方法用给定的数字来缩放向量。当我们需要将速度向量乘时间间隔,来获得那个时间的行走距离时,这就有用了。

不同类型的角色拥有他们自己的类,因为他们的行为非常不同。让我们定义这些类。稍后我们将看看他们的update方法。

玩家类拥有speed属性,存储了当前速度,来模拟动量和重力。

class Player {
  constructor(pos, speed) {
    this.pos = pos;
    this.speed = speed;
  }

  get type() { return "player"; }

  static create(pos) {
    return new Player(pos.plus(new Vec(0, -0.5)),
                      new Vec(0, 0));
  }
}

Player.prototype.size = new Vec(0.8, 1.5);

因为玩家高度是一个半格子,因此其初始位置相比于@字符出现的位置要高出半个格子。这样一来,玩家角色的底部就可以和其出现的方格底部对齐。

size属性对于Player的所有实例都是相同的,因此我们将其存储在原型上,而不是实例本身。我们可以使用一个类似type的读取器,但是每次读取属性时,都会创建并返回一个新的Vec对象,这将是浪费的。(字符串是不可变的,不必在每次求值时重新创建。)

构造Lava角色时,我们需要根据它所基于的字符来初始化对象。动态熔岩以其当前速度移动,直到它碰到障碍物。这个时候,如果它拥有reset属性,它会跳回到它的起始位置(滴落)。如果没有,它会反转它的速度并以另一个方向继续(弹跳)。

create方法查看Level构造函数传递的字符,并创建适当的熔岩角色。

class Lava {
  constructor(pos, speed, reset) {
    this.pos = pos;
    this.speed = speed;
    this.reset = reset;
  }

  get type() { return "lava"; }

  static create(pos, ch) {
    if (ch == "=") {
      return new Lava(pos, new Vec(2, 0));
    } else if (ch == "|") {
      return new Lava(pos, new Vec(0, 2));
    } else if (ch == "v") {
      return new Lava(pos, new Vec(0, 3), pos);
    }
  }
}

Lava.prototype.size = new Vec(1, 1);

Coin对象相对简单大多时候只需要待在原地即可。但为了使游戏更加有趣我们让硬币轻微摇晃也就是会在垂直方向上小幅度来回移动。每个硬币对象都存储了其基本位置同时使用wobble属性跟踪图像跳动幅度。这两个属性同时决定了硬币的实际位置存储在pos属性中

class Coin {
  constructor(pos, basePos, wobble) {
    this.pos = pos;
    this.basePos = basePos;
    this.wobble = wobble;
  }

  get type() { return "coin"; }

  static create(pos) {
    let basePos = pos.plus(new Vec(0.2, 0.1));
    return new Coin(basePos, basePos,
                    Math.random() * Math.PI * 2);
  }
}

Coin.prototype.size = new Vec(0.6, 0.6);

第十四章中我们知道了Math.sin可以计算出圆形的y坐标。因为我们沿着圆移动因此y坐标会以平滑的波浪形式来回移动正弦函数在实现波浪形移动中非常实用。

为了避免出现所有硬币同时上下移动每个硬币的初始阶段都是随机的。由Math.sin产生的波长是2π。我们可以将Math.random的返回值乘以2π计算出硬币波形轨迹的初始位置。

现在我们可以定义levelChars对象,它将平面图字符映射为背景网格类型,或角色类。

const levelChars = {
  ".": "empty", "#": "wall", "+": "lava",
  "@": Player, "o": Coin,
  "=": Lava, "|": Lava, "v": Lava
};

这给了我们创建Level实例所需的所有部件。

let simpleLevel = new Level(simpleLevelPlan);
console.log(`${simpleLevel.width} by ${simpleLevel.height}`);
// → 22 by 9

上面一段代码的任务是将特定关卡显示在屏幕上,并构建关卡中的时间与动作。

15.6 成为负担的封装

本章中大多数代码并没有过多考虑封装。首先,封装需要耗费额外精力。封装使得程序变得更加庞大,而且会引入额外的概念和接口。我尽量将程序的体积控制在较小的范围之内,避免读者因为代码过于庞大而走神。

其次,游戏中的大量元素是紧密耦合在一起的,如果其中一个元素行为改变,其他的元素很有可能也会发生变化。我们需要根据游戏的工作细节来为元素之间设计大量接口。这使得接口的效果不是很好。每当你改变系统中的某一部分时,由于其他部分的接口可能没有考虑到新的情况,因此你需要关心这一修改是否会影响到其他部分的代码。

系统中的某些分割点可以通过严格的接口对系统进行合理的划分,但某些分割点则不是如此。尝试去封装某些本没有合理边界的代码必然会导致浪费大量精力。当你犯下这种大错之际,你就会注意到你的接口变得庞大臃肿,而且随着程序不断演化,你需要频繁修改这些接口。

我们会封装的一部分代码是绘图子系统。其原因是我们会在下一章中使用另一种方式来展示相同的游戏。通过将绘图代码隐藏在接口之后,我们可以在下一章中使用相同的游戏程序,只需要插入新的显示模块即可。

15.7 绘图

我们通过定义一个“显示器”对象来封装绘图代码该对象显示指定关卡以及状态。本章定义的显示器类型名为DOMDisplay因为该类型使用简单的DOM元素来显示关卡。

我们会使用样式表来设定实际的颜色以及其他构建游戏中所需的固定的属性。创建这些属性时我们可以直接对元素的style属性进行赋值但这会使得游戏代码变得冗长。

下面的帮助函数提供了一种简洁的方法,来创建元素并赋予它一些属性和子节点:

function elt(name, attrs, ...children) {
  let dom = document.createElement(name);
  for (let attr of Object.keys(attrs)) {
    dom.setAttribute(attr, attrs[attr]);
  }
  for (let child of children) {
    dom.appendChild(child);
  }
  return dom;
}

我们创建显示器对象时需要指定其父元素,显示器将会创建在该父元素上,同时还需指定一个关卡对象。

class DOMDisplay {
  constructor(parent, level) {
    this.dom = elt("div", {class: "game"}, drawGrid(level));
    this.actorLayer = null;
    parent.appendChild(this.dom);
  }

  clear() { this.dom.remove(); }
}

由于关卡的背景网格不会改变因此只需要绘制一次即可。角色则需要在每次刷新显示时进行重绘。drawFame需要使用actorLayer属性来跟踪保存角色的动作因此我们可以轻松移除或替换这些角色。

我们的坐标和尺寸以网格单元为单位跟踪也就是说尺寸或距离中的1单元表示一个单元格。在设置像素级尺寸时我们需要将坐标按比例放大如果游戏中的所有元素只占据一个方格中的一个像素那将是多么可笑。而scale变量会给出一个单元格在屏幕上实际占据的像素数目。

const scale = 20;

function drawGrid(level) {
  return elt("table", {
    class: "background",
    style: `width: ${level.width * scale}px`
  }, ...level.rows.map(row =>
    elt("tr", {style: `height: ${scale}px`},
        ...row.map(type => elt("td", {class: type})))
  ));
}

前文提及过我们使用元素来绘制背景。这非常符合关卡中grid属性的结构。网格中的每一行对应表格中的一行元素。网格中的每个字符串对应表格单元格

)元素的类型名。扩展(三点)运算符用于将子节点数组作为单独的参数传给elt

下面的 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另一种方法是使用形如rgbRGB的格式其中R表示颜色中的红色成分G表示绿色成分B表示蓝色成分每个数字范围均为0到255。因此在rgb52166251红色成分为52绿色为166而蓝色是251。由于蓝色成分数值最大因此最后的颜色会偏向蓝色。而你可以看到.lava规则中第一个数字红色是最大的。

我们绘制每个角色时需要创建其对应的DOM元素并根据角色属性来设置元素坐标与尺寸。这些值都需要与scale相乘以将游戏中的尺寸单位转换为像素。

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类因为我们可以直接复用前文为熔岩单元格定义的规则。

.actor  { position: absolute;            }
.coin   { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64);   }

setState方法用于使显示器显示给定的状态。它首先删除旧角色的图形,如果有的话,然后在他们的新位置上重新绘制角色。试图将 DOM 元素重用于角色,可能很吸引人,但是为了使它有效,我们需要大量的附加记录,来关联角色和 DOM 元素,并确保在角色消失时删除元素。因为游戏中通常只有少数角色,重新绘制它们开销并不大。

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元素的样式即可。

.lost .player {
  background: rgb(160, 64, 64);
}
.won .player {
  box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}

在遇到熔岩之后,玩家的颜色应该变成深红色,暗示着角色被烧焦了。当玩家收集完最后一枚硬币时,我们使用两个模糊的白色盒装阴影来创建白色的光环效果,其中一个在左上角,一个在右上角。

我们无法假定关卡总是符合视口尺寸。所以我们需要调用scrollPlayerIntoVirw来确保如果关卡在视口范围之外我们可以滚动视口确保玩家靠近视口的中央位置。下面的CSS样式为包装器的DOM元素设置了一个最大尺寸以确保任何超出视口的元素都是不可见的。我们可以将外部元素的position设置为relative因此该元素中的角色总是相对于关卡的左上角进行定位。

.game {
  overflow: hidden;
  max-width: 600px;
  max-height: 450px;
  position: relative;
}

在scrollPlayerIntoView方法中我们找出玩家的位置并更新其包装器元素的滚动坐标。我们可以通过操作元素的scrollLeft和scrollTop属性当玩家接近视口边界时修改滚动坐标。

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设置为10DOM会将其修改为0。

最简单的做法是每次重绘时都滚动视口,确保玩家总是在视口中央。但这种做法会导致画面剧烈晃动,当你跳跃时,视图会不断上下移动。比较合理的做法是在屏幕中央设置一个“中央区域”,玩家在这个区域内部移动时我们不会滚动视口。

最后我们需要一个方法来清除显示的关卡。在游戏进入下一关卡或当前关卡重新开始时需要使用该方法。

DOMDisplay.prototype.clear = function() {
  this.wrap.parentNode.removeChild(this.wrap);
};

我们现在能够显示小型关卡。

<link rel="stylesheet" href="css/game.css">

<script>
  var simpleLevel = new Level(simpleLevelPlan);
  var display = new DOMDisplay(document.body, simpleLevel);
</script>

我们可以在link标签中使用rel="stylesheet"将一个CSS文件加载到页面中。文件game.css包含了我们的游戏所需的样式。

15.8 动作与冲突

现在我们是时候来添加一些动作了。这是游戏中最令人着迷的一部分。实现动作的最基本的方案(也是大多数游戏采用的)是将时间划分为一个个时间段,根据角色的每一步速度(每秒的移动距离)和时间长度(以秒为单位)将元素移动一段距离。

这其实非常简单。比较困难的一部分是处理元素之间的相互作用。当玩家撞到墙壁或者地板时,不可能简单地直接穿越过去。游戏必须注意特定的动作会导致两个对象产生碰撞,并需要采取相应措施。如果玩家遇到墙壁,则必须停下来,如果遇到硬币则必须将其收集起来。

想要解决通常情况下的碰撞问题是件艰巨任务。你可以找到一些我们称之为物理引擎的库,这些库会在二维或三维空间中模拟物理对象的相互作用。我们在本章中采用更合适的方案:只处理矩形物体之间的碰撞,并采用最简单的方案进行处理。

在移动角色或熔岩块时,我们需要测试该动作是否会将元素移动到背景不为空的部分。如果背景确实非空,我们只要取消整个动作即可。而对动作的反应则取决于移动元素类型。如果是玩家则停下来,如果是熔岩块则反弹回去。

这种方法需要保证每一步之间的时间间隔足够短,确保能够在对象实际碰撞之前取消动作。如果时间间隔太大,玩家最后会悬浮在离地面很高的地方。另一种方法明显更好但更加复杂,即寻找到精确的碰撞点并将元素移动到那个位置。我们会采取最简单的方案,并确保减少动画之间的时间间隔,以掩盖其问题。

该方法用于判断某个矩形(通过位置与尺寸限定)是否会覆盖在背景网格的非空白区域中。

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”。这可以确保玩家掉出世界时死亡。当元素完全在网格中时我们循环扫描其覆盖的网格并返回第一个找到的非空白方格。

若玩家和其他动态元素(硬币或移动的熔岩块)产生碰撞,需要在玩家移动后进行处理。当玩家的动作导致玩家与其他角色相撞,游戏会触发相应的动作,即收集硬币或死亡。

该方法的参数是一个角色,负责扫描角色数组并找出与参数中的角色重叠的角色。

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对象包含了玩家按下的所有方向键的信息。

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对象。

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方法处理。

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轴来处理玩家的动作因为如果触碰到了地面玩家会停止水平动作而如果触碰到墙壁玩家会停止下落或跳跃动作。下面代码实现了对水平部分的处理。

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方法处理玩家被熔岩烧死或收集硬币等事件。否则直接更新玩家对象的位置。

垂直动作部分实现原理类似,但需要处理跳跃和重力。

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方法如下所示。

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需要知道玩家应该收集那枚硬币。

最后,当玩家死亡(碰到熔岩),我们开始一段简短的动画,通过减少玩家对象的高度来实现玩家缩小下沉的动画效果。

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”事件当事件对应的按键代码存在于其存储的按键代码集合中时就更新对象。

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时整个动画停止。

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会以关卡状态为参数调用该函数。

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;
    }
  });
}

一个游戏是一个关卡序列。每当玩家死亡时就重新开始当前关卡。当完成关卡后,我们切换到下一关。我们可以使用下面的函数来完成该任务,该函数的参数为一个关卡平面图(字符串数组)数组和显示对象的构造函数。

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下载。下面这个网页用于调用runGame并启动实际游戏。

<link rel="stylesheet" href="css/game.css">

<body>
  <script>
    runGame(GAME_LEVELS, DOMDisplay);
  </script>
</body>

15.12 习题

15.12.1 游戏结束

按照惯例,平台游戏中玩家一开始会有有限数量的生命,每死亡一次就扣去一条生命。当玩家生命耗尽时,游戏就从头开始了。

调整runGame来实现生命机制。玩家一开始会有3条生命。

<link rel="stylesheet" href="css/game.css">

<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!");
      });
    }
    startLevel(0);
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>

15.12.2 暂停游戏

现在实现一个功能——当用户按下ESC键时可以暂停或继续游戏。

我们可以修改runLevel函数使用另一个键盘事件处理器来实现在玩家按下ESC键的时候中断或恢复动画。

乍看起来runAnimation无法完成该任务但如果我们使用runLevel来重新安排调度策略也是可以实现的。

当你完成该功能后可以尝试加入另一个功能。我们现在注册键盘事件处理器的方法多少有点问题。现在arrows对象是一个全局变量即使游戏没有运行时事件处理器也是有效的。我们称之为系统泄露。请扩展tracKeys提供一种方法来注销事件处理器接着修改runLevel在启动游戏时注册事件处理器并在游戏结束后注销事件处理器。

<link rel="stylesheet" href="css/game.css">

<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;
      }
    });
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>