前回、地図は「色を塗ったブロック」でしかなかった。緑・青・茶色の四角。ここにAIで作ったドット絵タイルを貼ると、同じマップが一気に「絵」に変わる。第2回のテーマは2つ。(1) 画像生成AIで地形タイルを作り、ゲームに組み込む量産フロー。(2) 道路を1本ずつ置くだけで、交差点もカーブも自動でつながる「オートタイリング」。AIにアセットを任せるのがバイブコーディングらしいところだが、任せっぱなしにはしない。採用するかどうかは見た目でなく数値で決める。その線引きも実演する。
▶ この連載の全記事はバイブコーディング開発実例(一覧)から。第1回(土台と3レイヤ設計)を読んでいなくても、この回だけで追えるように書いている。
目次
この連載で作るもの(全6回の地図)
- ① 土台と3レイヤ設計 —
sim/render/uiの分離・グリッド・Canvas描画 - ② AIでドット絵タイルを作る(この記事)— 生成→均一化→アルファ実測→組み込み、道路の自動接続
- ③ 街を「動かす」— 需要・成長・経済の決定論シミュレーション(MVP完成)
- ④ 4度つまずいて座標系ごと作り直した話 — 真のアイソメグリッドへの全面移行
- ⑤「面白くない」を4軸で解消する — 目標・施設・イベント・音
- ⑥ 静的ビルドをWordPressへ公開する — 検証と配信、そして連載の総括
第2回のゴール
この記事を読み終えると、草地・水・山のドット絵タイルがAIで生成され、寸法が揃い、地図に貼られている。さらに道路を置けば、隣り合う道路を見て直線・カーブ・T字・十字が自動で選ばれる。住宅(R)・商業(C)・工業(I)のゾーニングと、間違えたときの撤去(ブルドーザー)もそろう。まず、タイル1枚が出来上がるまでの流れを見てほしい。
Step 1. AIに地形タイルを生成させる
タイルの生成には、画像生成モデル Seedream を OpenRouter 経由のAPIで呼ぶ。ローカルに重いモデルを置かず、1枚あたり数円で試せるのが利点だ。ここではプロンプトの型を固定する。3種の地形で共通の「スタイル指定」を先に決め、地形名だけを差し替える。こうすると3枚のタイルの画風がそろう。
# tools/asset_gen/generate_terrain.py(抜粋)
STYLE = (
"top-down orthographic single terrain tile for a 2D city-building game, "
"16-bit pixel-art, seamless texture filling the whole square frame, "
"no border, no frame, no text, no shadow, flat even lighting, centered, "
)
PROMPTS = {
"plain": STYLE + "lush green grassland / plains",
"water": STYLE + "calm blue water surface",
"mountain": STYLE + "grey rocky mountain terrain",
}
ポイントは、共通スタイルに top-down(真上から)・seamless(敷き詰めても継ぎ目が出ない)・no border, no frame, no shadow(枠や影を描かせない)を明記していること。ゲームのタイルは同じ絵を何百マスも並べるので、影や額縁が付くと繰り返しがバレる。「1枚の絵」ではなく「敷き詰める素材」を作らせる、という指定だ。
Step 2. 「均一化」:生成物をそのまま使わない
生成された画像は、そのままではゲームに使えない。サイズはバラバラだし、モデルはよくタイルの縁をうっすら暗くする(=敷き詰めると格子状の継ぎ目が出る)。そこで、採用前に必ず2つの後処理を通す。(1) 四辺を少し内側に切り落として縁を捨てる。(2) ドット絵がボケないよう NEAREST(最近傍法)で 32×32 px に縮小する。
INSET = 0.14 # 四辺から14%ずつ切り落とす。モデルが描く暗い縁を捨て、
# 敷き詰めたときの「格子状の継ぎ目」を防ぐため。
def to_tile(raw_path, out_path):
img = Image.open(raw_path).convert("RGB")
w, h = img.size
s = min(w, h)
cx0, cy0 = (w - s) // 2, (h - s) // 2
d = int(s * INSET)
img = img.crop((cx0 + d, cy0 + d, cx0 + s - d, cy0 + s - d)) # 中央を正方形で切り、さらに縁を削る
img = img.resize((TILE, TILE), Image.NEAREST) # ドット絵はNEARESTでくっきり縮小
img.save(out_path)
INSET = 0.14(四辺14%)という数字の決め方も書いておく。これは理論値ではない。刻み方は機械的で、4%→8%→14%と段階を作り、それぞれのタイルを実際に敷き詰めたプレビューを並べて、格子状の継ぎ目が消える最小の値を採った。削るほど継ぎ目は消えるが、テクスチャの模様も痩せていく。「消える最小で止める」が調整の型だ。使う生成モデルや画風が変われば最適値も変わるので、持ち帰る価値があるのはこの決め方の方だ。
💡 「見た目でOK」と言わない:生成アセットの採否は、サムネイルを眺めて決めない。地形タイルなら「四隅の色が想定どおりか」「継ぎ目が出ないか」を、拡大やヒストグラムで数値として確かめる。特に透過が絡むアセット(後の回で出てくる建物)は、アルファ値を実測しないと「背景が残っている」「端が欠けている」を見逃す。この連載でいちばん高くついた失敗の多くは、ここを目視で済ませたときに起きた。
Step 3. アトラスに載せる(未ロードでもクラッシュさせない)
出来たタイルは tiles.json(タイル名→ファイル名の対応表)にまとめ、TileAtlas が読み込む。ここで守る契約がひとつある。まだ読み込めていないタイルを要求されても、落ちずに null を返す。描画側は null が返ってきたら「フォールバックの色」で塗ればいい。画像の読み込みは非同期なので、この保険が無いと起動直後に一瞬崩れる。
// src/render/atlas.ts(抜粋)
export class TileAtlas implements TileImageSource {
private images = new Map<string, { img: HTMLImageElement; ready: boolean }>();
load(): void {
for (const [key, file] of Object.entries(this.manifest)) {
const img = this.imageFactory();
const entry = { img, ready: false };
img.onload = () => { entry.ready = true; }; // 読めたら ready
img.onerror = () => { entry.ready = false; }; // 失敗しても落とさない
img.src = this.baseUrl + file;
this.images.set(key, entry);
}
}
imageFor(key: string): CanvasImageSource | null {
const e = this.images.get(key);
return e && e.ready ? e.img : null; // 未ロードは null → 呼ぶ側は色でフォールバック
}
}
これで地形が「色」から「絵」になった。草地には草のテクスチャ、水には水面、山には岩肌が乗る。

