[Astro] #141 Canvas2D × fillRect ドット絵で構築するブラウザ完結型レトロアーケード — PONG・平安京エイリアン・スペースインベーダーの実装記録

[Astro] #141 Canvas2D × fillRect ドット絵で構築するブラウザ完結型レトロアーケード — PONG・平安京エイリアン・スペースインベーダーの実装記録

はじめに

「PROTOCOL.LAIN」のデスクトップ空間上にフローティングウィンドウとして動作するレトロゲームコレクション「LAIN RETRO ARCADE」を構築しました。

特徴は 画像ファイルを一切使わず、すべてのグラフィックを fillRect による 1 ドット単位のピクセル描画だけで実現 している点です。320×240 の仮想キャンバスに imageRendering: 'pixelated' を適用し、CRT 風のドット感を維持したまま任意のウィンドウサイズにスケーリングします。

現在収録しているタイトルは以下の 3 本です。

No.タイトルオリジナル年ステータス
01PONG1972v1.0
02HEIANKYO ALIEN1979v1.0
03SPACE INVADER1978v1.0
04PAC-GHOST1980COMING SOON

技術記事の主要ハイライト

  • 全グラフィック fillRect 描画: 画像ファイルを一切使用せず、8×8 の数値配列によるスプライト描画で軽量化を実現。
  • ゲーム状態管理: React のレンダリングサイクルから分離するため useRef を活用し、requestAnimationFrame ループで直接 Canvas2D へ描画。
  • タイトル別実装:
    • PONG: 1打ごとに5%加速する物理計算とパドル中心オフセットによる反射角制御。
    • 平安京エイリアン: グリッド移動と穴掘り/埋めメカニクス、BFSを排した直線・視線追跡AI。
    • スペースインベーダー: 生存数依存の移動速度制御と、22×16 ビットマップによる破壊可能シールド。

スクリーンショット

起動方法

トップページの左下にある「ターミナル」で game コマンドを実行。

[Astro] #141 Canvas2D × fillRect ドット絵で構築するブラウザ完結型レトロアーケード — PONG・平安京エイリアン・スペースインベーダーの実装記録

タイトル画面

カーソルキーの上下で選択、ENTERで決定

[Astro] #141 Canvas2D × fillRect ドット絵で構築するブラウザ完結型レトロアーケード — PONG・平安京エイリアン・スペースインベーダーの実装記録

1.PONG (1972)

[Astro] #141 Canvas2D × fillRect ドット絵で構築するブラウザ完結型レトロアーケード — PONG・平安京エイリアン・スペースインベーダーの実装記録

02. HEIANKYO ALIEN (1979)

[Astro] #141 Canvas2D × fillRect ドット絵で構築するブラウザ完結型レトロアーケード — PONG・平安京エイリアン・スペースインベーダーの実装記録

03. SPACE INVADER (1978)

[Astro] #141 Canvas2D × fillRect ドット絵で構築するブラウザ完結型レトロアーケード — PONG・平安京エイリアン・スペースインベーダーの実装記録

動画(GIF)

1.PONG (1972)

[Astro] #141 Canvas2D × fillRect ドット絵で構築するブラウザ完結型レトロアーケード — PONG・平安京エイリアン・スペースインベーダーの実装記録

02. HEIANKYO ALIEN (1979)

[Astro] #141 Canvas2D × fillRect ドット絵で構築するブラウザ完結型レトロアーケード — PONG・平安京エイリアン・スペースインベーダーの実装記録

03. SPACE INVADER (1978)

[Astro] #141 Canvas2D × fillRect ドット絵で構築するブラウザ完結型レトロアーケード — PONG・平安京エイリアン・スペースインベーダーの実装記録

1. 全体アーキテクチャ

ファイル構成

src/components/ui/
├── WiredArcadeWindow.tsx   (534行 — シェル + タイトル画面 + PONG)
└── games/
    ├── sprites.ts           (123行 — 共有スプライトデータ & 描画関数)
    ├── alien.ts             (409行 — 平安京エイリアン)
    └── invader.ts           (392行 — スペースインベーダー)

設計方針

各ゲームは独立したモジュールとして分離し、シェル(WiredArcadeWindow.tsx)からは 1 行で呼び出す構成です。

// WiredArcadeWindow.tsx — ゲーム呼び出し部分
else if (currentGame === 'ALIEN') {
  updateAndDrawAlien(ctx, gameStateRef.current.alien, keysRef.current, config.textColor);
}
else if (currentGame === 'INVADER') {
  updateAndDrawInvader(ctx, gameStateRef.current.invader, keysRef.current, config.textColor);
}

各ゲームモジュールは統一インターフェースを持ちます。

// State のファクトリ関数
export function createAlienState(): AlienState { ... }

// 更新&描画を 1 フレームで行うエントリポイント
export function updateAndDrawAlien(
  ctx: CanvasRenderingContext2D,
  state: AlienState,
  keys: { [key: string]: boolean },
  textColor: string
): void { ... }

