個案研究 - Onslaught!運動場

Geoff Blair
Geoff Blair
Matt Hackett
Matt Hackett

簡介

我們在 2010 年 6 月得知,當地出版「雜誌」Boing Boing 舉辦遊戲開發競賽。我們認為這是一個絕佳的理由,可以使用 JavaScript 和 <canvas> 快速製作簡單的遊戲,因此就開始著手製作。比賽結束後,我們仍有許多想法,希望能完成我們開始的計畫。以下是個案研究的結果,這是一款名為「Onslaught」的小遊戲!競技場

復古像素風格

由於比賽主旨是開發以芯片音樂為主題的遊戲,因此我們的遊戲必須具有復古 Nintendo Entertainment System 遊戲的視覺和操作體驗。大多數遊戲都沒有這項要求,但由於素材資源容易製作,且對懷舊玩家來說自然吸引力十足,因此仍是常見的藝術風格 (尤其是獨立開發人員)。

猛攻!競技場像素大小
增加像素大小可以減少圖像設計工作。

考量這些圖像精靈的大小,我們決定將像素加倍,也就是說,16x16 圖像精靈現在會是 32x32 像素,以此類推。從一開始,我們就一直在資產建立方面加倍努力,而不是讓瀏覽器負擔繁重的工作。這麼做不但實作起來更容易,也能帶來一些明顯的外觀優勢。

以下是我們考慮的情況:

<style>
canvas {
  width: 640px;
  height: 320px;
}
</style>
<canvas width="320" height="240">
  Sorry, your browser is not supported.
</canvas>

這個方法會包含 1x1 圖像,而不是在素材資源建立端將圖像加倍。接著,CSS 會接手並調整畫布本身的大小。我們的基準測試顯示,這個方法的速度大約是渲染較大 (加倍) 圖片的兩倍,但不幸的是,CSS 大小調整會包含反鋸齒,而我們無法找到避免這種情況的方法。

調整畫布大小的選項
左側:在 Photoshop 中以像素完美的方式重複使用素材資源。右圖:CSS 調整大小功能會加入模糊效果。

這對我們的遊戲來說是個致命缺陷,因為個別像素非常重要。不過,如果您需要調整畫布的大小,且您認為抗鋸齒處理方式適合您的專案,則可以考慮採用這種做法來提升效能。

有趣的畫布技巧

我們都知道 <canvas> 是新熱門技術,但有時開發人員仍建議使用 DOM。如果您不確定要使用哪一個,以下範例說明 <canvas> 如何為我們節省大量時間和精力。

Onslaught!Arena,會閃爍紅色,並短暫顯示「痛苦」動畫。為了限制所需建立的圖形數量,我們只會在向下方向顯示「痛苦」的敵人。這在遊戲中看起來相當不錯,而且省下了大量製作圖像片段的時間。不過,對於 Boss 怪物而言,如果大型精靈 (64 x 64 像素或更大) 在痛苦畫面中從左或上方突然轉向下方,會讓人感到不適。

顯而易見的解決方法,就是為每個 Boss 在八個方向繪製痛苦影格,但這會耗費大量時間。多虧了 <canvas>,我們才能在程式碼中解決這個問題:

Beholder 在 Onslaught 中受到傷害!運動場
您可以使用 context.globalCompositeOperation 製作有趣的特效。

首先,我們將怪物繪製到隱藏的「緩衝區」<canvas>,並以紅色重疊,然後將結果算繪回螢幕。程式碼如下所示:

// Get the "buffer" canvas (that isn't visible to the user)
var bufferCanvas = document.getElementById("buffer");
var buffer = bufferCanvas.getContext("2d");

// Draw your image on the buffer
buffer.drawImage(image, 0, 0);

// Draw a rectangle over the image using a nice translucent overlay
buffer.save();
buffer.globalCompositeOperation = "source-in";
buffer.fillStyle = "rgba(186, 51, 35, 0.6)"; // red
buffer.fillRect(0, 0, image.width, image.height);
buffer.restore();

// Copy the buffer onto the visible canvas
document.getElementById("stage").getContext("2d").drawImage(bufferCanvas, x, y);

遊戲迴圈

遊戲開發與網頁開發有幾項明顯差異。在網頁堆疊中,通常會透過事件監聽器回應發生的事件。因此,除了監聽輸入事件之外,初始化程式碼可能不會執行任何操作。遊戲邏輯則不同,因為遊戲需要持續更新。舉例來說,如果玩家沒有移動,那麼惡狼人就不會因此停止攻擊!

以下是遊戲迴圈的範例:

function main () {
  handleInput();
  update();
  render();
};

setInterval(main, 1);

第一個重要的差異在於,handleInput 函式實際上並不會立即「執行」任何操作。如果使用者在一般網頁應用程式中按下按鍵,系統應立即執行所需動作。但在遊戲中,事件必須按時間順序發生,才能正確流動。

window.addEventListener("mousedown", function(e) {
  // A mouse click means the players wants to attack.
  // We don't actually do that yet, but instead tell the rest
  // of the program about the request.
  buttonStates[e.button] = true;
}, false);

