2026.09.15 泊まれる場所4,223 立ち寄りスポット46,522 営業中を確認したキャンプ場1,005 note を読む

結果画面に「猛暑」や「クマ出没」を出したくて、乱数の置き場所で悩んだ話

結果ページをもっと賑やかにしたい、という要望から季節のランダムイベントを足すことにした。
ただし、このエンジンには最初から「乱数を使わない」という縛りがあった。

キャンプ場経営ゲームをつくる第2回 / 全3回目次

公開直後、結果ページが数字とテーブルだけで単調だという声を受けて、季節ごとのランダムイベントとシーズン推移の比較グラフを追加することにした。「オートキャンプ白書2026」で触れられていた2025年の猛暑やクマ出没といった実際の環境要因をモチーフに、GW渋滞・川遊び日和・紅葉の人気・SNSでバズる・大雪なども加えた8種類を用意した。

このエンジンは、乱数を使わないことを前提に組んであった

「キャンプ場経営ゲーム」の計算エンジン(composeTurn)は、同じ入力からは必ず同じ結果が返る純粋関数として書いてきた。テストが書きやすく、プレビュー画面(確定前に結果を先出しする機能)も確定処理も同じ関数を呼べるからだ。ランダムイベントは名前のとおりランダム性が要るので、この前提と正面から衝突する。

Math.random()を関数の中で呼ばない

解決策として、乱数そのものは呼び出し側から「発生値」として渡す形にした。エンジン側は0以上1未満の数値を受け取って、それを季節ごとのイベント候補表に照らして1つを選ぶだけ。関数自体は相変わらず決定論的なままになる。

// エンジン側:乱数の発生源を知らない\nexport function composeTurn(turn, carryState, input, eventRandomValue = 0): TurnResult {\n  const event = pickEvent(season.key, eventRandomValue);\n  // ...\n}\n\n// 呼び出し側:プレビューは0固定(=常にイベント無し)、確定時だけ乱数を渡す\nconst preview = composeTurn(turn, carry, draftInput);              // プレビュー\nconst result  = composeTurn(turn, carry, draftInput, Math.random()); // 確定

既定値を0にしたのが地味に効いていて、既存のテストは1行も書き換えずに全部そのまま通った。乱数を渡さなければ「常にイベント無し」という一番安全な状態になるからだ。プレビュー画面でイベントを出さないのも、副作用として都合が良かった。意思決定中の画面でイベントが見えてしまうと、確定するたびに結果が変わってプレイヤーが混乱してしまう。

複数ターンをまとめて回すrunGameのテストだけは注意が要った

8シーズン分を一気に回すrunGameのテストは、「初期資金が少なく強気な支出を続けると倒産する」といった収支ギリギリの境界値を検証している。ここにランダムイベントの稼働率補正(±15〜40%程度)が乗ると、たまたま強気なイベントが続いて倒産しなかったり、逆に堅実な経営でも猛暑続きで詰んだりと、テストの結果が回すたびに変わりかねない。既存のテストには、全ターン分「イベント無し」を明示的に渡すよう1行ずつ追記して、決定論的な検証を保ったままにした。

グラフのほうは、資金・稼働率・満足度という単位もスケールも違う3つの指標を無理に1枚のグラフへ重ねると誤解を招きそうだったので、3枚並べる構成にした。既存の資金繰りタブ・稼働率タブが持っていた折れ線描画のロジックを流用しつつ、最終結果画面専用の軽量版として書き直している。

まる子パパ

まる子パパ

会社員。受託開発のエンジニアで、その前は寿司職人・長距離運転手・農業。 技術ブログは、キャンプの合間に踏んだバグと、AI・Web開発の運用の失敗を書いています。 このサイト自体が、開発に携わっているCMS「コンテナ」の稼働中の実例です。 このサイトについて

ほかにも書いています

note・スタンプ

※noteは運営者が個人で書いているものです。

技術ブログ一覧へ戻る