使用设备方向的二维迷宫游戏

在本教程中,我们将介绍构建一个 HTML 移动游戏的过程,该游戏使用 设备方向振动 API 来增强游戏玩法,并使用 Phaser 框架构建。建议您具备基本的 JavaScript 知识才能充分利用本教程。

示例游戏

在本教程结束时,您将拥有一个功能齐全的演示游戏:Cyber Orb。它看起来像这样

A 2D game board featuring a small yellow ball. There is a large black hole for the ball to escape down, and a number of barriers blocking the ball from escaping.

Phaser 框架

Phaser 是一个用于构建桌面和移动 HTML 游戏的框架。它比较新,但由于参与开发过程的热情社区,发展迅速。您可以查看 GitHub 上的代码,它开源了,阅读 在线文档 并浏览大量 示例。Phaser 框架为您提供了一套工具,可以加快开发速度并帮助处理完成游戏所需的一般任务,以便您可以专注于游戏理念本身。

从项目开始

您可以在 GitHub 上查看 Cyber Orb 源代码。文件夹结构非常简单:起点是 index.html 文件,我们在其中初始化框架并设置一个 <canvas> 来渲染游戏。

Screenshot of the GitHub repository with the Cyber Orb game code, listing the folders and the files in the main structure.

您可以在您喜欢的浏览器中打开 index 文件以启动游戏并尝试它。目录中还有三个文件夹

  • img: 我们将在游戏中使用的所有图像。
  • src: 包含游戏所有源代码的 JavaScript 文件。
  • audio: 游戏中使用的音频文件。

设置画布

我们将使用 Canvas 渲染游戏,但我们不会手动进行 - 这将由框架处理。让我们设置它:我们的起点是 index.html 文件,其内容如下。如果您想继续学习,可以自己创建它

html
<!doctype html>
<html lang="en-GB">
  <head>
    <meta charset="utf-8" />
    <title>Cyber Orb demo</title>
    <style>
      body {
        margin: 0;
        background: #333;
      }
    </style>
    <script src="src/phaser-arcade-physics.2.2.2.min.js"></script>
    <script src="src/Boot.js"></script>
    <script src="src/Preloader.js"></script>
    <script src="src/MainMenu.js"></script>
    <script src="src/Howto.js"></script>
    <script src="src/Game.js"></script>
  </head>
  <body>
    <script>
      (() => {
        const game = new Phaser.Game(320, 480, Phaser.CANVAS, "game");
        game.state.add("Boot", Ball.Boot);
        game.state.add("Preloader", Ball.Preloader);
        game.state.add("MainMenu", Ball.MainMenu);
        game.state.add("Howto", Ball.Howto);
        game.state.add("Game", Ball.Game);
        game.state.start("Boot");
      })();
    </script>
  </body>
</html>

到目前为止,我们有一个简单的 HTML 网站,在 <head> 部分有一些基本内容:字符集、标题、CSS 样式以及 JavaScript 文件的包含。<body> 包含 Phaser 框架的初始化和游戏状态的定义。

js
const game = new Phaser.Game(320, 480, Phaser.CANVAS, "game");

上面的行将初始化 Phaser 实例 - 参数是 Canvas 的宽度、Canvas 的高度、渲染方法(我们使用 CANVAS,但也有 WEBGLAUTO 选项可用)以及我们要放置 Canvas 的 DOM 容器的可选 ID。如果该最后一个参数中没有指定内容或找不到该元素,则 Canvas 将被添加到 <body> 标签中。没有框架,要将 Canvas 元素添加到页面,您需要在 <body> 标签中编写类似以下内容

html
<canvas id="game" width="320" height="480"></canvas>

需要记住的重要一点是,框架正在设置有用的方法来加速图像处理或资产管理等许多事情,这些事情手动进行将非常困难。

注意:您可以阅读 构建 Monster Wants Candy 文章,以深入了解基本的 Phaser 特定函数和方法。

回到游戏状态:下面这行将一个名为 Boot 的新状态添加到游戏中

js
game.state.add("Boot", Ball.Boot);

第一个值是状态的名称,第二个值是我们想要分配给它的对象。start 方法启动给定状态并使其处于活动状态。让我们看看状态到底是什么。

管理游戏状态

Phaser 中的状态是游戏逻辑的独立部分;在本例中,我们从独立的 JavaScript 文件中加载它们,以提高可维护性。此游戏中使用的基本状态是:BootPreloaderMainMenuHowtoGameBoot 将负责初始化一些设置,Preloader 将加载所有资产,如图形和音频,MainMenu 是带有开始按钮的菜单,Howto 显示“如何玩”说明,而 Game 状态允许您实际玩游戏。让我们快速浏览一下这些状态的内容。

