スーパーファミコン風アクションゲームを作った。Canvas 2Dで再現した90年代の操作感
子どもの頃、スーパーファミコンのアクションゲームに夢中になった。あの独特な操作感、ピクセルアートの美しさ、シンプルだけど奥深いゲーム性。最近、その感覚をブラウザで再現できないかと思い立った。
Canvas 2D(HTML5の描画機能)を使って、スーパーファミコン風のアクションゲームを作ってみた。左右移動、ジャンプ、ダッシュ、敵を踏みつける、コインを集める。基本的な要素を詰め込んだ。
なぜCanvas 2Dを選んだか
最初はWebGL(3D描画ライブラリ)も考えた。でも、スーパーファミコンの雰囲気を出すには、2Dの方が適している。WebGLは3D向けで、2Dのピクセルアートにはオーバースペックだ。
Canvas 2Dなら、ピクセル単位で描画できる。スプライト(キャラクター画像)をフレームごとに切り替えるだけで、アニメーションが作れる。シンプルで、制御しやすい。
実際にコードを書いてみると、Canvas 2Dの描画速度は十分だった。60FPS(1秒間に60回の描画)を安定して維持できる。モバイル端末でも、軽量で動作がスムーズだ。
物理演算の実装
ゲームの操作感を決めるのは、物理演算だ。重力、摩擦、加速度。これらの値を調整するのに、かなり時間をかけた。
最初は重力を1.0に設定した。でも、これだと落下が速すぎて、操作しづらかった。0.5に下げると、今度は軽すぎて、リアリティがない。0.8に落ち着いた。人間が感じる「ちょうどいい」重さだ。
摩擦も同じように試行錯誤した。地面での摩擦は0.75、空中での摩擦は0.9。空中では空気抵抗で減速するが、地面ではしっかり止まる。この差が、操作感を生む。
const GRAVITY = 0.8;
const MAX_FALL = 18;
const ACCEL = 0.9;
const FRICTION = 0.75;
const AIR_FRICTION = 0.9;
const JUMP_POWER = -13;
ジャンプの力は-13に設定した。マイナス値は上方向を意味する。13という数値は、試行錯誤の末に決めた。10だと低すぎる。15だと高すぎる。13が、ちょうどいい高さだ。
コヨーテタイムとジャンプバッファ
スーパーファミコンのゲームには、コヨーテタイムという仕組みがある。崖から落ちた直後でも、少しの間はジャンプできる。これがないと、操作が厳しすぎる。
実装では、地面から離れた瞬間から8フレーム(約0.13秒)の間、ジャンプ可能な状態を維持する。この短い時間が、操作の快適さを生む。
ジャンプバッファも実装した。ジャンプボタンを押したタイミングが少しずれても、6フレーム(約0.1秒)の間はジャンプを受け付ける。これで、操作のタイミングが厳しすぎない。
coyoteTime = wasOnGround ? 8 : Math.max(coyoteTime - dt, 0);
if (jumpHeld) jumpBuffer = Math.max(jumpBuffer, 6);
if (jumpBuffer > 0 && coyoteTime > 0) {
player.vy = JUMP_POWER;
jumpBuffer = 0;
coyoteTime = 0;
}
この2つの仕組みで、操作感が格段に良くなった。試行錯誤の価値があった。
敵AIの実装
敵には3種類のタイプを作った。walker(歩く敵)、flyer(飛ぶ敵)、hopper(跳ねる敵)。それぞれ、異なる動きをする。
walkerは地面を歩く。重力の影響を受け、プラットフォームに当たると反転する。シンプルだが、プレイヤーの動きを予測して、適度に難易度を上げる。
flyerは空中を飛ぶ。正弦波(sin波)で上下に動く。一定のパターンで動くので、プレイヤーはタイミングを計れる。でも、複数出現すると、避けるのが難しくなる。
hopperは地面で跳ねる。一定間隔でジャンプする。跳ねるタイミングを読めば、簡単に避けられる。でも、複数いると、予測が難しくなる。
if (e.type === 'walker') {
e.vy = Math.min(e.vy + GRAVITY * dt, MAX_FALL);
e.x += e.vx * dt * difficulty;
e.y += e.vy * dt;
resolvePlatformCollision(e, true);
} else if (e.type === 'flyer') {
e.x += e.vx * dt * difficulty;
e.y = 320 + Math.sin(e.time * 0.1) * 40;
} else if (e.type === 'hopper') {
if (e.onGround && e.time % 60 < 1) {
e.vy = -10;
}
e.vy = Math.min(e.vy + GRAVITY * dt, MAX_FALL);
e.x += e.vx * dt * difficulty;
e.y += e.vy * dt;
resolvePlatformCollision(e, true);
}
敵の出現頻度は、難易度に応じて変わる。距離が進むほど、難易度が上がる。最大で3倍まで上がる。これで、ゲームに緊張感が生まれる。
パララックス背景
背景を3層に分けて、パララックス効果(視差効果)を実装した。手前の層は速く動き、奥の層は遅く動く。これで、奥行き感が生まれる。
背景レイヤーは、速度が異なる。0.2倍速、0.4倍速、0.6倍速。カメラの位置に応じて、各レイヤーをずらして描画する。
const bgLayers = [
{ speed: 0.2, color: '#0f3460' },
{ speed: 0.4, color: '#16213e' },
{ speed: 0.6, color: '#1a1a2e' }
];
for (let i = 0; i < bgLayers.length; i++) {
const layer = bgLayers[i];
const offset = (cameraX * layer.speed) % BASE_WIDTH;
ctx.fillRect(-offset, 0, BASE_WIDTH, BASE_HEIGHT);
ctx.fillRect(BASE_WIDTH - offset, 0, BASE_WIDTH, BASE_HEIGHT);
}
雲も2層に分けて、異なる速度で動かした。これで、空の奥行きが表現できる。星も追加した。80個の星を、ランダムに配置した。カメラの動きに合わせて、ゆっくり動く。
パーティクルエフェクト
敵を倒した時、コインを取った時、ダッシュした時。様々な場面で、パーティクル(粒子)エフェクトを表示する。
パーティクルは、オブジェクトプール(再利用可能なオブジェクトの集まり)で管理する。新しく生成するのではなく、既存のオブジェクトを再利用する。これで、メモリの無駄を減らし、パフォーマンスを向上させる。
function spawnParticles(x, y, count, color, power, life, gravity) {
for (let i = 0; i < count; i++) {
const p = particlePool.pop() || {};
p.x = x;
p.y = y;
p.vx = (Math.random() - 0.5) * power;
p.vy = (Math.random() - 0.5) * power - power * 0.3;
p.size = 2 + Math.random() * 2;
p.color = color;
p.life = life;
p.gravity = gravity;
activeParticles.push(p);
}
}
パーティクルの色は、場面に応じて変える。敵を倒した時は赤、コインを取った時は黄色、ダッシュした時は青。色の違いで、視覚的なフィードバックを強化する。
音響効果
Web Audio API(ブラウザの音声処理機能)を使って、音響効果を実装した。ジャンプ、着地、敵を倒す、コインを取る。各アクションに、対応する音を付けた。
音は、オシレーター(波形生成器)で生成する。周波数と波形の種類を変えることで、異なる音を作る。ジャンプは660Hz、着地は220Hz、コインは880Hz。それぞれ、短い音で、ゲームの雰囲気を演出する。
function playSound(type) {
if (!audioCtx) return;
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'square';
let freq = 440;
let duration = 0.1;
if (type === 'jump') { freq = 660; duration = 0.08; }
if (type === 'land') { freq = 220; duration = 0.06; }
if (type === 'coin') { freq = 880; duration = 0.05; }
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.08, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(now);
osc.stop(now + duration);
}
BGM(背景音楽)も実装した。8音のシーケンス(音の並び)を繰り返す。250ミリ秒ごとに、次の音を再生する。シンプルだが、ゲームの雰囲気を盛り上げる。
スプライトアニメーション
プレイヤーと敵のアニメーションは、スプライトシート(複数のフレームを並べた画像)で実装した。Canvas上で、スプライトを動的に生成する。
プレイヤーには、idle(待機)、run(走る)、jump(ジャンプ)、dash(ダッシュ)の4つのアニメーションがある。それぞれ、異なるフレーム数を持つ。idleは4フレーム、runは6フレーム、jumpは2フレーム、dashは2フレーム。
const playerSprites = {
idle: createSpriteSheet(drawPlayerIdle, 4, 32),
run: createSpriteSheet(drawPlayerRun, 6, 32),
jump: createSpriteSheet(drawPlayerJump, 2, 32),
dash: createSpriteSheet(drawPlayerDash, 2, 32)
};
function createSpriteSheet(drawFrame, frameCount, frameSize) {
const sheet = document.createElement('canvas');
sheet.width = frameSize * frameCount;
sheet.height = frameSize;
const sctx = sheet.getContext('2d');
sctx.imageSmoothingEnabled = false;
for (let i = 0; i < frameCount; i++) {
sctx.save();
sctx.translate(i * frameSize, 0);
drawFrame(sctx, i, frameSize);
sctx.restore();
}
return { sheet, frameCount, frameSize };
}
アニメーションの切り替えは、プレイヤーの状態に応じて自動で行う。地面にいる時はidleかrun、空中にいる時はjump、ダッシュ中はdash。状態に応じたアニメーションで、操作感が向上する。
ローカルストレージでハイスコア保存
ハイスコアは、ローカルストレージ(ブラウザの保存機能)に保存する。ゲームオーバー時に、現在のスコアと保存されているハイスコアを比較し、更新する。
const highScoreKey = 'midori310_highscore';
const savedHighScore = Number(localStorage.getItem(highScoreKey) || 0);
function gameOver() {
const prevHigh = Number(localStorage.getItem(highScoreKey) || 0);
if (score > prevHigh) {
localStorage.setItem(highScoreKey, String(score));
}
}
シンプルな実装だが、これでプレイヤーのモチベーションが上がる。自分の記録を更新したいという気持ちが、ゲームを続ける原動力になる。
パフォーマンス最適化
ゲームループは、requestAnimationFrame(ブラウザの描画タイミングに合わせた処理)を使う。これで、60FPSを安定して維持できる。
敵とパーティクルは、オブジェクトプールで管理する。新しく生成するのではなく、既存のオブジェクトを再利用する。これで、メモリの無駄を減らし、ガベージコレクション(不要なメモリの解放)の負荷を軽減する。
描画範囲外のオブジェクトは、描画をスキップする。カメラの位置から一定距離離れたオブジェクトは、描画しない。これで、描画負荷を削減する。
function drawEnemies() {
for (const e of enemies) {
const ex = e.x - cameraX;
if (ex < -60 || ex > BASE_WIDTH + 60) continue;
// 描画処理
}
}
これらの最適化で、モバイル端末でも、スムーズに動作する。
まとめ
Canvas 2Dで、スーパーファミコン風のアクションゲームを作った。物理演算、敵AI、パララックス背景、パーティクルエフェクト、音響効果。様々な要素を組み合わせて、90年代の操作感を再現した。
試行錯誤の末に、操作感が良くなった。コヨーテタイムとジャンプバッファの実装で、操作の快適さが向上した。パフォーマンス最適化で、モバイル端末でも動作するようになった。
シンプルだが、奥深い。基本的な要素を組み合わせることで、楽しいゲームが作れる。これが、ゲーム開発の面白さだ。
使ってみて
実際に遊んでみてほしい。操作感を体感できる。スーパーファミコンの雰囲気を、ブラウザで楽しめる。
旧サイトのデモも、ぜひ試してみて。より多くの機能を体験できる。
関連記事では、Three.jsを使った3D表現やWeb Audio APIの詳細解説も紹介している。ゲーム開発に興味がある方は、ぜひ参考にしてみて。