JavaScript 编程精解 中文第三版 十6、项目:平台游戏

来源: ApacheCN『JavaScript 编程精解 中文第三版』翻译项目

原文:Project: A Platform Gamejavascript

译者:飞龙css

协议:CC BY-NC-SA 4.0html

自豪地采用谷歌翻译java

部分参考了《JavaScript 编程精解(第 2 版)》git

全部现实都是游戏。github

Iain Banks,《The Player of Games》apache

我最初对电脑的痴迷,就像许多小孩同样,与电脑游戏有关。我沉迷在那个计算机所模拟出的小小世界中,我能够操纵这个世界,我同时也沉迷在那些还没有展开的故事之中。但我沉迷其中并非由于游戏实际描述的故事,而是由于我能够充分发挥个人想象力,去构思故事的发展。编程

我并不但愿任何人把编写游戏做为本身的事业。就像音乐产业中,那些但愿加入这个行业的热忱年轻人与实际的人才需求之间存在巨大的鸿沟,也所以产生了一个极不健康的就业环境。不过,把编写游戏做为乐趣仍是至关不错的。canvas

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

游戏

咱们游戏大体基于由 Thomas Palef 开发的 Dark Blue。我之因此选择了这个游戏,是由于这个游戏既有趣又简单,并且不须要编写大量代码。该游戏看起来以下页图所示。

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

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

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

实现技术

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

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

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

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

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

关卡

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

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

var 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包含字符数组、平面图的行。咱们能够从中得出水平宽度和高度。可是咱们仍然必须将可移动元素与背景网格分开。咱们将其称为角色(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"

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

角色

角色对象表示,游戏中给定可移动元素的当前位置和状态。全部的角色对象都遵循相同的接口。它们的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产生的波长是。咱们能够将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(); }
}

因为关卡的背景网格不会改变,所以只须要绘制一次便可。角色则须要在每次刷新显示时进行重绘。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})))
  ));
}

前文说起过,咱们使用<table>元素来绘制背景。这很是符合关卡中grid属性的结构。网格中的每一行对应表格中的一行(<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;              }

其中某些属性(border-spacing和padding)用于取消一些咱们不想保留的表格默认行为。咱们不但愿在单元格之间或单元格内部填充多余的空白。

其中background规则用于设置背景颜色。CSS中可使用两种方式来指定颜色,一种方法是使用单词(white),另外一种方法是使用形如rgb(R,G,B)的格式,其中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类会赋予角色一个绝对坐标。咱们将角色的类型名称做为额外的 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;
}

在遇到岩浆以后,玩家的颜色应该变成深红色,暗示着角色被烧焦了。当玩家收集完最后一枚硬币时,咱们添加两个模糊的白色阴影来建立白色的光环效果,其中一个在左上角,一个在右上角。

咱们没法假定关卡老是符合视口尺寸,它是咱们在其中绘制游戏的元素。因此咱们须要调用scrollPlayerIntoView来确保若是关卡在视口范围以外,咱们能够滚动视口,确保玩家靠近视口的中央位置。下面的 CSS 样式为包装器的DOM元素设置了一个最大尺寸,以确保任何超出视口的元素都是不可见的。咱们能够将外部元素的position设置为relative,所以该元素中的角色老是相对于关卡的左上角进行定位。

.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类型来写出相对可读的计算代码。为了找出玩家的中心位置,咱们须要将左上角位置坐标加上其尺寸的一半。计算结果就是关卡坐标的中心位置。可是咱们须要将结果向量乘以显示比例,以将坐标转换成像素级坐标。

接下来,咱们对玩家的坐标进行一系列检测,确保其位置不会超出合法范围。这里须要注意的是这段代码有时候依然会设置无心义的滚动坐标,好比小于 0 的值或超出元素滚动区域的值。这是没问题的。DOM 会将其修改成可接受的值。若是咱们将scrollLeft设置为–10,DOM 会将其修改成 0。

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

咱们如今可以显示小型关卡。

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

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

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

动做与冲突

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

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

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

在移动角色或岩浆块时,咱们须要测试元素是否会移动到墙里面。若是会的话,咱们只要取消整个动做便可。而对动做的反应则取决于移动元素类型。若是是玩家则停下来,若是是岩浆块则反弹回去。

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

该方法用于判断某个矩形(经过位置与尺寸限定)是否会碰到给定类型的网格。

Level.prototype.touches = function(pos, size, type) {
  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);

  for (var y = yStart; y < yEnd; y++) {
    for (var 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,来计算与身体重叠的网格方块集合。记住网格方块的大小是1x1个单位。经过将盒子的边上下颠倒,咱们获得盒子接触的背景方块的范围。

咱们经过查找坐标遍历网格方块,并在找到匹配的方块时返回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));
  }
};

它经过将时间步长乘上当前速度,并将其加到其旧位置,来计算新的位置。若是新的位置上没有障碍,它移动到那里。若是有障碍物,其行为取决于岩浆块的类型:滴落岩浆具备reset位置,当它碰到某物时,它会跳回去。跳跃岩浆将其速度乘以-1,从而开始向相反的方向移动。

硬币使用它们的act方法来晃动。他们忽略了网格的碰撞,由于它们只是在它们本身的方块内部晃动。

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

运行游戏

咱们在第十四章中看到的requestAnimationFrames函数是一种产生游戏动画的好方法。但该函数的接口有点过于原始。该函数要求咱们跟踪上次调用函数的时间,并在每一帧后再次调用requestAnimationFrame方法。

咱们这里定义一个辅助函数来将这部分烦人的代码包装到一个名为runAnimation的简单接口中,咱们只需向其传递一个函数便可,该函数的参数是一个时间间隔,并用于绘制一帧图像。当帧函数返回false时,整个动画中止。

function runAnimation(frameFunc) {
  let lastTime = null;
  function frame(time) {
    let stop = false;
    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对象和显示对象的构造器,并返回一个PromiserunLevel函数(在document.body中)显示关卡,并使得用户经过该节点操做游戏。当关卡结束时(或胜或负),runLevel会多等一秒(让用户看看发生了什么),清除关卡,并中止动画,若是咱们指定了andThen函数,则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.setState(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返回PromiserunGame可使用async函数编写,如第十一章中所见。它返回另外一个Promise,当玩家完成游戏时获得解析。

本章的沙盒GAME_LEVELS绑定中,有一组可用的关卡平面图。这个页面将它们提供给runGame,启动实际的游戏:

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

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

习题

游戏结束

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

调整runGame来实现生命机制。玩家一开始会有 3 条生命。每次启动时输出当前生命数量(使用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来从新安排调度策略,也是能够实现的。

当你完成该功能后,能够尝试加入另外一个功能。咱们如今注册键盘事件处理器的方法多少有点问题。如今arrows对象是一个全局绑定,即便游戏没有运行时,事件处理器也是有效的。咱们称之为系统泄露。请扩展tracKeys,提供一种方法来注销事件处理器,接着修改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.setState(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>

怪物

它是传统的平台游戏,里面有敌人,你能够跳到它顶上来战胜它。这个练习要求你把这种角色类型添加到游戏中。

咱们称之为怪物。怪物只能水平移动。你可让它们朝着玩家的方向移动,或者像水平岩浆同样来回跳动,或者拥有你想要的任何运动模式。这个类没必要处理掉落,可是它应该确保怪物不会穿过墙壁。

当怪物接触玩家时,效果取决于玩家是否跳到它们顶上。你能够经过检查玩家的底部是否接近怪物的顶部来近似它。若是是这样的话,怪物就消失了。若是没有,游戏就输了。

<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>
相关文章
相关标签/搜索