第 3 版现已发布。点击此处阅读

第 15 章
项目:平台游戏

所有现实都是一场游戏。

伊恩·班克斯,游戏玩家

我最初对计算机的着迷,与许多孩子一样,源于电脑游戏。我被吸引到了那些我可以操控,并且故事(某种程度上)在其中展开的微型计算机模拟世界——我想,更多是因为我可以将我的想象力投射到其中,而不是因为它们实际上提供的可能性。

我不会希望任何人从事游戏编程的职业。就像音乐行业一样,渴望进入该行业的大量年轻人与实际需求之间的差距,造成了一个相当不健康的環境。但为了乐趣编写游戏是很有趣的。

本章将逐步介绍一个简单的平台游戏的实现过程。平台游戏(或“跳跃跑酷”游戏)是指玩家需要操控一个角色在一个世界中移动的游戏,这个世界通常是二维的,从侧面观看,并且需要进行大量的跳跃和越过障碍。

游戏

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

The game Dark Blue

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

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

游戏由一个固定的背景组成,背景像网格一样排列,移动元素叠加在背景上。网格上的每个区域要么为空,要么为实体,要么为熔岩。移动元素包括玩家、硬币和一些熔岩块。与第 7 章中的人工生命模拟不同,这些元素的位置不受限于网格——它们的坐标可以是分数,允许平滑的运动。

技术

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

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

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

在必须在没有明显延迟的情况下动画化图形并响应用户输入的游戏和其他程序中,效率至关重要。尽管 DOM 最初并非为高性能图形而设计,但实际上它比您预期的要好。您在第 13 章中看到了一些动画。在现代机器上,像这样的简单游戏表现良好,即使我们不太考虑优化。

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

关卡

第 7 章中,我们使用字符串数组来描述二维网格。我们也可以在这里这样做。这将允许我们设计关卡,而无需先构建关卡编辑器。

一个简单的关卡看起来像这样

var simpleLevelPlan = [
  "                      ",
  "                      ",
  "  x              = x  ",
  "  x         o o    x  ",
  "  x @      xxxxx   x  ",
  "  xxxxx            x  ",
  "      x!!!!!!!!!!!!x  ",
  "      xxxxxxxxxxxxxx  ",
  "                      "
];

固定网格和移动元素都包含在计划中。x 字符代表墙壁,空格字符代表空区域,感叹号代表固定的、不可移动的熔岩块。

@ 定义了玩家的起始位置。每个 o 都是一枚硬币,等号 (=) 代表一个左右水平移动的熔岩块。请注意,这些位置的网格将被设置为包含空区域,另一个数据结构用于跟踪此类移动元素的位置。

我们将支持另外两种类型的移动熔岩:管道字符 (|) 代表上下垂直移动的熔岩块,v 代表滴落熔岩——垂直移动的熔岩,它不会来回弹跳,只向下移动,当它撞到地面时跳回其起始位置。

一个完整的游戏由玩家必须完成的多个关卡组成。当所有硬币都被收集后,关卡就完成了。如果玩家碰到熔岩,当前关卡将恢复到其起始位置,玩家可以重新尝试。

读取关卡

以下构造函数构建了一个关卡对象。它的参数应该是定义关卡的字符串数组。

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(表示空方块)或字符串(指示方块的类型,即 "wall""lava")。

演员数组保存跟踪关卡中动态元素的当前位置和状态的对象。预期每个对象都具有一个 pos 属性,用于提供其位置(其左上角的坐标),一个 size 属性,用于提供其大小,以及一个 type 属性,用于保存一个标识该元素的字符串("lava""coin""player")。

构建网格后,我们使用 filter 方法查找玩家演员对象,并将该对象存储在关卡的属性中。status 属性跟踪玩家是否获胜或失败。当这种情况发生时,finishDelay 用于将关卡保持活动状态一小段时间,以便可以显示一个简单的动画。(立即重置或推进关卡看起来很便宜。)此方法可用于找出关卡是否已完成

Level.prototype.isFinished = function() {
  return this.status != null && this.finishDelay < 0;
};

演员

为了存储演员的位置和大小,我们将回到我们可靠的 Vector 类型,它将 x 坐标和 y 坐标分组到一个对象中。

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 方法按给定的量缩放向量。当我们需要将速度向量乘以时间间隔以获得该时间段内的行程距离时,它会很有用。

在上一节中,actorChars 对象被 Level 构造函数用来将字符与构造函数关联起来。该对象看起来像这样

var actorChars = {
  "@": Player,
  "o": Coin,
  "=": Lava, "|": Lava, "v": Lava
};

三个字符映射到 LavaLevel 构造函数将演员的源字符作为第二个参数传递给构造函数,Lava 构造函数使用该参数来调整其行为(水平弹跳、垂直弹跳或滴落)。

玩家类型使用以下构造函数构建。它有一个 speed 属性,用于存储其当前速度,这将有助于模拟动量和重力。

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

因为玩家的高度是 1.5 个方块,所以其初始位置被设置为出现在 @ 字符位置上方半个方块的位置。这样,其底部与它出现的方块的底部对齐。