Boot.js

Boot 状态是游戏中第一个状态。

js
const Ball = {
  _WIDTH: 320,
  _HEIGHT: 480,
};

Ball.Boot = function (game) {};
Ball.Boot.prototype = {
  preload() {
    this.load.image("preloaderBg", "img/loading-bg.png");
    this.load.image("preloaderBar", "img/loading-bar.png");
  },
  create() {
    this.game.scale.scaleMode = Phaser.ScaleManager.SHOW_ALL;
    this.game.scale.pageAlignHorizontally = true;
    this.game.scale.pageAlignVertically = true;
    this.game.state.start("Preloader");
  },
};

定义了主要 Ball 对象,我们添加了两个名为 _WIDTH_HEIGHT 的变量,它们是游戏 Canvas 的宽度和高度 - 它们将帮助我们在屏幕上定位元素。我们首先加载两个图像,这些图像将在以后的 Preload 状态中使用,以显示加载所有其他资产的进度。create 函数包含一些基本配置:我们正在设置 Canvas 的缩放和对齐方式,并在一切准备就绪后进入 Preload 状态。

Preloader.js

Preloader 状态负责加载所有资产

js
Ball.Preloader = function (game) {};
Ball.Preloader.prototype = {
  preload() {
    this.preloadBg = this.add.sprite(
      (Ball._WIDTH - 297) * 0.5,
      (Ball._HEIGHT - 145) * 0.5,
      "preloaderBg",
    );
    this.preloadBar = this.add.sprite(
      (Ball._WIDTH - 158) * 0.5,
      (Ball._HEIGHT - 50) * 0.5,
      "preloaderBar",
    );
    this.load.setPreloadSprite(this.preloadBar);

    this.load.image("ball", "img/ball.png");
    // …
    this.load.spritesheet("button-start", "img/button-start.png", 146, 51);
    // …
    this.load.audio("audio-bounce", [
      "audio/bounce.ogg",
      "audio/bounce.mp3",
      "audio/bounce.m4a",
    ]);
  },
  create() {
    this.game.state.start("MainMenu");
  },
};

框架加载了单个图像、精灵表和音频文件。在这个状态中,preloadBar 在屏幕上显示进度。框架使用一张图像来可视化已加载资产的进度。随着每项资产的加载,您可以看到 preloadBar 图像更多:从 0% 到 100%,在每一帧上更新。所有资产加载完成后,MainMenu 状态将被启动。

MainMenu 状态显示游戏的主菜单,您可以在其中单击按钮开始玩。

js
Ball.MainMenu = function (game) {};
Ball.MainMenu.prototype = {
  create() {
    this.add.sprite(0, 0, "screen-mainmenu");
    this.gameTitle = this.add.sprite(Ball._WIDTH * 0.5, 40, "title");
    this.gameTitle.anchor.set(0.5, 0);
    this.startButton = this.add.button(
      Ball._WIDTH * 0.5,
      200,
      "button-start",
      this.startGame,
      this,
      2,
      0,
      1,
    );
    this.startButton.anchor.set(0.5, 0);
    this.startButton.input.useHandCursor = true;
  },
  startGame() {
    this.game.state.start("Howto");
  },
};

要创建一个新按钮,可以使用 add.button 方法,该方法具有以下可选参数列表

  • Canvas 上的顶部绝对位置,以像素为单位。
  • Canvas 上的左侧绝对位置,以像素为单位。
  • 按钮使用的图像资产名称。
  • 当有人单击按钮时将执行的函数。
  • 执行上下文。
  • 图像资产中用作按钮“悬停”状态的帧。
  • 图像资产中用作按钮“普通”或“外部”状态的帧。
  • 图像资产中用作按钮“单击”或“按下”状态的帧。

anchor.set 正在为按钮设置锚点,所有位置计算都将应用于该锚点。在本例中,它从左侧边缘的中间位置以及顶部边缘的起点开始锚定,因此它可以轻松地在屏幕上水平居中,而无需知道其宽度。

当按下开始按钮时,游戏不会直接跳入操作,而是会显示一个屏幕,其中包含有关如何玩游戏的相关信息。

Howto.js

js
Ball.Howto = function (game) {};
Ball.Howto.prototype = {
  create() {
    this.buttonContinue = this.add.button(
      0,
      0,
      "screen-howtoplay",
      this.startGame,
      this,
    );
  },
  startGame() {
    this.game.state.start("Game");
  },
};

