ブラウザ版コズミックディフェンダーの実装を解説
自機と敵編隊の管理、弾の衝突判定、ステージ生成、コンボ、Canvas描画、キーボードとタッチ操作まで、ゲームページの実装を順番に紹介します。
コズミックディフェンダーは、宇宙船を左右に動かして敵のドローンを撃破する固定画面シューティングゲームです。全50セクターを進みながら、敵弾を避け、コンボをつなぎ、青いパワーアップを集めて星域を守ります。
ゲーム本体は、PHPで共通ヘッダーや広告を読み込み、CanvasとJavaScriptでゲーム画面を動かしています。自機、敵、弾、パーティクルなどをJavaScriptの配列とオブジェクトで管理し、requestAnimationFrameによるゲームループで状態更新と描画を繰り返す構成です。
ゲームの状態を変数と配列で管理する
自機はplayerオブジェクト、敵や弾は配列として保存しています。描画が終わった弾や倒された敵をすぐにDOMから探すのではなく、ゲーム内の状態をJavaScript側へ集約しています。
let player;
let enemies = [];
let shots = [];
let enemyShots = [];
let particles = [];
let powerups = [];
let score = 0;
let stage = 1;
let lives = 3;
let combo = 1;
let running = false;
let paused = false;
let over = false;敵や弾の配列には、画面上の座標だけでなく、体力、速度、当たり判定の大きさ、aliveやdeadに相当する状態も持たせます。表示とゲームの判定を同じ状態から行うため、倒した敵を描画し続けるといった不整合が起きにくくなります。
ステージの難易度を設定値から作る
ステージごとに敵の行数、列数、横移動速度、攻撃間隔、体力を計算します。ステージ数を引数にして設定を返すため、50ステージ分の設定を個別に書かずに、後半ほど難しくなるルールをまとめて表現できます。
function settings() {
return {
rows: Math.min(3 + Math.floor((stage - 1) / 5), 7),
cols: Math.min(7 + Math.floor((stage - 1) / 8), 10),
speed: 22 + stage * 2.1,
fire: Math.max(.24, 1.35 - stage * .019),
hp: 1 + Math.floor((stage - 1) / 15)
};
}例えば、5ステージ進むごとに敵の行数、8ステージ進むごとに列数が増えます。攻撃間隔には下限を設定しているため、ステージが高くなっても敵が毎フレーム攻撃する状態にはなりません。難易度の上昇と上限を同じ関数にまとめている点がポイントです。
敵編隊とボスを生成する
makeWaveでは、設定値から敵を格子状に配置します。敵の種類は行・列・ステージ番号から決め、敵の初期位相も保存しておきます。位相は描画時の小さな脈動表現に使います。
function makeWave() {
const s = settings();
const gapX = Math.min(48, (W - 70) / (s.cols - 1));
const gapY = 42;
const startX = W / 2 - gapX * (s.cols - 1) / 2;
enemies = [];
for (let row = 0; row < s.rows; row++) {
for (let col = 0; col < s.cols; col++) {
enemies.push({
x: startX + col * gapX,
y: 78 + row * gapY,
row, col,
type: (row + col + stage) % 3,
hp: s.hp,
maxHp: s.hp,
alive: true
});
}
}
if (stage % 5 === 0) {
enemies.push({ x: W / 2, y: 42, w: 70, h: 34,
type: 3, hp: 8 + stage, maxHp: 8 + stage,
alive: true, boss: true });
}
}5の倍数のステージでは、通常の敵に加えてボスを追加します。ボスは通常敵と同じenemies配列に入るため、移動、弾との判定、撃破判定を共通処理で扱えます。ボスだけ速度、サイズ、体力、得点、効果音を変える設計です。
敵を左右に移動させ、端で折り返す
敵は画面を横方向へ進み、編隊の端が画面端へ到達すると全体を少し下げて進行方向を反転します。個々の敵の座標を更新しながら、現在生きている敵の最小X座標と最大X座標を調べます。
const alive = enemies.filter(enemy => enemy.alive);
const minX = Math.min(...alive.map(enemy => enemy.x - enemy.w / 2));
const maxX = Math.max(...alive.map(enemy => enemy.x + enemy.w / 2));
if (minX < 18 && enemyDir < 0 ||
maxX > W - 18 && enemyDir > 0) {
enemyDir *= -1;
enemyStep = 15;
alive.forEach(enemy => enemy.y += enemyStep);
}
alive.forEach(enemy => {
enemy.x += enemyDir * settings().speed * dt;
enemy.phase += dt * 3;
});敵全体を一つのグループとして動かすのではなく、端の判定だけは編隊全体から求め、実際の移動は各敵へ適用しています。敵が倒されて列の端が変わっても、残っている敵の位置から折り返しを判断できます。
最下段の敵から弾を発射する
すべての敵が同時に弾を撃つと画面がすぐに埋まってしまうため、各列で最も下にいる敵だけを攻撃候補にします。候補の中からランダムに一体を選び、ステージが進んだ後半では自機の位置へ少し向かう弾を作ります。
function enemyFire() {
const alive = enemies.filter(enemy => enemy.alive);
const bottom = alive.filter(enemy =>
!alive.some(other =>
other.alive &&
Math.abs(other.x - enemy.x) < 18 &&
other.y > enemy.y
)
);
if (!bottom.length) return;
const enemy = bottom[Math.floor(Math.random() * bottom.length)];
const aim = stage > 25 ? (player.x - enemy.x) * .28 : 0;
enemyShots.push({ x: enemy.x, y: enemy.y + 15,
vx: aim, vy: 155 + stage * 3.1,
r: enemy.boss ? 5 : 3.5 });
}「下に敵がいない敵」を選ぶ処理によって、編隊の前列から弾が発射されます。敵弾には横速度と縦速度を持たせ、後半だけ自機の位置を参照して少し追尾するようにしています。
自弾と敵の衝突を判定する
自弾と敵は、中心座標と幅・高さから矩形に近い当たり判定を行います。Canvasの見た目を複雑な形にしていても、ゲーム中の判定はシンプルな範囲にすることで、計算量と調整のしやすさのバランスを取っています。
function hit(a, b, pad = 0) {
return Math.abs(a.x - b.x) <
((a.w || a.r * 2 || 4) + (b.w || b.r * 2 || 4)) / 2 - pad
&& Math.abs(a.y - b.y) <
((a.h || a.r * 2 || 8) + (b.h || b.r * 2 || 8)) / 2 - pad;
}
for (const shot of shots) {
for (const enemy of enemies) {
if (enemy.alive && hit(shot, enemy, 5)) {
shot.dead = true;
enemy.hp--;
if (enemy.hp <= 0) enemy.alive = false;
break;
}
}
}padは判定範囲を少し狭めるための余白です。見た目では触れていないように感じる弾まで命中扱いになるのを防ぎます。撃破された弾や敵は、更新処理の最後に配列から取り除きます。
残機、コンボ、パワーアップを更新する
敵弾が自機へ当たると残機を減らし、一定時間の無敵時間を設定します。連続して敵を倒すとコンボ倍率が上がり、一定時間撃破がないと少しずつ下がります。敵を倒したときは確率でパワーアップを落とし、取得すると8秒間だけ発射間隔が短くなります。
function damage() {
if (player.inv > 0) return;
lives--;
player.inv = 1.8;
combo = 1;
comboTime = 0;
burst(player.x, player.y, '#55e9ff', 22);
updateHud();
if (lives <= 0) finish(false);
}被弾直後にもう一度敵弾が当たっても残機を連続で失わないよう、player.invをタイマーとして使っています。描画時にもこの値を参照して自機を点滅させるため、内部状態と視覚的なフィードバックが同じ値から生まれます。
Canvasへ背景、敵、自機、演出を描く
drawでは、背景のグラデーション、星、敵、自弾、敵弾、パワーアップ、パーティクル、自機の順に描画します。後から描いた要素が手前に表示されるため、背景から前景へ順番を決めています。
function setupStars() {
stars = Array.from({ length: 85 }, (_, index) => ({
x: rand(index + 3) * W,
y: rand(index + 99) * H,
s: .5 + rand(index + 44) * 1.8,
v: 8 + rand(index + 71) * 22
}));
}
stars.forEach(star => {
star.y += star.v * dt;
if (star.y > H) {
star.y = 0;
star.x = Math.random() * W;
}
});星は最初に一定数を作り、画面下へ流れた星を上へ戻して再利用しています。毎フレーム新しい星を生成しないため、長時間プレイしても配列が増え続けません。敵の形状や爆発もCanvas APIの図形と小さな四角形のパーティクルで描画しています。
requestAnimationFrameで更新と描画を分ける
ゲームループは、前フレームからの経過時間を秒へ変換してupdateへ渡し、その後にdrawを呼び出します。ポーズ中は更新だけを止め、描画は続けるため、画面上の状態を残したままオーバーレイを表示できます。
function loop(now) {
if (!running) return;
const dt = Math.min((now - last) / 1000, .03);
last = now;
if (!paused) update(dt);
draw();
if (running) requestAnimationFrame(loop);
}dtに0.03秒の上限を設けているのは、ブラウザのタブを切り替えた後などに敵や弾が一気に進むのを防ぐためです。画面更新の回数ではなく経過時間で移動量を計算するため、環境による速度差も抑えられます。
キーボード、マウス、タッチ操作を統一する
キーボードでは左右矢印キーとA・Dで移動し、Spaceキーで発射、Pキーでポーズを切り替えます。キーが押されている間だけkeys.leftやkeys.fireをtrueにし、ゲームループ内で自機を動かします。
Canvas上のポインター位置は、表示サイズから内部の520×650座標へ変換して自機のX座標へ反映します。タッチ用ボタンも同じkeysオブジェクトを更新するため、キーボードとタッチで移動処理を分けずに済みます。
document.addEventListener('keydown', event => {
if (event.code === 'ArrowLeft' || event.code === 'KeyA') {
keys.left = true;
}
if (event.code === 'ArrowRight' || event.code === 'KeyD') {
keys.right = true;
}
if (event.code === 'Space') keys.fire = true;
});
document.addEventListener('keyup', event => {
if (event.code === 'ArrowLeft' || event.code === 'KeyA') keys.left = false;
if (event.code === 'ArrowRight' || event.code === 'KeyD') keys.right = false;
if (event.code === 'Space') keys.fire = false;
});ステージ切り替えと音声を管理する
敵をすべて倒すとnextStageでステージ番号を進め、次の開始ボタンを表示します。50ステージをクリアすると成功、残機がなくなるか敵が画面下へ到達すると失敗として、finishへ処理を集約します。
BGMと効果音はWeb Audio APIで生成しています。AudioContextはユーザーの「ミッション開始」操作の中で初期化し、自動再生制限に対応しています。BGMはsetIntervalで短い音を繰り返し、効果音は命中、被弾、ステージクリアなどのイベントで発音します。
実装して分かったこと
コズミックディフェンダーでは、シューティングゲームの処理を「入力」「状態更新」「衝突判定」「描画」に分けることが重要です。敵と弾を配列で管理し、倒した要素を更新の最後に削除することで、Canvas上の演出を加えながらゲームの状態を整理できます。
ゲームを遊ぶときは、実際のコズミックディフェンダーのページでキーボード、マウス、タッチの操作を試せます。コードを読むときは、まずsettings、makeWave、update、hit、drawの5つを追うと、ゲーム全体の流れをつかみやすくなります。