都市開発シミュを作る③|需要・成長・経済の決定論シミュレーション(MVP完成)【Aurum City制作】

アプリの作り方

都市開発シミュを作る③|需要・成長・経済の決定論シミュレーション(MVP完成)【Aurum City制作】

ここまでで街は「置ける」ようになった。でも、置いた家はただの絵で、動かない。第3回で、この街に命を入れる。住宅・商業・工業が、需要と道路のつながりに応じて自動で育つシミュレーションを作り、建設費・税収・維持費がかみ合う経済までを一気に組む。ここでMVP(遊べる最小の街づくり)が完成する。鍵になるのは「決定論」、同じ操作をすれば、いつでも同じ結果になる作りだ。これが、AIと一緒に開発しても品質を担保できる土台になる。

▶ この連載の全記事はバイブコーディング開発実例(一覧)から。第1回で3レイヤ設計、第2回でタイルと道路を作った。今回はその sim 層の本番だ。

目次
  1. この連載で作るもの(全6回の地図)
  2. 第3回のゴール
  3. Step 1. 「決定論」という土台:種から回す乱数
  4. Step 2. 需要モデル:RCIが互いを駆動する
  5. Step 3. 1tickの成長・衰退:道路につながっているか
  6. Step 4. お金を回す:建設費・税収・維持費
  7. Step 5. 保存と読込:壊れたデータは無視して新規開始
  8. 設計Q&A:数値と保存の疑問2つ
  9. まとめ:第3回の要点

この連載で作るもの(全6回の地図)

  1. ① 土台と3レイヤ設計 — sim / render / ui の分離・グリッド・Canvas描画
  2. ② AIでドット絵タイルを作る — 生成→均一化→アルファ実測→組み込み、道路の自動接続
  3. ③ 街を「動かす」(この記事)— 需要・成長・経済の決定論シミュレーション(MVP完成)
  4. ④ 4度つまずいて座標系ごと作り直した話 — 真のアイソメグリッドへの全面移行
  5. ⑤「面白くない」を4軸で解消する — 目標・施設・イベント・音
  6. ⑥ 静的ビルドをWordPressへ公開する — 検証と配信、そして連載の総括

第3回のゴール

この記事を読み終えると、道路につながった住宅・商業・工業が、需要に応じて自動で成長・衰退し、人口と資金が動くところまでが手元に出来上がる。速度(停止/1x/2x/3x)で時間を進め、赤字にも黒字にもなる。まず、1回の「時間の刻み(tick)」で何が起きるかを見てほしい。

図1:1tick=「需要を測る → 育てる/衰えさせる → お金を精算」 ① 需要を計算 ② 各ゾーンを 成長 / 衰退 ③ 税収 − 維持費 を資金へ 需要は RCI が互いを駆動する 職→R需要 人口→C需要 住宅 R 商業 C 工業 I 職(C+I)が増えると住みたい人(R)が増え、人口(R)が増えると店や工場(C・I)の需要が増える。ゼロから立ち上がる循環。

Step 1. 「決定論」という土台:種から回す乱数

成長には「ゆらぎ」が要る。毎tick、条件を満たしたマスが必ず育つのではなく、確率で育つほうが自然だ。だがここで普通の Math.random() を使うと、実行のたびに結果が変わり、テストできなくなる。そこで種(seed)から回す疑似乱数 mulberry32 を使う。同じ種を与えれば、いつでも同じ数列が出る。

// src/sim/world.ts
/** 決定論的な種つき乱数(mulberry32):同じ seed → 同じ数列 */
export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

🔎 なぜ決定論にこだわるか:「街を10手進めたら人口がちょうど120になる」という成長ルールの正しさを、自動テストで固定できるからだ。乱数が実行ごとに変わっていたら、こういうテストは書けない。AIに実装を手伝わせるほど、「壊れたら気づける仕掛け(テスト)」の価値は上がる。決定論は、そのテストを可能にする最初の一手だ。

Step 2. 需要モデル:RCIが互いを駆動する

街の成長エンジンは「需要」だ。住宅(R)・商業(C)・工業(I)の3つが、互いを引っぱり合う。ルールは軽い閉じた式で書く。住宅需要は「職(C+I)+わずかなベース需要 − 今の人口」。職が増えれば住みたい人が増え、人が増えれば店や工場が要る。

