はじめに
2010 年 6 月、地元の出版物「Zine」である Boing Boing がゲーム開発コンテストを開催していることが判明しました。これは、JavaScript と <canvas>
で簡単なゲームをすばやく作るための絶好の機会だと考え、作業を開始しました。コンテスト後も、まだ多くのアイデアがあり、始めたことを完成させたいと考えました。以下は、その結果として生まれたゲーム「Onslaught!Arena に移動します。
レトロなモザイク調
チップチューンをベースにしたゲームを開発するというコンテストの前提から、ゲームの外観と操作感はレトロな Nintendo Entertainment System ゲームのようにすることが重要でした。ほとんどのゲームにはこの要件はありませんが、アセットの作成が簡単で、ノスタルジックなゲーマーに自然に訴えるため、(特にインディー デベロッパーの間では)依然として一般的なアートスタイルです。
これらのスプライトが小さいため、ピクセルを 2 倍にすることにしました。つまり、16x16 スプライトは 32x32 ピクセルになります。当初から、ブラウザに負荷をかけることなく、アセット作成側の負荷を軽減してきました。これは実装が簡単なだけでなく、見た目にも明らかな利点がありました。
以下に、Google が検討したシナリオを示します。
<style>
canvas {
width: 640px;
height: 320px;
}
</style>
<canvas width="320" height="240">
Sorry, your browser is not supported.
</canvas>
この方法では、アセット作成側でスプライトを 2 倍にするのではなく、1x1 のスプライトを使用します。その後、CSS がキャンバス自体のサイズを変更します。ベンチマークでは、この方法は大きな画像(2 倍)のレンダリングよりも約 2 倍速いことが判明しましたが、残念ながら CSS のサイズ変更にはアンチ エイリアスが含まれており、これを回避する方法は見つかりませんでした。
個々のピクセルが非常に重要であるため、これはゲームにとって致命的でした。ただし、キャンバスのサイズを変更する必要がある場合や、アンチエイリアシングがプロジェクトに適している場合は、パフォーマンス上の理由からこのアプローチを検討できます。
楽しいキャンバス トリック
<canvas>
が新しいトレンドであることは誰もが知っていますが、DOM の使用をおすすめするデベロッパーもいます。どちらを使用するか迷っている場合は、<canvas>
が時間と労力を大幅に節約した例をご紹介します。
Onslaught! で敵が攻撃を受けたとき、Arena では、赤色で点滅し、一時的に「痛み」アニメーションが表示されます。作成するグラフィックの数を制限するため、敵が「痛み」を感じている様子は下を向いている方向にのみ表示されます。これはゲーム内で許容できる外観で、スプライトの作成にかかる時間を大幅に節約できました。ただし、ボス モンスターの場合、大きなスプライト(64x64 ピクセル以上)が痛みフレームのために左または上を向いていたのが突然下を向く様子は、不自然でした。
最も簡単な解決策は、8 つの方向ごとに各ボスの痛みフレームを描画することですが、これは非常に時間がかかります。<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);
ゲームループ
ゲーム開発には、ウェブ開発とは異なる点がいくつかあります。ウェブスタックでは、イベントリスナーを介して発生したイベントに反応することが一般的です。そのため、初期化コードは入力イベントをリッスンする以外何も行わない場合があります。ゲームのロジックは、常に更新する必要があるため異なります。たとえば、プレイヤーが移動していない場合でも、ゴブリンがプレイヤーを攻撃するのを止めることはできません。
ゲームループの例を次に示します。
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.x
値と direction.y
値は正規化する必要があります。つまり、常に -1
~1
の範囲内に収める必要があります。
コントロール
Onslaught! の開発において、操作は最大の障害でした。Arena にアクセスします。最初のデモではキーボードのみがサポートされていました。プレイヤーは矢印キーでメイン キャラクターを画面内を移動し、Space キーで向いている方向に射撃しました。直感的で理解しやすいものの、難易度の高いレベルではゲームをほとんどプレイできなくなります。常に数十もの敵と飛来する弾丸がプレイヤーを襲ってくるため、あらゆる方向に射撃しながら敵の間をすり抜ける必要があります。
同ジャンルの類似ゲームと比較できるように、マウス操作によるターゲティング レチクルの制御を追加しました。このレチクルは、キャラクターが攻撃の照準合わせに使用します。キャラクターは引き続きキーボードで移動できましたが、この変更により、360 度全方向に同時に発砲できるようになりました。ハードコア プレーヤーはこの機能を高く評価していましたが、トラックパッド ユーザーにとっては不満の原因となっていました。
トラックパッドのユーザーに対応するため、矢印キーの操作を復活させました。今回は、押した方向に射撃できるようにしました。すべてのタイプのプレーヤーに対応していると感じていた一方で、無意識のうちにゲームに過度な複雑さを導入していました。驚いたことに、チュートリアル モーダル(ほとんど無視されていた)にもかかわらず、一部のプレーヤーは、攻撃用のオプションのマウス(またはキーボード)コントロールを認識していなかったことが後でわかりました。
幸い、ヨーロッパにもファンがいらっしゃいますが、一般的な QWERTY キーボードをお持ちでない場合や、WASD キーを使用して方向転換できない場合、不満を感じているとの報告も寄せられています。左利きのプレーヤーからも同様の苦情が寄せられています。
実装したこの複雑なコントロール スキームには、モバイル デバイスでのプレイに関する問題もあります。実際、最もよくリクエストされるのは、Onslaught!Arena: Android、iPad、その他のタッチデバイス(キーボードがないデバイス)で利用できます。HTML5 の強みの 1 つはポータビリティです。そのため、これらのデバイスにゲームを導入することは可能です。ただし、多くの問題(特にコントロールとパフォーマンス)を解決する必要があります。
こうした多くの問題に対処するため、マウス(またはタップ)操作のみを伴う、単一入力方式のゲームプレイを試し始めました。プレイヤーが画面をクリックまたはタップすると、メイン キャラクターが押された場所に向かって歩き、最も近い敵を自動的に攻撃します。コードは次のようになります。
// 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」イベントがトリガーされないことが挙げられます。これにより、ゲームの読み込みフローが無期限にハングする可能性があります。こうした理由から、Google は「フォールフォワード」方式を採用しました。この方式では、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 技術を詳しく調べてみることにしました。ローカル ストレージ、セッション ストレージ、Web SQL データベースなど、選択肢は豊富です。
localStorage
は新しく、優れた機能で使いやすいため、localStorage
を使用することにしました。基本的な Key-Value ペアの保存をサポートしており、シンプルなゲームに必要なすべてが揃っています。使用方法の簡単な例を次に示します。
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 で構築されたゲームの将来は明るいと言えます。