updated some performance stuff
This commit is contained in:
+198
-126
@@ -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<MemberId, string> = {
|
||||
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<number | null>(null);
|
||||
const lastRenderedAt = useRef<number | null>(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<OutcomePhase | null>(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<number[]>([]);
|
||||
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 <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 }) {
|
||||
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 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 (
|
||||
<Canvas
|
||||
frameloop="never"
|
||||
frameloop={renderMode === "static" ? "demand" : "never"}
|
||||
shadows="basic"
|
||||
dpr={dpr}
|
||||
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
|
||||
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
|
||||
>
|
||||
<GameAssetProvider>
|
||||
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
|
||||
<SceneFrameScheduler
|
||||
dpr={dpr}
|
||||
mode={renderMode}
|
||||
outcomePhase={outcomePhaseActive ? phase : null}
|
||||
onDprChange={setRenderDpr}
|
||||
onOutcomeComplete={completeOutcomeTail}
|
||||
/>
|
||||
<BossRoom />
|
||||
<HockeyHealingPlayfield />
|
||||
<BlockbreakerPlayfield />
|
||||
<AetherAssaultPlayfield />
|
||||
<HockeyHealingPvpPlayfield />
|
||||
<ActiveModePlayfield />
|
||||
<EncounterActors playerAppearance={playerAppearance} />
|
||||
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
||||
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe mode={renderMode} />}
|
||||
</GameAssetProvider>
|
||||
</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 { 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);
|
||||
|
||||
@@ -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<THREE.Group>(null);
|
||||
const opacity = useRef(defeated ? 0 : 1);
|
||||
const patchedMeshes = useRef(new WeakSet<THREE.Mesh>());
|
||||
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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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