第 4 版现已推出。点击此处阅读

第 16 章项目:平台游戏

所有现实都是游戏。

Iain Banks,游戏玩家
Picture of a game character jumping over lava

像许多书呆子孩子一样,我对计算机的最初迷恋很大程度上与电脑游戏有关。我被吸引到那些我可以操纵的小型模拟世界中,在这些世界中,故事(有点)展开——我猜想,更多的是因为我把我的想象力投射到它们中,而不是因为它们真正提供的可能性。

我不希望任何人从事游戏编程的职业。就像音乐行业一样,想要从事游戏编程的年轻人数量与对这类人才的实际需求之间的差距,创造了一个相当不健康的环境。但为乐趣而编写游戏很有趣。

本章将逐步介绍一个小平台游戏的实现。平台游戏(或“跳跃跑动”游戏)是指玩家需要控制一个角色在世界中移动的游戏,该世界通常是二维的,从侧面观看,角色需要跳过和跳到物体上。

游戏

我们的游戏将大致基于 Thomas Palef 的 深蓝色。我选择这款游戏是因为它既有趣又极简,而且可以在不编写太多代码的情况下构建。它看起来像这样

The game Dark Blue

黑色方块代表玩家,玩家的任务是在避开红色物质(熔岩)的同时收集黄色方块(硬币)。当所有硬币都被收集起来时,一个关卡就完成了。

玩家可以使用左右箭头键移动,可以使用向上箭头键跳跃。跳跃是这个游戏角色的拿手好戏。它可以跳跃到自身高度的数倍,并且可以在空中改变方向。这可能并不完全现实,但它有助于让玩家感觉自己直接控制着屏幕上的化身。

游戏由一个静态背景组成,背景像网格一样排列,移动元素叠加在该背景上。网格上的每个区域要么是空的,要么是实心的,要么是熔岩。移动元素是玩家、硬币和某些熔岩块。这些元素的位置不受限于网格——它们的坐标可以是分数,允许平滑的运动。

技术

我们将使用浏览器 DOM 来显示游戏,并将通过处理键盘事件来读取用户输入。

与屏幕和键盘相关的代码只是构建此游戏所需工作的一小部分。由于所有内容看起来都像是彩色方块,因此绘制很简单:我们创建 DOM 元素并使用样式为它们提供背景颜色、大小和位置。

我们可以将背景表示为表格,因为它是一个不变的正方形网格。可以使用绝对定位的元素来叠加自由移动的元素。

在应该动画图形并对用户输入做出无明显延迟响应的游戏和其他程序中,效率非常重要。虽然 DOM 最初并不是为高性能图形而设计的,但它实际上比你想象的要好。你在第 14 章中看到了一些动画。在现代机器上,像这样的简单游戏执行良好,即使我们不太担心优化。

下一章中,我们将探讨另一种浏览器技术,<canvas> 标签,它提供了一种更传统的方式来绘制图形,以形状和像素而不是 DOM 元素的形式工作。

关卡

我们希望有一种人可以阅读和编辑的方式来指定关卡。由于所有内容都可以在网格上开始,因此我们可以使用大型字符串,其中每个字符代表一个元素——要么是背景网格的一部分,要么是移动元素。

一个小关卡的计划可能看起来像这样

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

