放置ゲームの作り方:オフライン中の計算と復帰処理
遊びをつくる執筆にClaudeを使用しています。

放置ゲームを作るとき、多くの人が最初につまずくのは「プレイヤーがいない間に何が起きるか」という問題です。画面を開いている間だけ数値を動かすのは簡単ですが、ブラウザを閉じて数時間後に戻ってきたプレイヤーに「何も進んでいなかった」と感じさせてしまうと、放置ゲームとしての核心が失われます。 この記事では、オフライン中の経過時間をどう計算するか、復帰時にどう反映するか、そしてどこまで巻き取るかという「上限設計」の考え方を、実装レベルで解説します。ゲームエンジンを使わずHTMLとJavaScriptだけで作る場合にも通じる内容です。
オフライン計算の基本的な仕組み
放置ゲームのオフライン処理は、大きく分けて「離脱時刻の記録」と「復帰時の差分計算」の二段階です。
ゲームを終了するとき(ページを閉じる直前、またはセーブのタイミング)に、現在のUnixタイムスタンプを保存します。Date.now()はミリ秒単位の整数を返すので、これをlocalStorageやクラウドセーブに書き込みます。
lastSavedAt: 1761012345678復帰時は、現在のDate.now()とlastSavedAtの差を取り、何秒(または何ミリ秒)が経過したかを求めます。この「経過秒数」を使って、オフライン中に生産されたはずのリソース量を一括で加算します。
重要なのは、この計算をゲームの状態復元よりも後に行うことです。セーブデータを読み込んでゲームの状態を組み立て、最後にオフライン分を乗せる順番にしないと、レートや倍率の取得に失敗します。
上限設計:どこまで巻き取るか
オフライン中の時間を無制限に計算すると、1か月ぶりに復帰したプレイヤーが一瞬でゲームバランスを壊すほどのリソースを得てしまいます。これを防ぐために「オフライン上限時間」を設定します。
上限の設定には、ゲームのループ設計に応じた判断が必要です。
- 短いサイクルで消費が多い(建設・研究系)、上限の目安:4〜8時間
- 長期的な蓄積が主体(放牧・農場系)、上限の目安:12〜24時間
- プレミアム要素として無限巻き取りを販売する場合、上限の目安:上限なし(別途設計)
上限に達したあとの処理方法も二種類あります。一つは単純にカット、もう一つは「上限を超えた時間は効率が落ちる」逓減モデルです。逓減モデルはより自然に感じさせられますが、実装と調整が複雑になるため、最初はカット方式で十分です。
実装手順:最小構成での書き方
以下は、HTMLとJavaScriptで書く最小構成の流れです。
1. セーブ関数に離脱時刻を含める
セーブデータオブジェクトにlastSavedAt: Date.now()を追加します。beforeunloadイベントでセーブ関数を呼ぶことで、ページを閉じる直前に記録できます。ただしbeforeunloadはモバイルブラウザで確実に発火しない場合があるため、定期的な自動セーブ(30秒〜1分間隔)と組み合わせることを推奨します。
2. ロード関数でオフライン経過時間を計算する
const now = Date.now();
const elapsed = Math.min(
(now - save.lastSavedAt) / 1000, // 秒換算
MAX_OFFLINE_SECONDS // 上限(例:28800 = 8時間)
);3. 経過時間をゲームロジックに渡して一括処理する
毎フレームの更新関数(update(deltaTime))が既にあるなら、それをそのままupdate(elapsed)と呼び出すのが最もシンプルです。ただし経過時間が数時間にわたる場合、内部で発生するイベント(レベルアップ、アンロックなど)の処理順に注意が必要です。
単純な足し算で済むリソース(金・木・食料など)ならgold += goldPerSecond * elapsedと一発で計算できます。一方、「一定量ごとにレートが変わる」ような複雑なシステムは、時間を小さいチャンクに分割して繰り返し処理するチャンク方式を使うことがあります。チャンク方式は正確ですが処理コストが上がるため、チャンクサイズの調整が必要です。
4. 復帰メッセージをUIに表示する
プレイヤーに「何が起きたか」を伝えることは体験の一部です。
「8時間経過しました。金 +1,200、木 +640 を獲得しました。」このメッセージは変数として組み立てておき、ゲームの描画が完了したあとに一度だけ表示します。毎フレームの更新ループに混ぜると意図しない繰り返しが起きやすいため、復帰フラグ(isFirstLoad)で制御するのが安全です。
よくある失敗と対処
時計の改ざんへの対処
Date.now()はクライアントのシステム時刻を参照するため、プレイヤーが時刻を手動で進めると不正な巻き取りが可能になります。クライアント単体では完全な防止が難しいですが、「前回セーブ時刻より未来すぎる場合はキャップをかける」「サーバー時刻と照合する」といった緩和策が使えます。ローカル保存前提の小規模ゲームなら、過度に心配せず上限設計で実害を抑えるほうが現実的です。
負の経過時間
ごくまれにlastSavedAtが未来になっている(NaNや未定義を含む古いセーブデータなど)ケースが発生します。elapsedが負にならないようMath.max(0, elapsed)で保護しておきます。
オフライン計算を複数箇所で行う
ロード時に一度だけ処理するはずが、別の初期化関数でも似た処理を書いてしまうミスがあります。オフライン経過の適用は1か所に集約し、関数名を明示する(例:applyOfflineProgress(elapsed))と見通しが良くなります。
オートセーブとの組み合わせ方
放置ゲームのセーブ設計は「なるべく頻繁に、なるべく軽く」が基本です。localStorageへの書き込みは文字列化の処理込みでも一般的なゲームデータなら数ミリ秒以内に収まります。30秒ごとに全データをシリアライズして保存する方式は、ほとんどの場合で十分です。
セーブデータが大きくなってきた場合は、「頻繁に変わる値(リソース量)」と「めったに変わらない値(アンロック状態)」を分けて保存するとパフォーマンスを保ちやすくなります。前者だけを高頻度でセーブし、後者はイベント発生時のみ書き込む方式です。
アソバースのSDKに対応すれば、クラウドセーブを利用して複数デバイス間でセーブデータを共有できます。その場合でも、オフライン計算のロジック自体はゲーム側に実装する必要があります。
次の行動
まずlastSavedAtを記録し、ロード時にMath.min((Date.now() - lastSavedAt) / 1000, 上限秒数)を計算するだけの最小実装を試してみてください。動作が確認できたら、復帰メッセージの表示と、レベルアップなどイベント処理の順番を検証する順で進めると整理しやすいです。完成したゲームはZIPファイルとしてアソバースに投稿できます。
参考資料
- MDN Web Docs「Date.now()」
- MDN Web Docs「Window: beforeunload イベント」
- MDN Web Docs「Window.localStorage」