Howto 状态在开始游戏之前在屏幕上显示游戏玩法说明。单击屏幕后,将启动实际游戏。

Game.js

Game.js 文件中的 Game 状态是所有神奇之处发生的地方。所有初始化都在 create() 函数中(在游戏开始时启动一次)。之后,某些功能将需要进一步的代码才能控制 - 我们将编写自己的函数来处理更复杂的任务。尤其是要注意 update() 函数(在每一帧执行),该函数更新球的位置等内容。

js
Ball.Game = function (game) {};
Ball.Game.prototype = {
  create() {},
  initLevels() {},
  showLevel(level) {},
  updateCounter() {},
  managePause() {},
  manageAudio() {},
  update() {},
  wallCollision() {},
  handleOrientation(e) {},
  finishLevel() {},
};

createupdate 函数是框架特有的,而其他函数将是我们自己的创建

  • initLevels 初始化关卡数据。
  • showLevel 在屏幕上打印关卡数据。
  • updateCounter 更新玩每个关卡所花费的时间,并记录玩游戏所花费的总时间。
  • managePause 暂停和恢复游戏。
  • manageAudio 打开和关闭音频。
  • wallCollision 当球撞击墙壁或其他物体时执行。
  • handleOrientation 是绑定到负责设备方向 API 的事件的函数,在游戏在具有相应硬件的移动设备上运行时提供运动控制。
  • finishLevel 当当前关卡完成时加载新关卡,或在完成最后一关时完成游戏。

添加球及其运动机制

首先,让我们转到 create() 函数,初始化球对象本身,并为其分配一些属性

js
this.ball = this.add.sprite(this.ballStartPos.x, this.ballStartPos.y, "ball");
this.ball.anchor.set(0.5);
this.physics.enable(this.ball, Phaser.Physics.ARCADE);
this.ball.body.setSize(18, 18);
this.ball.body.bounce.set(0.3, 0.3);

在这里,我们在屏幕上的指定位置添加了一个精灵,并使用从加载的图形资源中加载的'ball'图像。我们还将任何物理计算的锚点设置为球的中心,启用 Arcade 物理引擎(它处理球运动的所有物理),并设置用于碰撞检测的物体大小。bounce属性用于设置球体在撞击障碍物时的弹性。

控制球体

让球体准备好被扔进游戏区域很酷,但能够实际移动它也很重要!现在我们将添加使用桌面设备键盘控制球体的方法,然后我们将移动到设备方向 API 的实现。让我们首先关注键盘,在create()函数中添加以下内容

js
this.keys = this.game.input.keyboard.createCursorKeys();

如您所见,有一个名为createCursorKeys()的特殊 Phaser 函数,它将为我们提供一个包含四个箭头键事件处理程序的对象,以便我们使用:上、下、左和右。

接下来,我们将以下代码添加到update()函数中,以便它在每一帧都触发。this.keys对象将根据玩家输入进行检查,以便球体可以根据预定义的力相应地做出反应

js
if (this.keys.left.isDown) {
  this.ball.body.velocity.x -= this.movementForce;
} else if (this.keys.right.isDown) {
  this.ball.body.velocity.x += this.movementForce;
}
if (this.keys.up.isDown) {
  this.ball.body.velocity.y -= this.movementForce;
} else if (this.keys.down.isDown) {
  this.ball.body.velocity.y += this.movementForce;
}

这样,我们就可以检查在给定帧按下哪个键,并将定义的力施加到球体上,从而增加适当方向上的速度。

实现设备方向 API

也许游戏中最有趣的部分是在移动设备上使用 **设备方向 API** 进行控制。借助它,您可以通过倾斜设备来控制球体滚动的方向。以下是来自负责此操作的create()函数的代码

js
window.addEventListener("deviceorientation", this.handleOrientation, true);

我们将一个事件监听器添加到"deviceorientation"事件中,并将handleOrientation函数绑定在一起,该函数看起来像这样

js
handleOrientation(e) {
  const x = e.gamma;
  const y = e.beta;
  Ball._player.body.velocity.x += x;
  Ball._player.body.velocity.y += y;
},

您倾斜设备的程度越大,施加到球体上的力就越大,因此球体移动的速度越快(速度越高)。

An explanation of the X, Y and Z axes of a Flame mobile device with the Cyber Orb game demo on the screen.

添加洞

游戏中的主要目标是从起始位置移动球体到结束位置:地面上的一个洞。实现起来与我们创建球体时非常相似,并且也添加到了我们Game状态的create()函数中