构建动态 Lava 对象时,我们需要根据其基础字符以不同的方式初始化该对象。动态熔岩以其给定的速度沿直线移动,直到遇到障碍。此时,如果它具有 repeatPos 属性,它将跳回其起始位置(滴落)。如果没有,它将反转其速度并继续向另一个方向移动(弹跳)。构造函数只设置了必要的属性。执行实际移动的方法将在后面编写。

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 属性中)。

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 坐标。当我们沿着圆移动时,该坐标以平滑的波浪形式来回移动,这使得正弦函数可用于模拟波浪运动。

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

我们现在已经编写了表示关卡状态所需的所有部分。

var simpleLevel = new Level(simpleLevelPlan);
console.log(simpleLevel.width, "by", simpleLevel.height);
// → 22 by 9

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

封装的负担

本章中的大部分代码都不关心封装,原因有两个。首先,封装需要额外的努力。它使程序变得更大,并且需要引入额外的概念和接口。由于在读者目光变得呆滞之前,您只能向他们展示一定数量的代码,因此我一直努力使程序保持精简。

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

系统中的一些切入点适合通过严格的接口进行分离,但另一些则不适合。试图封装不适合边界的内容,肯定会浪费很多精力。当你犯这个错误时,你通常会注意到你的接口变得笨拙、庞大且详细,并且需要在程序演变过程中经常修改。

在本章中,我们将封装一项内容,那就是绘图子系统。这样做的原因是,我们将以不同的方式在下一章中显示同一个游戏。通过将绘图放在接口后面,我们可以简单地在那里加载同一个游戏程序,并插入一个新的显示模块。

绘图

绘图代码的封装是通过定义一个display对象来完成的,该对象显示给定关卡。我们在本章中定义的显示类型称为DOMDisplay,因为它使用简单的DOM元素来显示关卡。

我们将使用一个样式表来设置构成游戏的元素的实际颜色和其他固定属性。也可以在创建元素时直接为元素的style属性赋值,但这会产生更冗长的程序。

以下辅助函数提供了一种简短的方式来创建元素并为其指定一个类

function elt(name, className) {
  var elt = document.createElement(name);
  if (className) elt.className = className;
  return elt;
}

显示是通过为其指定一个父元素(它应该将自己追加到该元素)和一个关卡对象来创建的。

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返回附加元素的事实,在一个语句中创建了包装元素并将其存储在wrap属性中。

关卡的背景不会改变,因此只绘制一次。每次更新显示时,都会重新绘制角色。actorLayer属性将被drawFrame用于跟踪包含角色的元素,以便可以轻松地将其移除和替换。

我们的坐标和大小以相对于网格大小的单位跟踪,其中大小或距离为1表示1个网格单位。当设置像素大小时,我们将不得不放大这些坐标——游戏中的一切在每个方块只有一个像素的情况下会非常小。scale变量表示屏幕上一个单位所占的像素数。

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有助于生成的表格看起来像我们想要的背景

.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,才能从游戏单位转换为像素。

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类为角色提供其绝对位置。它们的类型名称用作一个额外的类来为它们指定颜色。我们不需要再次定义lava类,因为我们复用了之前为熔岩网格方块定义的类。

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

drawFrame方法在更新显示时,首先删除旧的角色图形(如果有),然后在它们的新位置重新绘制它们。你可能很想尝试复用角色的DOM元素,但要做到这一点,我们需要在显示代码和模拟代码之间传递大量额外的信息流。我们需要将角色与DOM元素相关联,并且绘图代码必须在角色消失时删除元素。由于游戏中通常只有几个角色,因此重新绘制所有角色并不昂贵。

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规则。

.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() {
  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类型上的方法如何允许以可读的方式编写对对象的计算。要找到角色的中心,我们将它的位置(它的左上角)加上它的一半大小。这是关卡坐标中的中心,但我们需要的是像素坐标,因此我们将得到的向量乘以我们的显示比例。

接下来,一系列检查验证玩家位置是否在允许范围内。注意,有时这会设置无意义的滚动坐标,低于零或超过元素的可滚动区域。这是可以的——DOM会将它们限制在合理的范围内。将scrollLeft设置为-10会导致它变为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文件包含我们游戏所需的样式。

运动和碰撞

现在我们到了可以开始添加运动的时候了——这是游戏中最有趣的部分。大多数类似游戏的基本方法是将时间分成小步,并在每一步中,根据角色的速度(每秒移动的距离)乘以时间步长的大小(以秒为单位)来移动角色。

这很容易。困难的部分是处理元素之间的交互。当玩家撞到墙壁或地板时,他们不应该直接穿过它。游戏必须注意到当给定的运动导致一个物体撞击另一个物体时,并做出相应的反应。对于墙壁,运动必须停止。对于硬币,硬币必须被收集,等等。

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

在移动玩家或一块熔岩之前,我们测试该运动是否会使它进入背景的非空部分。如果它进入,我们简单地完全取消该运动。对这种碰撞的响应取决于角色的类型——玩家会停止,而熔岩块会反弹。

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

此方法告诉我们一个矩形(由位置和大小指定)是否与背景网格上的任何非空空间重叠。

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.floorMath.ceil来计算物体与之重叠的网格方块集。请记住,网格方块的大小为 1×1 个单位。通过对盒子的边进行向上和向下取整,我们得到了盒子所接触的背景方块的范围。

Finding collisions on a grid

如果物体超出关卡范围,我们始终为侧面和顶部返回"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;
  }
};