function handleInput() {
  // Here is where we respond to the click
  if (buttonStates[LEFT_BUTTON]) {
    player.attacking = true;
    delete buttonStates[LEFT_BUTTON];
  }
};

我們現在已瞭解輸入內容,因此可以將其納入 update 函式,並確保其遵循其他遊戲規則。

function update() {
  // Check for collisions, states, whatever else is needed

  // If after that the player can still attack, do it!
  if (player.attacking && player.canAttack()) {
    player.attack();
  }
};

最後,在所有運算完成後,您就可以重新繪製畫面!在 DOM 世界中,瀏覽器會處理這項重複的動作。不過,使用 <canvas> 時,每當發生某些事件 (通常是每個影格),就必須手動重繪。

function render() {
  // First erase everything, something like:
  context.clearRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);

  // Draw the player (and whatever else you need)
  context.drawImage(
    player.getImage(),
    player.x, player.y
  );
};

時間型模擬

以時間為基礎的模擬是指根據上一個影格更新後經過的時間長度,移動圖像集的概念。這項技巧可讓遊戲盡可能以最快的速度執行,同時確保精靈以一致的速度移動。

為了使用以時間為基礎的模擬,我們需要擷取自上次繪製影格起的經過時間。我們需要擴充遊戲迴圈的 update() 函式,才能追蹤這項資訊。

function update() {

  // NOTE: You'll need to initially seed this.lastUpdate
  // with the current time when your game loop starts
  // this.lastUpdate = Date.now();

  // Calculate elapsed time since last frame
  var now = Date.now();
  var elapsed = (now - this.lastUpdate);
  this.lastUpdate = now;

  // Do stuff with elapsed

};

有了經過的時間,我們就能計算指定的單格角色應在每個影格移動多遠。首先,我們需要追蹤精靈物件上的幾項資訊:目前位置、速度和方向。

var Sprite = function() {

  // The sprite's position relative to the top left of the game world
  this.position = {x: 0, y: 0};

  // The sprite's direction. A positive x value indicates moving to the right
  this.direction = {x: 1, y: 0};

  // How many pixels the sprite moves per second
  this.speed = 50;
};

考量這些變數後,我們會使用以時間為基礎的模擬方式移動上述圖像文字類別的例項:

// Determine how far this sprite will move this frame
var distance = (sprite.speed / 1000) * elapsed;

// Apply the movement distance to the sprite's current position
// taking into account its direction
sprite.position.x += (distance * sprite.direction.x);
sprite.position.y += (distance * sprite.direction.y);

請注意,direction.xdirection.y 值應經過正規化,也就是說,它們一律應介於 -11 之間。

控制項

在開發 Onslaught!競技場。最早的示範僅支援鍵盤,玩家可透過方向鍵在畫面上移動主角,並透過空格鍵朝向角色面向的方向開火。雖然這項功能相當直覺且容易上手,但在較困難的關卡中,玩家幾乎無法順利進行遊戲。由於隨時都有數十個敵人和飛彈朝玩家飛來,因此您必須能夠在任何方向射擊的同時,穿梭於敵人之間。

為了與同類型遊戲進行比較,我們新增了滑鼠支援功能,用於控制角色用來瞄準攻擊目標的瞄準十字線。角色仍可透過鍵盤移動,但在這個變更之後,角色可以同時在任何 360 度方向發射。雖然核心玩家很喜歡這項功能,但不幸的是,這會讓使用觸控板的使用者感到不便。

猛攻!競技場控制項互動視窗 (已淘汰)
Onslaught 中的舊版控制項或「如何玩」彈出式視窗!競技場。

為方便使用觸控板的使用者,我們已將箭頭鍵控制項重新加入,這次是為了讓箭頭鍵在按下的方向中發射。雖然我們認為我們可以滿足所有類型的玩家,但我們也無意間為遊戲引入過多複雜性。令人意外的是,我們後來得知,有些玩家並未注意到攻擊的選用滑鼠 (或鍵盤) 控制項,儘管教學課程會話方塊 (大多遭到忽略) 已提供相關資訊。

猛攻!Arena 控制項教學課程
玩家大多會忽略教學課程疊加層,他們更想直接玩遊戲並享受樂趣!

我們很榮幸擁有一些歐洲粉絲,但我們聽到他們的抱怨,表示他們可能沒有一般 QWERTY 鍵盤,無法使用 WASD 鍵進行方向移動。左撇子玩家也提出類似的抱怨。

我們實作了這個複雜的控制方案,但在行動裝置上播放時也會發生問題。事實上,我們最常收到的要求之一,就是要推出 Onslaught!Arena 適用於 Android、iPad 和其他觸控裝置 (沒有鍵盤的裝置)。HTML5 的核心優勢之一就是可移植性,因此將遊戲移至這些裝置絕對可行,我們只需要解決許多問題 (最明顯的是控制項和效能)。

為瞭解決這些問題,我們開始使用單一輸入方法進行遊戲,只使用滑鼠 (或觸控) 互動。玩家點選或輕觸螢幕後,主角會朝按下的位置走去,並自動攻擊最近的壞人。程式碼如下所示:

