项目:平台游戏

项目:平台游戏
所有现实都是一场游戏。
—— 伊恩・班克斯,《游戏玩家》
(插图:一个电脑游戏角色在二维世界中跳过岩浆)
和许多 nerd 小孩一样,我最初对计算机的痴迷,很大程度上与电脑游戏有关。我被那些小小的模拟世界所吸引 —— 在那里我可以进行操控,故事(某种程度上)得以展开。我想,更多是因为我将自己的想象力投射到了这些世界中,而非它们实际提供的可能性。
我不希望任何人将游戏编程作为职业。就像音乐行业一样,渴望渴望 渴望进入这个领域的年轻人数量与实际需求之间的差距,造就了一个相当不健康的环境。但出于乐趣而编写游戏则是一件有趣的事。
本章将逐步实现一个小型平台游戏。平台游戏(或 "跳跃与奔跑" 游戏)要求玩家操控一个角色在世界中移动 —— 通常是二维的、从侧面视角观看的世界,同时跳跃过障碍物并跳到某些物体上。
游戏介绍
我们的游戏大致基于托马斯・帕莱夫的《深蓝》(Dark Blue)。我选择这款游戏是因为它既有趣又简约,而且不需要太多代码就能构建。游戏画面如下:
(《深蓝》游戏截图:展示了一个由彩色方块构成的世界。黑色方块代表玩家,站在白色线条上,背景是蓝色。黄色的小硬币漂浮在空中,背景的某些部分是红色,代表岩浆。)
黑色方块代表玩家,其任务是收集黄色方块(硬币),同时避开红色物质(岩浆)。收集完所有硬币后,关卡即完成。
玩家可以使用左右和右箭头键行走,使用上箭头键跳跃。跳跃是这个游戏角色的特长 —— 可以跳到自身高度的几倍,并且可以在半空中改变方向。这可能并不完全符合现实,但有助于给玩家一种直接控制屏幕上角色的感觉。
游戏由静态背景(按网格为网格)和叠加在其上的移动元素组成。网格中的每个格子要么是空的、实心的,要么是岩浆。移动元素包括玩家、硬币和某些岩浆块。这些元素的位置不受网格限制 —— 它们的坐标可以是小数,以实现平滑运动。
技术实现
我们将使用浏览器 DOM 来显示游戏,并通过处理键盘事件来读取用户输入。
与屏幕和键盘相关的代码只是构建这个游戏所需工作的一小部分。由于所有东西都看起来像彩色方块,绘制并不复杂:我们创建 DOM 元素,并使用样式给它们设置背景颜色、大小和位置。
我们可以将背景表示为表格,因为它是一个不变的正方形网格。自由移动的元素可以使用绝对定位的元素叠加在上面。
在游戏和其他需要动画图形并对用户输入做出无延迟响应的程序中,效率很重要。尽管 DOM 最初并非为高性能图形设计,但实际上它在这方面的表现比你预期的要好。你在第 14 章中看到了一些动画。在现代机器上,像这样的简单游戏表现良好,即使我们不太担心优化问题。
在下一章中,我们将探索另一种浏览器技术 ——<canvas> 标签,它提供了一种更传统的绘制图形的方式,基于形状和像素而非 DOM 元素。
关卡设计
我们需要一种人类可读、可编辑的方式来定义关卡。由于所有东西最初都位于网格上,我们可以使用大字符串,其中每个字符代表一个元素 —— 要么是背景网格的一部分,要么是移动元素。
一个小关卡的规划可能如下所示:
javascript
let simpleLevelPlan = ...................... ..#................#.. ..#..............=.#.. ..#.........o.o....#.. ..#.@......#####...#.. ..#####............#.. ......#++++++++++++#.. ......##############.. ......................;
句点(.)表示空空间,井号(#)表示墙壁,加号(+)表示岩浆。玩家的起始位置是 @符号。每个 o 字符是一个硬币,顶部的等号(=)是一个水平来回移动的岩浆块。
我们将支持另外两种移动的岩浆:竖线字符(|)创建垂直移动的岩浆块,v 表示滴落的岩浆 —— 垂直移动但不来回弹跳,只向下移动,撞到地面时跳回起始位置。
整个游戏由多个关卡组成,玩家必须完成所有关卡。收集完所有硬币后,关卡即完成。如果玩家碰到岩浆,当前关卡将恢复到起始状态,玩家可以重试。
读取关卡
以下类存储关卡对象。其参数应该是定义关卡的字符串。
javascript
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 包含一个数组的数组(字符数组),即规划的行。我们可以从中得出关卡的宽度和高度。但我们仍必须将移动元素与背景网格分离。我们将移动元素称为 "角色"(actors)。它们将存储在对象数组中。背景将是一个字符串数组的数组,包含诸如 "empty"、"wall" 或 "lava" 之类的字段类型。
为了创建这些数组,我们先映射行,然后映射其内容。请记住,map 会将数组索引作为第二个参数传递给映射函数,这告诉我们给定字符的 x 和 y 坐标。游戏中的位置将存储为坐标对,左上角为 0,0,每个背景方块的高度和宽度为 1 单位。
为了解释规划中的字符,Level 构造函数使用 levelChars 对象,对于关卡描述中使用的每个字符,如果是背景类型,则包含一个字符串;如果产生角色,则包含一个类。当类型是角色类时,其静态 create 方法用于创建对象,该对象被添加到 startActors,映射函数为此背景方块返回 "empty"。
角色的位置存储为 Vec 对象。这是一个二维向量,一个具有 x 和 y 属性的对象,如第 6 章的练习中所见。
随着游戏的运行,角色最终会到达不同的位置,甚至完全消失(如硬币被收集时)。我们将使用 State 类来跟踪运行中游戏的状态。
javascript
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"。
这又是一个持久的数据结构 —— 更新游戏状态会创建一个新状态,并保持旧状态不变。
角色(Actors)
角色对象表示游戏中给定移动元素(玩家、硬币或移动的岩浆)的当前位置和状态。所有角色对象都遵循相同的接口。它们具有 size 和 pos 属性,分别持有表示该角色的矩形的大小和左上角坐标,以及一个 update 方法。
这个 update 方法用于计算给定时间步长后它们的新状态和位置。它模拟角色的行为 —— 玩家响应箭头键移动,岩浆来回弹跳 —— 并返回一个新的、更新后的角色对象。
type 属性包含一个字符串,标识角色的类型 ——"player"、"coin" 或 "lava"。这在绘制游戏时很有用 —— 为角色绘制的矩形的外观基于其类型。
角色类具有静态 create 方法,Level 构造函数用于从关卡规划中的字符创建角色。它被赋予字符的坐标和字符本身,这是必要的,因为 Lava 类处理几个不同的字符。
这是我们将用于二维值的 Vec 类,例如角色的位置和大小。
javascript
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 属性,存储其当前速度以模拟动量和重力。
javascript
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.5 个方块,其初始位置设置为比 @字符出现的位置高 0.5 个方块。这样,它的底部与出现的方块的底部对齐。
size 属性对于 Player 的所有实例都是相同的,因此我们将其存储在原型上,而不是实例本身上。我们可以使用像 type 这样的 getter,但这会在每次读取属性时创建并返回一个新的 Vec 对象,这很浪费。(字符串是不可变的,不必每次求值时都重新创建。)
在构造 Lava 角色时,我们需要根据它所基于的字符不同地初始化对象。动态岩浆按当前速度移动,直到碰到障碍物。此时,如果它有 reset 属性,它将跳回起始位置(滴落)。如果没有,它将反转速度并继续向相反方向移动(弹跳)。
create 方法查看 Level 构造函数传递的字符,并创建适当的岩浆角色。
javascript
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);
硬币角色相对简单。它们大多只是停留在自己的位置。但为了让游戏更生动一些,它们有一个 "摆动"(wobble)—— 轻微的垂直来回运动。为了跟踪这一点,硬币对象存储一个基准位置以及一个跟踪弹跳运动相位的 wobble 属性。这些共同决定了硬币的实际位置(存储在 pos 属性中)。
javascript
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 对象,它将规划字符映射到背景网格类型或角色类。
javascript
const levelChars = {
".": "empty", "#": "wall", "+": "lava",
"@": Player, "o": Coin,
"=": Lava, "|": Lava, "v": Lava
};
这为我们提供了创建 Level 实例所需的所有部分。
javascript
let simpleLevel = new Level(simpleLevelPlan);
console.log(${simpleLevel.width} by ${simpleLevel.height});
// → 22 by 9
接下来的任务是在屏幕上显示这些关卡,并在其中模拟时间和运动。
绘制
在下一章中,我们将以不同的方式显示同一个游戏。为了实现这一点,我们将绘制逻辑放在一个接口后面,并将其作为参数传递给游戏。这样,我们可以使用相同的游戏程序与不同的新显示模块。
游戏显示对象绘制给定的关卡和状态。我们将其构造函数传递给游戏,以便可以替换它。我们在本章中定义的显示类称为 DOMDisplay,因为它使用 DOM 元素来显示关卡。
我们将使用样式表来设置构成游戏的元素的实际颜色和其他固定属性。在创建元素时直接分配给元素的 style 属性也是可能的,但这会产生更冗长的程序。
以下辅助函数提供了一种简洁的方式来创建元素并为其赋予一些属性和子节点:
javascript
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;
}
通过给显示对象一个父元素(它应该附加到该元素)和一个关卡对象来创建显示。
javascript
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 常量给出一个单位在屏幕上占用的像素数。
javascript
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>元素的形式很好地对应于 level 的 rows 属性的结构——网格的每一行都变成一个表格行(<tr>元素)。网格中的字符串用作表格单元格(<td>)元素的类名。代码使用扩展(三点)运算符将子节点数组作为单独的参数传递给 elt。
以下 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; }
其中一些(table-layout、border-spacing 和 padding)用于抑制不需要的默认行为。我们不希望表格的布局取决于其单元格的内容,也不希望表格单元格之间有空间或内部有填充。
background 规则设置背景颜色。CSS 允许颜色既可以用单词(white)指定,也可以用诸如 rgb (R, G, B) 的格式指定,其中颜色的红、绿、蓝分量被分为三个从 0 到 255 的数字。在 rgb (52, 166, 251) 中,红色分量是 52,绿色是 166,蓝色是 251。由于蓝色分量最大,结果颜色将呈蓝色。在.lava 规则中,第一个数字(红色)最大。
我们通过为每个角色创建一个 DOM 元素,并根据角色的属性设置该元素的位置和大小来绘制每个角色。这些值必须乘以 scale 才能从游戏单位转换为像素。
javascript
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 类,因为我们正在重用之前为熔岩网格方块定义的类。
css
.actor { position: absolute; }
.coin { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64); }
syncState 方法用于使显示显示给定的状态。它首先移除旧的角色图形(如果有),然后在新位置重绘角色。尝试重用角色的 DOM 元素可能很诱人,但要使其工作,我们需要大量额外的记录来将角色与 DOM 元素相关联,并确保在角色消失时移除元素。由于游戏中通常只有少数角色,重绘所有角色并不昂贵。
javascript
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 规则。
css
.lost .player {
background: rgb(160, 64, 64);
}
.won .player {
box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}
触碰岩浆后,玩家会变成暗红色,暗示被烧焦。收集最后一枚硬币后,我们添加两个模糊的白色阴影 —— 一个在左上角,一个在右上角 —— 以创建白色光晕效果。
我们不能假设关卡总是适合视口(我们在其中绘制游戏的元素)。这就是为什么我们需要 scrollPlayerIntoView 调用:它确保如果关卡突出到视口之外,我们滚动该视口以确保玩家靠近其中心。以下 CSS 为游戏的包装 DOM 元素提供最大尺寸,并确保任何突出到元素框外的内容都不可见。我们还给它一个相对位置,以便其中的角色相对于关卡的左上角定位。
css
.game {
overflow: hidden;
max-width: 600px;
max-height: 450px;
position: relative;
}
在 scrollPlayerIntoView 方法中,我们找到玩家的位置并更新包装元素的滚动位置。当玩家离边缘太近时,我们通过操作该元素的 scrollLeft 和 scrollTop 属性来更改滚动位置。
javascript
DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
let width = this.dom.clientWidth;
let height = this.dom.clientHeight;
let margin = width / 3;