// src/sim/rules.ts(抜粋)
export const BASE_R = 40;   // 職ゼロでも住みたい人の下限
export function computeDemand(grid: Grid): Demand {
  const Rsum = levelSum(grid, 'R');   // 各ゾーンの成長レベルの合計
  const Csum = levelSum(grid, 'C');
  const Isum = levelSum(grid, 'I');
  const pop  = Rsum * RES_PER_LEVEL;
  const jobs = (Csum + Isum) * JOBS_PER_LEVEL;
  return {
    R: BASE_R + jobs - pop,                              // 職が増えると住宅需要↑
    C: Math.round(pop * COMM_RATIO) - Csum * SERVE_PER_LEVEL,  // 人口が増えると商業需要↑
    I: Math.round(pop * IND_RATIO) - Isum * SERVE_PER_LEVEL,
  };
}

この rules.ts純関数だけGrid を読んで数を返すだけ)でできている。だから「人口がこのときR需要はいくつ」を、ブラウザ抜きで1行ずつテストできる。BASE_R のような数値は、あとで実際にプレイして測りながら調整する(第5回で本格的にやる)。

Step 3. 1tickの成長・衰退:道路につながっているか

需要が分かったら、各ゾーンのマスを育てるか衰えさせるか決める。条件はシンプル。「道路に隣接していて、かつその種別の需要が正」なら、確率で成長(レベル+1、上限3)。そうでなければ確率で衰退(レベル−1、下限0)。道路につながっていない孤立した区画は育たない。これが街に「道路を敷く意味」を与える。

// src/sim/world.ts の step()(抜粋)
step(): void {
  const grid = this.grid;
  const d = computeDemand(grid);          // このtickの需要を1回だけ測る
  for (let y = 0; y < grid.height; y++) {
    for (let x = 0; x < grid.width; x++) {
      const cell = grid.get(x, y);
      if (cell.content !== 'R' && cell.content !== 'C' && cell.content !== 'I') continue;
      const wantGrow = isRoadConnected(grid, x, y) && d[cell.content] > 0;
      if (wantGrow) {
        if (cell.level < 3 && this.rng() < GROW_PROB) cell.level++;   // 確率で成長
      } else {
        if (cell.level > 0 && this.rng() < DECAY_PROB) cell.level--;   // 確率で衰退
      }
    }
  }
  this.funds += taxIncome(population(grid)) - upkeep(grid);   // ③ お金の精算(次のStep)
}

建物のレベル(0〜3)は、描画側で色の濃淡として表れる。レベルが上がるほど濃く描く。まだ専用の建物タイルは無いが、成長は色の濃さで一目で分かる

道路沿いに住宅(緑)・商業(青)・工業(茶)が濃淡さまざまに育った地図。上部に人口120とRCI需要バー。
道路につながった区画が需要に応じて成長した状態。色が濃いほどレベルが高い。上部の「人口 120」とRCI需要バーが、シミュレーションの結果をリアルタイムに映す。

Step 4. お金を回す:建設費・税収・維持費

街づくりを「ゲーム」にするのはお金だ。道路もゾーンもタダでは置けず、毎tick、人口から税収が入り、道路と建物の維持費が出ていく。まず配置のコスト。ここで大事な工夫は、「実際に変化したときだけ」課金すること。同じマスを塗り直しても(=変化なし)お金は減らない。ドラッグで連続して塗っても多重課金にならない。

// src/sim/world.ts(配置は World に集約し、費用を見てから commands に委譲)
placeZone(x: number, y: number, kind: Zone): boolean {
  const g = this.grid;
  if (!g.inBounds(x, y)) return false;
  if (g.get(x, y).content === kind) return true;      // 同じ内容 → 変化なし → 無料
  if (g.get(x, y).terrain !== 'plain') return false;  // 平地以外は不可
  if (this.funds < COST_ZONE) return false;           // 資金不足なら置けない(例外にしない)
  cmdZone(g, x, y, kind);
  this.funds -= COST_ZONE;                             // 変化したときだけ課金
  return true;
}

税収と維持費は毎tick精算する(Step 3 の step() の最後の1行)。税収は人口に比例、維持費は道路の数と建物レベルの合計に比例する。数値は素直な定数で持ち、あとで調整する。

// src/sim/economy.ts(抜粋)
export const STARTING_FUNDS = 5000;
export const TAX_PER_POP = 0.1;      // 人口1あたりの税収
export const UPKEEP_ROAD = 0.2;      // 道路1マスの維持費
export const UPKEEP_BLDG = 0.5;      // 建物レベル1あたりの維持費

