横スクロールシューティングの作り方 — JavaScriptでブラウザゲームを作ろう

このゲームで遊ぶ → ソースコード全文 →

このチュートリアルで学べること

横スクロールシューティングは、画面が右から左へと自動スクロールし、右から現れる敵を倒しながら進むゲームです。グラディウスを参考にしたこのゲームを作ることで、スクロール処理・パワーアップシステム・プロシージャル地形生成など、ゲーム開発の実践的な技術が身につきます。

ステップ1: 横スクロールと地形生成を実装しよう

横スクロールシューティングでは、ゲーム全体が毎フレーム少しずつ左にスクロールします。地形(上下の壁)はランダムに生成し、スクロールに合わせて描画します。

var SCROLL_SPEED = 1.0;      // 1フレームあたりのスクロール量
var TERRAIN_SEGMENT_W = 20; // 地形の1ブロックの幅
var terrainTop = [];    // 上壁の高さの配列
var terrainBottom = []; // 下壁の高さの配列
var terrainOffset = 0;  // スクロール量の累積

// ゲームの状態更新: 毎フレーム呼ばれる
function update() {
  // スクロール量を増やす
  terrainOffset += SCROLL_SPEED;

  // 新しい地形が必要になったら追加生成する
  extendTerrain();
}

// 地形を伸ばす(プロシージャル生成)
function extendTerrain() {
  var needed = Math.ceil((terrainOffset + BASE_W) / TERRAIN_SEGMENT_W) + 5;
  while (terrainTop.length < needed) {
    var last = terrainTop[terrainTop.length - 1];
    // 前のブロックから少しランダムにずれた高さを追加
    terrainTop.push(clamp(
      last + rand(-4, 4), // ±4ピクセルの変動
      10,                 // 最小の高さ
      40                  // 最大の高さ
    ));
  }
  // terrainBottom も同様に生成
}

terrainOffset は累積スクロール量です。描画するときにこのオフセットを引くことで「画面が右から左へ動いている」ように見せます。地形は先の分まで先読みして生成しておくことで、スクロール中もなめらかに表示できます。

地形の描画

地形は座標計算して折れ線グラフのように描きます。スクロール量 terrainOffset を引くことで、正しい位置に表示できます。

function drawTerrain() {
  ctx.fillStyle = '#334455';
  ctx.beginPath();

  // 現在の表示開始セグメントを計算
  var startSeg = Math.floor(terrainOffset / TERRAIN_SEGMENT_W);

  // 上壁を描画
  ctx.moveTo(0, 0);
  for (var i = 0; i <= Math.ceil(BASE_W / TERRAIN_SEGMENT_W) + 1; i++) {
    var seg = startSeg + i;
    // スクロール位置からのX座標を計算
    var x = i * TERRAIN_SEGMENT_W - (terrainOffset % TERRAIN_SEGMENT_W);
    var topH = terrainTop[seg] || 10;
    ctx.lineTo(x * S, topH * S);
  }
  ctx.lineTo(BASE_W * S, 0);
  ctx.closePath();
  ctx.fill();

  // 下壁も同様に描画
}

terrainOffset % TERRAIN_SEGMENT_W(余り算)を使うのがコツです。1ブロック分スクロールするごとに startSeg が1増えるので、どこまでスクロールしても正しい地形が表示されます。

ステップ2: パワーアップゲージシステムを作ろう

グラディウス風のパワーアップシステムの特徴は「カプセルを取るとゲージが進み、好きなタイミングで発動できる」点です。スピード・ミサイル・ダブル・レーザー・オプション・バリアの6種類を管理します。

// パワーアップの種類ラベル
var GAUGE_LABELS = ['SPD', 'MSL', 'DBL', 'LSR', 'OPT', 'BRR'];

var gaugeLevel = 0;   // 現在のゲージ位置(0〜5)
var speedLevel = 0;   // スピードアップの段階(最大3)
var hasMissile = false;
var hasDouble = false;
var hasLaser = false;
var options = [];     // オプション(分身)の配列
var barrierHP = 0;    // バリアの残りHP

// カプセルを取ったとき: ゲージを1つ進める
function activateCapsule() {
  gaugeLevel = Math.min(gaugeLevel + 1, GAUGE_LABELS.length - 1);
}

// Enterキーを押したとき: 現在のゲージ位置のパワーアップを発動
function activatePowerUp() {
  if (gaugeLevel === 0) {
    // スピードアップ
    speedLevel = Math.min(speedLevel + 1, MAX_SPEED_LEVEL);
  } else if (gaugeLevel === 1) {
    hasMissile = true;
  } else if (gaugeLevel === 2) {
    hasDouble = true;
  } else if (gaugeLevel === 3) {
    hasLaser = true;
  } else if (gaugeLevel === 4 && options.length < MAX_OPTIONS) {
    options.push({ x: 0, y: 0 }); // オプション追加
  } else if (gaugeLevel === 5) {
    barrierHP = BARRIER_HITS; // バリア発動
  }
  gaugeLevel = 0; // 発動したらゲージをリセット
}

パワーアップの状態はすべてフラグ(true/false)や数値で管理します。発射処理のときにこれらのフラグを確認して、装備に応じた弾を追加します。シンプルな設計ですが、ゲームの幅が大きく広がります。

