import type * as THREE from "three"; import type { GamePhase, GameplayActivity } from "../game/types"; export type SceneRenderMode = "active" | "outcome" | "static" | "suspended"; export type OutcomePhase = Extract; // Visible modes stay in R3F's demand clock domain. Suspended mode blocks loader // and host-commit invalidations as well as the explicit gameplay request loop. export function sceneCanvasFrameloop(mode: SceneRenderMode): "demand" | "never" { return mode === "suspended" ? "never" : "demand"; } export function resetSceneClockForMode(clock: Pick, mode: SceneRenderMode) { if (mode === "active" || mode === "outcome") clock.start(); } export const GAMEPLAY_FRAME_INTERVAL_MS = 1_000 / 60; export const OUTCOME_FRAME_INTERVAL_MS = 1_000 / 30; export const FRAME_INTERVAL_JITTER_MS = 1.5; export const SIMULATION_STEP_SECONDS = 0.1; export const MAX_SIMULATION_STEPS_PER_FRAME = 3; export const MAX_FRAME_DELTA_SECONDS = 0.25; export const OUTCOME_RENDER_TAIL_SECONDS = 7; export interface SceneRenderState { phase: GamePhase; paused: boolean; visible: boolean; outcomeTailComplete: boolean; } export function isOutcomePhase(phase: GamePhase): phase is OutcomePhase { return phase === "victory" || phase === "defeat" || phase === "intermission"; } export function outcomeElapsedAfterPhaseChange( previousPhase: OutcomePhase | null, nextPhase: OutcomePhase | null, elapsedSeconds: number, ) { return previousPhase === nextPhase ? Math.max(0, elapsedSeconds) : 0; } export function selectSceneRenderMode({ phase, paused, visible, outcomeTailComplete, }: SceneRenderState): SceneRenderMode { if (!visible) return "suspended"; if (phase === "combat" && !paused) return "active"; if (isOutcomePhase(phase) && !outcomeTailComplete) return "outcome"; return "static"; } export function sceneFrameIntervalMs(mode: SceneRenderMode) { if (mode === "active") return GAMEPLAY_FRAME_INTERVAL_MS; if (mode === "outcome") return OUTCOME_FRAME_INTERVAL_MS; return null; } export function sceneTargetFps(mode: SceneRenderMode) { if (mode === "active") return 60; if (mode === "outcome") return 30; return 0; } export interface SimulationStepResult { steps: number; remainderSeconds: number; } export function consumeSimulationSteps( accumulatorSeconds: number, elapsedSeconds: number, ): SimulationStepResult { const boundedElapsed = Math.min(MAX_FRAME_DELTA_SECONDS, Math.max(0, elapsedSeconds)); let remainderSeconds = Math.max(0, accumulatorSeconds) + boundedElapsed; const steps = Math.min( MAX_SIMULATION_STEPS_PER_FRAME, Math.floor((remainderSeconds + Number.EPSILON) / SIMULATION_STEP_SECONDS), ); remainderSeconds -= steps * SIMULATION_STEP_SECONDS; return { steps, remainderSeconds }; } export interface SceneFrameSample { elapsedMs: number; elapsedSeconds: number; outcomeElapsedSeconds: number; } export interface SceneFrameLoopOptions { mode: "active" | "outcome"; initialOutcomeElapsedSeconds?: number; requestFrame: (callback: FrameRequestCallback) => number; cancelFrame: (handle: number) => void; onFrame: (sample: SceneFrameSample) => void; onOutcomeComplete?: () => void; } /** * Starts the bounded request loop used for active gameplay and finite outcome * animation tails. Static and suspended modes intentionally have no loop. */ export function startSceneFrameLoop({ mode, initialOutcomeElapsedSeconds = 0, requestFrame, cancelFrame, onFrame, onOutcomeComplete, }: SceneFrameLoopOptions) { const intervalMs = sceneFrameIntervalMs(mode)!; let frameHandle: number | null = null; let stopped = false; let lastRenderedAt: number | null = null; let outcomeElapsedSeconds = mode === "outcome" ? Math.max(0, initialOutcomeElapsedSeconds) : 0; const scheduleNext = () => { frameHandle = requestFrame(runFrame); }; const runFrame: FrameRequestCallback = (now) => { if (stopped) return; const previous = lastRenderedAt; if (previous === null) { lastRenderedAt = now; onFrame({ elapsedMs: 0, elapsedSeconds: 0, outcomeElapsedSeconds }); } else { const elapsedMs = now - previous; if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= intervalMs) { lastRenderedAt = now; const wallElapsedSeconds = Math.max(0, elapsedMs / 1_000); const elapsedSeconds = mode === "active" ? Math.min(MAX_FRAME_DELTA_SECONDS, wallElapsedSeconds) : wallElapsedSeconds; if (mode === "outcome") outcomeElapsedSeconds += wallElapsedSeconds; onFrame({ elapsedMs, elapsedSeconds, outcomeElapsedSeconds }); if (mode === "outcome" && outcomeElapsedSeconds >= OUTCOME_RENDER_TAIL_SECONDS) { stopped = true; frameHandle = null; onOutcomeComplete?.(); return; } } } scheduleNext(); }; scheduleNext(); return () => { stopped = true; if (frameHandle !== null) cancelFrame(frameHandle); frameHandle = null; }; } export type ActivePlayfieldKind = Exclude; export function activePlayfieldKind(activity: GameplayActivity): ActivePlayfieldKind | null { return activity === "boss" ? null : activity; } export interface FramePerformanceSummary { mode: SceneRenderMode; targetFps: number; budgetMs: number | null; averageMs: number; p95Ms: number; p99Ms: number; overBudget: number; samples: number; } function percentile(sorted: readonly number[], ratio: number) { if (!sorted.length) return 0; return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))]; } export function summarizeFramePerformance( mode: SceneRenderMode, samples: readonly number[], ): FramePerformanceSummary { const targetFps = sceneTargetFps(mode); const budgetMs = targetFps > 0 ? 1_000 / targetFps : null; const sorted = [...samples].sort((left, right) => left - right); let total = 0; let overBudget = 0; for (const duration of samples) { total += duration; if (mode === "active" && budgetMs !== null && duration > budgetMs + FRAME_INTERVAL_JITTER_MS) overBudget += 1; } return { mode, targetFps, budgetMs, averageMs: samples.length ? total / samples.length : 0, p95Ms: percentile(sorted, 0.95), p99Ms: percentile(sorted, 0.99), overBudget, samples: samples.length, }; }