export function taxIncome(population: number): number {
  return Math.round(population * TAX_PER_POP);
}
export function upkeep(grid: Grid): number {
  const buildingLevels = levelSum(grid, 'R') + levelSum(grid, 'C') + levelSum(grid, 'I');
  return Math.round(roadCount(grid) * UPKEEP_ROAD + buildingLevels * UPKEEP_BLDG);
}

あとは速度制御(停止/1x/2x/3x)で時間の刻み幅を変え、資金・人口・需要を上部のパネルに出す。これで「街を作る→育つ→お金が回る」というゲームのループが完成する。

速度ボタン(停止/1x/2x/3x)と、資金$4700・人口40・RCI需要バー、右上にホバー詳細(plain / R Lv1)を表示した画面。
速度制御(停止/1x/2x/3x)と情報パネル(資金・人口・RCI需要)が加わった。右上はホバー中のマスの詳細(plain / R Lv1)。ここで「ゲームとして回る」状態になった。

Step 5. 保存と読込:壊れたデータは無視して新規開始

MVPの仕上げは、街の保存と読込だ。純関数 serialize で盤面と資金をJSON化し、localStorage に入れる。読込は逆だが、ここに守るべき原則がある。壊れた・古い保存データは、エラーで落とさず null を返して無視し、新規開始する。バージョンや形が合わないデータを読み込んでゲームを壊さないための保険だ。

// src/sim/persistence.ts(抜粋)
export function deserialize(json: string): SaveData | null {
  let parsed: unknown;
  try { parsed = JSON.parse(json); } catch { return null; }   // 壊れたJSON → null
  if (!parsed || typeof parsed !== 'object') return null;
  const o = parsed as Record<string, unknown>;
  if (o.version !== SAVE_VERSION) return null;                 // バージョン不一致 → null
  if (!Array.isArray(o.cells) || o.cells.length !== o.width * o.height) return null;  // 形が違う → null
  // …各セルの terrain/content/level も1つずつ検証…
  return o as unknown as SaveData;
}

保存ボタンで街を書き出し、いろいろ変更したあと読込ボタンを押すと、盤面も資金も元通りに戻る。壊れた保存を渡しても、警告だけ出してまっさらな街から始まる。これで Phase 0〜6 の MVP が完成だ。地形、AIタイル、道路の自動接続、ゾーニング、需要による成長、経済、保存/読込。ブラウザで動く、最小だが「回る」街づくりが手に入った。

設計Q&A:数値と保存の疑問2つ

Q. GROW_PROB(成長確率)のような数値は、どう調整する?
A. 決定論がここでも効く。同じ種(seed)・同じ操作手順を固定し、数値だけ変えて2回走らせれば、差分は全部その数値のせいだと言い切れる。「成長確率0.3だと30tickで人口いくつ、0.5だといくつ」を並べて、狙いのテンポに合う値を選ぶ。乱数が毎回違う作りだと、この比較実験がそもそも成立しない。バランス調整は感覚仕事に見えて、決定論の上では実験科学になる。試した値と結果は表で残しておくと、後の第5回でゲーム性を積むときの土台資料にもなる。

Q. 保存先が localStorage で足りるのか。サーバーは要らない?
A. この規模なら足りる。盤面48×48と資金のJSONは数十KB程度で、localStorage の容量(一般に5MB前後)に余裕で収まる。サーバー保存が要るのは「複数端末で続きを遊ぶ」「ランキングを共有する」といった機能が欲しくなってからだ。バックエンドを持った瞬間、運用・安全・費用の心配が全部付いてくる。要らないうちは持たないのが、個人開発の体力を守る。

まとめ:第3回の要点

  • 決定論mulberry32 を種から回し、同じ操作なら同じ結果に。だから成長ルールを自動テストで固定できる。
  • 需要モデル:R=職+ベース−人口、C/I=人口比−供給。RCIが互いを駆動してゼロから立ち上がる。純関数でテスト可能。
  • 成長・衰退:道路接続 かつ 需要正 → 確率で成長(上限3)。孤立区画は育たず、道路に意味が出る。
  • 経済:変化したときだけ課金・不足なら false。毎tick 税収−維持費を精算。速度で時間を進める。
  • 保存/読込:純関数でJSON化、壊れた/古いデータは null で無視して新規開始。MVP完成