句号表示空的空间,井号 (#) 字符表示墙壁,加号表示熔岩。玩家的起始位置是 at 符号 (@)。每个 O 字符都是一个硬币,顶部的等号 (=) 是一个左右水平移动的熔岩块。

我们将支持两种额外的移动熔岩类型:管道字符 (|) 创建垂直移动的熔岩块,v 表示滴落的熔岩——垂直移动的熔岩,它不会来回弹跳,而是向下移动,当它碰到地面时,它会跳回它的起始位置。

整个游戏包含多个关卡,玩家必须完成这些关卡。当所有硬币都被收集起来时,一个关卡就完成了。如果玩家碰到熔岩,当前关卡将恢复到其起始位置,玩家可以再次尝试。

读取关卡

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

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 保存了一个字符数组的数组,即计划的行。我们可以从这些行中推导出关卡的宽度和高度。但我们仍然必须将移动元素与背景网格分开。我们将移动元素称为角色。它们将存储在一个对象数组中。背景将是一个字符串数组的数组,保存诸如 "empty""wall""lava" 之类的区域类型。

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

为了解释计划中的字符,Level 构造函数使用了 levelChars 对象,该对象将背景元素映射到字符串,将角色字符映射到类。当 type 是一个角色类时,将使用其静态 create 方法创建一个对象,该对象将添加到 startActors 中,而映射函数将为此背景方块返回 "empty"

角色的位置存储为 Vec 对象。这是一个二维向量,一个具有 xy 属性的对象,如第 6 章中的练习所示。

随着游戏的运行,角色最终将出现在不同的位置,甚至完全消失(就像硬币被收集时一样)。我们将使用 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"

这又是一个持久的数据结构——更新游戏状态将创建一个新的状态,并保留旧状态。

角色

角色对象表示游戏中给定移动元素的当前位置和状态。所有角色对象都符合相同的接口。它们的 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);

由于玩家的高度是 1 个半方格,因此其初始位置被设置为 @ 字符出现位置上方半格。这样,它的底部与它出现的方格的底部对齐。

size 属性对所有 Player 实例都是一样的,因此我们将其存储在原型上,而不是存储在实例本身。我们本可以使用像 type 这样的 getter,但这将每次读取属性时都创建并返回一个新的 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);

第 14 章中,我们看到 Math.sin 为我们提供了圆上一点的 y 坐标。当我们沿着圆移动时,该坐标以平滑的波形来回移动,这使得正弦函数对于模拟波浪运动很有用。

为了避免所有硬币同步上下移动的情况,每个硬币的起始相位都是随机的。Math.sin 波形的周期,即它产生的波形的宽度,是 2π。我们将 Math.random 返回的值乘以该数字,以便给硬币一个随机的波形起始位置。

现在我们可以定义 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

接下来的任务是在屏幕上显示这些关卡,并在其中模拟时间和运动。

封装作为负担

本章中的大部分代码都并不十分关注封装,原因有两个。首先,封装需要额外的努力。它会使程序变得更大,并且需要引入额外的概念和接口。由于你所能向读者展示的代码量是有限的,在我努力保持程序体积小的情况下,他们可能会感到厌倦。

其次,此游戏中的各种元素紧密地联系在一起,如果其中一个元素的行为发生变化,其他元素不太可能保持不变。元素之间的接口最终会对游戏运行方式做出很多假设。这使得它们效率低下——无论何时更改系统的一部分,你仍然需要担心它对其他部分的影响,因为它们的接口不会涵盖新情况。

系统中的一些切割点适合通过严格的接口进行分离,而另一些则不适合。试图封装不适合边界的元素,无疑会浪费大量精力。当你犯这个错误时,你通常会注意到你的接口变得笨拙、庞大且详细,并且需要经常更改,因为程序在不断发展。

有一件事我们将会封装,那就是绘图子系统。这样做的原因是,我们将在下一章中以不同的方式显示相同的游戏。通过将绘图置于接口后面,我们可以将相同的游戏程序加载到那里,并插入一个新的显示模块。

绘图

绘图代码的封装是通过定义一个显示对象来完成的,该对象显示给定的关卡和状态。我们在本章中定义的显示类型称为 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(); }
}

关卡的背景网格,它永远不会改变,被绘制一次。每次使用给定状态更新显示时,都会重新绘制演员。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})))
  ));
}

如前所述,背景被绘制为一个 <table> 元素。这很好地对应于关卡的 rows 属性的结构——网格的每一行都被转换为一个表格行 (<tr> 元素)。网格中的字符串用作表格单元格 (<td>) 元素的类名。扩展 (三个点) 运算符用于将子节点数组作为单独的参数传递给 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;              }