パワーアップに応じた発射処理

function firePlayerWeapon(fx, fy) {
  // 基本弾(常に発射)
  playerBullets.push({
    x: fx, y: fy,
    vx: BULLET_SPEED, vy: 0,
    type: 'normal'
  });

  // ダブル: 斜め上にも弾を追加
  if (hasDouble) {
    playerBullets.push({
      x: fx, y: fy,
      vx: BULLET_SPEED * Math.cos(-Math.PI / 6),
      vy: BULLET_SPEED * Math.sin(-Math.PI / 6),
      type: 'normal'
    });
  }

  // ミサイル: 下向きにミサイルを追加
  if (hasMissile) {
    playerBullets.push({
      x: fx, y: fy,
      vx: MISSILE_SPEED * 0.7,
      vy: MISSILE_SPEED * 0.7, // 右下方向
      type: 'missile'
    });
  }
}

ミサイルは毎フレーム vy += 0.08 で重力を加えることで、放物線を描いて落ちていきます。三角関数で角度を指定しているので、自由な方向への弾も簡単に追加できます。

ステップ3: オプションの追従処理を実装しよう

グラディウスの特徴的な要素「オプション(分身)」は、プレイヤーの動きを少し遅れてついてきます。これを実現するには、プレイヤーの過去の位置を配列に記録しておく「移動履歴」の仕組みを使います。

var OPTION_TRAIL_DELAY = 15; // オプションが何フレーム遅れてついてくるか
var MAX_OPTIONS = 2;

// プレイヤーの更新処理の中で、位置履歴を記録する
function updatePlayer() {
  // ... 移動処理 ...

  // 毎フレーム現在位置を配列の先頭に追加
  player.posHistory.unshift({ x: player.x, y: player.y });

  // 配列が長くなりすぎないよう古い履歴を削除
  var maxLen = OPTION_TRAIL_DELAY * (MAX_OPTIONS + 1);
  if (player.posHistory.length > maxLen) {
    player.posHistory.length = maxLen;
  }

  // オプションの位置を履歴から取得
  for (var oi = 0; oi < options.length; oi++) {
    // オプション1は15フレーム前の位置、オプション2は30フレーム前
    var trailIdx = OPTION_TRAIL_DELAY * (oi + 1);
    if (trailIdx < player.posHistory.length) {
      options[oi].x = player.posHistory[trailIdx].x;
      options[oi].y = player.posHistory[trailIdx].y;
    }
  }
}

posHistory は「プレイヤーが通ってきた座標の記録」です。unshift で先頭に追加することで、インデックス0が最新の位置、インデックスが大きいほど過去の位置になります。オプションは OPTION_TRAIL_DELAY 個前の位置に置くだけで、自動的に追従します。

バリアの回転処理

バリアはプレイヤーの周囲を回転するシールドです。角度を毎フレーム増やすだけで実現できます。

var BARRIER_HITS = 3;   // バリアが耐えられる被弾数
var barrierAngle = 0;   // バリアの現在の回転角度

function updateBarrier() {
  // 毎フレーム少しずつ回転
  barrierAngle += 0.03;
}

function drawBarrier() {
  if (barrierHP <= 0) return;
  var px = sc(player.x);
  var py = sc(player.y);
  var radius = sc(player.w * BARRIER_RADIUS_RATIO);

  // バリアの軌道(円)を描画
  ctx.beginPath();
  ctx.arc(px, py, radius, 0, Math.PI * 2);
  ctx.strokeStyle = 'rgba(68, 170, 255, 0.4)';
  ctx.stroke();

  // 残りHP数だけバリアの「粒」を等間隔に配置
  for (var i = 0; i < barrierHP; i++) {
    var angle = barrierAngle + (i * Math.PI * 2 / BARRIER_HITS);
    var bx = px + Math.cos(angle) * radius;
    var by = py + Math.sin(angle) * radius;
    ctx.beginPath();
    ctx.arc(bx, by, sc(3), 0, Math.PI * 2);
    ctx.fillStyle = '#44aaff';
    ctx.fill();
  }
}

バリアの「粒」の位置も三角関数で計算しています。cos(angle)sin(angle) で円周上の座標が得られるので、HPが減るにつれて粒の数が減る表現も自然に実現できます。

まとめ — 次のステップ

おつかれさまでした!ここまでで横スクロールシューティングの基本が完成しました。スクロール処理でゲームに奥行きを作り、パワーアップシステムでゲームの幅を広げ、移動履歴を使ってオプションを実装しました。さらに発展させるなら、ボスの攻撃パターンを増やしたり、地形の種類を増やしたり、BGMや効果音を追加したりしてみましょう。

よくある質問

Q: スクロールが重くなる原因は何ですか?

A: 主な原因は弾や敵の数が増えすぎることです。このゲームでは MAX_ENEMY_BULLETS のような上限を設けて対処しています。また、画面外に出た弾や敵は配列から削除することも大切です。削除しないとメモリが増え続けてしまいます。

Q: どのくらい時間がかかりますか?

A: 基本部分は約30分〜1時間で作れます。パワーアップの種類を増やしたりステージを追加したりすると、さらに楽しく発展させられます。