次回(第4回)は、この連載でいちばんの山場だ。「MVPが完成した」と思った直後、実機のスクリーンショットを並べて4回連続でつまずいた。建物が見切れる。街並みが重なって散らばる。個別のバグを4回直しても、症状は形を変えて再発し続けた。真因は、もっと深いところにあった。正方形グリッドという座標系そのものが、これから載せたいアイソメトリックの建物と、幾何学的にかみ合っていなかったのだ。座標系を丸ごと作り直した記録を、包み隠さず開く。

※ 掲載コードは実際のソース(MVP時点)からの抜粋です。地形タイルは第2回で作ったAI生成タイルを使用しています。© 2026 Cooliris(記事本文と掲載コードの権利は作者に帰属します)。


👉 ▶ この連載の続き(バイブコーディング開発実例)ほかのゲーム開発の実例いろんなアプリの作り方

WordPressの管理画面に出ないバックドア — mu-pluginsを狙う7月の攻撃と、自分のサイトを実測点検する手順WordPressの管理画面に出ないバックドア — mu-pluginsを狙う7月の攻撃と、自分のサイトを実測点検する手順前のページ

「Opus級」の実像 — Grok 4.5 はベンチ4位、それでも $2/$6 が効く用途次のページ「Opus級」の実像 — Grok 4.5 はベンチ4位、それでも $2/$6 が効く用途

ピックアップ記事

  1. 高精度OCRデスクトップアプリの作り方 — PaddleOCR-VLとPyIns…

  2. 競艇予想AIの作り方 — LightGBMで「当たる順位」を学習させる実装ガイド…

  3. Claude Mission Control の作り方 — Tauri+Pyth…

  4. New Eden Intelligence Hub の作り方 — EVE Onl…

関連記事

  1. バイブコーディングとは?プログラミング未経験が最初に知っておきたい全体像【入門1】

    バイブコーディング

    バイブコーディングとは?プログラミング未経験が最初に知っておきたい全体像【入門1】

    コードを1行も書けなくても、日本語でAIに頼むだけで“動くアプリ”が作…

  2. Grokで回すXメディア生成パイプラインの作り方 — 検索から画像・音声・動画まで自動化する
  3. JavaScriptでブラウザゲームを作る①|ビルド不要(file://)で動くシーン制の土台と全体設計【Archipelago Saga制作】
  4. ブラウザで動く都市開発シミュを作る①|React+TypeScript+Canvasの土台と3レイヤ設計【Aurum City制作】
  5. 背景除去アプリを作る④|BiRefNetをONNXでローカル推論する — モデル別前処理とDirectML→CPUフォールバック【背景除去Studio制作】
  6. 正解が分かる音声を自分で作って、日本語ASRを測る(そして、その限界)

    アプリの作り方

    正解が分かる音声を自分で作って、日本語ASRを測る(そして、その限界)

    文字起こしの精度を測ろうとして、正解テキストが無いことに詰まる。音声合…

注目

AIで、ここまで作れる

AIで作った2D RPGを、ブラウザでそのまま遊べます。その「作り方=最後まで完成させる進め方」も実例つきで公開中。

▶ ゲームを遊ぶやり方を読む

PR

ロリポップ!レンタルサーバー(PR)

レンタルサーバ:ロリポップ!(本サイトの稼働環境・PR)

  1. Gemini Notebook のコード実行、公式は仕様を出していない — 出回っている数字は別物の API のもの

    AI・テック動向

    Gemini Notebook のコード実行、公式は仕様を出していない — 出回…
  2. ブラウザで動く都市開発シミュを作る①|React+TypeScript+Canvasの土台と3レイヤ設計【Aurum City制作】

    アプリの作り方

    ブラウザで動く都市開発シミュを作る①|React+TypeScript+Canv…
  3. GitHub MCP Server がステートレス化 — 自作 MCP サーバを 2026-07-28 仕様へ移す棚卸しと検証手順

    AI・テック動向

    GitHub MCP Server がステートレス化 — 自作 MCP サーバを…
  4. Qwen-Audio-3.0-TTS は重みが無い — 音声合成を「撤退できるか」で選ぶという判断

    AI・テック動向

    Qwen-Audio-3.0-TTS は重みが無い — 音声合成を「撤退できるか…
  5. ローカルAIで背景除去アプリを作る①|PyQt6と3枚の純関数レイヤで「壊れない土台」を作る【背景除去Studio制作】

    アプリの作り方

    ローカルAIで背景除去アプリを作る①|PyQt6と3枚の純関数レイヤで「壊れない…
PAGE TOP

TAG CLOUD

ドラッグで回転・クリックでそのタグの記事一覧へ