其中一些 (table-layoutborder-spacingpadding) 用于抑制不需要的默认行为。我们不希望表格的布局依赖于其单元格的内容,也不希望单元格之间有间距或单元格内部有填充。

background 规则设置背景颜色。CSS 允许使用单词 (white) 或 rgb(R, G, B) 这样的格式来指定颜色,其中颜色的红色、绿色和蓝色分量被分成三个介于 0 到 255 之间的数字。因此,在 rgb(52, 166, 251) 中,红色分量为 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 类赋予演员绝对位置。它们的类型名用作额外的类,为它们赋予颜色。我们不必再次定义 lava 类,因为我们正在重复使用之前为熔岩网格方块定义的类。

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

syncState 方法用于使显示显示给定状态。它首先删除旧的演员图形(如果有),然后以它们的新位置重新绘制演员。你可能很想尝试重复使用演员的 DOM 元素,但要做到这一点,我们需要很多额外的簿记工作来将演员与 DOM 元素相关联,并确保当演员消失时删除元素。由于游戏中通常只有少数演员,因此重新绘制所有演员并不昂贵。

DOMDisplay.prototype.syncState = 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 规则,该规则仅在玩家的祖先元素具有给定类时生效。

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

触摸熔岩后,玩家的颜色会变成暗红色,表明灼烧。当收集到最后一个硬币时,我们会添加两个模糊的白色阴影——一个在左上角,一个在右上角——以创建一个白色的光晕效果。

我们不能假设关卡始终适合视口——我们绘制游戏的元素。这就是为什么需要 scrollPlayerIntoView 调用。它确保如果关卡超出视口范围,我们会滚动视口以确保玩家靠近其中心。以下 CSS 为游戏的包装 DOM 元素提供最大尺寸,并确保任何超出元素框的内容都不可见。我们还赋予它一个相对位置,以便其中的演员相对于关卡的左上角进行定位。

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

scrollPlayerIntoView 方法中,我们找到玩家的位置并更新包装元素的滚动位置。当玩家过于靠近边缘时,我们会通过操纵该元素的 scrollLeftscrollTop 属性来更改滚动位置。

DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
  let width = this.dom.clientWidth;
  let height = this.dom.clientHeight;
  let margin = width / 3;

  // The viewport
  let left = this.dom.scrollLeft, right = left + width;
  let top = this.dom.scrollTop, bottom = top + height;

  let player = state.player;
  let center = player.pos.plus(player.size.times(0.5))
                         .times(scale);

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

找到玩家中心的的方式展示了我们 Vec 类型的哪些方法允许以相对可读的方式编写与对象一起的计算。为了找到演员的中心,我们添加了它的位置(它的左上角)和它的一半大小。那是关卡坐标中的中心,但我们需要它在像素坐标中,因此我们将生成的向量乘以我们的显示比例。

接下来,一系列检查验证玩家位置是否超出允许范围。请注意,有时这会设置无意义的滚动坐标,这些坐标低于零或超出元素的可滚动区域。这没关系——DOM 会将它们限制在可接受的值范围内。将 scrollLeft 设置为 -10 将使其变为 0。

始终尝试将播放器滚动到视窗中心会更简单。但这样会产生一种很不和谐的效果。当你跳跃时,视图会不断上下移动。在屏幕中间留出一个“中立”区域,这样你可以在其中移动而不会造成任何滚动,会更舒服。

现在我们可以展示我们的小关卡了。

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

<script>
  let simpleLevel = new Level(simpleLevelPlan);
  let display = new DOMDisplay(document.body, simpleLevel);
  display.syncState(State.start(simpleLevel));
</script>

当使用rel="stylesheet"时,<link>标签是将CSS文件加载到页面的方法。game.css文件包含我们的游戏所需的样式。

运动和碰撞

