見た目は整った。街は育ち、建物は正しく並ぶ。だが、遊んでみるとこう感じる。「作れるけど、面白くない」。自分で動かして、はっきりそう思った。不満は4つの軸に整理できた。①目標・達成感がない ②打ち手が少ない ③変化・驚きがない ④音がない。第5回は、この4つを1つずつ埋めていく。ポイントは、すでに完成しているシミュレーションを1行も壊さずに、新しいシステムを積み重ねること。それを可能にする設計のコツ(専用の乱数系列と機能フラグ)も開く。
▶ この連載の全記事はバイブコーディング開発実例(一覧)から。「動く」の次にある「面白い」を、どう設計で作るかの回だ。
目次
この連載で作るもの(全6回の地図)
- ① 土台と3レイヤ設計 —
sim/render/uiの分離・グリッド・Canvas描画 - ② AIでドット絵タイルを作る — 生成→均一化→アルファ実測→組み込み、道路の自動接続
- ③ 街を「動かす」— 需要・成長・経済の決定論シミュレーション(MVP完成)
- ④ 4度つまずいて座標系ごと作り直した話 — 真のアイソメグリッドへの全面移行
- ⑤「面白くない」を4軸で解消する(この記事)— 目標・施設・イベント・音
- ⑥ 静的ビルドをWordPressへ公開する — 検証と配信、そして連載の総括
第5回のゴール
この記事を読み終えると、都市ランクとミッション(目標)、サービス施設と幸福度(打ち手)、通勤・交通、ランダムイベントとニュース速報(変化)、BGMと効果音が、既存のシミュを壊さずに積み上がる。まず「目標」から。人口で街が昇格し、ミッションを達成すると資金がもらえる。

Step 1. ①目標:都市ランクとミッション
まず作ったのが「目標」だ。理由は、ミッションは後から足す新要素を”目標”として取り込める器だから。ランクは人口の閾値で昇格する(降格はしない)。値は実際にプレイして測った数字で凍結する。
// src/sim/goals.ts(純関数・乱数なし)
export const RANKS = [
{ name: '開拓地', popThreshold: 0, rewardFunds: 0 },
{ name: '村', popThreshold: 150, rewardFunds: 500 },
{ name: '町', popThreshold: 500, rewardFunds: 1000 },
{ name: '市', popThreshold: 1500, rewardFunds: 2000 },
{ name: '大都市', popThreshold: 3500, rewardFunds: 4000 },
];
export function rankIndex(population: number): number {
let i = 0;
for (let r = 1; r < RANKS.length; r++) if (population >= RANKS[r].popThreshold) i = r;
return i;
}
ミッション(例:「人口800人を達成」「純収支+$100/tickを達成」)は、World が組み立てたスナップショットを受け取って判定する純関数。goals.ts は World を知らない(第1回の層分離の作法)。だから「達成条件」だけを、乱数なしで確実にテストできる。ミッションの id はセーブに残るので改名禁止。ここは後の全フェーズで守り続けたルールだ。
Step 2. ②打ち手:サービス施設と幸福度
次は「打ち手」。プレイヤーが街に働きかける手段を増やす。公園(1×1・安い・半径小)/学校(2×1・中)/病院(2×2・高い・半径大)を置くと、そのカバレッジ内の建物の幸福度が上がる。幸福度は高密度化を加速し、税収にボーナスを与える。罰ではなく、ご褒美で誘導する設計だ(「緊張感より達成感」の方針)。
カバレッジは事前計算する。施設の効果範囲(チェビシェフ距離)をマップに焼いておき、毎tickの判定を軽くする。施設もまた独立したレジストリと純関数で、既存のシミュに割り込まない。

Step 3. 通勤・交通:道路に「意味」を足す
打ち手の一環として、通勤・交通も入れた。純関数 traffic.ts が2つの層で働く。(1) ゲームルール:住宅から道路を辿って到達できる職(effectiveJobs)だけが住宅需要に効く。橋を1本落として街を分断すると、有効な職が激減し、需要が崩れて経済崩壊のカスケードが起きる。(2) 可視化:住宅→職場のルートを道路に積算し、負荷をヒートマップ(緑→琥珀→赤)で見せる。完全につながった街では、この交通層は既存のバランスに影響しない(=互換)。

