项目:平台游戏

所有现实都是游戏。

伊恩·班克斯,《游戏玩家》
Illustration showing a computer game character jumping over lava in a two dimensional world

我最初对电脑的迷恋,就像很多书呆子孩子一样,都与电脑游戏有关。我被吸引到那些我可以操纵的微型模拟世界中,故事(某种意义上)在那里展开——我认为,与其说是因为它们提供的可能性,不如说是因为我将自己的想象力投射到了它们中。

我不希望任何人从事游戏编程的职业。就像音乐行业一样,渴望在其中工作的人数与实际需求之间的差距造成了一个相当不健康的环境。但出于乐趣编写游戏很有趣。

本章将介绍一个小型平台游戏的实现过程。平台游戏(或“跳跃跑酷”游戏)是一种要求玩家在世界中移动一个人物的游戏,世界通常是二维的,从侧面观看,玩家需要跳过和跳上物体。

游戏

我们的游戏将大致基于托马斯·帕莱夫的《深蓝色》。我选择那个游戏是因为它既娱乐又极简,并且可以不用太多代码构建。它看起来像这样

Screenshot of the 'Dark Blue' game, showing a world made out of colored boxes. There's a black box representing the player, standing on lines of white against a blue background. Small yellow coins float in the air, and some parts of the background are red, representing lava.

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

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

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

技术

我们将使用浏览器 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") {
          let pos = new Vec(x, y);
          this.startActors.push(type.create(pos, ch));
          type = "empty";
        }
        return type;
      });
    });
  }
}

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"

这再次是一个持久数据结构——更新游戏状态会创建一个新的状态,并保持旧状态不变。

角色

角色对象代表游戏中给定移动元素(玩家、硬币或移动熔岩)的当前位置和状态。所有角色对象都符合相同的接口。它们具有 sizepos 属性,分别保存代表此角色的矩形的大小和左上角的坐标,以及一个 update 方法。

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 的 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>

<link> 标签在与 rel="stylesheet" 结合使用时,是一种将 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 个单位。通过将方框的边向上和向下取整,我们获得了方框接触的背景方块的范围。

Diagram showing a grid with a black box overlaid on it. All of the grid squares that are partially covered by the block are marked.

我们循环遍历通过取整坐标找到的网格方块块,并在找到匹配的方块时返回 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")。

运行游戏

requestAnimationFrame 函数(我们在 第 14 章 中看到过)提供了一种很好的动画游戏的方法。但它的接口非常原始——使用它需要我们跟踪我们的函数上次被调用的时间,并在每一帧之后再次调用 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,该 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方法(删除角色)或Lavacollide方法(将状态更改为"lost"),具体取决于玩家的位置。