简介
2010 年 6 月,我们注意到当地出版“Zine”Boing Boing 正在举办游戏开发比赛。我们认为这是用 JavaScript 和 <canvas>
快速制作一款简单游戏的绝佳理由,于是就开始着手制作了。比赛结束后,我们仍有很多想法,希望完成我们已开始的工作。下面是该案例研究的结果,一个名为 Onslaught! Arena。
像素化怀旧风格
鉴于比赛前提是根据芯片音乐开发游戏,因此我们的游戏必须具有复古的 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 调整大小会包含抗锯齿,而我们找不到防止这种情况的方法。
由于单个像素非常重要,因此这对我们的游戏来说是致命的缺点。不过,如果您需要调整画布大小,并且抗锯齿处理适合您的项目,则可以出于性能原因考虑这种方法。
有趣的画布特技
我们都知道 <canvas>
是新宠,但有时开发者仍建议使用 DOM。如果您不确定该使用哪种方法,请参考以下示例,了解 <canvas>
如何为我们节省大量时间和精力。
当敌人在冲击!Arena 时,它会闪烁红色并短暂显示“疼痛”动画。为了限制需要创建的图形数量,我们仅向下显示处于“痛苦”状态的敌人。这在游戏中看起来还不错,而且节省了大量的像素图形创建时间。不过,对于首领怪物,如果看到一个大精灵(64x64 像素或更大)从向左或向上突然转为向下,会让人感到不舒服。
一个显而易见的解决方案是,为每个头目在八个方向上绘制一个痛苦帧,但这非常耗时。得益于 <canvas>
,我们能够在代码中解决此问题:
首先,我们将怪物绘制到隐藏的“缓冲区”<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 开发存在一些显著差异。在 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 中,浏览器会处理这些繁重的工作。但是,使用 <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.x
和 direction.y
值应归一化,这意味着它们应始终介于 -1
和 1
之间。
控件
在开发 Onslaught! Arena。第一个演示版仅支持键盘;玩家可以使用方向键在屏幕上移动主角,并使用空格键朝主角所朝方向射击。虽然这种方式直观且易于掌握,但在更高难度的关卡中,玩家几乎无法玩下去。由于在任何给定时间都有数十个敌人和飞向玩家的飞行物体,因此必须能够在恶棍之间穿梭,同时向任意方向射击。
为了与同类型的其他游戏进行比较,我们添加了鼠标支持,以控制瞄准十字线,角色会使用瞄准十字线来瞄准攻击目标。角色仍然可以使用键盘移动,但在此更改之后,他可以同时向任何 360 度方向射击。铁杆玩家对此功能赞不绝口,但不幸的是,它会让触控板用户感到沮丧。
为了方便触控板用户,我们重新引入了箭头键控件,这次允许在按下的方向发射。虽然我们认为自己在迎合所有类型的玩家,但也不知不觉中为游戏引入了过多的复杂性。后来,我们惊讶地发现,尽管游戏提供了教程模态,但大多数玩家都忽略了这些模态,因此并不知道可以使用鼠标(或键盘!)进行攻击。
我们还很幸运地拥有一些欧洲粉丝,但他们也向我们反映了一些问题,他们可能没有典型的 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 音频。代码如下所示:
/*
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 数据库。
我们决定使用 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 构建的游戏的前景一片光明。