现在我们已经到了可以开始添加运动的阶段——这是游戏最有趣的方面。大多数类似的游戏采用了一种基本方法,即把时间分成很小的步骤,并在每一步中,根据角色的速度乘以时间步长的大小来移动角色。我们将以秒为单位测量时间,因此速度以每秒单位表示。

移动物体很容易。困难的部分是处理元素之间的交互。当玩家撞到墙壁或地板时,他们不应该直接穿过去。游戏必须注意到,当某个动作导致一个物体撞击另一个物体时,应该做出相应的反应。对于墙壁来说,动作必须停止。当撞到硬币时,硬币必须被收集起来。当碰到熔岩时,游戏应该结束。

解决一般情况下的问题是一项艰巨的任务。你可以找到一些库,通常称为物理引擎,它们模拟二维或三维空间中物理物体之间的相互作用。本章我们将采用更简单的方法,只处理矩形物体之间的碰撞,并且以一种相当简单的方式处理它们。

在移动玩家或一块熔岩之前,我们会测试该动作是否会使其进入墙壁内部。如果是,我们只需完全取消该动作。对这种碰撞的反应取决于角色的类型——玩家会停止,而熔岩块会反弹。

这种方法要求我们的时间步长非常小,因为它会导致动作在物体实际接触之前停止。如果时间步长(以及动作步长)太大,玩家最终会悬停在地面上方一段明显的距离。另一种方法,可能更好但更复杂,是找到确切的碰撞点并移动到那里。我们将采用简单的方法,并通过确保动画以小步骤进行来隐藏其问题。

此方法告诉我们一个矩形(由位置和大小指定)是否接触到指定类型的网格元素。

