ソースコード解説・目次

CHAPTER 07 · 全23項目

敵の編隊とウェーブ生成

50体の相対位置、折り返し、残り数とウェーブに応じた速度、射撃する敵の選び方と、ウェーブが進むと厳しくなる射撃を追います。

主な参照先:makeWave / form / formBounds / formSpeed / pickShooter / enemyFireInterval / maxEnemyBullets / aimedChance / enemyShot

行と列に共通の原点を足す

game.html · L796–798ソースを見る ↗
const ROWS = 5, COLS = 10, CELL_W = 36, CELL_H = 30, DROP = 14;
const ROW_TYPE = ["a", "b", "b", "c", "c"];
const TOTAL = ROWS * COLS;

通常ウェーブの編隊は5行 × 10列です。敵は行 r と列 c を持ち、共通の原点 form.ox・form.oy を動かすと全体が移動します。敵1体ごとに速度を持たせる必要はありません。5の倍数のウェーブでは編隊の代わりにボスを生成します。

game.html · L935–947ソースを見る ↗
function makeWave() {
  aliens = [];
  boss = null;
  if (wave % 5 === 0) makeBoss();
  else {
    for (let r = 0; r < ROWS; r++)
      for (let c = 0; c < COLS; c++)
        aliens.push({ r, c, type: ROW_TYPE[r], alive: true });
  }
  aliveN = aliens.length;
  form.ox = 60;
  form.oy = 96 + Math.min(wave - 1, 5) * 10;
  form.dir = 1; form.anim = 0; form.animT = 0;

生きている敵の端で折り返す

formBounds() は生存中で編隊に残っている敵だけの左右端を調べます。画面の端から8 pxの境界を越えたら超過分を戻し、向きを反転して DROP だけ下へ進めます。空になった列を除いて幅を測るため、敵が減ると移動できる範囲が広がります。

残り数で難しさを変える

game.html · L1474–1482ソースを見る ↗
function formSpeed() {
  const r = aliveN / TOTAL;
  // ウェーブごとの基本速度はウェーブ 13 で頭打ち。それ以上速いと端での折り返しが続いて一気に降下し、
  // 弾をかわす前に侵攻で終わってしまうため、後半は弾の攻撃で難しくする。
  let s = 16 + Math.min(wave - 1, 12) * 7 + 130 * Math.pow(1 - r, 1.7);
  if (aliveN <= 3) s += 55;
  if (aliveN === 1) s += 75;
  return Math.min(s, 240);
}

r は生存数の割合です。1 - r が大きいほど加速し、残り3体以下と最後の1体ではさらに加算します。最終的な速度は240で頭打ちです。アニメーションの切り替え周期も残り数に応じて短くなります。

ウェーブごとの基本速度 16 + (wave - 1) * 7 は、Math.min(wave - 1, 12) によってウェーブ13の毎秒100 pxで止まります。編隊は端で折り返すたびに DROP の14 pxずつ降りるので、横に速く動くほど折り返しが増えて速く降下します。基本速度が上がり続けると、50体そろった編隊が0.5秒ごとに折り返して一気に地上へ迫り、弾をかわす前に侵入で終わってしまいます。そのため後半は、次の節の射撃で難しくしています。

射撃と地上への侵入

game.html · L1463–1472ソースを見る ↗
// 各列のいちばん下にいる(編隊にいる)敵から 1 体を選び、その矩形を返す。
function pickShooter() {
  const bottoms = [];
  for (let c = 0; c < COLS; c++) {
    let low = null;
    for (const a of aliens) if (a.alive && !a.flight && a.c === c && (!low || a.r > low.r)) low = a;
    if (low) bottoms.push(low);
  }
  return bottoms.length ? alienRect(bottoms[(Math.random() * bottoms.length) | 0]) : null;
}

pickShooter() は各列で最も下にいる敵を候補にし、そこからランダムに射撃役を選んで、その四角形を返します。突入中の敵はこの候補に入りません。

game.html · L1677–1693ソースを見る ↗
  // --- 敵の射撃 ---
  fireT -= dt;
  const maxEB = maxEnemyBullets();
  if (fireT <= 0 && bannerT <= 0 && aliveN > 0) {
    fireT = enemyFireInterval() * rand(0.5, 1.5) * (0.55 + 0.45 * (aliveN / TOTAL));
    const r = eBullets.length < maxEB && pickShooter();
    if (r) enemyShot(r.x + r.w / 2 - 1.5, r.y + r.h, Math.random() < aimedChance());
  }
  // ウェーブ 16 からは、ときどき編隊の 1 体が 3 方向に広がる弾をまとめて撃つ(間隔は少しずつ縮む)。
  if (wave >= VOLLEY_WAVE && bannerT <= 0 && aliveN > 0) {
    volleyT -= dt;
    if (volleyT <= 0) {
      volleyT = Math.max(2.5, 6 - (wave - VOLLEY_WAVE) * 0.25) * rand(0.8, 1.2);
      const r = eBullets.length + 3 <= maxEB && pickShooter();
      if (r) for (const vx of [-70, 0, 70]) enemyShot(r.x + r.w / 2 - 1.5, r.y + r.h, false, vx);
    }
  }

fireT が0になるたびに1発撃ち、次の発射までの時間を決め直します。間隔はウェーブで決まる基本値に乱数を掛け、さらに敵が減るほど短くします。画面内の敵弾が上限に達しているときは撃ちません。ウェーブ16からは別のタイマー volleyT で、ときどき1体が3方向に広がる弾をまとめて撃ちます。3発がすべて上限に収まるときだけ撃つので、上限を超えることはありません。

編隊内の敵が自機の高さまで到達したり自機と重なると、残機とは別に侵入によるゲームオーバーになります。

ウェーブが進むほど射撃が厳しくなる

game.html · L1442–1461ソースを見る ↗
// ---- 難易度の上がり方 ----
// 序盤(〜13 ウェーブ)は従来どおりに上がり、その先も 30 ウェーブ前後までゆっくり厳しくなり続ける。
function enemyFireInterval() {
  return wave <= 13 ? Math.max(0.3, 1.15 - (wave - 1) * 0.07) : Math.max(0.18, 0.3 - (wave - 13) * 0.007);
}

function maxEnemyBullets() { // 画面内の敵弾の上限。ウェーブ 5 で 7 発、以降 4 ウェーブごとに 1 発ずつ増えて最大 12 発
  return wave <= 5 ? 2 + wave : Math.min(12, 7 + Math.floor((wave - 5) / 4));
}

function aimedChance() { // 編隊の弾のうち自機を狙う割合。ウェーブ 6 の 15% から、ウェーブ 20 で 50% まで
  return wave < AIMED_WAVE ? 0 : Math.min(0.5, 0.15 + (wave - AIMED_WAVE) * 0.025);
}

// 敵弾を 1 発出す。狙い撃ち弾は撃った瞬間の自機の位置へ向かう(横の速さは避けられる程度に制限)。
function enemyShot(x, y, aimed, vx = 0) {
  const vy = Math.min(330, 150 + wave * 14) * rand(0.9, 1.1);
  if (aimed) vx = clamp((player.x - x) * vy / Math.max(60, PLAYER_Y - y), -110, 110);
  eBullets.push({ x, y, w: 3, h: 9, vx, vy, aimed });
}

射撃の厳しさは小さな関数に分けてあり、どれも wave だけから値を決めます。発射間隔はウェーブ13までは従来と同じ割合で縮み、その先も30ウェーブ前後まで少しずつ縮みます。画面内の敵弾の上限は、ウェーブ5の7発から4ウェーブごとに1発ずつ増え、最大12発です。

ウェーブ発射間隔の基本値敵弾の上限狙い撃ちの割合
11.15秒3発0%
60.80秒7発15%
130.31秒9発33%
200.25秒10発50%
300.18秒12発50%

ウェーブ6からは、編隊の弾の一部が自機を狙って斜めに飛びます。enemyShot() は狙い撃ちのとき、撃った瞬間の自機の位置へ届くように横の速さ vx を計算します。自機までの縦の距離を落下の速さで割れば到達までの時間が分かり、その時間で横の距離を進めばよい、という考え方です。横の速さは避けられるよう毎秒110 pxまでに制限します。狙い撃ちの弾は aimed を持ち、画面では赤く描いて、まっすぐ落ちる黄色の弾と見分けられるようにしています。

新しい攻撃が始まるウェーブ(6・11・16)とボスのレベルが上がるウェーブ(10・15・20)は WAVE_NEWS にまとめてあり、その直前のウェーブクリア画面で予告します(「14 ウェーブクリアと次のウェーブ」)。