带设备方向的 2D 迷宫游戏
在本教程中,我们将逐步完成构建一个 HTML 移动游戏的过程,该游戏使用设备方向和振动 API 来增强游戏玩法,并使用 Phaser 框架构建。建议具备基本的 JavaScript 知识,以充分利用本教程。
示例游戏
在本教程结束时,您将拥有一个功能齐全的演示游戏:Cyber Orb。它看起来像这样:

Phaser 框架
Phaser 是一个用于构建桌面和移动 HTML 游戏的框架。它相对较新,但由于热情投入开发过程的社区而迅速发展。您可以在 GitHub 上查看其开源代码,阅读在线文档并浏览大量的示例。Phaser 框架为您提供了一套工具,可以加快开发速度并帮助处理完成游戏所需的通用任务,因此您可以专注于游戏创意本身。
项目启动
您可以在 GitHub 上查看 Cyber Orb 源代码。文件夹结构非常简单:起点是 index.html 文件,我们在这里初始化框架并设置一个 <canvas> 来渲染游戏。

您可以在您喜欢的浏览器中打开 index 文件以启动游戏并尝试。目录中还有三个文件夹:
img:游戏中将使用的所有图像。src:包含游戏所有源代码的 JavaScript 文件。audio:游戏中使用的声音文件。
设置 Canvas
我们将在 Canvas 上渲染游戏,但我们不会手动操作——这将由框架处理。让我们设置它:我们的起点是包含以下内容的 index.html 文件。如果您想跟着做,可以自己创建它。
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8" />
<title>Cyber Orb demo</title>
<style>
body {
margin: 0;
background: #333333;
}
</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 框架的初始化和游戏状态的定义。
const game = new Phaser.Game(320, 480, Phaser.CANVAS, "game");
上面这行代码将初始化 Phaser 实例——参数是 Canvas 的宽度、Canvas 的高度、渲染方法(我们使用 CANVAS,但也有 WEBGL 和 AUTO 选项可用)以及我们希望将 Canvas 放入的 DOM 容器的可选 ID。如果最后一个参数中没有指定任何内容或未找到元素,Canvas 将被添加到
<canvas id="game" width="320" height="480"></canvas>
需要记住的重要一点是,该框架正在设置有用的方法,以加速许多事情,例如图像处理或资产管理,这些手动操作起来会困难得多。
注意:您可以阅读构建《怪兽想要糖果》一文,深入了解 Phaser 特定的基本功能和方法。
回到游戏状态:下面这行代码将一个名为 Boot 的新状态添加到游戏中。
game.state.add("Boot", Ball.Boot);
第一个值是状态的名称,第二个值是我们想要分配给它的对象。start 方法启动给定状态并使其变为活动状态。让我们看看状态实际上是什么。
管理游戏状态
Phaser 中的状态是游戏逻辑的独立部分;在我们的案例中,为了更好的可维护性,我们从独立的 JavaScript 文件中加载它们。本游戏中使用的基本状态有:Boot、Preloader、MainMenu、Howto 和 Game。Boot 将负责初始化一些设置,Preloader 将加载所有资产,如图形和音频,MainMenu 是带有开始按钮的菜单,Howto 显示“如何玩”说明,而 Game 状态让您实际玩游戏。让我们快速浏览一下这些状态的内容。
Boot.js
Boot 状态是游戏中的第一个状态。
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,它们是游戏画布的宽度和高度 — 它们将帮助我们定位屏幕上的元素。我们首先加载两张图片,稍后将在 Preload 状态中用于显示所有其他资产的加载进度。create 函数包含一些基本配置:我们正在设置画布的缩放和对齐,并在一切准备就绪后进入 Preload 状态。
Preloader.js
Preloader 状态负责加载所有资产。
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
MainMenu 状态显示游戏主菜单,您可以通过点击按钮开始游戏。
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
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() 函数(每帧执行),它会更新球的位置等内容。
Ball.Game = function (game) {};
Ball.Game.prototype = {
create() {},
initLevels() {},
showLevel(level) {},
updateCounter() {},
managePause() {},
manageAudio() {},
update() {},
wallCollision() {},
handleOrientation(e) {},
finishLevel() {},
};
create 和 update 函数是框架特定的,而其他的将是我们自己的创作:
initLevels初始化关卡数据。showLevel在屏幕上打印关卡数据。updateCounter更新每个关卡的游戏时间并记录游戏总时间。managePause暂停和恢复游戏。manageAudio打开和关闭音频。wallCollision在球撞到墙壁或其他物体时执行。handleOrientation是绑定到负责设备方向 API 的事件的函数,当游戏在具有适当硬件的移动设备上运行时,它提供运动控制。finishLevel在当前关卡完成时加载新关卡,如果最终关卡完成则结束游戏。
添加球及其运动机制
首先,让我们进入 create() 函数,初始化球对象本身并为其分配一些属性:
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() 函数中:
this.keys = this.game.input.keyboard.createCursorKeys();
如您所见,有一个名为 createCursorKeys() 的特殊 Phaser 函数,它将为我们提供一个包含四个箭头键事件处理程序的对象,可供我们玩:上、下、左和右。
接下来,我们将以下代码添加到 update() 函数中,以便它在每一帧都触发。this.keys 对象将根据玩家输入进行检查,以便球能够根据预定义的力做出相应的反应:
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() 函数中负责此功能的代码:
window.addEventListener("deviceorientation", this.handleOrientation);
我们正在向 "deviceorientation" 事件添加一个事件监听器,并绑定 handleOrientation 函数,该函数看起来像这样:
Ball.Game.prototype = {
// …
handleOrientation(e) {
const x = e.gamma;
const y = e.beta;
Ball._player.body.velocity.x += x;
Ball._player.body.velocity.y += y;
},
// …
};
您倾斜设备的角度越大,作用在球上的力就越大,因此球移动得越快(速度越高)。