Step 4. ゾーニングと「排他セル」(住宅・商業・工業)
地形の上に、街の要素を置いていく。道路・住宅(R)・商業(C)・工業(I)だ。ここで設計上の判断がひとつ。1つのマスに「道路であり同時に住宅でもある」状態はあり得ないので、それを型で表現不能にする。セルは content という1つの値だけを持ち、'empty' | 'road' | 'R' | 'C' | 'I' のどれか1つになる。新しく何かを置けば、前の内容は上書きされる(排他)。
// src/sim/commands.ts(純関数・テストしやすい)
// 道路・ゾーンは平地にだけ置ける(水・山は建設不可)
function buildable(grid: Grid, x: number, y: number): boolean {
return grid.inBounds(x, y) && grid.get(x, y).terrain === 'plain';
}
export function placeZone(grid: Grid, x: number, y: number, kind: Zone): boolean {
if (!buildable(grid, x, y)) return false; // 置けないなら false(例外にしない)
grid.get(x, y).content = kind; // 排他:道路や別ゾーンを上書き
return true;
}
export function placeRoad(grid: Grid, x: number, y: number): boolean {
if (!buildable(grid, x, y)) return false;
grid.get(x, y).content = 'road';
return true;
}
export function bulldoze(grid: Grid, x: number, y: number): boolean {
if (!grid.inBounds(x, y)) return false;
grid.get(x, y).content = 'empty'; // 撤去は地形を変えず、内容だけ空に戻す
return true;
}
この commands.ts は Grid を受け取って書き換えるだけの純関数で、Reactもcanvasも知らない(第1回の sim 層の作法)。だから「平地にしか置けない」「置けないときは false を返す(例外を投げない)」といったルールを、単体テストで細かく守れる。
Step 5. 道路が自動でつながる(オートタイリング)
今回の山場。道路タイルは、直線・カーブ・T字・十字の4種類あればいい。あとは「隣の4マスのうち、どこが道路か」を見て、正しい形を自動で選び、90°単位で回す。これがオートタイリングだ。
まず、上(N)・右(E)・下(S)・左(W)の4方向を 1・2・4・8 のビットに割り当て、隣接する道路を1つの数(0〜15)にまとめる。
// src/sim/autotile.ts
export const N = 1, E = 2, S = 4, W = 8; // 4方向をビットに
export function roadMask(grid: Grid, x: number, y: number): number {
const isRoad = (cx, cy) => grid.inBounds(cx, cy) && grid.get(cx, cy).content === 'road';
let m = 0;
if (isRoad(x, y - 1)) m |= N; // 上が道路
if (isRoad(x + 1, y)) m |= E; // 右が道路
if (isRoad(x, y + 1)) m |= S; // 下が道路
if (isRoad(x - 1, y)) m |= W; // 左が道路
return m; // 0〜15 の値になる
}
この 0〜15 の16通りを、「どの基本タイルを・何度回すか」に対応させる表を持つ。4種類の絵 × 90°回転で、16通りすべてを表現できるのがミソだ(=タイルは4枚で足りる)。
// rot 0 の向き:straight=縦, curve=N+E(右上), tee=N+E+S(左が開く), cross=十字
const TABLE = {
[N | S]: { base: 'straight', rot: 0 }, // 縦の直線
[E | W]: { base: 'straight', rot: 90 }, // 横の直線(縦を90°回す)
[N | E]: { base: 'curve', rot: 0 },
[E | S]: { base: 'curve', rot: 90 },
[S | W]: { base: 'curve', rot: 180 },
[N | W]: { base: 'curve', rot: 270 },
[N | E | S]: { base: 'tee', rot: 0 },
[N | E | S | W]: { base: 'cross', rot: 0 },
// …端(1接続)・孤立(0接続)は直線のスタブで代用…
};
export function roadShape(mask: number) { return TABLE[mask & 0b1111]; }
🔎 なぜ道路だけAIで4枚描かせなかったか:実は、AIに「T字路」「カーブ」を単体で描かせると、道幅や接続位置が毎回ズレてタイル同士がつながらない。そこで採った手が、十字路(cross)だけをAIで生成し、腕を1本ずつ消して直線・カーブ・T字を派生させる方式。こうすれば全4種の道幅が完全に一致し、接続が必ず合う。「AIに全部描かせる」より「1枚だけ生成して、残りは手続きで作る」ほうが確実。この連載で何度も出てくる判断の、最初の1例だ。
回転は ctx.rotate で描画時に適用する。imageSmoothingEnabled = false(第1回)のおかげで、90°回してもピクセルは1つもボケない。結果、道路を1本引くだけで交差点やカーブが勝手に整う。

