「シューティングゲーム」と聞くと、移動、照準、射撃、武器の切り替えなど、たくさんの操作を想像するかもしれません。GALAXY SHOOTでは、そのうち射撃を自動化し、プレイヤーが考えることを左右の移動と、次にどの色を狙うかに絞りました。1回60秒。説明を読み切る前でも動かせて、終わった直後には「次はチェインを切らさない」と再挑戦できる長さです。

操作を減らすことは、遊びを減らすことではありません。判断の焦点を鋭くすることです。

この記事では、遊びの核を決めた順番から、ブラウザ上で安定して動かす実装、難易度の測り方までを追います。コードを初めて読む人には「画面の裏で何が起きているか」を、経験者には数値設計やフレーム更新の判断材料を届けることを目標にします。

1. 60秒で伝わる「移動だけ」のルール

最初に決めたのは、宇宙船が画面下部にいて、敵が上から降り、自機は弾を自動で撃つという骨格です。プレイヤーは左右に動いて、弾の通り道と敵の位置を合わせます。PCでは左右キーまたはA/Dキー、スマートフォンでは画面のドラッグと左右ボタンを使えます。

射撃ボタンをなくした理由は、連打の速さを競うゲームにしたくなかったからです。弾は通常時に約0.235秒間隔で発射されます。そのため、成否を分けるのは「押せた回数」ではなく「次の敵を見て、早めに移動を始めたか」です。入力から自機の反応までを短くしつつ、急な方向転換で画面が読みにくくならないよう、ドラッグ時は目標座標へなめらかに近づけています。

1プレイを60秒にしたのにも意味があります。序盤の理解、チェインが続く中盤、敵が速く増える終盤を1分の中に置ける一方、失敗しても損をした感覚が強くありません。30秒を越えると背景も次の宇宙域へ溶けるように変わり、数字だけでなく風景からも進行を感じられるようにしました。

2. 同じ色を続けるチェインが判断を生む

敵にはシアン、マゼンタ、ゴールドの3色があります。同じ色の敵を連続で破壊するとチェインが伸び、3体つなぐごとに倍率が上がります。倍率は1倍から最大5倍までです。違う色を壊すと0点になるのではなく、新しい色のチェインが1から始まります。この「完全な失敗にしない」設計が重要でした。

たとえばシアンを2体続けた直後、中央にマゼンタ、端にシアンが見えたとします。近いマゼンタを安全に取るか、少し大きく動いてシアンの3体目を狙い倍率を上げるか。操作は左右だけでも、状況には小さな迷いが生まれます。これがGALAXY SHOOTの中心です。

同じ色を続けると光のチェインが伸び、異なる色で途切れる流れ

左は単発、中央はシアンを連続破壊した状態、右は異なる色へ切り替わる瞬間です。色と光の長さだけでチェインの変化を表しています。

実装では、直前の色と今回の色を比較するだけです。複雑な履歴配列は必要ありません。

// 直前と同色なら継続し、違えば新しいチェインを始めます。
if (chainColorIndex === enemy.colorIndex) {
  chain += 1;
} else {
  chainColorIndex = enemy.colorIndex;
  chain = 1;
}
 
const multiplier = Math.min(5, 1 + Math.floor((chain - 1) / 3));
score += 100 * multiplier;

敵に接触するとチェインはリセットされ、150点を失います。ただしスコアは0未満にはなりません。リスクを感じさせながら、初心者を序盤で借金状態にしないためです。また、一定数を撃破すると強化アイテムが現れ、8秒間は弾速と射線が増えます。チェインだけを追い続ける単調さに、短い「押し返す時間」を挟んでいます。

3. 状態を一か所に集めてゲームを動かす

ゲーム画面では、敵、弾、エフェクト、残り時間、ポーズ画面が同時に動いて見えます。しかし処理の考え方は、現在の状態を更新してから描く、という繰り返しです。状態とは、たとえば自機のX座標、敵の配列、経過時間、スコア、現在の画面モードです。

画面モードは introplayingpausedresult のように分けました。ポーズ中は描画を残したまま更新だけを止め、結果画面では再挑戦ボタン以外の入力を受けません。真偽値を何個も組み合わせるより、「今はどの状態か」を1つの値で表すほうが、あり得ない組み合わせを防げます。

入力、状態更新、衝突判定、描画、親サイトへの保存が循環する設計図

上から時計回りに、入力、時間と状態の更新、衝突判定、描画、記録の受け渡しを表しています。保存だけはゲームの循環から親サイトへ安全に渡します。

ブラウザの requestAnimationFrame は、次に画面を描けるタイミングで関数を呼びます。ただし、前回からの時間は端末や負荷によって変わります。そこで移動量を「1フレームに何ピクセル」ではなく「1秒あたりの速さ × 経過秒」で求めます。

function frame(now) {
  // タブ復帰直後の巨大な時間差を上限で抑えます。
  const delta = Math.min(0.055, Math.max(0, (now - lastFrame) / 1000));
  lastFrame = now;
 
  if (gameState === "playing") {
    update(delta);
  }
  render();
  requestAnimationFrame(frame);
}

上限を0.055秒にしているのは、別タブから戻った直後などに、敵が一気に画面を通り抜けるのを防ぐためです。厳密な物理シミュレーションなら固定時間刻みと補間を使う方法もありますが、60秒の小規模なアーケードゲームでは、差分時間を制限する方法が実装量と安定性の良い折衷になりました。

