63 lines
3.1 KiB
TypeScript
63 lines
3.1 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import type { StateSnapshot } from './game/state';
|
||
import { WORLD_TIME_SCALE } from './worldClock';
|
||
|
||
// ISS-007 (v0.16.1): the sim clock ticks smoothly on the CLIENT between polls instead of
|
||
// jumping once per snapshot. The server's authoritative rate is exactly real-time × scale
|
||
// (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so
|
||
// client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to
|
||
// server truth, bounding any drift by network latency only. ISS-117 (v0.52.0): the scale is the
|
||
// fixed WORLD_TIME_SCALE; #135 (v0.62.0): while nothing is happening it stays FROZEN at the anchor
|
||
// value — mirroring the server's freeze, so polls re-anchor without any jump.
|
||
|
||
export interface SimClockAnchor {
|
||
// Server truth at anchor time — the snapshot's simState.clockMs.
|
||
clockMs: number;
|
||
// Date.now() when that snapshot was received.
|
||
at: number;
|
||
// Whether the world clock is advancing (any plane flying or in repair).
|
||
advancing: boolean;
|
||
}
|
||
|
||
// Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
|
||
// real-time × WORLD_TIME_SCALE; while fully grounded it stays frozen at the anchor value (#135).
|
||
// nowMs before `at` clamps to the anchor value instead of going backwards. Unit-testable in node.
|
||
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
|
||
if (!anchor.advancing) return anchor.clockMs; // frozen while everything is grounded (#135)
|
||
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_SCALE;
|
||
}
|
||
|
||
// ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders.
|
||
const DISPLAY_TICK_MS = 100;
|
||
|
||
// Returns the display clockMs to render, or null until the first snapshot arrives.
|
||
export function useSimClock(snapshot: StateSnapshot | null): number | null {
|
||
const anchorRef = useRef<SimClockAnchor | null>(null);
|
||
const [displayMs, setDisplayMs] = useState<number | null>(null);
|
||
|
||
// Re-sync on every snapshot: snap to server truth. The snapshot object is fresh per poll
|
||
// (useGameState), so this runs once per poll cycle as well.
|
||
useEffect(() => {
|
||
if (!snapshot) return;
|
||
// Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): full rate only while a plane
|
||
// is flying or in repair, frozen otherwise (#135). One line on purpose — keep it in sync.
|
||
const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing');
|
||
anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
|
||
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
|
||
}, [snapshot]);
|
||
|
||
useEffect(() => {
|
||
const id = setInterval(() => {
|
||
const anchor = anchorRef.current;
|
||
if (anchor) setDisplayMs(advanceSimClock(anchor, Date.now()));
|
||
}, DISPLAY_TICK_MS);
|
||
return () => clearInterval(id);
|
||
}, []);
|
||
|
||
// First paint after a snapshot arrives happens before the re-sync effect runs — show the
|
||
// snapshot value directly so the clock never blanks for a frame.
|
||
return displayMs ?? (snapshot ? snapshot.simState.clockMs : null);
|
||
}
|