状態は useRef で保持し、React の再レンダリングサイクルから完全に分離しています。requestAnimationFrame ループ内で直接 Canvas2D に描画するため、DOM 操作のオーバーヘッドはゼロです。


2. fillRect ドット絵スプライトシステム

画像ファイルなしでキャラクターを描画する

すべてのスプライトは 8×8 の数値配列として定義します。1 のピクセルを fillRect で指定色・指定スケールで描画します。

// sprites.ts — インベーダー(イカ型、30pt)
export const SPRITE_ALIEN: number[][] = [
  [0,0,1,0,0,1,0,0],
  [0,1,1,1,1,1,1,0],
  [1,1,0,1,1,0,1,1],
  [1,1,1,1,1,1,1,1],
  [0,1,1,1,1,1,1,0],
  [0,0,1,0,0,1,0,0],
  [0,1,0,1,1,0,1,0],
  [1,0,0,0,0,0,0,1],
];

// 描画関数
export function drawSprite(
  ctx: CanvasRenderingContext2D,
  sprite: number[][],
  px: number, py: number,
  color: string,
  scale: number = 2
) {
  ctx.fillStyle = color;
  for (let y = 0; y < sprite.length; y++) {
    for (let x = 0; x < sprite[y].length; x++) {
      if (sprite[y][x]) {
        ctx.fillRect(px + x * scale, py + y * scale, scale, scale);
      }
    }
  }
}

スプライト一覧

スプライト名用途フレーム数使用ゲーム
SPRITE_PLAYER / PLAYER2検非違使(プレイヤー)2ALIEN
SPRITE_ALIEN / ALIEN2イカ型エイリアン(30pt)2ALIEN, INVADER
SPRITE_CRAB / CRAB2カニ型エイリアン(20pt)2INVADER
SPRITE_OCTO / OCTO2タコ型エイリアン(10pt)2INVADER
SPRITE_SHIP自機1INVADER

スプライトは games/sprites.ts に集約し、複数ゲームで共有します。アニメーションはフレームカウンタで 2 パターンを交互に切り替えるだけです。


3. PONG(1972)の実装

シェル(WiredArcadeWindow.tsx)内に直接実装。最もシンプルなゲームなので分離していません。

物理パラメータ

const INITIAL_BALL_SPEED = 2.5;
const MAX_BALL_SPEED = 6.5;
const SPEED_ACCELERATION = 1.05; // 1打毎に5%加速
const MAX_BOUNCE_ANGLE = Math.PI / 3.5; // 最大反射角 (約51度)
const CPU_SPEED = 2.2;

ボールがパドルに当たるたびに速度が 5% ずつ加速し、パドル中心からのオフセットで反射角が変化します。CPU はボールの Y 座標を追従しますが、CPU_SPEED に上限があるため、ボールが加速するとプレイヤー側が有利になるバランス設計です。


4. 平安京エイリアン(1979)の実装

マップ構造

19×13 のグリッドで京都の碁盤目を再現。3 セルごとに通路(0)、間は壁(1)です。

function makeMap(): number[][] {
  const m: number[][] = [];
  for (let y = 0; y < ROWS; y++) {
    m[y] = [];
    for (let x = 0; x < COLS; x++) {
      if (x === 0 || x === COLS - 1 || y === 0 || y === ROWS - 1) m[y][x] = 0;
      else if (x % 3 === 0 || y % 3 === 0) m[y][x] = 0;
      else m[y][x] = 1; // 壁(通行不可)
    }
  }
  return m;
}

ゲームメカニクス

操作キー効果
移動←↑↓→グリッド上の通路を移動
穴掘りZ向いている方向の通路に穴を設置(500ms)
穴埋めX敵が落ちた穴を埋めて撃破(400ms、100pt × レベル)

敵 AI — BFS を排した直線移動+視線追跡

本家に近い動きを再現するため、BFS 全探索を廃止し、以下のアルゴリズムを採用しました。

1. 同一行 or 同一列にプレイヤーがいて、間に壁がなければ → 直進追跡
2. そうでなければ → 現在の方向に直進を継続(85%確率)
3. 壁にぶつかったら → ランダムに方向転換(逆走は最後の手段)

ポイントは エイリアンは穴の存在を認識しない こと。穴回避ロジックを一切持たないため、通路を歩いているつもりで穴に落ちます。これが本家の「罠を仕掛けて待つ」ゲーム性を生み出します。

穴のライフサイクル

[通路] → Z キー → [掘削中 500ms] → [穴(空)]

                          エイリアン歩行 ──┘

                                    [穴(敵落下中)]
                                      │         │
                               X キー ┘    4秒経過 ┘
                                  ▼              ▼
                            [穴消滅+撃破]    [穴消滅+脱出]