添加洞
游戏的主要目标是将球从起始位置移动到终点位置:地面上的一个洞。实现看起来与我们创建球的部分非常相似,它也添加在我们的 Game 状态的 create() 函数中:
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);
不同之处在于,当球击中洞时,洞的身体不会移动,并且会计算碰撞检测(这将在本文后面讨论)。
搭建积木迷宫
为了让游戏更难、更有趣,我们将在球和出口之间添加一些障碍物。我们可以使用关卡编辑器,但为了本教程的目的,让我们自己创建一些东西。
为了保存方块信息,我们将使用一个关卡数据数组:对于每个方块,我们将存储以像素为单位的顶部和左侧绝对位置(x 和 y),以及方块的类型——水平或垂直(t,其中 'w' 值表示宽度,'h' 值表示高度)。然后,为了加载关卡,我们将解析数据并显示该关卡特定的方块。在 initLevels 函数中,我们有:
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" },
],
// …
];
每个数组元素都包含一组具有 x 和 y 位置以及 t 值的块。在 levelData 之后,但仍在 initLevels 函数中,我们使用一些框架特定的方法在 for 循环中将块添加到数组中:
for (let i = 0; i < this.maxLevels; i++) {
const newLevel = this.add.group();
newLevel.enableBody = true;
newLevel.physicsBodyType = Phaser.Physics.ARCADE;
for (const item of this.levelData[i]) {
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 数组中,该数组默认不可见。要加载特定关卡,我们确保之前的关卡已隐藏,并显示当前关卡:
Ball.Game.prototype = {
// …
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() 函数中指定碰撞对象:
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() 函数中定义:
this.bounceSound = this.game.add.audio("audio-bounce");
如果音频状态为 true(即游戏中已启用声音),我们可以在 wallCollision 函数中播放它:
if (this.audioStatus) {
this.bounceSound.play();
}
就是这样 — 使用 Phaser 加载和播放声音非常简单。
实现振动 API
当碰撞检测按预期工作时,让我们借助振动 API 添加一些特殊效果。

在我们的情况下,最好的使用方式是每次球撞到墙壁时让手机振动——在 wallCollision 函数内部:
if ("vibrate" in window.navigator) {
window.navigator.vibrate(100);
}
如果浏览器支持 vibrate 方法并且在 window.navigator 对象中可用,则手机振动 100 毫秒。就是这样!
添加经过时间
为了提高可玩性并让玩家有机会互相竞争,我们将存储经过的时间——玩家可以尝试提高他们的最佳游戏完成时间。为此,我们必须创建一个变量来存储从游戏开始以来经过的实际秒数,并在游戏中向玩家显示。让我们首先在 create 函数中定义这些变量:
this.timer = 0; // time elapsed in the current level
this.totalTimer = 0; // time elapsed in the whole game
然后,紧接着,我们可以初始化必要的文本对象,以向用户显示此信息:
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,
);
我们正在定义文本的顶部和左侧位置、将显示的内容以及应用于文本的样式。我们已经将其打印在屏幕上,但最好每秒更新一次值:
this.time.events.loop(Phaser.Timer.SECOND, this.updateCounter, this);
此循环(同样在 create 函数中)将从游戏开始的每秒执行 updateCounter 函数,因此我们可以相应地应用更改。完整的 updateCounter 函数如下所示:
Ball.Game.prototype = {
// …
updateCounter() {
this.timer++;
this.timerText.setText(`Time: ${this.timer}`);
this.totalTimeText.setText(`Total time: ${this.totalTimer + this.timer}`);
},
// …
};
如您所见,我们正在递增 this.timer 变量,并在每次迭代中更新文本对象的内容,以便玩家看到经过的时间。
完成关卡和游戏
球在屏幕上滚动,计时器正在运行,我们已经创建了必须到达的洞。现在让我们设置实际完成关卡的可能性!update() 函数中的以下代码行添加了一个监听器,当球到达洞时触发。
this.physics.arcade.overlap(this.ball, this.hole, this.finishLevel, null, this);
这与前面解释的 collide 方法类似。当球与洞重叠(而不是碰撞)时,执行 finishLevel 函数:
Ball.Game.prototype = {
// …
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,但还有许多其他值得考虑的框架,如 ImpactJS、Construct 3 或 PlayCanvas ——这取决于您的偏好、编码技能(或缺乏)、项目规模、需求和其他方面。您应该都了解一下,然后决定哪一个最适合您的需求。