[Astro] #141 Canvas2D × fillRect ドット絵で構築するブラウザ完結型レトロアーケード — PONG・平安京エイリアン・スペースインベーダーの実装記録
はじめに
「PROTOCOL.LAIN」のデスクトップ空間上にフローティングウィンドウとして動作するレトロゲームコレクション「LAIN RETRO ARCADE」を構築しました。
特徴は 画像ファイルを一切使わず、すべてのグラフィックを fillRect による 1 ドット単位のピクセル描画だけで実現 している点です。320×240 の仮想キャンバスに imageRendering: 'pixelated' を適用し、CRT 風のドット感を維持したまま任意のウィンドウサイズにスケーリングします。
現在収録しているタイトルは以下の 3 本です。
| No. | タイトル | オリジナル年 | ステータス |
|---|---|---|---|
| 01 | PONG | 1972 | v1.0 |
| 02 | HEIANKYO ALIEN | 1979 | v1.0 |
| 03 | SPACE INVADER | 1978 | v1.0 |
| 04 | PAC-GHOST | 1980 | COMING SOON |
技術記事の主要ハイライト
- 全グラフィック
fillRect描画: 画像ファイルを一切使用せず、8×8 の数値配列によるスプライト描画で軽量化を実現。 - ゲーム状態管理: React のレンダリングサイクルから分離するため
useRefを活用し、requestAnimationFrameループで直接 Canvas2D へ描画。 - タイトル別実装:
- PONG: 1打ごとに5%加速する物理計算とパドル中心オフセットによる反射角制御。
- 平安京エイリアン: グリッド移動と穴掘り/埋めメカニクス、BFSを排した直線・視線追跡AI。
- スペースインベーダー: 生存数依存の移動速度制御と、22×16 ビットマップによる破壊可能シールド。
スクリーンショット
起動方法
トップページの左下にある「ターミナル」で game コマンドを実行。
タイトル画面
カーソルキーの上下で選択、ENTERで決定
1.PONG (1972)
02. HEIANKYO ALIEN (1979)
03. SPACE INVADER (1978)
動画(GIF)
1.PONG (1972)
02. HEIANKYO ALIEN (1979)
03. SPACE INVADER (1978)
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 | 検非違使(プレイヤー) | 2 | ALIEN |
| SPRITE_ALIEN / ALIEN2 | イカ型エイリアン(30pt) | 2 | ALIEN, INVADER |
| SPRITE_CRAB / CRAB2 | カニ型エイリアン(20pt) | 2 | INVADER |
| SPRITE_OCTO / OCTO2 | タコ型エイリアン(10pt) | 2 | INVADER |
| SPRITE_SHIP | 自機 | 1 | INVADER |
スプライトは 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) | #ff0066 | 30pt |
| 1-2(中段) | カニ型(CRAB) | #ff6600 / #ffaa00 | 20pt |
| 3-4(下段) | タコ型(OCTO) | #00ff88 / #00ffcc | 10pt |
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 技術のレイヤーを重ねることで、レトロとサイバーが共存する空間が生まれました。