js
this.hole = this.add.sprite(Ball._WIDTH * 0.5, 90, "hole");
this.physics.enable(this.hole, Phaser.Physics.ARCADE);
this.hole.anchor.set(0.5);
this.hole.body.setSize(2, 2);

不同之处在于,当我们用球体击中洞的物体时,它不会移动,并且将计算碰撞检测(将在本文后面讨论)。

构建块迷宫

为了使游戏更具挑战性且更有趣,我们将在球体和出口之间添加一些障碍物。我们可以使用关卡编辑器,但为了本教程的说明,让我们自己创建一些东西。

为了保存块信息,我们将使用一个关卡数据数组:对于每个块,我们将以像素为单位存储顶部和左边的绝对位置(xy),以及块的类型 - 水平或垂直(t,其中'w'表示宽度,'h'表示高度)。然后,要加载关卡,我们将解析数据并显示该关卡特有的块。在initLevels函数中,我们有

js
this.levelData = [
  [{ x: 96, y: 224, t: "w" }],
  [
    { x: 72, y: 320, t: "w" },
    { x: 200, y: 320, t: "h" },
    { x: 72, y: 150, t: "w" },
  ],
  // …
];

每个数组元素都包含一个块集合,每个块都有xy位置,以及t值。在levelData之后,但仍在initLevels函数中,我们使用一些特定于框架的方法在for循环中将块添加到数组中

js
for (let i = 0; i < this.maxLevels; i++) {
  const newLevel = this.add.group();
  newLevel.enableBody = true;
  newLevel.physicsBodyType = Phaser.Physics.ARCADE;
  for (let e = 0; e < this.levelData[i].length; e++) {
    const item = this.levelData[i][e];
    newLevel.create(item.x, item.y, `element-${item.t}`);
  }
  newLevel.setAll("body.immovable", true);
  newLevel.visible = false;
  this.levels.push(newLevel);
}

首先,add.group()用于创建一个新的项目组。然后,为该组设置ARCADE物体类型以启用物理计算。newLevel.create方法使用起始的左右位置和自身的图像在组中创建新项目。如果您不想再次遍历项目列表以显式地将属性添加到每个项目中,则可以在组上使用setAll将其应用于该组中的所有项目。

这些对象存储在this.levels数组中,该数组默认情况下是不可见的。要加载特定关卡,我们将确保先前关卡已隐藏,并显示当前关卡

js
showLevel(level) {
  const lvl = level | this.level;
  if (this.levels[lvl - 2]) {
    this.levels[lvl - 2].visible = false;
  }
  this.levels[lvl - 1].visible = true;
}

由于这一点,游戏给玩家带来了挑战 - 现在他们必须将球体滚过游戏区域,并引导它穿过由块构建的迷宫。这仅仅是加载关卡的示例,并且只有 5 个关卡用于展示这个想法,但您可以自己扩展这些功能。

碰撞检测

在这一点上,我们已经拥有由玩家控制的球体,要到达的洞以及阻挡去路的障碍物。但是,存在一个问题 - 我们的游戏还没有任何碰撞检测,因此当球体撞击块时不会发生任何事情 - 它只是穿透。让我们解决这个问题!好消息是该框架将负责计算碰撞检测,我们只需要在update()函数中指定碰撞物体

js
this.physics.arcade.collide(
  this.ball,
  this.borderGroup,
  this.wallCollision,
  null,
  this,
);
this.physics.arcade.collide(
  this.ball,
  this.levels[this.level - 1],
  this.wallCollision,
  null,
  this,
);

这将告诉框架在球体撞击任何墙壁时执行wallCollision函数。我们可以使用wallCollision函数来添加我们想要的任何功能,例如播放弹跳声和实现 **振动 API**。

添加声音

在预加载的资源中,有一首音频轨道(以各种格式提供,以实现浏览器兼容性),我们现在可以使用它。它必须首先在create()函数中定义

js
this.bounceSound = this.game.add.audio("audio-bounce");

如果音频状态为true(因此游戏中的声音已启用),我们可以在wallCollision函数中播放它

js
if (this.audioStatus) {
  this.bounceSound.play();
}

就是这样 - 使用 Phaser 加载和播放声音很简单。

实现振动 API

当碰撞检测按预期工作时,让我们借助振动 API 添加一些特殊效果。

A visualization of the vibrations of a Flame mobile device with the Cyber Orb game demo on the screen.

在我们这种情况下使用它的最佳方法是在每次球体撞击墙壁时振动手机 - 在wallCollision函数内

js
if ("vibrate" in window.navigator) {
  window.navigator.vibrate(100);
}