// 视口
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。
虽然总是尝试将玩家滚动到视口中心会稍微简单一些,但这会产生相当令人不安的效果。当你跳跃时,视图会不断上下移动。在屏幕中间有一个 "中性" 区域,你可以在其中移动而不会引起任何滚动,这样更舒适。
我们现在能够显示我们的小关卡了。
html
预览
<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 包含我们游戏所需的样式。
运动和碰撞
现在我们可以开始添加运动了。大多数此类游戏采用的基本方法是将时间分成小的步骤,对于每个步骤,将角色移动一段距离,该距离对应于它们的速度乘以时间步长的大小。我们将以秒为单位测量时间,因此速度以单位 / 秒表示。
移动事物很容易。困难的部分是处理元素之间的相互作用。当玩家撞到墙壁或地板时,他们不应该简单地穿过它。游戏必须注意到给定的运动何时导致一个物体撞到另一个物体,并做出相应的反应。对于墙壁,运动必须停止。当撞到硬币时,该硬币必须被收集。当触摸岩浆时,游戏应该失败。
在一般情况下解决这个问题是一项主要任务。你可以找到通常称为物理引擎的库,它们模拟二维或三维物理对象之间的相互作用。在本章中,我们将采取更温和的方法,仅处理矩形对象之间的碰撞,并以相当简单的方式处理它们。
在移动玩家或熔岩块之前,我们测试该运动是否会使其进入墙壁。如果是,我们只需完全取消运动。对这种碰撞的响应取决于角色的类型 —— 玩家会停止,而熔岩块会反弹。
这种方法要求我们的时间步长相当小,因为它会导致运动在物体实际接触之前停止。如果时间步长(以及因此的运动步长)太大,玩家最终会在离地面明显距离的地方悬停。另一种可以说更好但更复杂的方法是找到确切的碰撞点并移动到那里。我们将采用简单的方法,并通过确保动画以小步骤进行来隐藏其问题。
此方法告诉我们矩形(由位置和大小指定)是否接触给定类型的网格元素。
javascript
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.floor 和 Math.ceil 来计算身体重叠的网格方块集。请记住,网格方块的大小为 1x1 单位。通过向上和向下舍入盒子的边,我们得到盒子接触的背景方块范围。
(图表:显示一个网格,上面覆盖着一个黑盒子。所有被该块部分覆盖的网格方块都被标记。)
我们遍历通过舍入坐标找到的网格方块块,并在找到匹配的方块时返回 true。关卡外的方块总是被视为 "wall",以确保玩家不能离开世界,并且我们不会意外尝试读取 rows 数组边界之外的内容。
状态更新方法使用 touches 来判断玩家是否正在接触岩浆。
javascript
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 轴上都重叠时就是这种情况。
javascript
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"。
javascript
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 对象。
javascript
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 方法通过将时间步长和当前速度的乘积添加到其旧位置来计算新位置。如果没有障碍物阻挡该新位置,它就会移动到那里。如果有障碍物,行为取决于熔岩块的类型 —— 滴落的熔岩有一个重置位置,当它碰到某物时会跳回该位置。弹跳熔岩通过将其速度乘以 - 1 来反转其速度,以便它开始向相反方向移动。
硬币使用它们的 update 方法来摆动。它们忽略与网格的碰撞,因为它们只是在自己的方块内摆动。
javascript
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 的参数以找到波上的新位置。然后,硬币的当前位置根据其基准位置和基于该波的偏移量计算。
这就留下了玩家本身。玩家运动按轴分别处理,因为撞到地板不应阻止水平运动,撞到墙壁不应停止下落或跳跃运动。
javascript
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" 事件注册事件处理程序,当事件中的键代码存在于它正在跟踪的代码集中时,更新该对象。
javascript
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 时,动画停止。
javascript
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 调用将被暂停,直到选项卡或窗口再次显示。在这种情况下,lastTime 和 time 之间的差异将是页面被隐藏的整个时间。在单个步骤中使游戏前进这么多会看起来很傻,并且可能导致奇怪的副作用,例如玩家掉穿地板。
该函数还将时间步长转换为秒,这比毫秒更容易思考。
runLevel 函数接受一个 Level 对象和一个显示构造函数,并返回一个 promise。它在 document.body 中显示关卡,让用户玩通关。当关卡完成(失败或获胜)时,runLevel 再等待一秒钟(让用户看到发生了什么),然后清除显示,停止动画,并将 promise 解析为游戏的结束状态。
javascript
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;
}
});
});
}
游戏是一系列关卡。每当玩家死亡时,当前关卡会重新开始。完成一个关卡后,我们进入下一个关卡。这可以通过以下函数表示,该函数接受一组关卡规划(字符串)和一个显示构造函数:
javascript
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,开始实际游戏。
html
预览
<link rel="stylesheet" href="css/game.css">

