「AIに任せたら、動くものは出てきた。でも、このコード、この先どう育てればいいんだ?」。バイブコーディングの最初の壁はここにある。土台の設計を自分で握っていないと、機能を1つ足すたびに全体が崩れていく。この連載では、ブラウザで動く都市開発シミュ「Aurum City」を、実際のコードで・順を追って・自分で再現できる形で1から作る。第1回のテーマは土台。React + TypeScript + Canvas で、48×48マスの地図をパン・ズームしながら地形を塗れるところまでを、「あとから壊れない」設計で組み上げる。
▶ この連載の全記事はバイブコーディング開発実例(一覧)から。完成したアプリのデモは連載の最終回(第6回)で公開する。
目次
この連載で作るもの(全6回の地図)
住宅・商業・工業をゾーニングし、道路を敷き、街が自動で育ち、目標をこなし、イベントに耐える。そんなブラウザ完結の都市開発シミュを、システムごとに分けて再現していく。
- ① 土台と3レイヤ設計(この記事)—
sim/render/uiの分離・グリッド・Canvas描画・パン/ズーム - ② AIでドット絵タイルを作る — 生成→均一化→アルファ実測→組み込み、道路の自動接続
- ③ 街を「動かす」— 需要・成長・経済の決定論シミュレーション(MVP完成)
- ④ 4度つまずいて座標系ごと作り直した話 — 真のアイソメグリッドへの全面移行
- ⑤「面白くない」を4軸で解消する — 目標・施設・イベント・音
- ⑥ 静的ビルドをWordPressへ公開する — 検証と配信、そして連載の総括
第1回のゴール
この記事を読み終えると、48×48マスの地図がCanvasに描かれ、ドラッグでパン・ホイールでズームでき、選んだ地形をクリックで塗れるところまでが手元に出来上がる。だが本題はその見た目ではない。以降の回(AIタイル・シミュレーション・アイソメ移行)で機能をどれだけ積んでも土台が崩れないように、「ロジック・描画・UI」を最初にきれいに分ける。その骨格を体に入れるのが目的だ。まず完成形の全体像を見てほしい。
Step 1. なぜ「3つの層」に分けるのか
まず結論から。この都市シミュは、フォルダを役割で 3つ に分ける。sim(ロジック)・render(描画)・ui(React) だ。そして最重要のルールがひとつある。sim は描画も画面も一切知らない。sim が扱うのは「地図のマス目に何があるか」という状態だけで、それを何色でどこに描くかは render の仕事、ボタンやパネルは ui の仕事、ときっぱり分ける。
面倒に見えるかもしれない。だが、この分け方には後で効いてくる理由が3つある。
- ロジックを高速にテストできる:sim は画面に依存しないただの関数とデータなので、ブラウザを立ち上げなくても単体テストが一瞬で回る。「需要が上がったら建物が育つ」といった核心のルールを、目視でなく自動テストで守れる(第3回で実演する)。
- 見た目を丸ごと差し替えても、中身は無傷:描画を Canvas から別方式に変えても、sim は1行も変わらない。実際この連載の第4回では、地図の見せ方をトップダウンからアイソメトリックへ全面的に作り替えるが、sim には手を触れずに済む。層を分けておいた最大の恩恵がここで出る。
- 「ロジック」と「見た目」を分けて説明できる:どこが街のルールで、どこが表示の都合なのかが混ざらない。自分が後から読み返すときにも、AIに手伝ってもらうときにも、迷子になりにくい。
💡 バイブコーディングの勘所:AIにコードを書かせるほど、この「層の境界」を人間が握っておく価値は上がる。境界さえ守られていれば、各層は小さく独立しているので、AIに1ファイルずつ安全に任せられる。逆に境界が無いと、1つの修正が思わぬ場所を壊し、原因も追えなくなる。
Step 2. ディレクトリ構成
3層の考え方を、そのままフォルダに落とす。src/ の下を3つに割るだけだ。
src/
├── sim/ # ① ロジック(純TS・Reactもcanvasも知らない)
│ └── grid.ts # グリッド/セルのモデル
├── render/ # ② 描画(Canvas 2D)
│ ├── camera.ts # 座標変換(画面 ↔ マス目)・パン・ズーム
│ ├── palette.ts # 地形の色
│ └── renderer.ts # 毎フレームCanvasに描く
├── ui/ # ③ React(外枠UIのみ)
│ ├── App.tsx # canvasの設置・入力・描画ループ
│ ├── Toolbar.tsx # ツール選択ボタン
│ └── store.ts # Zustand(選択中ツール等)
└── main.tsx # 起動(Appをマウントするだけ)
覚えるのは src/ の3分割だけでいい。sim=状態、render=描画、ui=操作。この後は、この順番(依存されない側の sim から、依存する側の ui へ)で1層ずつ作っていく。
Step 3. sim層:グリッドとセル(画面を知らないデータ)
最初に作るのは、街の土地そのもの。48×48 のマス目(グリッド)だ。各マス(セル)は、いまはまだ「地形」だけを持つ。plain(平地)・water(水)・mountain(山)の3種類。ここに色やピクセルの話は一切出てこないのが肝心なところだ。
// src/sim/grid.ts
export type Terrain = 'plain' | 'water' | 'mountain';
export interface Cell {
terrain: Terrain; // いまは地形だけ。道路や建物は後の回で足す
}
export class Grid {
readonly width: number;
readonly height: number;
private cells: Cell[];
constructor(width: number, height: number, fill: Terrain = 'plain') {
this.width = width;
this.height = height;
// 幅×高さ ぶんのセルを一列に並べて持つ(1次元配列)
this.cells = Array.from({ length: width * height }, () => ({ terrain: fill }));
}
inBounds(x: number, y: number): boolean {
return x >= 0 && y >= 0 && x < this.width && y < this.height;
}
private index(x: number, y: number): number {
if (!this.inBounds(x, y)) throw new RangeError(`out of bounds: (${x},${y})`);
return y * this.width + x; // (x,y) → 一列に並べた配列の位置
}
get(x: number, y: number): Cell { return this.cells[this.index(x, y)]; }
set(x: number, y: number, cell: Cell): void { this.cells[this.index(x, y)] = cell; }
}
ポイントは3つ。(1) 2次元の地図を、あえて1次元の配列で持つ。(x, y) は y * width + x という式で配列の位置に変換する(この式は「行を縦に積む」という意味で、後の保存・読込でもそのまま効いてくる)。(2) inBounds で範囲外を弾き、盤の外を触ろうとしたら例外で止める。(3) この Grid は get / set しか外に見せない。中の配列がどう並んでいるかは、外側からは気にしなくていい。
💡 ここが分離の本体:grid.ts に import 文が1つも無いことに注目してほしい。React も Canvas も、色も座標も出てこない。だからこのファイルは、ブラウザ抜きでそのままテストできる。「状態を持つ層は、見た目を知らない」。この徹底が、第4回の大改修を無傷で乗り切る布石になる。
Step 4. render層:カメラ(マス目と画面をつなぐ計算)
次は、この 48×48 のマス目を画面に映す係だ。ここで必要になるのが2つの座標系の橋渡し。マス目の座標(何マス目か)と画面のピクセル座標(画面の何px目か)を、行き来できるようにする。それを担うのが camera.ts。
1マスの大きさを TILE = 32(px)と決める。カメラは「いま地図のどこを・どの拡大率で見ているか」を offsetX / offsetY / zoom の3つで持つ。トップダウン(真上から見下ろす正方形マップ)なので、変換はとても素直な掛け算になる。
// src/render/camera.ts
export const TILE = 32;
export interface Camera {
offsetX: number; // 画面の原点に対応する「ワールドpx」
offsetY: number;
zoom: number;
}
// ワールドpx → 画面px(見ている位置を引いて、拡大率を掛けるだけ)
export function worldToScreen(cam: Camera, wx: number, wy: number) {
return { x: (wx - cam.offsetX) * cam.zoom, y: (wy - cam.offsetY) * cam.zoom };
}
// 画面px → ワールドpx(上の逆算)
export function screenToWorld(cam: Camera, sx: number, sy: number) {
return { x: sx / cam.zoom + cam.offsetX, y: sy / cam.zoom + cam.offsetY };
}
// 画面px → 「何マス目か」(ワールドpxを TILE で割って切り捨て)
export function screenToCell(cam: Camera, sx: number, sy: number) {
const w = screenToWorld(cam, sx, sy);
return { cx: Math.floor(w.x / TILE), cy: Math.floor(w.y / TILE) };
}
worldToScreen は「見ている左上(offset)を引いて、拡大率(zoom)を掛ける」だけ。screenToCell はその逆算で、クリックした画面の場所が地図の何マス目かを割り出す。マウス操作を地図の座標に翻訳するのは、すべてこの関数の仕事だ。
パンとズームも、この座標変換の上に乗る。特にズームは「カーソルの下にあるマスを固定したまま拡大する」ようにしたい。素朴に拡大率だけ変えると地図が左上に逃げていくので、拡大の前後でカーソル位置のワールド座標を測り、そのズレを offset で打ち消す。
export function panCamera(cam: Camera, dxScreen: number, dyScreen: number): Camera {
// ドラッグした画面px を、拡大率で割って offset へ反映(掴んだ地図が指について来る)
return { ...cam, offsetX: cam.offsetX - dxScreen / cam.zoom, offsetY: cam.offsetY - dyScreen / cam.zoom };
}
export function zoomCamera(cam: Camera, factor: number, sx: number, sy: number): Camera {
const before = screenToWorld(cam, sx, sy); // 拡大前、カーソルの下にある点
const zoom = Math.max(0.25, Math.min(4, cam.zoom * factor)); // 0.25〜4倍にクランプ
const next: Camera = { ...cam, zoom };
const after = screenToWorld(next, sx, sy); // 拡大後、同じ画面位置の点
// ズレ(before − after)を offset で打ち消す=カーソル下のマスが動かない
return { ...next, offsetX: next.offsetX + (before.x - after.x), offsetY: next.offsetY + (before.y - after.y) };
}
🔎 なぜ camera を独立させるのか:座標変換を camera.ts の数個の関数に閉じ込めておくと、「地図の見せ方」を変えるときここだけ直せばよくなる。第4回でトップダウンをアイソメトリック(斜め見下ろし)に作り替えるとき、書き換えるのは主にこのファイルの数式だけだ。いま素直な掛け算にしているのは、その伏線でもある。
Step 5. render層:Canvasに描く(画面外は描かない)
座標変換ができたので、あとは毎フレーム、全マスをCanvasに塗るだけ。ただし 48×48 = 2304 マスを何も考えず全部描くのは無駄が多い。画面の外にはみ出したマスは描かない(カリング)。このひと工夫で、拡大して一部しか見えていないときの描画がぐっと軽くなる。
// src/render/renderer.ts
import { Grid } from '../sim/grid';
import { Camera, worldToScreen, TILE } from './camera';
import { TERRAIN_COLORS } from './palette';
export class Renderer {
constructor(private ctx: CanvasRenderingContext2D, private grid: Grid, private cam: Camera) {
this.ctx.imageSmoothingEnabled = false; // ドット絵をくっきり(ぼかさない)
}
drawFrame(hover?: { cx: number; cy: number }): void {
const { ctx, grid, cam } = this;
const cw = ctx.canvas.width, ch = ctx.canvas.height;
ctx.clearRect(0, 0, cw, ch);
const size = TILE * cam.zoom;
for (let y = 0; y < grid.height; y++) {
for (let x = 0; x < grid.width; x++) {
const s = worldToScreen(cam, x * TILE, y * TILE);
// 画面の外なら描かずに飛ばす(カリング)
if (s.x + size <= 0 || s.y + size <= 0 || s.x >= cw || s.y >= ch) continue;
ctx.fillStyle = TERRAIN_COLORS[grid.get(x, y).terrain]; // simの状態を「読むだけ」
ctx.fillRect(s.x, s.y, size, size);
}
}
// ホバー中のマスを、ブランドのゴールドで縁取る
if (hover && grid.inBounds(hover.cx, hover.cy)) {
const s = worldToScreen(cam, hover.cx * TILE, hover.cy * TILE);
ctx.strokeStyle = '#C9A227'; // Aurum = 金。ブランドカラー
ctx.lineWidth = 2;
ctx.strokeRect(s.x, s.y, size, size);
}
}
}
地形の色は、別ファイル palette.ts に切り出しておく。「どの地形を何色で塗るか」は表示の都合であって、街のルールではない。だから sim ではなく render 側に置く。
// src/render/palette.ts
import { Terrain } from '../sim/grid';
export const TERRAIN_COLORS: Record<Terrain, string> = {
plain: '#3f5d3a', // 平地(草地)
water: '#2a4b6e', // 水
mountain: '#6b5d4f', // 山(建設不可になる予定)
};
ここで renderer.ts が grid.get(x, y).terrain を読むだけで、書き換えていないことに注目してほしい。render は sim の状態を映す鏡であって、状態そのものは触らない。ホバー中のマスに引く #C9A227 のゴールド枠が、このアプリの「Aurum(金)」というブランドの最初の顔になる。