如果浏览器支持vibrate方法,并且它在window.navigator对象中可用,则使手机振动 100 毫秒。就这样!

添加经过的时间

为了提高可重玩性并为玩家提供相互竞争的选项,我们将存储经过的时间 - 这样玩家就可以尝试提高他们在游戏完成时的最佳时间。为了实现这一点,我们必须创建一个变量来存储从游戏开始到现在的实际经过秒数,并在游戏中将其显示给玩家。让我们首先在create函数中定义变量

js
this.timer = 0; // time elapsed in the current level
this.totalTimer = 0; // time elapsed in the whole game

然后,就在之后,我们可以初始化必要的文本对象以将此信息显示给用户

js
this.timerText = this.game.add.text(
  15,
  15,
  `Time: ${this.timer}`,
  this.fontBig,
);
this.totalTimeText = this.game.add.text(
  120,
  30,
  `Total time: ${this.totalTimer}`,
  this.fontSmall,
);

我们定义了文本的顶部和左侧位置、将显示的内容以及应用于文本的样式。我们将其打印在屏幕上,但最好每秒更新一次这些值

js
this.time.events.loop(Phaser.Timer.SECOND, this.updateCounter, this);

此循环也位于create函数中,它将从游戏开始时每秒执行一次updateCounter函数,以便我们可以相应地应用更改。以下是完整的updateCounter函数的外观

js
updateCounter() {
  this.timer++;
  this.timerText.setText(`Time: ${this.timer}`);
  this.totalTimeText.setText(`Total time: ${this.totalTimer+this.timer}`);
},

如您所见,我们正在递增this.timer变量,并在每次迭代中使用当前值更新文本对象的内容,以便玩家看到经过的时间。

完成关卡和游戏

球体正在屏幕上滚动,计时器正在运行,我们创建了要到达的洞。现在让我们设置实际完成关卡的可能性!update()函数中的以下行添加了一个监听器,该监听器在球体到达洞时触发。

js
this.physics.arcade.overlap(this.ball, this.hole, this.finishLevel, null, this);

这与之前解释的collide方法类似。当球体与洞重叠(而不是发生碰撞)时,将执行finishLevel函数

js
finishLevel() {
  if (this.level >= this.maxLevels) {
    this.totalTimer += this.timer;
    alert(`Congratulations, game completed!\nTotal time of play: ${this.totalTimer} seconds!`);
    this.game.state.start('MainMenu');
  } else {
    alert(`Congratulations, level ${this.level} completed!`);
    this.totalTimer += this.timer;
    this.timer = 0;
    this.level++;
    this.timerText.setText(`Time: ${this.timer}`);
    this.totalTimeText.setText(`Total time: ${this.totalTimer}`);
    this.levelText.setText(`Level: ${this.level} / ${this.maxLevels}`);
    this.ball.body.x = this.ballStartPos.x;
    this.ball.body.y = this.ballStartPos.y;
    this.ball.body.velocity.x = 0;
    this.ball.body.velocity.y = 0;
    this.showLevel();
  }
},

如果当前关卡等于最大关卡数(在本例中为 5),则游戏结束 - 您将收到一条祝贺消息以及整个游戏经过的秒数,以及一个按钮,按下它将带您回到主菜单。

如果当前关卡小于 5,则所有必要的变量将被重置,并加载下一关卡。

新功能的想法

这仅仅是一个游戏工作的演示,它可能有很多额外的功能。例如,我们可以添加可以在途中收集的能量提升,这些能量提升将使我们的球体滚动得更快,暂停计时器几秒钟,或赋予球体特殊能力以穿过障碍物。还有空间放置陷阱,这些陷阱会减慢球体的速度,或者使其更难到达洞穴。您可以创建更多难度递增的关卡。您甚至可以为游戏中的不同操作实现成就、排行榜和奖牌。可能性是无限的 - 它们只取决于您的想象力。

总结

我希望本教程能帮助您深入了解 2D 游戏开发,并激励您自己创建很棒的游戏。您可以玩演示游戏 Cyber Orb 并查看其 GitHub 上的源代码.

HTML 为我们提供了原始工具,构建在其之上的框架变得越来越快,功能也越来越强大,因此现在正是进入 Web 游戏开发的绝佳时机。在本教程中,我们使用了 Phaser,但还有许多 其他框架 值得考虑,例如 ImpactJSConstruct 3PlayCanvas - 这取决于您的偏好、编码技能(或缺乏编码技能)、项目规模、要求和其他方面。您应该检查所有这些框架,并决定哪一个最适合您的需求。