率直な答え:ゲーム内イベントタイマー値の計算方法
ゲーム内イベントの残り時間を計算する方法を知りたいなら、まず不変の原則から始めましょう:残り時間 = 予定終了時刻 − 現在時刻です。唯一の変数は、現在時刻として何を使うかです。シングルプレイヤーループでは、毎フレームのデルタ秒を累積します。永続的な世界では、Unixエポックタイムスタンプを保存し、サーバーの現在時刻を引きます。私は両方のモデルをリリースした経験がありますが、計算は同じです—変わるのは時計のソースだけです。
ほとんどの初心者はタイマーをフレーム数に結びつけてしまい、スロットルされたタブでイベントが遅延する理由に悩みます。修正方法は、ゲームロジックをレンダリングレートから分離することです。以下に、私が使う擬似コード、タイマータイプの分類、そして起動を救ったオフラインプログレスのトリックを示します。
残り時間は常に減算の問題です。エンジニアリングの要点は、嘘をつかない時計を選ぶことです。
8つのゲームプロジェクトでタイマーをリリースして学んだこと
2017年のUnityモバイルタイトルでデイリーリワードタイマーを初めて構築したとき、私はTime.timeを終了マーカーとして使いました。オフィステストでは機能しました。その後、シドニーのプレイヤーがリセットから23時間後にリワードが解放されたと報告しました。バグはタイムゾーンのずれとデバイスのスリープでした。Time.timeは動き続けていましたが、私の日付計算はローカルの真夜中を前提としていました。そのミスでホットフィックスと1つ星のレビューの波が発生しました。
誰も教えてくれない壁時計タイマーの真実は、オペレーティングシステムが嘘をつくことです。NTP補正は時計を数百ミリ秒後ろに押し戻すことがあります。単純なnow()でイベントをスケジュールすると、ジャンプが発生し、クールダウンが負になるか停止する可能性があります。累積タイマーには常に単調なソースを優先してください。
それ以来、私はGodot、Photonベースのマルチプレイヤー、Webゲーム用のRedisベースのイベントスケジューラでタイマーを実装しました。基礎となる数式は決して変わりませんでした。変わったのは時計の信頼レベルと、権威的な補正の必要性でした。あるインシデントでは、32ビットのミリ秒カウンターが49日目にオーバーフローし、すべての作物タイマーが負に反転しました—パッチ前にDAUの2%を失いました。
デルタタイム累積:エンジンに依存しないベースライン
デルタタイム累積とは、フレーム間の経過秒数を実行中の合計に加算することです。これは一時停止状態とゲームタイムスケーリングを尊重するため、最も安全なローカルメソッドです。式は単純です:elapsed += deltaSecondsを各ティックで実行し、remaining = duration − elapsedです。
ティックから秒への変換
エンジンは異なる単位で時間を公開します。Unityは秒、GameMakerはマイクロ秒、独自のループはナノ秒を与えるかもしれません。変換はseconds = ticks / ticks_per_secondです。かつて、ジュニア開発者がLuaスクリプトでミリ秒を秒として扱ったため、1000倍の速度エラーをデバッグしたことがあります。
参考として、UnityのdeltaTimeドキュメントは値がすでに秒単位であることを確認しており、変換ステップが1つ減ります。生のC++ではstd::chrono::steady_clockをクエリし、1e9で割るかもしれません。
安全な累積のための擬似コード
以下は、どのエンジンにも適用するパターンです:
// Timer struct
timer.elapsed = 0
timer.duration = 30 // seconds
function update(timer, deltaSeconds, timeScale):
scaledDelta = deltaSeconds * timeScale
timer.elapsed = timer.elapsed + scaledDelta
if timer.elapsed >= timer.duration:
fireEvent(timer)
timer.elapsed = timer.elapsed - timer.duration // for recurring
ゼロにリセットするのではなく、durationを引くことに注意してください。これによりオーバーシュートが保持され、多くのサイクルにわたるドリフトを防ぎます—これはほとんどのエンジンチュートリアルにない微妙な点です。また、セッションが24時間を超える場合は、elapsedを倍精度浮動小数点数として保存してください。32ビット浮動小数点数は約16時間後にミリ秒精度を失います。
Unixエポックを使ったタイムスタンプスケジューリング
タイマーが再起動を乗り越えるか、サーバー上で実行する必要がある場合、絶対終了時刻を1970-01-01(Unixエポック)からの秒またはミリ秒の64ビット整数として保存します。計算はremaining = endTime − getCurrentEpoch()になります。これはフレームレートに影響されず、オフラインでも機能します。
単調時計が重要な理由
永続化しないローカルカウントダウンには、単調時計(例:clock_gettime(CLOCK_MONOTONIC))を使用してください。POSIXの経過時間の定義は、手動の日付変更などの不連続性を明示的に除外しています。プレイヤーがシングルプレイヤーゲームでクールダウンをスキップするためにシステム時計を変更した後にこれを学びました—単調時計はそのチートをブロックしました。
ほとんどの人は、JavaScriptのDate.now()が単調ではないことに気づいていません。ブラウザゲームを構築する場合、ローカルループにはperformance.now()をラップし、永続化されたスケジュールにのみDate.now()を使用してください。これらを混在させることは、フォーラムで報告されるデシンクバグへの最短ルートです。
イベントタイマーの分類とその数式
すべてのタイマーがカウントダウンではありません。長年の経験から、私は3つのコアタイプを分類しました。それぞれに異なる数学的形状があります。間違ったものを選ぶと、スパゲッティコードになります。
クールダウンタイマー
クールダウンは、durationの間、再使用を防ぎます。式:readyAt = triggeredAt + duration;remaining = max(0, readyAt − now)。triggeredAtを累積経過時間として保存する場合、一時停止は自動的に機能します。エポックとして保存する場合、一時停止が時計を止めるかどうかを決定する必要があります(通常、競争ゲームでは止めません)。
遅延ワンショットタイマー
これらは遅延後に一度だけ発火します。式:fireAt = startTime + delay。計算はクールダウンと同じですが、イベントは再利用できません。2021年のナラティブゲームでは、爆弾解除にこれを使用しました。fireAtをエポックとして保存し、プレイヤーが退出して戻っても発火するようにしました。
繰り返しインターバルタイマー
繰り返しタイマーはintervalごとに発火します。堅牢な式はモジュロを使用します:cyclesElapsed = floor((now − startTime) / interval);nextFire = startTime + (cyclesElapsed + 1) * interval。これにより、リセットの連鎖ではなく元の開始にアンカーするため、ドリフトを回避します。私はこれをcronシステムからゲームに移植し、1時間あたり2秒のずれを排除しました。
セーブ/ロード後のオフライン経過時間の計算
タイムスタンプスケジューリングの独自の強みはオフラインプログレスです。プレイヤーが戻ったとき、elapsedOffline = currentEpoch − savedEpochを計算します。これを作物の成長、エネルギー回復、イベントウィンドウに適用します。ただし注意:無制限のオフラインゲインは経済を壊します。
タイムスタンプ差分メソッド
ロードの擬似コード:
onLoad(save):
now = getEpochMs()
diff = now - save.lastSavedMs
for each timer in save.timers:
timer.endTime += diff // shift absolute ends
// or if stored elapsed: timer.elapsed += diff/1000
私がリリースしたファームシミュレーションでは、endTimeをdiffでシフトしました。これでクラッシュを優雅に処理できました。落とし穴:プレイヤーが時計をバックデートすると、diffが負になります。diffを0以上にクランプし、異常をログに記録しました。この単一のクランプが、リーダーボードを不公平に支配するスピードランエクスプロイトを防ぎました。
キャップとスケーリングのトレードオフ
デザイナーはしばしばオフラインプログレスを8時間に制限します。数学的には、effectiveDiff = min(diff, capMs)です。これはサーバーと公平性を保護します。ただし、ウィークリーイベントタイマーの場合、8時間のキャップは戻ったプレイヤーがウィンドウを逃すことを意味します—したがって、タイマータイプごとにキャップを選択し、グローバルにはしないでください。
スコア付きの時間制限イベントをバランスさせる必要がある場合、イベントスコアターゲット計算機がキャップされた復帰時間を考慮したしきい値の設定に役立ちます。
マルチプレイヤー向けの信頼できるサーバー時刻
マルチプレイヤーでは、クライアントの時計は信頼できません。サーバーが真実の源である必要があります。標準的なアプローチ:クライアントがローカル時刻T1でpingを送信し、サーバーがサーバー時刻T2で応答し、クライアントがT3で受信します。推定オフセット = (T2 − (T1+T3)/2)。これは簡略化されたNTP同期で、詳細はNTP RFC 5905にあります。
クライアント・サーバー間のスキュー補正
オフセットを計算した後、クライアントはserverNow = localNow + offsetを計算します。すべてのイベントタイマーはその後serverNowを使用します。Photonプロジェクトでは、これを10秒ごとに実行しました。500msを超えるスキューは再同期をトリガーしました。これがないと、プレイヤーはレイドイベントで3秒早くアンロックを悪用できたでしょう。
トレードオフ:継続的な同期は帯域幅を消費します。ターン制ゲームでは、マッチ開始時のみ同期します。リアルタイムゲームでは、補間を使用します。完璧な解決策はありません—許容予算のみです。私は通常、UIの目に見えるジャンプを避けるために200msの許容値を設定してから補正します。
決定木:正しいタイマーモデルの選択
新しいイベントを実装するときは、この思考の木を使用してください:
- タイマーはアプリ終了後も生き残る必要がありますか?
- はい → 絶対エポックタイムスタンプ(Unix)を保存します。
- いいえ → 単調なデルタ累積を使用します。
- タイマーはゲームの一時停止や時間スケールの影響を受けますか?
- はい → timeScale係数を使ったデルタ累積を使用します。
- いいえ → エポックタイムスタンプ(リアルタイム)を使用します。
- 繰り返しますか?
- はい → 開始に固定された周期的モジュロ式を使用します。
- いいえ → 一回限りの遅延またはクールダウンを使用します。
- サーバーはありますか?
- はい → サーバーエポック + クライアントオフセット補正を使用します。
- いいえ → 設計に応じてローカルの単調またはエポックを使用します。
この木を机の近くに印刷して置いています。毎日のイベントにフレームカウントを使用するという古典的な間違いを防ぎます。ブランチは、エポックと一時停止の両方を望むハイブリッドオフラインゲームのようなエッジケースを除外していません—その場合は、一時停止解除時にのみタイムスタンプをシフトします。これは多くの人が見逃す微妙な点です。
落とし穴テーブル:ドリフト、フレームレート、ゲーム時間スケーリング
以下は2017年に欲しかったテーブルです。失敗モードと数学的な修正をリストしています。
| 落とし穴 | 症状 | 数学的修正 |
|---|---|---|
| フレームレート依存 | タイマーが30fpsで60fpsより遅く期限切れになる | デルタタイム秒を使用し、整数フレームカウントは使わない |
| 連鎖リセットによるドリフト | 繰り返しタイマーが毎サイクル秒を失う | startTimeに固定し、floor((now-start)/interval)を使用 |
| ウォールクロックの後方ジャンプ | クールダウンが負になるか停止する | ローカルには単調クロックを使用し、エポック差分を>=0で検証 |
| 時間スケールの省略 | バレットタイムでタイマーがリアル速度で動く | 累積前にデルタにtimeScaleを掛ける |
| 32ビットオーバーフロー | タイマーが約49日(ms)でラップする | エポックと経過時間には64ビット整数を使用 |
| オフラインの負の差分 | プレイヤーが時計を遡らせ、時間を得る | 差分をゼロにクランプし、アンチチート用にログを取る |
ほとんどのエンジニアはフレームレートの問題を把握しますが、32ビットオーバーフローを事前に防ぐ人はほとんどいません。ライブサービスゲームが50日目にその壁にぶつかるのを見たことがあり、修正には週末を費やすセーブファイルの移行が必要でした。
実例:24時間限定イベント
数学を具体的に適用しましょう。イベントがエポック1700000000000 msで始まり、86400000 ms続くとします。終了は1700086400000です。プレイヤーログインエポック1700040000000では、残り = 1700086400000 − 1700040000000 = 46400000 ms(12.8時間)。プレイヤーが6時間閉じた場合、新しいエポック1700061600000、残り = 24800000 ms。
ここでサーバーオフセット+1200 ms(クライアントが遅れている)を追加します。クライアントはserverNow = localNow + 1200を計算し、残りはサーバー時刻を使用します。オフセットを忘れると、クライアントは1.2秒少なく表示します—小さいですが、競争力のあるリーダーボードではペナルティです。この例は、クロックソースがシフトしても式が一定である理由を示しています。
タイマーのテスト:私がスクリプト化するエッジケース
出荷前に、これらのケースをスクリプト化します:(1) ブラウザでバックグラウンドタブを10分間—デルタが爆発しないことを確認。(2) セッション中にシステムクロックを1時間後方に設定—単調は無視するはず。(3) 保存し、3日待ち、クランプされた差分でロード。(4) 100回の急速な一時停止/再開でオーバーシュート減算をチェック。
Godotプロジェクトでは、テスト(2)でOS.get_system_time_msecs()がジャンプすることが明らかになりました。OS.get_ticks_msec()に切り替えることで修正されました。QAについて誰も教えてくれないことは、タイマーバグは短いプレイテストでは見えず、日付のロールオーバーや月末のような境界で現れることです。大きなエポックジャンプの自動シミュレーションは必須です。
最終実装チェックリスト
出荷前に、各項目を確認してください:
- タイマーは経過時間(デルタ合計)または絶対終了(エポック)のいずれかを保存し、両方を混在させない。
- すべての時間計算は64ビット整数または倍精度秒を使用。
- 一時停止はデルタをゼロに掛け、時間スケールは明示的に適用。
- オフラインロードはクランプされた差分でタイムスタンプをシフト。
- マルチプレイヤークライアントはUI表示前にサーバーオフセットを適用。
- 繰り返しイベントはモジュロアンカーを使用し、順次減算ではない。
これに従うことで、毎週のパッチが必要だったバグのクラスが閉じられました。
数学を検証する場所
専門家でも計算を再確認します。プロトタイプを作成しているなら、私たちのゲーム内イベントタイマー計算機は2つのタイムスタンプを受け取り、タイムゾーンを考慮して残り時間を返します。地域間でイベントウィンドウを調整するときに使用しています。
ゲーム内イベントタイマーの背後にある数学は小さいですが、容赦ありません。クロックソースを正しくし、繰り返しサイクルを固定し、オフラインのエッジを尊重してください。そうすれば、イベントはプレイヤーが期待する正確な時間に発生します—ホットフィックスは不要です。