まとめ:第2回の要点
- 生成はAI、採否は数値:Seedream(OpenRouter)でタイルを生成し、共通スタイルで画風をそろえる。ただし採用は見た目でなく実測で判断する。
- 均一化は必須:縁を削って継ぎ目を消し、
NEARESTで32×32に。生成物をそのまま使わない。 - 未ロードは色でフォールバック:
TileAtlasは読めていないタイルにnullを返し、描画側が色で代替。起動直後も落ちない。 - 排他セル:
contentを1値にして「道路かつ住宅」を型で表現不能に。コマンドは純関数でテストしやすく。 - オートタイリング:4近傍を4bit(0〜15)にして、基本4タイル×90°回転で全16通り。道路は十字1枚から派生させて接続を保証。
次回(第3回)は、いよいよ街を動かす。いま塗った住宅・商業・工業の「空き地」に、需要と道路接続に応じて建物が育つ決定論シミュレーションを作る。同じ操作なら同じ結果になる(=テストできる)ように乱数を種から回し、人口・資金・税収がかみ合う経済までを一気に組む。ここまででMVP(遊べる最小の街づくり)が完成する回だ。
※ AI生成コンテンツの開示:地形・道路・ゾーンのドット絵タイルは、画像生成AI(Seedream / OpenRouter経由)の出力を後処理・検証して使用しています。出力の商用利用については各サービスの利用条件を確認のうえ使用しています(2026-06-16 確認)。掲載コードは実際のソースからの抜粋です。© 2026 Cooliris(本作のコード・構成・データの権利は作者に帰属します)。