敵が脱出した場合も穴は消滅するため、再度掘り直す必要があります。


5. スペースインベーダー(1978)の実装

隊列移動システム

8 列 × 5 行の敵隊列を formX / formY のオフセット値で一括管理します。各敵は「列番号 × 行番号」で位置を算出し、フォーメーション全体の移動に追従します。

// 敵の実座標計算
const ex = ENEMY_START_X + e.col * ENEMY_SPACING_X + s.formX;
const ey = ENEMY_START_Y + e.row * ENEMY_SPACING_Y + s.formY;

移動速度は 生存する敵の数に反比例 します。

const moveInterval = Math.max(80, 100 + aliveCount * 12 - s.level * 15);
生存数moveInterval(Lv.1)体感
40体565msのろのろ
20体325msじわじわ
5体145ms焦る
1体97ms超速

破壊可能シールド(ビットマップ方式)

4 基のシールドは 22×16 のビットマップ配列(boolean[][])として定義します。弾が当たった座標を中心に円形範囲のピクセルを false に設定することで、本家のように不規則に崩れていく表現を実現しています。

function damageShield(shield: boolean[][], hitX: number, hitY: number, radius: number) {
  for (let dy = -radius; dy <= radius; dy++) {
    for (let dx = -radius; dx <= radius; dx++) {
      if (dx * dx + dy * dy <= radius * radius) {
        const sy = hitY + dy, sx = hitX + dx;
        if (sy >= 0 && sy < SHIELD_H && sx >= 0 && sx < SHIELD_W) {
          shield[sy][sx] = false;
        }
      }
    }
  }
}

味方の弾(半径 2)と敵の弾(半径 3)で削れ方を変えることで、自分の弾でもシールドが削れる本家仕様を再現しています。

シールドの初期形状

アーチ型+下部ノッチの本家風シルエットをコードで生成します。

function makeShield(): boolean[][] {
  const s: boolean[][] = [];
  for (let y = 0; y < SHIELD_H; y++) {
    s[y] = [];
    for (let x = 0; x < SHIELD_W; x++) {
      // 上部の丸み
      if (y < 4 && (x < 3 || x >= SHIELD_W - 3)) s[y][x] = false;
      else if (y < 2 && (x < 5 || x >= SHIELD_W - 5)) s[y][x] = false;
      // 下部の切り欠き(自機が入る隙間)
      else if (y >= SHIELD_H - 5 && x >= 7 && x < SHIELD_W - 7) s[y][x] = false;
      else s[y][x] = true;
    }
  }
  return s;
}

行ごとの敵タイプと配色

スプライト得点
0(最上段)イカ型(ALIEN)#ff006630pt
1-2(中段)カニ型(CRAB)#ff6600 / #ffaa0020pt
3-4(下段)タコ型(OCTO)#00ff88 / #00ffcc10pt

6. 共通シェルの仕組み

フローティングウィンドウ

WiredArcadeWindow.tsx は他のウィンドウコンポーネント(WiredSshWindow など)と同じ設計で、ドラッグ移動・リサイズ・最小化・設定パネルを備えています。

タイトル画面とゲーム切り替え

type GameType = 'TITLE' | 'PONG' | 'ALIEN' | 'INVADER' | 'PACMAN';

currentGame で描画分岐し、ESC キーでタイトル画面に戻ります。ゲーム復帰時は initialized = false フラグで初期化をトリガーします。

Canvas スケーリング

<canvas
  width={320} height={240}
  style={{
    width: '100%', height: '100%',
    objectFit: 'contain',
    imageRendering: 'pixelated',  // ドット感を維持
  }}
/>

仮想解像度 320×240 で描画し、CSS でウィンドウサイズにフィットさせます。imageRendering: 'pixelated' によりアンチエイリアスを無効化し、拡大時もシャープなドット感を保ちます。


7. まとめ

  • 画像ファイルゼロ: すべてのグラフィックは fillRect で描画。スプライトは 8×8 の数値配列で定義し、ファイルサイズ増加なし。
  • モジュール分離: 各ゲームは独立した .ts ファイルで、シェルからは updateAndDraw*() 1 行で呼び出し。新しいゲームの追加はファイルを 1 つ作るだけ。
  • React の DOM と完全分離: useRef でステートを保持し requestAnimationFrame で直接 Canvas に描画するため、React の再レンダリングコストはゼロ。
  • スプライト共有: sprites.ts に全キャラクターを集約し、平安京エイリアンのエイリアンとインベーダーのイカ型が同じスプライトデータを共有。
  • ビットマップシールド: boolean[][] 配列と円形マスク関数で、弾が当たるたびに不規則に崩れる本家の表現を再現。

3D 空間に浮かぶフローティングウィンドウ上で、1970 年代のアーケードゲームが fillRect だけで動く——Web 技術のレイヤーを重ねることで、レトロとサイバーが共存する空間が生まれました。