Files
Flight-Ops/lib/useSimClock.ts
T
2026-09-23 07:37:36 +00:00

63 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
}