updated some performance stuff

This commit is contained in:
Warren H
2026-07-17 15:39:31 -04:00
parent 10ff124466
commit 638aef22b2
7 changed files with 660 additions and 130 deletions
+179 -107
View File
@@ -74,7 +74,7 @@ import {
import { resolveCharacterEquipment } from "../game/characterEquipment"; import { resolveCharacterEquipment } from "../game/characterEquipment";
import { HEALER_CLASS_ORDER } from "../game/healers"; import { HEALER_CLASS_ORDER } from "../game/healers";
import { useGameStore } from "../game/store"; 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 { BossRoom } from "./BossRoom";
import { HealerClassAccessory } from "./HealerClassAccessory"; import { HealerClassAccessory } from "./HealerClassAccessory";
import { ModularCharacterBody } from "./ModularCharacterBody"; import { ModularCharacterBody } from "./ModularCharacterBody";
@@ -110,6 +110,20 @@ import {
aetherShipColorIndex, aetherShipColorIndex,
} from "./aetherAssaultVisuals"; } from "./aetherAssaultVisuals";
import { clampToBossArenaWithPortals } from "../game/rpgRoguelike/playSpace"; 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<MemberId, string> = { const PARTY_MODEL_LEGACY_URLS: Record<MemberId, string> = {
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href, 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 MIN_RENDER_DPR = 1;
const MAX_RENDER_DPR = 1.25; const MAX_RENDER_DPR = 1.25;
/** /**
* Owns the only gameplay RAF. R3F stays manually advanced, while simulation remains * Owns manual rendering only while gameplay or a finite outcome animation is active.
* in the store and is advanced at its existing fixed 10 Hz cadence. * Static scenes use R3F demand rendering; suspended scenes render nothing.
*/ */
function SceneFrameScheduler({ dpr, onDprChange }: { dpr: number; onDprChange: (next: number) => void }) { function SceneFrameScheduler({
const { advance } = useThree(); dpr,
const gameplayActive = useGameStore((state) => state.phase === "combat" && !state.paused); mode,
const frameId = useRef<number | null>(null); outcomePhase,
const lastRenderedAt = useRef<number | null>(null); onDprChange,
onOutcomeComplete,
}: {
dpr: number;
mode: SceneRenderMode;
outcomePhase: OutcomePhase | null;
onDprChange: (next: number) => void;
onOutcomeComplete: () => void;
}) {
const { advance, invalidate } = useThree();
const simulationAccumulator = useRef(0); const simulationAccumulator = useRef(0);
const manualTimeSeconds = useRef(0);
const outcomeElapsedSeconds = useRef(0);
const previousOutcomePhase = useRef<OutcomePhase | null>(null);
const slowFrameMs = useRef(0); const slowFrameMs = useRef(0);
const stableFrameMs = useRef(0); const stableFrameMs = useRef(0);
const dprRef = useRef(dpr); const dprRef = useRef(dpr);
dprRef.current = dpr; dprRef.current = dpr;
useEffect(() => { useEffect(() => {
const schedule = (now: number) => { outcomeElapsedSeconds.current = outcomeElapsedAfterPhaseChange(
const interval = gameplayActive ? GAMEPLAY_FRAME_INTERVAL_MS : BACKGROUND_FRAME_INTERVAL_MS; previousOutcomePhase.current,
const previous = lastRenderedAt.current; outcomePhase,
if (previous === null) { outcomeElapsedSeconds.current,
lastRenderedAt.current = now; );
advance(now / 1000, true); previousOutcomePhase.current = outcomePhase;
} else { if (mode !== "active") {
const elapsedMs = now - previous; simulationAccumulator.current = 0;
if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= interval) { slowFrameMs.current = 0;
lastRenderedAt.current = now; stableFrameMs.current = 0;
const elapsedSeconds = Math.min(elapsedMs / 1000, 0.25); }
if (gameplayActive) { if (mode === "static") {
simulationAccumulator.current += elapsedSeconds; manualTimeSeconds.current = 0;
let steps = 0; invalidate();
while (simulationAccumulator.current >= SIMULATION_STEP_SECONDS && steps < MAX_SIMULATION_STEPS_PER_FRAME) { 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; const startedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0;
useGameStore.getState().tick(SIMULATION_STEP_SECONDS); useGameStore.getState().tick(SIMULATION_STEP_SECONDS);
if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt); if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt);
simulationAccumulator.current -= SIMULATION_STEP_SECONDS;
steps += 1;
} }
if (elapsedMs > 20) { if (sample.elapsedMs > 20) {
slowFrameMs.current += elapsedMs; slowFrameMs.current += sample.elapsedMs;
stableFrameMs.current = 0; stableFrameMs.current = 0;
if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) { if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) {
onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125)); onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125));
slowFrameMs.current = 0; slowFrameMs.current = 0;
} }
} else { } else if (sample.elapsedMs > 0) {
slowFrameMs.current = 0; slowFrameMs.current = 0;
stableFrameMs.current += elapsedMs; stableFrameMs.current += sample.elapsedMs;
if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) { if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) {
onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125)); onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125));
stableFrameMs.current = 0; stableFrameMs.current = 0;
} }
} }
} else { } else {
simulationAccumulator.current = 0; outcomeElapsedSeconds.current = sample.outcomeElapsedSeconds;
slowFrameMs.current = 0;
stableFrameMs.current = 0;
} }
advance(now / 1000, true);
}
}
frameId.current = requestAnimationFrame(schedule);
};
frameId.current = requestAnimationFrame(schedule); advance(sample.manualTimeSeconds, true);
return () => { },
if (frameId.current !== null) cancelAnimationFrame(frameId.current); onOutcomeComplete,
frameId.current = null; });
lastRenderedAt.current = null; }, [advance, invalidate, mode, onDprChange, onOutcomeComplete, outcomePhase]);
simulationAccumulator.current = 0;
};
}, [advance, gameplayActive, onDprChange]);
return null; return null;
} }
@@ -2543,17 +2567,46 @@ type PerformanceMemory = Performance & {
memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number }; memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number };
}; };
function percentile(sorted: readonly number[], ratio: number) { function PerformanceProbe({ mode }: { mode: SceneRenderMode }) {
if (!sorted.length) return 0;
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
}
function PerformanceProbe() {
const { gl } = useThree(); const { gl } = useThree();
const frameSamples = useRef<number[]>([]); const frameSamples = useRef<number[]>([]);
const observedFrames = useRef(0);
const longTaskCount = useRef(0); const longTaskCount = useRef(0);
const longTaskDuration = useRef(0); const longTaskDuration = useRef(0);
const lastPublishAt = 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(() => { useEffect(() => {
if (!PERFORMANCE_PROBE_ENABLED || typeof PerformanceObserver === "undefined") return; 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) => { useFrame(({ clock }, delta) => {
if (!PERFORMANCE_PROBE_ENABLED) return; 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; const samples = frameSamples.current;
if (samples.length === 300) samples.shift(); 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; if (clock.elapsedTime - lastPublishAt.current < 1 || samples.length < 30) return;
lastPublishAt.current = clock.elapsedTime; lastPublishAt.current = clock.elapsedTime;
const sorted = [...samples].sort((left, right) => left - right); publishSnapshot(mode, samples);
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 },
});
}); });
return null; return null;
} }
@@ -2705,29 +2733,73 @@ function CombatFx() {
return <FxBurst key={pulse.id} kind={pulse.kind} targetId={pulse.targetId} />; return <FxBurst key={pulse.id} kind={pulse.kind} targetId={pulse.targetId} />;
} }
function ActiveModePlayfield() {
const activityMode = useGameStore((state) => state.activityMode);
const playfield = activePlayfieldKind(activityMode);
if (playfield === "hockey-healing") return <HockeyHealingPlayfield />;
if (playfield === "blockbreaker") return <BlockbreakerPlayfield />;
if (playfield === "aether-assault") return <AetherAssaultPlayfield />;
if (playfield === "hockey-healing-pvp") return <HockeyHealingPvpPlayfield />;
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 }) { 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<GamePhase | null>(null);
const outcomeTailComplete = completedOutcomePhase === phase;
const [dpr, setDpr] = useState(() => Math.min(MAX_RENDER_DPR, Math.max(MIN_RENDER_DPR, window.devicePixelRatio || 1))); const [dpr, setDpr] = useState(() => Math.min(MAX_RENDER_DPR, Math.max(MIN_RENDER_DPR, window.devicePixelRatio || 1)));
const setRenderDpr = useCallback((next: number) => { const setRenderDpr = useCallback((next: number) => {
setDpr((current) => Math.abs(current - next) < 0.001 ? current : next); 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 ( return (
<Canvas <Canvas
frameloop="never" frameloop={renderMode === "static" ? "demand" : "never"}
shadows="basic" shadows="basic"
dpr={dpr} dpr={dpr}
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }} camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }} gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
> >
<GameAssetProvider> <GameAssetProvider>
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} /> <SceneFrameScheduler
dpr={dpr}
mode={renderMode}
outcomePhase={outcomePhaseActive ? phase : null}
onDprChange={setRenderDpr}
onOutcomeComplete={completeOutcomeTail}
/>
<BossRoom /> <BossRoom />
<HockeyHealingPlayfield /> <ActiveModePlayfield />
<BlockbreakerPlayfield />
<AetherAssaultPlayfield />
<HockeyHealingPvpPlayfield />
<EncounterActors playerAppearance={playerAppearance} /> <EncounterActors playerAppearance={playerAppearance} />
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />} {PERFORMANCE_PROBE_ENABLED && <PerformanceProbe mode={renderMode} />}
</GameAssetProvider> </GameAssetProvider>
</Canvas> </Canvas>
); );
+3 -2
View File
@@ -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 { barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { HEALER_ABILITIES } from "../game/healers"; import { HEALER_ABILITIES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog"; import { BOSS_DEFINITIONS } from "../game/bossCatalog";
@@ -11,7 +11,8 @@ import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay"; import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
import type { CharacterAppearanceV1 } from "../game/characterAppearance"; 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() { function CompactParty() {
const party = useGameStore((state) => state.party); const party = useGameStore((state) => state.party);
+16 -2
View File
@@ -8,7 +8,13 @@ import { angleTo } from "../../game/geometry";
import { useGameStore } from "../../game/store"; import { useGameStore } from "../../game/store";
import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types"; import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types";
import { BreathParticleVfx, CircleMechanicVfx, LaneEnergyVfx } from "./BossAttackVfx"; 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"; import { CircleHazardVfx } from "./CircleHazardVfx";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const; const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
@@ -821,6 +827,7 @@ function BossMechanicIndicatorSet({ bossIndex }: { bossIndex: number }) {
const group = useRef<THREE.Group>(null); const group = useRef<THREE.Group>(null);
const opacity = useRef(defeated ? 0 : 1); const opacity = useRef(defeated ? 0 : 1);
const patchedMeshes = useRef(new WeakSet<THREE.Mesh>()); const patchedMeshes = useRef(new WeakSet<THREE.Mesh>());
const patchState = useRef(createBossIndicatorPatchState(defeated));
const [retainIndicators, setRetainIndicators] = useState(!defeated); const [retainIndicators, setRetainIndicators] = useState(!defeated);
const patchMeshOpacity = useCallback((child: THREE.Object3D) => { const patchMeshOpacity = useCallback((child: THREE.Object3D) => {
@@ -852,11 +859,18 @@ function BossMechanicIndicatorSet({ bossIndex }: { bossIndex: number }) {
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [defeated]); }, [defeated]);
useLayoutEffect(() => {
patchState.current = updateBossIndicatorPatchState(patchState.current, defeated);
}, [defeated]);
useFrame((_, delta) => { useFrame((_, delta) => {
if (!group.current) return; if (!group.current) return;
if (patchState.current.pending) {
group.current.traverse(patchMeshOpacity);
patchState.current = consumeBossIndicatorPatch(patchState.current);
}
opacity.current = advanceBossIndicatorOpacity(opacity.current, defeated, delta); opacity.current = advanceBossIndicatorOpacity(opacity.current, defeated, delta);
group.current.visible = opacity.current > 0; group.current.visible = opacity.current > 0;
group.current.traverse(patchMeshOpacity);
}); });
return ( return (
@@ -9,6 +9,9 @@ import {
bossDeathDespawnSeconds, bossDeathDespawnSeconds,
bossDeathHoldSeconds, bossDeathHoldSeconds,
bossDeathOpacity, bossDeathOpacity,
consumeBossIndicatorPatch,
createBossIndicatorPatchState,
updateBossIndicatorPatchState,
} from "./bossDeathVisuals"; } from "./bossDeathVisuals";
describe("boss death visuals", () => { describe("boss death visuals", () => {
@@ -25,6 +28,22 @@ describe("boss death visuals", () => {
expect(advanceBossIndicatorOpacity(0.4, false, 1 / 60)).toBe(1); 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", () => { 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)).toBe(1);
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS / 2)).toBeCloseTo(0.5); expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS / 2)).toBeCloseTo(0.5);
+21
View File
@@ -9,6 +9,27 @@ export {
export const BOSS_INDICATOR_DEATH_FADE_MS = 250; 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) { export function bossCanTrackTarget(hp: number) {
return hp > 0; return hp > 0;
} }
+201
View File
@@ -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<number, FrameRequestCallback>();
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 });
});
});
+202
View File
@@ -0,0 +1,202 @@
import type { GamePhase, GameplayActivity } from "../game/types";
export type SceneRenderMode = "active" | "outcome" | "static" | "suspended";
export type OutcomePhase = Extract<GamePhase, "victory" | "defeat" | "intermission">;
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<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,
};
}