4. 円同士の当たり判定で読みやすさを優先する

敵はおおむね円形なので、弾との当たり判定には中心間距離を使います。平方根を計算する必要はありません。距離の二乗と半径の二乗を比べれば、同じ答えになります。

const dx = bullet.x - enemy.x;
const dy = bullet.y - enemy.y;
const hitRadius = enemy.radius + 5;
 
if (dx * dx + dy * dy <= hitRadius * hitRadius) {
  bullet.alive = false;
  destroyEnemy(enemy);
}

当たり判定の半径は、絵の端ぎりぎりより少しだけ内側に寄せています。見た目より判定が大きいと「避けたのに当たった」と感じますが、わずかに小さい判定は「うまく抜けた」という快感になります。一方、弾は視認幅より少し広い5ピクセル分を足し、狙いがほぼ合っているときは命中させます。自機と敵、弾と敵で同じ厳しさにしないのがポイントです。

弾と敵を二重ループで調べる方法は、要素が非常に多いゲームでは重くなります。しかし本作では画面内の数を小さく保ち、命中した弾はその時点でループを抜けます。毎フレーム、画面外へ出た要素も配列から除きます。この規模なら空間分割を導入するより、単純なコードのほうが検証しやすく、バグも見つけやすいと判断しました。

5. キー、ボタン、ドラッグを同じ移動へ変換する

入力方法が複数あっても、ゲーム内部では最終的に「左へ進む」「右へ進む」「目標X座標へ進む」の3つへそろえます。キーボードと画面下のボタンは heldDirection に -1、0、1を入れ、ドラッグは targetX を更新します。描画や敵の処理は、入力機器が何だったかを知る必要がありません。

ドラッグ座標は、ページ全体ではなくCanvas内の座標へ変換します。さらに画面幅に合わせてCanvasが拡大縮小されても、論理座標960×540へ換算します。この正規化を挟まないと、スマートフォンでは指と宇宙船の位置がずれます。

入力体験で特に確認したのは、押しっぱなし、指がボタン外へ出たとき、複数回の素早いタップ、ブラウザのタブ切り替えです。pointercancelpointerleave でも方向を0へ戻し、ページが非表示になったら自動でポーズします。ゲームの難しさではない理由で自機が動き続けないようにするためです。

6. 数式で作り、目と耳で調整する難易度

敵の落下速度は、開始時の基礎値に経過時間の係数を足しています。敵の生成間隔は約0.62秒から徐々に短くなり、下限は0.30秒です。いきなり別ルールへ切り替えるのではなく、連続した式で密度を上げるため、プレイヤーは自然に集中を深められます。

ただし数式だけでは「楽しい難しさ」になりません。調整では次の値を分けて観察しました。

  • 最初の10秒で1体も倒せない割合
  • 3チェインへ到達するまでの時間
  • 接触後、再びチェインを始められるまでの時間
  • 強化アイテム中に画面を制圧できる感覚
  • 45秒以降に左右どちらにも逃げ場がない瞬間の有無

音も情報の一部です。命中音は敵色と倍率に応じて音程を少し変え、チェインが続くと耳でも上昇を感じられます。被弾音は低く短くし、何が起きたかを画面から目を離さず理解できるようにしました。Web Audio APIのオシレーターで短い音を合成するため、音声ファイルの読み込み待ちもありません。

動きに弱い人向けには prefers-reduced-motion を参照します。設定が有効なら、流れる星や画面揺れを抑え、背景切り替えも段階的な大移動ではなく簡潔にします。演出をゼロにするのではなく、ゲーム情報を保ったまま刺激だけを減らす方針です。

7. セーブブリッジと検証で「もう一回」を守る

ベストスコアはゲームのiframe内へ直接保存せず、親サイトのセーブブリッジへ postMessage で依頼します。送信先にはURLパラメータで渡された親のオリジンを使い、* は指定しません。受信時も event.originevent.source の両方を確認します。これにより、ゲームを別のPagesプロジェクトへ移しても、親サイト側の保存方針を一か所で管理できます。

保存するデータは現在 { bestScore } だけです。バージョン番号をメッセージへ含めているため、将来「最大チェイン」や「累計撃破数」を追加しても移行処理を作れます。保存に失敗してもプレイは続けられ、表示上でのみ状態を知らせます。記録機能の障害が、ゲーム本体の障害へ連鎖しない設計です。

最後の検証は、ただ60秒遊ぶだけでは足りません。敵が同時に重なる状況、強化終了の瞬間、スコア0での被弾、ポーズと再開、画面幅の変更、画像読み込み失敗を個別に試します。さらに、毎回同じ操作をしても乱数で結果が変わる箇所は、ログへ生成数と色分布を出して偏りを確認します。

短いゲームほど、一つの違和感が全体を占める割合は大きくなります。

GALAXY SHOOTの中毒性は、派手な機能の数ではなく、「次の同色まであと少し」という未完了感から生まれます。操作を左右移動へ絞り、色の連続を得点へ変え、60秒で必ず区切る。その小さなループを、入力の取りこぼしや保存の不安なしに繰り返せるところまで整えることが、今回の開発で最も大切な作業でした。