Step 4. ③変化:ランダムイベントとニュース速報
「変化・驚き」は、ランダムイベントで作る。好況・慶事・疫病・火災・犯罪。効果は数種類(一時金・需要バイアス・税率倍率・建物損壊)に絞り、カタログを唯一の真実にする。災害はランク2以上かつ建物15以上でのみ発火する(序盤に理不尽が来ない)。
// src/sim/events.ts(カタログ抜粋・id はセーブに残るので改名禁止)
export const EVENTS = [
{ id: 'ev_invest_boom', category: 'boom', headline: '投資ブームが到来',
weight: 10, minRank: 1, durationTicks: 30, effect: { demandBias: { C: 30, I: 30 }, taxMul: 1.1 } },
{ id: 'ev_fire', category: 'fire', headline: '{area}で火災が発生',
weight: 5, minRank: 2, minBuildings: 15, durationTicks: 0,
effect: { destroy: 2, shield: 'fire' } }, // shield=消防カバーが損壊を防ぐ
// …疫病(shield:hospital)・犯罪(shield:police)・慶事・好況…
];
ここで Step 2 の施設が「打ち手」として効いてくる。火災は消防のカバー外の建物だけを壊し(shield: 'fire')、疫病は病院、犯罪は警察のカバー率でダメージが和らぐ。先に打ち手(施設)を用意してから、変化(イベント)を入れた。順序に意味がある。イベントの見出しは、画面下部を流れるニュース速報(ティッカー)に、カテゴリ色つきで出る。
Step 5. 既存を壊さず積む:専用の乱数系列と機能フラグ
ここが今回いちばんの設計のコツだ。第3回で、成長シミュは決定論(同じ種→同じ結果)で、その正しさをテストで凍結した、と書いた。イベントを足すとき、うっかり同じ乱数系列を使うと、乱数の消費順が変わって成長の結果まで変わってしまう(凍結したテストが全部壊れる)。
そこで、イベント専用の乱数系列を別に持つ。種を seed ^ 0x9e3779b9 でずらし、成長用の this.rng とは絶対に混ぜない。さらに enableEvents という機能フラグを持ち、テストはフラグを off にして、コアのシミュがイベント導入前とビット単位で一致することを機械的に検証する。
// src/sim/world.ts
constructor(grid, seed = 1, funds = STARTING_FUNDS, opts?: { enableEvents?: boolean }) {
this.rng = mulberry32(seed); // 成長用(第3回から不変)
this.enableEvents = opts?.enableEvents ?? true;
this.eventRng = mulberry32((seed ^ 0x9e3779b9) >>> 0); // イベント専用の別系列
}
// イベント処理は this.rng を絶対に使わない(eventRng のみ)。
// enableEvents=false ならイベントは一切走らず、コア挙動は導入前と完全一致。
💡 再利用できる設計知見:既存の「凍結した正しさ」を壊さずに新機能を足すには、①乱数系列を分ける ②機能フラグで off にできる ③off のとき旧挙動とビット一致するテストを持つ。この3点セットがあれば、大きなサブシステム(イベント・交通)を後付けしても、土台のシミュは無傷のまま検証できる。目標・施設・イベントを次々に積めたのは、この規律のおかげだ。
Step 6. ④音:sim を知らない音声ファサード
最後は「音」。効果音(配置・撤去・達成・昇格・イベント)は数値からその場で合成(Web Audio)、BGMは生成AIで作った2曲をランクに応じてクロスフェードする。ここでも層を守る。音声は ui 層のファサードで、sim を1行も変えない。App が「操作」や「イベント」を観測して音声メソッドを呼ぶだけだ。
// src/audio/engine.ts
/** UI 層の音声ファサード。sim を知らず、App がイベント/操作を観測して呼ぶ。
* 未対応/未 unlock/ミュート時は全メソッド無音で正常 return(例外を投げない)。 */
export class AudioEngine {
/** 最初のユーザー操作で呼ぶ。AudioContext を生成/resume し BGM を開始。二重呼び出しは no-op。 */
unlock(): void { /* ブラウザの自動再生規制に従い、初回クリック/キーで解錠 */ }
}
全メソッドが「失敗しても無音で正常に返る」のが肝だ。音声ファイルが無くても、ブラウザが未対応でも、ミュートでも、ゲームは一切落ちない。BGMは商用利用可を検証したうえで、生成AI(ACE-Step 1.5・MIT。モデルカードが出力の商用利用を明示しており 2026-07-04 に一次確認済み)で作った。これで不満4軸すべて(目標・打ち手・変化・音)が埋まった。
設計Q&A:フラグとバランスの疑問
Q. enableEvents のような機能フラグは、完成したら消していい?
A. 消さない方がいい。このフラグは「off にすればコアが旧挙動とビット一致する」というテストの土台で、消した瞬間にその検証手段も消える。次の機能を足すときも、また同じ3点セット(別乱数・フラグ・一致テスト)を使うので、フラグは開発の足場として恒久的に残す。実行時のコストはif文1つぶんしかない。
Q. イベントの weight(出やすさ)はどう決める?
A. 体感からの逆算で決める。「好況はたまに来ると嬉しい、災害は忘れた頃に来ると怖い」なら、好況の weight を災害の2倍にして、実際に30分遊んで頻度の体感を確かめる。数字そのものに正解はなく、「遊んでいて理不尽と感じないか」が唯一の判定基準だ。序盤に災害が来ない条件(ランク2以上・建物15以上)を付けたのも、数値でなくプレイ体感からの要請だった。
まとめ:第5回の要点
- ①目標:人口で昇格する都市ランク+達成で報酬のミッション。純関数・
id改名禁止。新要素を取り込む器として最初に作る。 - ②打ち手:施設(公園/学校/病院)のカバレッジで幸福度→密度化・税収をご褒美で誘導。罰は使わない。
- 交通:到達できる職だけが需要に効く+渋滞ヒートマップ。分断すると経済崩壊。完全連結なら旧バランスと互換。
- ③変化:ランダムイベント+ニュース速報。災害は施設カバー外だけを壊す(打ち手→変化の順)。
- 壊さず積む設計:専用乱数系列+機能フラグ+off時ビット一致テスト。凍結した正しさを守りながら拡張。
- ④音:sim非依存の音声ファサード。全メソッド無音フォールバックで絶対に落ちない。
次回(第6回・最終回)は、この完成した街を世界に出す。バックエンド無しの静的ビルドを作り、WordPressのサイトへ公開する手順、そして「動いている」をどう機械的に証明するか(246個のテスト・型チェック・実機検証)を扱う。あわせて、全パネルをドラッグで動かせるUIなどの仕上げと、この連載の4度の失敗も含めた全工程の総括をする。
※ AI生成コンテンツの開示:BGMは生成AI(ACE-Step・商用利用可を検証のうえ使用)、効果音は数値合成、建物・地形はこれまでの回の生成アセットです。掲載コードは実際のソースからの抜粋です。© 2026 Cooliris(記事本文と掲載コードの権利は作者に帰属します)。

