Files
i-want-to-heal-mmo/src/components/sceneFramePolicy.ts
T

209 lines
6.3 KiB
TypeScript

import type * as THREE from "three";
import type { GamePhase, GameplayActivity } from "../game/types";
export type SceneRenderMode = "active" | "outcome" | "static" | "suspended";
export type OutcomePhase = Extract<GamePhase, "victory" | "defeat" | "intermission">;
// 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<THREE.Clock, "start">, 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<GameplayActivity, "boss">;
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,
};
}