plain)なので、一面の緑になる。ここまでで sim・render・描画ループがつながった。Step 6. ui層:Reactは薄い外枠(描画ループはReactの外で回す)
いよいよReactの出番。だが役割は意外なほど小さい。Reactは <canvas> を1枚置き、その中の描画ループはReactの再レンダーの外で回す。地図の1マスが変わるたびにReactを再描画していたら、到底60fpsは出ない。だから地図はCanvasに任せ、Reactはツールバーなどの外枠だけを受け持つ。
これを実現するのが useEffect と requestAnimationFrame の組み合わせだ。App.tsx の中核を見てみよう。
// src/ui/App.tsx(中核・抜粋)
useEffect(() => {
const canvas = canvasRef.current; if (!canvas) return;
const ctx = canvas.getContext('2d'); if (!ctx) return;
const grid = new Grid(48, 48); // sim層
const cam = makeCamera(); // render層(カメラ)
const renderer = new Renderer(ctx, grid, cam);
let hover; let dragging = false; let lastX = 0, lastY = 0;
// 画面のマウス位置 → 何マス目か
const cellAt = (e) => {
const rect = canvas.getBoundingClientRect();
return screenToCell(cam, e.clientX - rect.left, e.clientY - rect.top);
};
// いま選んでいる地形を、そのマスに塗る(=simを書き換える)
const paintAt = (e) => {
const tool = useAppStore.getState().tool;
if (tool === 'pan') return;
const { cx, cy } = cellAt(e);
if (grid.inBounds(cx, cy)) grid.set(cx, cy, { terrain: tool });
};
const onMove = (e) => {
hover = cellAt(e); // ホバー枠の位置を更新
if (!dragging) return;
if (useAppStore.getState().tool === 'pan') {
const dx = e.clientX - lastX, dy = e.clientY - lastY;
lastX = e.clientX; lastY = e.clientY;
Object.assign(cam, panCamera(cam, dx, dy)); // ドラッグで地図を動かす
} else paintAt(e); // ドラッグで連続して塗る
};
const onWheel = (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
Object.assign(cam, zoomCamera(cam, factor, e.clientX - rect.left, e.clientY - rect.top));
};
// …down/up/leave のリスナ登録は省略…
// 描画ループ:毎フレーム、いまの状態を描き直すだけ
let raf = 0;
const loop = () => { renderer.drawFrame(hover); raf = requestAnimationFrame(loop); };
raf = requestAnimationFrame(loop);
// 後始末:アンマウント時にループとリスナを必ず止める
return () => { cancelAnimationFrame(raf); /* removeEventListener … */ };
}, []);
構造はシンプルだ。useEffect の中で sim・render を1回だけ組み立て、requestAnimationFrame のループが毎フレーム drawFrame を呼ぶ。マウス操作は、パンならカメラを動かし、地形ツールなら grid.set で sim を書き換える。「入力で状態を変える → ループが次のフレームで映す」という、図0のデータフローがそのまま形になっている。
💡 つまずきポイント:useEffect の最後で return する後始末を書き忘れると、開発中のホットリロードのたびに requestAnimationFrame のループが二重・三重に走り、じわじわ重くなる。「ループやリスナを張ったら、必ず後始末で止める」。Reactでcanvasを扱うときの、いちばん大事なお作法だ。
Step 7. 地形を塗る:Zustandで「いま選んでいる道具」を持つ
最後のピース。「いまユーザーがどの道具(パン/平地/水/山)を選んでいるか」を、UI全体で共有したい。ここで軽量な状態管理ライブラリ Zustand を使う。持つのは選択中のツール1つだけ、という最小構成から始める。
// src/ui/store.ts
import { create } from 'zustand';
import { Terrain } from '../sim/grid';
export type Tool = 'pan' | Terrain; // 'pan' | 'plain' | 'water' | 'mountain'
export interface AppState {
tool: Tool;
setTool: (t: Tool) => void;
}
export const useAppStore = create<AppState>((set) => ({
tool: 'pan',
setTool: (t) => set({ tool: t }),
}));
ここで Tool の型が 'pan' | Terrain になっているのが小さな工夫だ。地形の種類(Terrain)をそのままツールとして再利用しているので、ツールバーで「水」を選んで塗れば、その値がそのまま grid.set(cx, cy, { terrain: 'water' }) に渡る。ツールバー(Toolbar.tsx)は各ボタンで setTool を呼ぶだけの薄い部品で、選択中のツールはゴールドで強調する。
これで第1回のパーツが全部そろった。ツールバーで「水」を選び、地図をドラッグすれば青いマスが引かれ、ホバーしたマスにはゴールドの枠が付く。ホイールでカーソルの下を中心に拡大でき、パンで地図を動かせる。あなたはいま、sim(状態)・render(描画)・ui(操作)が一方向につながった、崩れない土台を動かしたことになる。