Level.prototype.touches = function(pos, size, type) {
  let xStart = Math.floor(pos.x);
  let xEnd = Math.ceil(pos.x + size.x);
  let yStart = Math.floor(pos.y);
  let yEnd = Math.ceil(pos.y + size.y);

  for (let y = yStart; y < yEnd; y++) {
    for (let x = xStart; x < xEnd; x++) {
      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.floorMath.ceil来计算物体与之重叠的网格方块集。请记住,网格方块的大小为1乘1单位。通过向上和向下舍入方块的边,我们可以得到方块接触到的背景方块的范围。

Finding collisions on a grid

我们循环遍历通过舍入坐标找到的网格方块块,并在找到匹配的方块时返回true。关卡外的方块始终被视为"wall",以确保玩家无法离开世界,并且不会意外地尝试读取rows数组的边界之外。

状态update方法使用touches来判断玩家是否接触到熔岩。

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

  for (let actor of actors) {
    if (actor != player && overlap(actor, player)) {
      newState = actor.collide(newState);
    }
  }
  return newState;
};

该方法传递一个时间步长和一个数据结构,告诉它哪些键被按下。它首先对所有角色调用update方法,生成一个更新后的角色数组。角色也获得了时间步长、键和状态,因此它们可以根据这些来进行更新。只有玩家才会实际读取键,因为它是唯一由键盘控制的角色。

如果游戏已经结束,就不需要进行任何进一步的处理(游戏在结束之后无法获胜,反之亦然)。否则,该方法会测试玩家是否接触到背景熔岩。如果是,游戏结束,我们完成了。最后,如果游戏确实还在进行,它会查看是否有其他角色与玩家重叠。

角色之间的重叠由overlap函数检测。它接受两个角色对象,并在它们接触时返回true——当它们沿x轴和y轴都重叠时,就会发生这种情况。

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

如果任何角色确实重叠,它的collide方法将有机会更新状态。接触到熔岩角色会将游戏状态设置为"lost"。当你接触到硬币时,它们会消失,并在它们是关卡中最后一个硬币时,将状态设置为"won"

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对象被忽略。

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

update方法通过将时间步长和当前速度的乘积添加到其旧位置来计算一个新的位置。如果没有障碍物阻挡那个新的位置,它就会移动到那里。如果有障碍物,熔岩块的行为取决于其类型——滴落的熔岩有一个reset位置,当它碰到东西时,它会跳回到那里。反弹的熔岩通过将其速度乘以-1来反转其速度,使其开始朝相反的方向移动。

硬币使用它们的update方法来摇摆。它们忽略与网格的碰撞,因为它们只是在自己的方块内摇摆。

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的参数来找到波浪上的新位置。然后从硬币的基准位置和基于该波浪的偏移量计算出硬币的当前位置。

最后是玩家本身。玩家的运动按轴单独处理,因为碰到地板不应该阻止水平运动,而碰到墙壁不应该阻止下落或跳跃运动。

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) 首先被加速以说明重力。

我们再次检查墙壁。如果我们没有碰到任何墙壁,新的位置将被使用。如果墙壁,则有两种可能的结果。当向上箭头被按下并且我们正在向下移动(这意味着我们碰到的东西在我们下方)时,速度被设置为一个相对较大的负值。这会导致玩家跳跃。如果情况并非如此,玩家只是碰到了什么东西,速度被设置为零。

重力强度、跳跃速度,以及游戏中几乎所有其他常量都是通过反复试验设置的。我测试了一些值,直到找到我喜欢的组合。

跟踪键

对于像这样的游戏,我们不希望键在每次按键时都起作用。相反,我们希望它们的效果(移动玩家图形)在它们被按下时保持活跃。

我们需要设置一个键处理程序,它存储左右箭头键和向上箭头键的当前状态。我们还希望为这些键调用preventDefault,以防止它们最终滚动页面。

以下函数,当给定一个键名数组时,将返回一个跟踪这些键的当前位置的对象。它注册了"keydown""keyup"事件的事件处理程序,并且当事件中的键代码出现在它正在跟踪的代码集中时,它会更新该对象。

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")。

运行游戏

我们在第14章中看到的requestAnimationFrame函数提供了一种很好的方式来为游戏设置动画。但它的接口非常原始——使用它需要我们跟踪上次调用函数的时间,并在每一帧之后再次调用requestAnimationFrame

让我们定义一个辅助函数,它将这些无聊的部分封装在一个方便的接口中,并允许我们简单地调用runAnimation,向它传递一个期望时间差作为参数并绘制单帧的函数。当帧函数返回false值时,动画停止。

function runAnimation(frameFunc) {
  let lastTime = null;
  function frame(time) {
    if (lastTime != null) {
      let timeStep = Math.min(time - lastTime, 100) / 1000;
      if (frameFunc(timeStep) === false) return;
    }
    lastTime = time;
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}

我设置了100毫秒(十分之一秒)的最大帧步长。当浏览器标签或包含我们页面的窗口被隐藏时,requestAnimationFrame调用将被暂停,直到标签或窗口再次显示。在这种情况下,lastTimetime之间的差值将是我们页面隐藏的整个时间。用单步向前移动游戏这么长时间看起来很奇怪,而且可能会导致奇怪的副作用,例如玩家从地板上掉下去。

该函数还将时间步长转换为秒,秒比毫秒更容易思考。

runLevel函数接受一个Level对象和一个显示构造函数,并返回一个promise。它显示关卡(在document.body中),并允许用户玩完关卡。当关卡结束(失败或成功)时,runLevel会等待一秒钟(让用户看到发生了什么),然后清除显示,停止动画,并将promise解析为游戏的结束状态。

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.syncState(state);
      if (state.status == "playing") {
        return true;
      } else if (ending > 0) {
        ending -= time;
        return true;
      } else {
        display.clear();
        resolve(state.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!");
}

因为我们让runLevel返回一个promise,runGame可以使用async函数编写,如第 11 章所示。它返回另一个promise,当玩家完成游戏时解析。

本章的沙盒中,GAME_LEVELS绑定中提供了一组关卡计划。此页面将它们提供给runGame,启动一个实际游戏。

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

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

看看你是否能打败它们。我构建它们时玩得很开心。

练习

游戏结束

对于平台游戏来说,玩家通常以有限的生命数量开始,每次死亡都会减掉一条生命。当玩家的生命耗尽时,游戏会从头开始。

调整runGame来实现生命。让玩家从三条生命开始。每次关卡开始时输出当前的生命数量(使用console.log)。

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

<body>
<script>
  // The old runGame function. Modify it...
  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!");
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>

暂停游戏

通过按下Esc键可以暂停(挂起)和取消暂停游戏。

这可以通过更改runLevel函数来使用另一个键盘事件处理程序,并在每次按下Esc键时中断或恢复动画来完成。

乍一看,runAnimation接口可能看起来不适合这种情况,但如果你重新排列runLevel调用它的方式,它就是适合的。

当你完成这项工作后,还有其他事情你可以尝试。我们一直注册键盘事件处理程序的方式有些问题。arrowKeys对象目前是一个全局绑定,即使没有游戏运行,它的事件处理程序也会保留下来。你可以说它们从我们的系统中泄露出来。扩展trackKeys以提供一种取消注册其处理程序的方法,然后更改runLevel以在启动时注册其处理程序,并在完成时再次取消注册它们。

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

<body>
<script>
  // The old runLevel function. Modify this...
  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.syncState(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>

动画可以通过从传递给runAnimation的函数返回false来中断。它可以通过再次调用runAnimation来继续。

因此,我们需要将我们正在暂停游戏的事实传达给传递给runAnimation的函数。为此,你可以使用一个绑定,事件处理程序和该函数都可以访问它。

在寻找取消注册trackKeys注册的处理程序的方法时,请记住,传递给addEventListener完全相同的函数值必须传递给removeEventListener才能成功删除处理程序。因此,在trackKeys中创建的handler函数值必须可供取消注册处理程序的代码使用。

你可以向trackKeys返回的对象添加一个属性,其中包含该函数值或直接处理取消注册的方法。

怪物

平台游戏中通常有你可以跳上去击败的敌人。本练习要求你在游戏中添加这种类型的角色。

我们称之为怪物。怪物只在水平方向移动。你可以让它们朝玩家方向移动、像水平熔岩一样来回弹跳,或者让它们具有你想要的任何移动模式。这个类不必处理掉落,但它应该确保怪物不会穿过墙壁。

当怪物碰到玩家时,效果取决于玩家是否跳在它们上面。你可以通过检查玩家底部是否靠近怪物顶部来近似地做到这一点。如果是这样,怪物就会消失。如果不是,游戏就会失败。

<link rel="stylesheet" href="css/game.css">
<style>.monster { background: purple }</style>

<body>
  <script>
    // Complete the constructor, update, and collide methods
    class Monster {
      constructor(pos, /* ... */) {}

      get type() { return "monster"; }

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

      update(time, state) {}

      collide(state) {}
    }

    Monster.prototype.size = new Vec(1.2, 2);

    levelChars["M"] = Monster;

    runLevel(new Level(`
..................................
.################################.
.#..............................#.
.#..............................#.
.#..............................#.
.#...........................o..#.
.#..@...........................#.
.##########..............########.
..........#..o..o..o..o..#........
..........#...........M..#........
..........################........
..................................
`), DOMDisplay);
  </script>
</body>

如果你想实现一种有状态的运动类型,例如弹跳,请确保你将必要的状态存储在角色对象中——将其作为构造函数参数包含在内,并将其添加为属性。

请记住,update返回一个新的对象,而不是更改旧对象。

在处理碰撞时,在state.actors中找到玩家,并将它的位置与怪物的位置进行比较。要获得玩家的底部,你必须将它的垂直大小添加到它的垂直位置。更新状态的创建将类似于Coincollide方法(删除角色)或Lava的方法(将状态更改为"lost"),具体取决于玩家位置。