<body>
<script>
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
看看你能否通关。我在构建它们时很开心。
练习
游戏结束
平台游戏的传统是让玩家开始时有有限的生命,每次死亡减去一条生命。当玩家没有生命时,游戏从开始重新开始。
调整 runGame 以实现生命功能。让玩家从三条命开始。每次关卡开始时输出当前的生命数量(使用 console.log)。
html
预览
<link rel="stylesheet" href="css/game.css">

<body>
<script>
// 旧的 runGame 函数。修改它...
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 在开始时注册其处理程序,并在完成时再次注销它们。
html
预览
<link rel="stylesheet" href="css/game.css">

<body>
<script>
// 旧的 runLevel 函数。修改这个...
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>
(显示提示...)
一个怪物
平台游戏的传统是有敌人,你可以通过跳到它们上面来击败它们。这个练习要求你向游戏中添加这样一种角色类型。
我们将这种角色称为怪物。怪物只水平移动。你可以让它们向玩家的方向移动,像水平熔岩一样来回弹跳,或者有任何其他你想要的移动模式。该类不必处理下落,但它应该确保怪物不会穿过墙壁。
当怪物碰到玩家时,效果取决于玩家是否跳到它们上面。你可以通过检查玩家的底部是否靠近怪物的顶部来近似这一点。如果是这种情况,怪物就会消失。如果不是,游戏就输了。
html
预览
<link rel="stylesheet" href="css/game.css">
<style>.monster { background: purple }</style>

<body>
<script>
// 完成构造函数、update 和 collide 方法
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>
(显示提示...)

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容