案例研究 - 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> 如何为我们节省了大量时间和精力。

当敌人在猛攻!竞技场时,它闪烁红色,并短暂地显示“痛苦”动画。为了限制必须创建的图形数量,我们仅会按“痛苦”地显示向下方向的敌人。这在游戏内看起来可以接受,并且节省了大量精灵创建时间。然而,对于大 Boss 怪物来说,如果一个大精灵(64x64 像素或更高)从正面或向上弹跳,然后突然面朝下,就能看到它作为痛苦的画面,这让人感觉很不舒服。

显而易见的解决方案是在 8 个方向分别为每个 boss 绘制一个痛点帧,但这会非常耗时。多亏了 <canvas>,我们才能在代码中解决此问题:

看守者在 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);

游戏循环

游戏开发与 Web 开发存在一些显著差异。在网络堆栈中,通常对通过事件监听器发生的事件做出反应。因此,初始化代码只能监听输入事件。游戏的逻辑是不同的,因为需要不断自行更新。比方说,即使玩家一动不动,也不会影响地精们!

下面是一个游戏循环的示例:

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

setInterval(main, 1);

第一个重要区别是,handleInput 函数实际上并不会立即执行任何操作,如果用户在典型 Web 应用中按下某个键,则可以立即执行所需的操作。但在游戏中,事情必须按时间顺序正确进行。

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-land 中,浏览器处理这种繁重的工作。但使用 <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 中的旧控件或“操作方法”模态!表演场馆。

为了适应触控板用户,我们恢复了箭头键控件,这次允许按按下方向触发。虽然我们觉得我们能够满足所有类型的玩家,但我们也在不知不觉间为游戏引入了太多复杂性。令我们感到惊讶的是,后来我们得知有些玩家不知道用来攻击的可选鼠标(或键盘!)控件,尽管教程模态在很大程度上被忽略了。

猛攻!竞技场控件教程
玩家大多会忽略教程叠加层,而是更喜欢玩游戏和玩乐!

我们也非常幸运获得了一些欧洲粉丝,但我们收到了他们的不满情绪,他们可能没有典型的 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() 与实际播放声音之间都会出现延迟。这可能会破坏玩家的体验,尤其是在玩我们这样的快节奏游戏时。

其他问题包括无法触发“progress”事件,它可能会导致游戏的加载流程无限期挂起。出于这些原因,我们采用了所谓的“后备”方法,即当 Flash 无法加载时,我们会切换到 HTML5 音频。代码如下所示:

/*
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 技术。当然,选择也不在话下,包括本地存储、会话存储和 Web SQL 数据库。

ALT_TEXT_HERE
系统会保存最高得分以及您击败每个 Boss 后在游戏中的排名。

我们决定使用 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 非常出色,大多数实现都会处理游戏开发者所需的所有内容,从图形到保存游戏状态,不一而足。虽然还存在一些日益严重的痛点(例如 <audio> 标记问题),但浏览器开发者正在迅速发展,并且取得了出色的成果,但基于 HTML5 构建的游戏的前景一片光明。

猛攻!带有隐藏的 HTML5 徽标的场馆
玩 Onslaught! 时,您只需输入“html5”即可获得 HTML5 盾牌!表演场馆。