帶裝置方向的 2D 迷宮遊戲

在本教程中,我們將逐步完成構建一個 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

我們將在 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: #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 框架的初始化和遊戲狀態的定義。

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

上面這行程式碼將初始化 Phaser 例項——引數是 Canvas 的寬度、Canvas 的高度、渲染方法(我們使用 CANVAS,但也有 WEBGLAUTO 選項可用)以及我們希望將 Canvas 放入的 DOM 容器的可選 ID。如果最後一個引數中沒有指定任何內容或未找到元素,Canvas 將被新增到 標籤中。如果沒有框架,要將 Canvas 元素新增到頁面,您需要在 標籤中編寫類似以下內容:

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

需要記住的重要一點是,該框架正在設定有用的方法,以加速許多事情,例如影像處理或資產管理,這些手動操作起來會困難得多。

注意:您可以閱讀構建《怪獸想要糖果》一文,深入瞭解 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,它們是遊戲畫布的寬度和高度 — 它們將幫助我們定位螢幕上的元素。我們首先載入兩張圖片,稍後將在 Preload 狀態中用於顯示所有其他資產的載入進度。create 函式包含一些基本配置:我們正在設定畫布的縮放和對齊,並在一切準備就緒後進入 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);

我們正在向 "deviceorientation" 事件新增一個事件監聽器,並繫結 handleOrientation 函式,該函式看起來像這樣:

js
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;
  },
  // …
};

您傾斜裝置的角度越大,作用在球上的力就越大,因此球移動得越快(速度越高)。

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 (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 陣列中,該陣列預設不可見。要載入特定關卡,我們確保之前的關卡已隱藏,並顯示當前關卡:

js
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() 函式中指定碰撞物件:

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
Ball.Game.prototype = {
  // …
  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
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,但還有許多其他值得考慮的框架,如 ImpactJSConstruct 3PlayCanvas ——這取決於您的偏好、編碼技能(或缺乏)、專案規模、需求和其他方面。您應該都瞭解一下,然後決定哪一個最適合您的需求。