角色和动作

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的步骤。例如,0.12 秒的step将被切成两个 0.05 秒的步骤和一个 0.02 秒的步骤。

角色对象有一个act方法,该方法接受时间步长、关卡对象和keys对象作为参数。这是一个例子,用于Lava角色类型,它忽略了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的参数以创建波浪,该波浪用于计算新位置。

剩下的就是玩家本身。玩家的运动是按轴线分别处理的,因为撞到地板不应阻止水平运动,撞到墙壁不应阻止下落或跳跃运动。此方法实现了水平部分。

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

在方法开始时,玩家会垂直加速以解释重力。游戏中的重力、跳跃速度以及几乎所有其他常量都是通过反复试验确定的。我测试了各种值,直到找到我喜欢的组合。

接下来,我们再次检查障碍物。如果我们撞到障碍物,将有两种可能的结果。当向上箭头被按下并且我们正在向下移动(意味着我们撞到的东西在我们下方)时,速度将被设置为一个相对较大的负值。这会导致玩家跳跃。如果不是这种情况,我们就只是撞到了某个东西,速度将重置为零。

实际的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"

这给了我们一个可以实际动画的关卡。现在唯一缺少的是驱动动画的代码。

跟踪按键

对于像这样的游戏,我们不希望按键每次按下时都生效。相反,我们希望它们的效果(移动玩家图形)只要它们被按下就持续发生。

我们需要设置一个按键处理程序,该处理程序存储左右和向上箭头键的当前状态。我们还希望为这些按键调用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")。

运行游戏

requestAnimationFrame函数(我们在第 13 章中看到过)提供了一个很好的方法来为游戏制作动画。但它的界面非常原始——使用它需要我们跟踪我们的函数上次被调用时的時間,并在每一帧之后再次调用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调用将暂停,直到标签或窗口再次显示。在这种情况下,lastTimetime之间的差值将是页面隐藏的整个时间。用一个步骤提前游戏那么多时间看起来很愚蠢,而且可能很多工作量(请记住animate方法中的时间分割)。

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

runLevel函数接受一个Level对象、一个显示的构造函数,以及可选的一个函数。它显示关卡(在document.body中),并让用户玩它。当关卡结束(输掉或赢得)时,runLevel清除显示,停止动画,并且,如果给出了andThen函数,则用关卡的状态调用该函数。

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

这些函数显示了一种特殊的编程风格。runAnimationrunLevel都是高阶函数,但它们不符合我们在第 5 章中看到的风格。函数参数用于安排一些事情在将来的某个时间发生,这两个函数都没有返回任何有用的东西。从某种意义上说,它们的任務是安排操作。将这些操作包装在函数中,使我们能够将它们存储为一个值,以便它们可以在正确的时间被调用。

这种编程风格通常被称为异步编程。事件处理也是这种风格的一个例子,我们将在处理可以花费任意时间量的任务(例如第 17 章中的网络请求以及第 20 章中的一般输入输出)时看到更多这种风格的应用。

GAME_LEVELS变量中提供了一组关卡计划。此页面将它们提供给runGame,从而开始一场实际的游戏。

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

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

看看你是否能打败它们。我建造它们的时候非常开心。

练习

游戏结束

平台游戏的传统做法是让玩家一开始拥有有限的生命,并在他们每次死亡时减去一条生命。当玩家的生命耗尽时,游戏会从头开始。

调整runGame以实现生命。让玩家从三条生命开始。

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

最明显的解决方案是将lives变量放入runGame中,这样startLevel闭包就可以访问它。

另一种方法,与函数其他部分的精神相符,是在startLevel中添加第二个参数来指定生命数。当系统的所有状态都存储在函数参数中时,调用该函数提供了一种优雅的方式来转换到新的状态。

无论哪种方式,当关卡失败时,应该有两种可能的状态转换。如果是最后一命,则返回到初始关卡,并恢复初始生命数。如果不是最后一命,则重复当前关卡,生命数减一。

暂停游戏

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

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

乍一看,runAnimation接口可能不适合此操作,但实际上是可以的,只要重新排列runLevel调用它的方式即可。

当您完成此操作后,可以尝试其他一些操作。我们一直在注册键盘事件处理程序的方式存在一些问题。arrows对象当前是一个全局变量,即使没有游戏运行,它的事件处理程序也会保留。可以说,它们泄漏到了我们的系统之外。扩展trackKeys以提供一种注销其处理程序的方法,然后更改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>

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

为了向传递给runAnimation的函数传达应中断动画的信息,以便它能够返回false,可以使用一个变量,该变量既可以被事件处理程序访问,也可以被该函数访问。

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

可以在由trackKeys返回的对象中添加一个属性,该属性包含该函数值或一个直接处理注销操作的方法。