#C9A227 のゴールド枠が付く。地形を塗り替えるたび、grid.set が sim の状態を書き換えている。まとめ:第1回の要点
- 3レイヤ分離:
sim(ロジック・純TS)/render(Canvas描画)/ui(React外枠)。依存は「ui → sim → render」の一方向。 - simは見た目を知らない:
grid.tsにimportゼロ。だからブラウザ抜きで高速にテストでき、描画方式を変えても無傷。 - camera=座標変換の一点集中:マス目↔画面pxの橋渡し・パン・カーソル固定ズームをここに閉じる。第4回の改修に効く。
- 描画ループはReactの外:
useEffect+requestAnimationFrameでcanvasを回し、Reactは外枠だけ。後始末を必ず書く。 - 状態管理は最小から:Zustandで「選択中ツール」だけを持ち、必要になったら足す。
次回(第2回)は、この地図にAIで作ったドット絵タイルを貼っていく。画像生成AIで平地・水・山のタイルを作り、寸法を均一化し、切り抜きを見た目でなくアルファ値の実測で検証してから組み込む。その量産フローを実コードで開き、道路を1本ずつ置くだけで交差点やカーブが自動でつながる「オートタイリング」の仕組みも作る。色のブロックだった街が、一気に「絵」になる回だ。
※ この連載について:Aurum City は、設計・実装・アセット生成の各工程でAIを併用して開発した個人プロジェクトです。掲載するコードは実際のソースからの抜粋で、動作は自動テストと実機で検証しています。© 2026 Cooliris(記事本文と掲載コードの権利は作者に帰属します)。