// Find the nearest hostile target (if any) to the player
var player = this.getPlayerObject();
var hostile = this.getNearestHostile(player);
if (hostile !== null) {
  // Found one! Shoot in its direction
  var shoot = hostile.boundingBox().center().subtract(
    player.boundingBox().center()
  ).normalize();
}

// Move towards where the player clicked/touched
var move = this.targetReticle.position.clone().subtract(
  player.boundingBox().center()
).normalize();
var distance = this.targetReticle.position.clone().subtract(
  player.boundingBox().center()
).magnitude();

// Prevent jittering if the character is close enough
if (distance < 3) {
  move.zero();
}

// Move the player
if ((move.x !== 0) || (move.y !== 0)) {
  player.setDirection(move);
}

移除必須瞄準敵人的額外因素,在某些情況下可能會讓遊戲變得更簡單,但我們認為為玩家簡化操作有許多好處。其他策略也隨之出現,例如必須將角色設在靠近危險敵人的地點,才能鎖定敵人,而支援觸控裝置的能力也非常重要。

音訊

在控制項和效能方面,Onslaught!Arena 是 HTML5 的 <audio> 標記。最糟糕的情況可能是延遲:在幾乎所有瀏覽器中,呼叫 .play() 和實際播放音訊之間都會有延遲。這可能會破壞玩家的遊戲體驗,尤其是在玩像我們這類節奏快速的遊戲時。

其他問題包括「進度」事件無法觸發,這可能導致遊戲的載入流程無限期掛起。基於這些原因,我們採用了所謂的「備援」方法,如果 Flash 無法載入,我們會改用 HTML5 Audio。程式碼如下所示:

/*
This example uses the SoundManager 2 library by Scott Schiller:
http://www.schillmania.com/projects/soundmanager2/
*/

// Default to sm2 (Flash)
var api = "sm2";

function initAudio (callback) {
  switch (api) {
    case "sm2":
      soundManager.onerror = (function (init) {
        return function () {
          api = "html5";
          init(callback);
        };
      }(arguments.callee));
      break;
    case "html5":
      var audio = document.createElement("audio");

      if (
        audio
        && audio.canPlayType
        && audio.canPlayType("audio/mpeg;")
      ) {
        callback();
      } else {
        // No audio support :(
      }
      break;
  }
};

遊戲也應支援無法播放 MP3 檔案的瀏覽器 (例如 Mozilla Firefox)。在這種情況下,系統可以偵測支援情況,並切換至 Ogg Vorbis 等,程式碼如下所示:

/*
Note: you could instead use "new Audio()" here,
but the client will throw an error if it doesn't support Audio,
which makes using "document.createElement" a safer approach.
*/

var audio = document.createElement("audio");

if (audio && audio.canPlayType) {
  if (!audio.canPlayType("audio/mpeg;")) {
    // Here you know you CANNOT use .mp3 files
    if (audio.canPlayType("audio/ogg; codecs=vorbis")) {
      // Here you know you CAN use .ogg files
    }
  }
}

儲存資料

沒有高分,就無法在街機風格的射擊遊戲中脫穎而出!我們知道需要保留部分遊戲資料,雖然可以使用 Cookie 等舊技術,但我們想深入瞭解新穎的 HTML5 技術。當然,您可以選擇多種選項,包括本機儲存空間、工作階段儲存空間和網路 SQL 資料庫。

ALT_TEXT_HERE
系統會儲存高分,以及你擊敗每個頭目後在遊戲中的排名。

我們決定使用 localStorage,因為它是新功能,且易於使用。它支援儲存基本鍵/值組合,這是簡單遊戲所需的所有內容。以下是使用方式的簡單範例:

if (typeof localStorage == "object") {
  localStorage.setItem("foo", "bar");
  localStorage.getItem("foo"); // Value is "bar"
  localStorage.removeItem("foo");
  localStorage.getItem("foo"); // Value is now null
}

但也有一些「陷阱」需要留意。無論您傳入什麼,值都會儲存為字串,這可能會導致一些非預期的結果:

localStorage.setItem("foo", false);
typeof localStorage.getItem("foo"); // Value is "false" (a string literal)
if (localStorage.getItem("foo")) {
  // It's true!
}

// Don't pass objects into setItem
localStorage.setItem("bar", {"key": "value"});
localStorage.getItem("bar"); // Value is "[object Object]" (a string literal)

// JSON stringify and parse when dealing with localStorage
localStorage.setItem("json", JSON.stringify({"key": "value"}));
typeof localStorage.getItem("json"); // string
JSON.parse(localStorage.getItem("json")); // {"key": "value"}

摘要

HTML5 是相當優異的程式語言,大多數實作項目都會處理遊戲開發人員需要的所有內容,從圖形到儲存遊戲狀態。雖然 HTML5 仍有待改進之處 (例如 <audio> 標記的問題),但瀏覽器開發人員的腳步相當迅速,且 HTML5 遊戲的表現已相當出色,因此未來前景一片光明。

猛攻!隱藏 HTML5 標誌的競技場
玩「Onslaught」時,只要輸入「html5」,即可獲得 HTML5 盾牌!競技場。