From 638aef22b29c3e35281bc343e0793cd7e97dcec0 Mon Sep 17 00:00:00 2001 From: Warren H Date: Fri, 17 Jul 2026 15:39:31 -0400 Subject: [PATCH] updated some performance stuff --- src/components/GameScene.tsx | 324 +++++++++++------- src/components/TopScreen.tsx | 5 +- .../boss/BossMechanicIndicators.tsx | 18 +- src/components/boss/bossDeathVisuals.test.ts | 19 + src/components/boss/bossDeathVisuals.ts | 21 ++ src/components/sceneFramePolicy.test.ts | 201 +++++++++++ src/components/sceneFramePolicy.ts | 202 +++++++++++ 7 files changed, 660 insertions(+), 130 deletions(-) create mode 100644 src/components/sceneFramePolicy.test.ts create mode 100644 src/components/sceneFramePolicy.ts diff --git a/src/components/GameScene.tsx b/src/components/GameScene.tsx index 1b93a7d..3598c6f 100644 --- a/src/components/GameScene.tsx +++ b/src/components/GameScene.tsx @@ -74,7 +74,7 @@ import { import { resolveCharacterEquipment } from "../game/characterEquipment"; import { HEALER_CLASS_ORDER } from "../game/healers"; import { useGameStore } from "../game/store"; -import type { BossId, HealerClassId, MemberId, PulseKind } from "../game/types"; +import type { BossId, GamePhase, HealerClassId, MemberId, PulseKind } from "../game/types"; import { BossRoom } from "./BossRoom"; import { HealerClassAccessory } from "./HealerClassAccessory"; import { ModularCharacterBody } from "./ModularCharacterBody"; @@ -110,6 +110,20 @@ import { aetherShipColorIndex, } from "./aetherAssaultVisuals"; import { clampToBossArenaWithPortals } from "../game/rpgRoguelike/playSpace"; +import { + activePlayfieldKind, + consumeSimulationSteps, + FRAME_INTERVAL_JITTER_MS, + GAMEPLAY_FRAME_INTERVAL_MS, + isOutcomePhase, + outcomeElapsedAfterPhaseChange, + SIMULATION_STEP_SECONDS, + selectSceneRenderMode, + startSceneFrameLoop, + summarizeFramePerformance, + type OutcomePhase, + type SceneRenderMode, +} from "./sceneFramePolicy"; const PARTY_MODEL_LEGACY_URLS: Record = { aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href, @@ -947,87 +961,97 @@ function PartyCharacterModel({ ); } -const GAMEPLAY_FRAME_INTERVAL_MS = 1000 / 60; -const BACKGROUND_FRAME_INTERVAL_MS = 1000 / 30; -const FRAME_INTERVAL_JITTER_MS = 1.5; -const SIMULATION_STEP_SECONDS = 0.1; -const MAX_SIMULATION_STEPS_PER_FRAME = 3; const MIN_RENDER_DPR = 1; const MAX_RENDER_DPR = 1.25; /** - * Owns the only gameplay RAF. R3F stays manually advanced, while simulation remains - * in the store and is advanced at its existing fixed 10 Hz cadence. + * Owns manual rendering only while gameplay or a finite outcome animation is active. + * Static scenes use R3F demand rendering; suspended scenes render nothing. */ -function SceneFrameScheduler({ dpr, onDprChange }: { dpr: number; onDprChange: (next: number) => void }) { - const { advance } = useThree(); - const gameplayActive = useGameStore((state) => state.phase === "combat" && !state.paused); - const frameId = useRef(null); - const lastRenderedAt = useRef(null); +function SceneFrameScheduler({ + dpr, + mode, + outcomePhase, + onDprChange, + onOutcomeComplete, +}: { + dpr: number; + mode: SceneRenderMode; + outcomePhase: OutcomePhase | null; + onDprChange: (next: number) => void; + onOutcomeComplete: () => void; +}) { + const { advance, invalidate } = useThree(); const simulationAccumulator = useRef(0); + const manualTimeSeconds = useRef(0); + const outcomeElapsedSeconds = useRef(0); + const previousOutcomePhase = useRef(null); const slowFrameMs = useRef(0); const stableFrameMs = useRef(0); const dprRef = useRef(dpr); dprRef.current = dpr; useEffect(() => { - const schedule = (now: number) => { - const interval = gameplayActive ? GAMEPLAY_FRAME_INTERVAL_MS : BACKGROUND_FRAME_INTERVAL_MS; - const previous = lastRenderedAt.current; - if (previous === null) { - lastRenderedAt.current = now; - advance(now / 1000, true); - } else { - const elapsedMs = now - previous; - if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= interval) { - lastRenderedAt.current = now; - const elapsedSeconds = Math.min(elapsedMs / 1000, 0.25); - - if (gameplayActive) { - simulationAccumulator.current += elapsedSeconds; - let steps = 0; - while (simulationAccumulator.current >= SIMULATION_STEP_SECONDS && steps < MAX_SIMULATION_STEPS_PER_FRAME) { - const startedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0; - useGameStore.getState().tick(SIMULATION_STEP_SECONDS); - if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt); - simulationAccumulator.current -= SIMULATION_STEP_SECONDS; - steps += 1; - } - - if (elapsedMs > 20) { - slowFrameMs.current += elapsedMs; - stableFrameMs.current = 0; - if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) { - onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125)); - slowFrameMs.current = 0; - } - } else { - slowFrameMs.current = 0; - stableFrameMs.current += elapsedMs; - if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) { - onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125)); - stableFrameMs.current = 0; - } - } - } else { - simulationAccumulator.current = 0; - slowFrameMs.current = 0; - stableFrameMs.current = 0; - } - advance(now / 1000, true); - } - } - frameId.current = requestAnimationFrame(schedule); - }; - - frameId.current = requestAnimationFrame(schedule); - return () => { - if (frameId.current !== null) cancelAnimationFrame(frameId.current); - frameId.current = null; - lastRenderedAt.current = null; + outcomeElapsedSeconds.current = outcomeElapsedAfterPhaseChange( + previousOutcomePhase.current, + outcomePhase, + outcomeElapsedSeconds.current, + ); + previousOutcomePhase.current = outcomePhase; + if (mode !== "active") { simulationAccumulator.current = 0; - }; - }, [advance, gameplayActive, onDprChange]); + slowFrameMs.current = 0; + stableFrameMs.current = 0; + } + + if (mode === "static") { + manualTimeSeconds.current = 0; + invalidate(); + return; + } + if (mode === "suspended") return; + + return startSceneFrameLoop({ + mode, + initialManualTimeSeconds: manualTimeSeconds.current, + initialOutcomeElapsedSeconds: outcomeElapsedSeconds.current, + requestFrame: requestAnimationFrame, + cancelFrame: cancelAnimationFrame, + onFrame: (sample) => { + manualTimeSeconds.current = sample.manualTimeSeconds; + if (mode === "active") { + const stepResult = consumeSimulationSteps(simulationAccumulator.current, sample.elapsedSeconds); + simulationAccumulator.current = stepResult.remainderSeconds; + for (let step = 0; step < stepResult.steps; step += 1) { + const startedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0; + useGameStore.getState().tick(SIMULATION_STEP_SECONDS); + if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt); + } + + if (sample.elapsedMs > 20) { + slowFrameMs.current += sample.elapsedMs; + stableFrameMs.current = 0; + if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) { + onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125)); + slowFrameMs.current = 0; + } + } else if (sample.elapsedMs > 0) { + slowFrameMs.current = 0; + stableFrameMs.current += sample.elapsedMs; + if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) { + onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125)); + stableFrameMs.current = 0; + } + } + } else { + outcomeElapsedSeconds.current = sample.outcomeElapsedSeconds; + } + + advance(sample.manualTimeSeconds, true); + }, + onOutcomeComplete, + }); + }, [advance, invalidate, mode, onDprChange, onOutcomeComplete, outcomePhase]); return null; } @@ -2543,17 +2567,46 @@ type PerformanceMemory = Performance & { memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: 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))]; -} - -function PerformanceProbe() { +function PerformanceProbe({ mode }: { mode: SceneRenderMode }) { const { gl } = useThree(); const frameSamples = useRef([]); + const observedFrames = useRef(0); const longTaskCount = useRef(0); const longTaskDuration = useRef(0); const lastPublishAt = useRef(0); + const skipNextFrameSample = useRef(true); + + const publishSnapshot = useCallback((currentMode: SceneRenderMode, samples: readonly number[]) => { + const memory = performance as PerformanceMemory; + const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[]; + let transferredBytes = 0; + let decodedBytes = 0; + for (const resource of resources) { + transferredBytes += resource.transferSize; + decodedBytes += resource.decodedBodySize; + } + document.documentElement.dataset.gamePerf = JSON.stringify({ + frame: summarizeFramePerformance(currentMode, samples), + renderer: { + observedFrames: observedFrames.current, + calls: gl.info.render.calls, + triangles: gl.info.render.triangles, + geometries: gl.info.memory.geometries, + textures: gl.info.memory.textures, + pixelRatio: gl.getPixelRatio(), + renderWidth: gl.domElement.width, + renderHeight: gl.domElement.height, + }, + simulation: simulationTickSnapshot(), + memory: memory.memory ? { + usedJSHeapSize: memory.memory.usedJSHeapSize, + totalJSHeapSize: memory.memory.totalJSHeapSize, + jsHeapSizeLimit: memory.memory.jsHeapSizeLimit, + } : null, + resources: { transferredBytes, decodedBytes, count: resources.length }, + longTasks: { count: longTaskCount.current, durationMs: longTaskDuration.current }, + }); + }, [gl]); useEffect(() => { if (!PERFORMANCE_PROBE_ENABLED || typeof PerformanceObserver === "undefined") return; @@ -2575,56 +2628,31 @@ function PerformanceProbe() { }; }, []); + useEffect(() => { + frameSamples.current = []; + lastPublishAt.current = 0; + skipNextFrameSample.current = true; + publishSnapshot(mode, []); + }, [mode, publishSnapshot]); + useFrame(({ clock }, delta) => { if (!PERFORMANCE_PROBE_ENABLED) return; + observedFrames.current += 1; + if (mode === "static" || mode === "suspended") { + publishSnapshot(mode, []); + return; + } + if (skipNextFrameSample.current) { + skipNextFrameSample.current = false; + lastPublishAt.current = clock.elapsedTime; + return; + } const samples = frameSamples.current; if (samples.length === 300) samples.shift(); - samples.push(delta * 1000); + if (delta > 0) samples.push(delta * 1000); if (clock.elapsedTime - lastPublishAt.current < 1 || samples.length < 30) return; lastPublishAt.current = clock.elapsedTime; - const sorted = [...samples].sort((left, right) => left - right); - let total = 0; - let overBudget = 0; - for (const duration of samples) { - total += duration; - if (duration > 16.67) overBudget += 1; - } - const memory = performance as PerformanceMemory; - const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[]; - let transferredBytes = 0; - let decodedBytes = 0; - for (const resource of resources) { - transferredBytes += resource.transferSize; - decodedBytes += resource.decodedBodySize; - } - document.documentElement.dataset.gamePerf = JSON.stringify({ - frame: { - targetFps: 60, - budgetMs: 16.67, - averageMs: total / samples.length, - p95Ms: percentile(sorted, 0.95), - p99Ms: percentile(sorted, 0.99), - overBudget, - samples: samples.length, - }, - renderer: { - calls: gl.info.render.calls, - triangles: gl.info.render.triangles, - geometries: gl.info.memory.geometries, - textures: gl.info.memory.textures, - pixelRatio: gl.getPixelRatio(), - renderWidth: gl.domElement.width, - renderHeight: gl.domElement.height, - }, - simulation: simulationTickSnapshot(), - memory: memory.memory ? { - usedJSHeapSize: memory.memory.usedJSHeapSize, - totalJSHeapSize: memory.memory.totalJSHeapSize, - jsHeapSizeLimit: memory.memory.jsHeapSizeLimit, - } : null, - resources: { transferredBytes, decodedBytes, count: resources.length }, - longTasks: { count: longTaskCount.current, durationMs: longTaskDuration.current }, - }); + publishSnapshot(mode, samples); }); return null; } @@ -2705,29 +2733,73 @@ function CombatFx() { return ; } +function ActiveModePlayfield() { + const activityMode = useGameStore((state) => state.activityMode); + const playfield = activePlayfieldKind(activityMode); + if (playfield === "hockey-healing") return ; + if (playfield === "blockbreaker") return ; + if (playfield === "aether-assault") return ; + if (playfield === "hockey-healing-pvp") return ; + return null; +} + +function useDocumentVisible() { + const [visible, setVisible] = useState(() => typeof document === "undefined" || document.visibilityState !== "hidden"); + + useEffect(() => { + const updateVisibility = () => setVisible(document.visibilityState !== "hidden"); + const suspend = () => setVisible(false); + document.addEventListener("visibilitychange", updateVisibility); + window.addEventListener("pagehide", suspend); + window.addEventListener("pageshow", updateVisibility); + return () => { + document.removeEventListener("visibilitychange", updateVisibility); + window.removeEventListener("pagehide", suspend); + window.removeEventListener("pageshow", updateVisibility); + }; + }, []); + + return visible; +} + export function GameScene({ playerAppearance }: { playerAppearance?: CharacterAppearanceV1 }) { + const phase = useGameStore((state) => state.phase); + const paused = useGameStore((state) => state.paused); + const visible = useDocumentVisible(); + const outcomePhaseActive = isOutcomePhase(phase); + const [completedOutcomePhase, setCompletedOutcomePhase] = useState(null); + const outcomeTailComplete = completedOutcomePhase === phase; const [dpr, setDpr] = useState(() => Math.min(MAX_RENDER_DPR, Math.max(MIN_RENDER_DPR, window.devicePixelRatio || 1))); const setRenderDpr = useCallback((next: number) => { setDpr((current) => Math.abs(current - next) < 0.001 ? current : next); }, []); + const completeOutcomeTail = useCallback(() => setCompletedOutcomePhase(phase), [phase]); + const renderMode = selectSceneRenderMode({ phase, paused, visible, outcomeTailComplete }); + + useEffect(() => { + if (!outcomePhaseActive && completedOutcomePhase !== null) setCompletedOutcomePhase(null); + }, [completedOutcomePhase, outcomePhaseActive]); return ( - + - - - - + - {PERFORMANCE_PROBE_ENABLED && } + {PERFORMANCE_PROBE_ENABLED && } ); diff --git a/src/components/TopScreen.tsx b/src/components/TopScreen.tsx index 3c75c69..6e4c070 100644 --- a/src/components/TopScreen.tsx +++ b/src/components/TopScreen.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { lazy, memo, Suspense, useEffect, useRef, useState } from "react"; import { barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store"; import { HEALER_ABILITIES } from "../game/healers"; import { BOSS_DEFINITIONS } from "../game/bossCatalog"; @@ -11,7 +11,8 @@ import { blockbreakerTimeMultiplier } from "../game/blockbreaker"; import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay"; import type { CharacterAppearanceV1 } from "../game/characterAppearance"; -const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))); +const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })))); +GameScene.displayName = "MemoizedGameScene"; function CompactParty() { const party = useGameStore((state) => state.party); diff --git a/src/components/boss/BossMechanicIndicators.tsx b/src/components/boss/BossMechanicIndicators.tsx index 01f75af..7d8e771 100644 --- a/src/components/boss/BossMechanicIndicators.tsx +++ b/src/components/boss/BossMechanicIndicators.tsx @@ -8,7 +8,13 @@ import { angleTo } from "../../game/geometry"; import { useGameStore } from "../../game/store"; import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types"; import { BreathParticleVfx, CircleMechanicVfx, LaneEnergyVfx } from "./BossAttackVfx"; -import { BOSS_INDICATOR_DEATH_FADE_MS, advanceBossIndicatorOpacity } from "./bossDeathVisuals"; +import { + BOSS_INDICATOR_DEATH_FADE_MS, + advanceBossIndicatorOpacity, + consumeBossIndicatorPatch, + createBossIndicatorPatchState, + updateBossIndicatorPatchState, +} from "./bossDeathVisuals"; import { CircleHazardVfx } from "./CircleHazardVfx"; const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const; @@ -821,6 +827,7 @@ function BossMechanicIndicatorSet({ bossIndex }: { bossIndex: number }) { const group = useRef(null); const opacity = useRef(defeated ? 0 : 1); const patchedMeshes = useRef(new WeakSet()); + const patchState = useRef(createBossIndicatorPatchState(defeated)); const [retainIndicators, setRetainIndicators] = useState(!defeated); const patchMeshOpacity = useCallback((child: THREE.Object3D) => { @@ -852,11 +859,18 @@ function BossMechanicIndicatorSet({ bossIndex }: { bossIndex: number }) { return () => window.clearTimeout(timeout); }, [defeated]); + useLayoutEffect(() => { + patchState.current = updateBossIndicatorPatchState(patchState.current, defeated); + }, [defeated]); + useFrame((_, delta) => { if (!group.current) return; + if (patchState.current.pending) { + group.current.traverse(patchMeshOpacity); + patchState.current = consumeBossIndicatorPatch(patchState.current); + } opacity.current = advanceBossIndicatorOpacity(opacity.current, defeated, delta); group.current.visible = opacity.current > 0; - group.current.traverse(patchMeshOpacity); }); return ( diff --git a/src/components/boss/bossDeathVisuals.test.ts b/src/components/boss/bossDeathVisuals.test.ts index 6fe4a20..5678681 100644 --- a/src/components/boss/bossDeathVisuals.test.ts +++ b/src/components/boss/bossDeathVisuals.test.ts @@ -9,6 +9,9 @@ import { bossDeathDespawnSeconds, bossDeathHoldSeconds, bossDeathOpacity, + consumeBossIndicatorPatch, + createBossIndicatorPatchState, + updateBossIndicatorPatchState, } from "./bossDeathVisuals"; describe("boss death visuals", () => { @@ -25,6 +28,22 @@ describe("boss death visuals", () => { expect(advanceBossIndicatorOpacity(0.4, false, 1 / 60)).toBe(1); }); + it("patches indicator meshes once per defeat, including a replacement boss", () => { + let state = createBossIndicatorPatchState(false); + expect(state.pending).toBe(false); + + state = updateBossIndicatorPatchState(state, true); + expect(state.pending).toBe(true); + state = consumeBossIndicatorPatch(state); + expect(state.pending).toBe(false); + expect(updateBossIndicatorPatchState(state, true)).toBe(state); + + state = updateBossIndicatorPatchState(state, false); + expect(state.pending).toBe(false); + state = updateBossIndicatorPatchState(state, true); + expect(state.pending).toBe(true); + }); + it("holds the death pose for a few seconds, then fades the model", () => { expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS)).toBe(1); expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS / 2)).toBeCloseTo(0.5); diff --git a/src/components/boss/bossDeathVisuals.ts b/src/components/boss/bossDeathVisuals.ts index e1127c6..0c35822 100644 --- a/src/components/boss/bossDeathVisuals.ts +++ b/src/components/boss/bossDeathVisuals.ts @@ -9,6 +9,27 @@ export { export const BOSS_INDICATOR_DEATH_FADE_MS = 250; +export interface BossIndicatorPatchState { + defeated: boolean; + pending: boolean; +} + +export function createBossIndicatorPatchState(defeated: boolean): BossIndicatorPatchState { + return { defeated, pending: defeated }; +} + +export function updateBossIndicatorPatchState( + state: BossIndicatorPatchState, + defeated: boolean, +): BossIndicatorPatchState { + if (state.defeated === defeated) return state; + return { defeated, pending: defeated }; +} + +export function consumeBossIndicatorPatch(state: BossIndicatorPatchState): BossIndicatorPatchState { + return state.pending ? { ...state, pending: false } : state; +} + export function bossCanTrackTarget(hp: number) { return hp > 0; } diff --git a/src/components/sceneFramePolicy.test.ts b/src/components/sceneFramePolicy.test.ts new file mode 100644 index 0000000..ecd9f60 --- /dev/null +++ b/src/components/sceneFramePolicy.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi } from "vitest"; +import { bossDeathDespawnSeconds } from "../game/bossDeath"; +import { + OUTCOME_RENDER_TAIL_SECONDS, + activePlayfieldKind, + consumeSimulationSteps, + outcomeElapsedAfterPhaseChange, + sceneFrameIntervalMs, + selectSceneRenderMode, + startSceneFrameLoop, + summarizeFramePerformance, + type SceneFrameSample, +} from "./sceneFramePolicy"; + +function createFakeFrames() { + let nextHandle = 1; + const callbacks = new Map(); + return { + requestFrame(callback: FrameRequestCallback) { + const handle = nextHandle++; + callbacks.set(handle, callback); + return handle; + }, + cancelFrame(handle: number) { + callbacks.delete(handle); + }, + runNext(now: number) { + const entry = callbacks.entries().next().value as [number, FrameRequestCallback] | undefined; + if (!entry) return false; + callbacks.delete(entry[0]); + entry[1](now); + return true; + }, + pending() { + return callbacks.size; + }, + }; +} + +describe("scene render policy", () => { + it("selects active, outcome, static, and suspended modes", () => { + expect(selectSceneRenderMode({ phase: "combat", paused: false, visible: true, outcomeTailComplete: false })).toBe("active"); + expect(selectSceneRenderMode({ phase: "victory", paused: false, visible: true, outcomeTailComplete: false })).toBe("outcome"); + expect(selectSceneRenderMode({ phase: "victory", paused: false, visible: true, outcomeTailComplete: true })).toBe("static"); + expect(selectSceneRenderMode({ phase: "briefing", paused: false, visible: true, outcomeTailComplete: false })).toBe("static"); + expect(selectSceneRenderMode({ phase: "combat", paused: true, visible: true, outcomeTailComplete: false })).toBe("static"); + expect(selectSceneRenderMode({ phase: "combat", paused: false, visible: false, outcomeTailComplete: false })).toBe("suspended"); + }); + + it("keeps simulation fixed at 10 Hz and caps delayed catch-up at three steps", () => { + expect(consumeSimulationSteps(0, 0.09)).toEqual({ steps: 0, remainderSeconds: 0.09 }); + const oneStep = consumeSimulationSteps(0.09, 0.02); + expect(oneStep.steps).toBe(1); + expect(oneStep.remainderSeconds).toBeCloseTo(0.01); + + const delayed = consumeSimulationSteps(0.09, 1); + expect(delayed.steps).toBe(3); + expect(delayed.remainderSeconds).toBeCloseTo(0.04); + }); + + it("has no continuous interval for static or suspended scenes", () => { + expect(sceneFrameIntervalMs("static")).toBeNull(); + expect(sceneFrameIntervalMs("suspended")).toBeNull(); + }); + + it("mounts only the selected special playfield", () => { + expect(activePlayfieldKind("boss")).toBeNull(); + expect(activePlayfieldKind("hockey-healing")).toBe("hockey-healing"); + expect(activePlayfieldKind("blockbreaker")).toBe("blockbreaker"); + expect(activePlayfieldKind("aether-assault")).toBe("aether-assault"); + expect(activePlayfieldKind("hockey-healing-pvp")).toBe("hockey-healing-pvp"); + }); + + it("keeps outcome rendering longer than the longest authored boss death", () => { + expect(OUTCOME_RENDER_TAIL_SECONDS).toBeGreaterThan(bossDeathDespawnSeconds("gravehorn-triceratops")); + }); + + it("restarts the outcome tail when the outcome phase identity changes", () => { + expect(outcomeElapsedAfterPhaseChange("victory", "victory", 3.5)).toBe(3.5); + expect(outcomeElapsedAfterPhaseChange("victory", "intermission", 3.5)).toBe(0); + expect(outcomeElapsedAfterPhaseChange("defeat", null, 3.5)).toBe(0); + }); +}); + +describe("manual scene frame loop", () => { + it("caps active rendering at 60 FPS", () => { + const fake = createFakeFrames(); + const samples: SceneFrameSample[] = []; + const stop = startSceneFrameLoop({ + mode: "active", + requestFrame: fake.requestFrame, + cancelFrame: fake.cancelFrame, + onFrame: (sample) => samples.push(sample), + }); + + expect(fake.runNext(0)).toBe(true); + expect(fake.runNext(8)).toBe(true); + expect(fake.runNext(16.7)).toBe(true); + expect(fake.runNext(25)).toBe(true); + expect(fake.runNext(33.4)).toBe(true); + expect(samples).toHaveLength(3); + expect(samples.map((sample) => sample.manualTimeSeconds)).toEqual([0, 0.0167, 0.0334]); + + stop(); + expect(fake.pending()).toBe(0); + }); + + it("renders an outcome for seven animation seconds without simulation ownership", () => { + const fake = createFakeFrames(); + const completed = vi.fn(); + const samples: SceneFrameSample[] = []; + startSceneFrameLoop({ + mode: "outcome", + requestFrame: fake.requestFrame, + cancelFrame: fake.cancelFrame, + onFrame: (sample) => samples.push(sample), + onOutcomeComplete: completed, + }); + + fake.runNext(0); + let now = 0; + while (fake.pending() && now < 8_000) { + now += 1000 / 30; + fake.runNext(now); + } + + expect(completed).toHaveBeenCalledTimes(1); + expect(samples.at(-1)?.outcomeElapsedSeconds).toBeGreaterThanOrEqual(OUTCOME_RENDER_TAIL_SECONDS); + expect(fake.pending()).toBe(0); + }); + + it("finishes an outcome after seven visible wall seconds when RAF is throttled", () => { + const fake = createFakeFrames(); + const completed = vi.fn(); + const samples: SceneFrameSample[] = []; + startSceneFrameLoop({ + mode: "outcome", + requestFrame: fake.requestFrame, + cancelFrame: fake.cancelFrame, + onFrame: (sample) => samples.push(sample), + onOutcomeComplete: completed, + }); + + fake.runNext(0); + for (let now = 1_000; now <= 7_000; now += 1_000) fake.runNext(now); + + expect(completed).toHaveBeenCalledTimes(1); + expect(samples.at(-1)?.outcomeElapsedSeconds).toBe(7); + expect(samples.at(-1)?.manualTimeSeconds).toBe(7); + expect(fake.pending()).toBe(0); + }); + + it("cancels immediately and resumes without hidden-time catch-up", () => { + const fake = createFakeFrames(); + const beforeHide: SceneFrameSample[] = []; + const stop = startSceneFrameLoop({ + mode: "active", + requestFrame: fake.requestFrame, + cancelFrame: fake.cancelFrame, + onFrame: (sample) => beforeHide.push(sample), + }); + fake.runNext(100); + fake.runNext(117); + stop(); + expect(fake.pending()).toBe(0); + + const afterResume: SceneFrameSample[] = []; + const stopResumed = startSceneFrameLoop({ + mode: "active", + initialManualTimeSeconds: beforeHide.at(-1)?.manualTimeSeconds, + requestFrame: fake.requestFrame, + cancelFrame: fake.cancelFrame, + onFrame: (sample) => afterResume.push(sample), + }); + fake.runNext(60_000); + + expect(beforeHide.at(-1)?.elapsedSeconds).toBeCloseTo(0.017); + expect(afterResume[0]).toMatchObject({ + elapsedMs: 0, + elapsedSeconds: 0, + manualTimeSeconds: beforeHide.at(-1)?.manualTimeSeconds, + }); + stopResumed(); + }); +}); + +describe("performance summaries", () => { + it("uses mode-specific targets and only counts active missed frames", () => { + const active = summarizeFramePerformance("active", [16, 18, 20]); + expect(active.targetFps).toBe(60); + expect(active.overBudget).toBe(1); + + const outcome = summarizeFramePerformance("outcome", [32, 35]); + expect(outcome.targetFps).toBe(30); + expect(outcome.budgetMs).toBeCloseTo(1000 / 30); + expect(outcome.overBudget).toBe(0); + + expect(summarizeFramePerformance("static", [])).toMatchObject({ targetFps: 0, budgetMs: null, samples: 0, overBudget: 0 }); + expect(summarizeFramePerformance("suspended", [])).toMatchObject({ targetFps: 0, budgetMs: null, samples: 0, overBudget: 0 }); + }); +}); diff --git a/src/components/sceneFramePolicy.ts b/src/components/sceneFramePolicy.ts new file mode 100644 index 0000000..c2c9a02 --- /dev/null +++ b/src/components/sceneFramePolicy.ts @@ -0,0 +1,202 @@ +import type { GamePhase, GameplayActivity } from "../game/types"; + +export type SceneRenderMode = "active" | "outcome" | "static" | "suspended"; +export type OutcomePhase = Extract; + +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; + manualTimeSeconds: number; + outcomeElapsedSeconds: number; +} + +export interface SceneFrameLoopOptions { + mode: "active" | "outcome"; + initialManualTimeSeconds?: number; + initialOutcomeElapsedSeconds?: number; + requestFrame: (callback: FrameRequestCallback) => number; + cancelFrame: (handle: number) => void; + onFrame: (sample: SceneFrameSample) => void; + onOutcomeComplete?: () => void; +} + +/** + * Starts the bounded manual R3F loop used by active gameplay and finite outcome + * animation tails. Static and suspended modes intentionally have no manual loop. + */ +export function startSceneFrameLoop({ + mode, + initialManualTimeSeconds = 0, + 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 manualTimeSeconds = Math.max(0, initialManualTimeSeconds); + 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, manualTimeSeconds, 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; + manualTimeSeconds += elapsedSeconds; + if (mode === "outcome") outcomeElapsedSeconds += wallElapsedSeconds; + onFrame({ elapsedMs, elapsedSeconds, manualTimeSeconds, 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, + }; +}