updated some performance stuff
This commit is contained in:
+179
-107
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user