Release Healer Man 0.1.9

This commit is contained in:
phenom committed 2026-08-30 14:16:56 -04:00
1 parent c245a199b6
commit d060296ade
74 files changed
+5901 -334

No files matched your search

+11 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { dprForPerformanceFactor } from "./adaptiveGraphicsQualityMath";
import {
dprForPerformanceFactor,
performanceBoundsForFrameRate,
} from "./adaptiveGraphicsQualityMath";
describe("adaptive graphics quality", () => {
it("maps the performance range onto the supported pixel ratio", () => {
@@ -12,4 +15,11 @@ describe("adaptive graphics quality", () => {
expect(dprForPerformanceFactor(-1)).toBe(1);
expect(dprForPerformanceFactor(2)).toBe(1.5);
});
it("uses bounds that match an intentional frame-rate cap", () => {
expect(performanceBoundsForFrameRate(30, 144)).toEqual([26, 29]);
expect(performanceBoundsForFrameRate(60, 144)).toEqual([52, 58]);
expect(performanceBoundsForFrameRate("display", 144)).toEqual([60, 100]);
expect(performanceBoundsForFrameRate("display", 60)).toEqual([52, 58]);
});
});
+8 -2
View File
@@ -1,9 +1,15 @@
import { PerformanceMonitor } from "@react-three/drei";
import { dprForPerformanceFactor } from "./adaptiveGraphicsQualityMath";
import type { GameplayFrameRateLimit } from "../game/frameRateSettings";
import {
dprForPerformanceFactor,
performanceBoundsForFrameRate,
} from "./adaptiveGraphicsQualityMath";
export function AdaptiveGraphicsQuality({
frameRateLimit,
onDprChange,
}: {
readonly frameRateLimit: GameplayFrameRateLimit;
readonly onDprChange: (dpr: number) => void;
}) {
return (
@@ -13,7 +19,7 @@ export function AdaptiveGraphicsQuality({
ms={250}
step={0.25}
threshold={0.75}
bounds={(refreshRate) => refreshRate > 100 ? [60, 100] : [52, 58]}
bounds={(refreshRate) => performanceBoundsForFrameRate(frameRateLimit, refreshRate)}
onChange={({ factor }) => onDprChange(dprForPerformanceFactor(factor))}
/>
);
+25
View File
@@ -0,0 +1,25 @@
import { useEffect } from "react";
import { useThree } from "@react-three/fiber";
import { startCappedFrameLoop } from "./cappedFrameLoopRuntime";
export function CappedFrameLoop({
active = true,
targetFps,
}: {
readonly active?: boolean;
readonly targetFps: number;
}) {
const advance = useThree((state) => state.advance);
const clock = useThree((state) => state.clock);
useEffect(() => {
if (!active) return;
return startCappedFrameLoop({
targetFps,
getElapsedSeconds: () => clock.elapsedTime,
advance,
});
}, [active, advance, clock, targetFps]);
return null;
}
+41 -27
View File
@@ -1,8 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import {
AdditiveBlending,
Color,
DoubleSide,
Group,
MeshBasicMaterial,
@@ -19,34 +18,32 @@ import {
type CombatVfxQuality,
} from "../game/combatPresentation";
import {
combatAudioOutputEnabled,
configureCombatAudio,
playCombatAudio,
resetCombatAudio,
setCombatAudioListener,
stopCombatAudioGroup,
} from "../game/combatAudio";
interface ActiveCombatEffect {
readonly key: string;
readonly event: CombatPresentationEvent;
}
import {
advanceCombatEffectFrame,
capCombatEffects,
updateCombatEffectQueue,
type ActiveCombatEffect,
} from "./combatEffectRuntime";
export interface CombatEffectsProps {
readonly active: boolean;
readonly safeGraphics?: boolean;
}
const QUALITY_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, number>> = {
off: 0,
low: 24,
high: 64,
};
const QUALITY_PARTICLE_COUNT: Readonly<Record<Exclude<CombatVfxQuality, "off">, number>> = {
low: 48,
high: 128,
};
const COMBAT_EFFECT_DATASET_KEY = "combatEffectLoad";
function effectDurationMs(event: CombatPresentationEvent): number {
if (event.phase === "cast-start") return Math.max(280, Math.min(4_500, event.durationMs ?? 900));
if (event.phase === "tick") return 320;
@@ -77,7 +74,7 @@ function deterministicParticles(seed: number, count: number): Float32Array {
return result;
}
function CombatEffectPrimitive({
const CombatEffectPrimitive = memo(function CombatEffectPrimitive({
event,
quality,
active,
@@ -119,15 +116,16 @@ function CombatEffectPrimitive({
useFrame((_state, delta) => {
if (!active || doneRef.current || !groupRef.current) return;
elapsedRef.current += Math.min(0.05, delta) * 1_000;
const progress = Math.min(1, elapsedRef.current / durationMs);
const frame = advanceCombatEffectFrame(elapsedRef.current, delta, durationMs);
elapsedRef.current = frame.elapsedMs;
const progress = frame.progress;
const eased = 1 - (1 - progress) ** 3;
if (projectile) {
groupRef.current.position.lerpVectors(origin, target, eased);
groupRef.current.position.y += 1.05 + Math.sin(progress * Math.PI) * 0.28;
groupRef.current.scale.setScalar(0.72 + Math.sin(progress * Math.PI) * 0.32);
} else {
groupRef.current.rotation.y += delta * (event.school === "physical" ? 4 : 1.8);
groupRef.current.rotation.y += frame.motionDeltaSeconds * (event.school === "physical" ? 4 : 1.8);
const scale = event.phase === "cast-start"
? 0.85 + Math.sin(progress * Math.PI * 3) * 0.08
: 0.35 + eased * (event.delivery === "area" ? Math.max(1.2, event.radius ?? 2.4) : 1.75);
@@ -140,7 +138,7 @@ function CombatEffectPrimitive({
if (pointsMaterialRef.current) opacity >= 0 && (pointsMaterialRef.current.opacity = opacity * 0.9);
if (primaryMaterialRef.current) primaryMaterialRef.current.opacity = opacity * 0.72;
if (secondaryMaterialRef.current) secondaryMaterialRef.current.opacity = opacity * 0.55;
if (progress >= 1) {
if (frame.done) {
doneRef.current = true;
onDone();
}
@@ -234,12 +232,13 @@ function CombatEffectPrimitive({
</mesh>
</>
)}
{quality === "high" && event.school !== "physical" ? (
<pointLight color={new Color(style.primary)} intensity={style.emissive * 1.5} distance={5} decay={2} />
) : null}
</group>
);
}
}, (previous, next) => (
previous.event === next.event
&& previous.quality === next.quality
&& previous.active === next.active
));
export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) {
const settings = useShellStore((state) => state.combatPresentationSettings);
@@ -253,6 +252,21 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
const qualityRef = useRef(effectiveQuality);
const camera = useThree((state) => state.camera);
const forward = useMemo(() => new Vector3(), []);
const audioOutputEnabled = combatAudioOutputEnabled(settings, masterVolume);
useEffect(() => {
if (!import.meta.env.DEV) return;
document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY] = JSON.stringify({
quality: effectiveQuality,
systems: effects.length,
activeLights: 0,
allocatedLights: 0,
});
}, [effectiveQuality, effects.length]);
useEffect(() => () => {
if (import.meta.env.DEV) delete document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY];
}, []);
useEffect(() => {
settingsRef.current = settings;
@@ -260,11 +274,11 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
configureCombatAudio(settings, masterVolume);
if (
qualityRef.current !== "off"
|| Object.values(settings.audioEnabled).some(Boolean)
|| combatAudioOutputEnabled(settings, masterVolume)
) void preloadCombatEffectManifest();
setEffects((current) => current.filter(({ event }) => (
setEffects((current) => capCombatEffects(current.filter(({ event }) => (
qualityRef.current !== "off" && settings.vfxEnabled[event.actorGroup]
)));
)), qualityRef.current));
for (const group of ["player", "party", "enemy"] as const) {
if (!settings.audioEnabled[group] || settings.audioVolume[group] <= 0) stopCombatAudioGroup(group);
}
@@ -298,14 +312,14 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|| quality === "off"
|| !presentationSettings.vfxEnabled[event.actorGroup]
) return withoutCast;
const limit = QUALITY_SYSTEM_LIMIT[quality];
return [...withoutCast, { key: `${event.sessionId}:${event.id}`, event }].slice(-limit);
return updateCombatEffectQueue(withoutCast, event, quality);
});
}), []);
useEffect(() => () => resetCombatAudio(), []);
useFrame(() => {
if (!active || !audioOutputEnabled) return;
camera.getWorldDirection(forward);
setCombatAudioListener(
[camera.position.x, camera.position.y, camera.position.z],
+24 -1
View File
@@ -47,6 +47,10 @@ import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { OnlinePlayerPopulation } from "./OnlinePlayerPopulation";
import { DevelopmentPerformanceProbe } from "./DevelopmentPerformanceProbe";
import { AdaptiveGraphicsQuality } from "./AdaptiveGraphicsQuality";
import { useCombatStore } from "../game/combatStore";
import { numericFrameRateLimit } from "../game/frameRateSettings";
import { useDocumentVisibility } from "../platform/useDocumentVisibility";
import { CappedFrameLoop } from "./CappedFrameLoop";
export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -95,8 +99,10 @@ export function GameScene() {
const paused = useGameStore((state) => state.paused);
const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen);
const gameplayFrameRate = useCombatStore((state) => state.settings.gameplayFrameRate);
const sharedOnline = useOnlineGroupStore((state) => state.activeActivity !== null);
const onlineAuthority = useOnlineSessionStore((state) => state.snapshot?.authority ?? true);
const documentVisible = useDocumentVisibility();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
const [safeGraphics, setSafeGraphics] = useState(false);
@@ -106,6 +112,7 @@ export function GameScene() {
const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision);
const simulationBlocked = graphicsRecovery !== null
|| !worldReady
|| (!documentVisible && !sharedOnline)
|| (!sharedOnline && (paused || mapOpen || companionOpen));
const frameloop = sceneFrameLoop({
worldReady,
@@ -114,7 +121,10 @@ export function GameScene() {
paused,
mapOpen,
companionOpen,
documentVisible,
frameRateLimit: gameplayFrameRate,
});
const targetFps = numericFrameRateLimit(gameplayFrameRate);
const allowedRuntimeIds = useMemo(
() => gameMode === "manastorm" && manastormEncounter
? manastormChaoticLinkRuntimeIds(manastormEncounter, manastormPhase)
@@ -204,6 +214,8 @@ export function GameScene() {
onLostPointerCapture={endMouseLook}
onContextMenu={(event) => event.preventDefault()}
aria-label="Healer Man 3D dungeon view"
data-frame-rate-limit={gameplayFrameRate}
data-frame-loop={frameloop}
>
<Canvas
key={rendererCanvasKey(canvasRevision)}
@@ -226,8 +238,19 @@ export function GameScene() {
powerPreference: safeGraphics ? "default" : "high-performance",
}}
>
{frameloop === "never" && targetFps !== null && (
<CappedFrameLoop
active={documentVisible || sharedOnline}
targetFps={targetFps}
/>
)}
<GameGltfLoaderLifecycle>
{!safeGraphics && worldReady && <AdaptiveGraphicsQuality onDprChange={setAdaptiveDpr} />}
{!safeGraphics && worldReady && (
<AdaptiveGraphicsQuality
frameRateLimit={gameplayFrameRate}
onDprChange={setAdaptiveDpr}
/>
)}
{import.meta.env.DEV && <DevelopmentPerformanceProbe />}
<color attach="background" args={[dungeon.presentation.background]} />
<fog attach="fog" args={[dungeon.presentation.fog.color, dungeon.presentation.fog.near, dungeon.presentation.fog.far]} />
+11
View File
@@ -1,3 +1,14 @@
import type { GameplayFrameRateLimit } from "../game/frameRateSettings";
export function dprForPerformanceFactor(factor: number): number {
return 1 + Math.max(0, Math.min(1, factor)) * 0.5;
}
export function performanceBoundsForFrameRate(
frameRateLimit: GameplayFrameRateLimit,
refreshRate: number,
): [number, number] {
if (frameRateLimit === 30) return [26, 29];
if (frameRateLimit === 60) return [52, 58];
return refreshRate > 100 ? [60, 100] : [52, 58];
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { startCappedFrameLoop } from "./cappedFrameLoopRuntime";
function createFrameQueue() {
const callbacks = new Map<number, (timestampMs: number) => void>();
const cancelled: number[] = [];
let nextFrame = 1;
return {
callbacks,
cancelled,
requestFrame(callback: (timestampMs: number) => void) {
const frame = nextFrame;
nextFrame += 1;
callbacks.set(frame, callback);
return frame;
},
cancelFrame(frame: number) {
cancelled.push(frame);
callbacks.delete(frame);
},
run(frame: number, timestampMs: number) {
const callback = callbacks.get(frame);
callbacks.delete(frame);
callback?.(timestampMs);
},
};
}
describe("capped frame-loop runtime", () => {
it("starts from Fiber's committed elapsed-seconds clock", () => {
const queue = createFrameQueue();
const advances: number[] = [];
const stop = startCappedFrameLoop({
targetFps: 30,
getElapsedSeconds: () => 42,
advance: (elapsedSeconds) => advances.push(elapsedSeconds),
requestFrame: queue.requestFrame,
cancelFrame: queue.cancelFrame,
});
queue.run(1, 1_000);
queue.run(2, 1_034);
expect(advances).toEqual([42, 42.034]);
stop();
});
it("does not schedule a zombie frame after re-entrant cleanup", () => {
const queue = createFrameQueue();
let stop: () => void = () => undefined;
stop = startCappedFrameLoop({
targetFps: 60,
getElapsedSeconds: () => 0,
advance: () => stop(),
requestFrame: queue.requestFrame,
cancelFrame: queue.cancelFrame,
});
queue.run(1, 500);
expect(queue.callbacks.size).toBe(0);
expect(queue.cancelled).toEqual([1]);
});
});
+48
View File
@@ -0,0 +1,48 @@
import {
createFrameRateGovernor,
stepFrameRateGovernor,
} from "./frameRateGovernor";
interface CappedFrameLoopRuntimeOptions {
readonly targetFps: number;
readonly getElapsedSeconds: () => number;
readonly advance: (elapsedSeconds: number) => void;
readonly requestFrame?: (callback: (timestampMs: number) => void) => number;
readonly cancelFrame?: (frame: number) => void;
}
export function startCappedFrameLoop({
targetFps,
getElapsedSeconds,
advance,
requestFrame = requestAnimationFrame,
cancelFrame = cancelAnimationFrame,
}: CappedFrameLoopRuntimeOptions): () => void {
let frame = 0;
let cancelled = false;
let governor = createFrameRateGovernor(targetFps);
let elapsedBase: number | null = null;
const renderAtTargetRate = (timestampMs: number) => {
if (cancelled) return;
if (elapsedBase === null) {
// Read after the Canvas has committed its frameloop change; Fiber resets
// the clock when entering "never" mode.
const elapsedSeconds = getElapsedSeconds();
elapsedBase = Number.isFinite(elapsedSeconds)
? Math.max(0, elapsedSeconds)
: 0;
}
const step = stepFrameRateGovernor(governor, timestampMs);
governor = step.state;
if (step.shouldRender) advance(elapsedBase + step.elapsedSeconds);
if (!cancelled) frame = requestFrame(renderAtTargetRate);
};
frame = requestFrame(renderAtTargetRate);
return () => {
cancelled = true;
cancelFrame(frame);
};
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import type { CombatPresentationEvent } from "../game/combatPresentation";
import {
COMBAT_EFFECT_SYSTEM_LIMIT,
advanceCombatEffectFrame,
updateCombatEffectQueue,
type ActiveCombatEffect,
} from "./combatEffectRuntime";
function event(
id: number,
patch: Partial<CombatPresentationEvent> = {},
): CombatPresentationEvent {
return {
id,
sessionId: 1,
occurredAt: id,
phase: "impact",
actorGroup: "enemy",
sourceActorId: `enemy-${id}`,
targetActorId: "player",
abilityId: "fixture",
name: "Fixture",
source: "fallback",
school: "shadow",
delivery: "projectile",
origin: [0, 0, 0],
targetPosition: [1, 0, 0],
...patch,
};
}
function enqueue(
events: readonly CombatPresentationEvent[],
quality: "off" | "low" | "high",
): readonly ActiveCombatEffect[] {
return events.reduce<readonly ActiveCombatEffect[]>(
(current, next) => updateCombatEffectQueue(current, next, quality),
[],
);
}
describe("combat-effect runtime budget", () => {
it("expires effects by wall time while bounding motion after a hitch", () => {
let frame = advanceCombatEffectFrame(0, 1 / 60, 420);
frame = advanceCombatEffectFrame(frame.elapsedMs, 1 / 60, 420);
frame = advanceCombatEffectFrame(frame.elapsedMs, 0.5, 420);
expect(frame.elapsedMs).toBeCloseTo(533.333, 2);
expect(frame.motionDeltaSeconds).toBe(0.05);
expect(frame.progress).toBe(1);
expect(frame.done).toBe(true);
expect(advanceCombatEffectFrame(frame.elapsedMs, Number.NaN, 420).elapsedMs)
.toBe(frame.elapsedMs);
});
it("bounds large bursts while retaining player feedback and the newest event", () => {
const player = event(1, {
actorGroup: "player",
sourceActorId: "player",
school: "holy",
});
const enemies = Array.from({ length: 512 }, (_, index) => event(index + 2, {
phase: "tick",
}));
const high = enqueue([player, ...enemies], "high");
const low = enqueue([player, ...enemies], "low");
expect(high).toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.high);
expect(low).toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.low);
expect(enqueue([player], "off")).toEqual([]);
expect(updateCombatEffectQueue(enqueue([player], "high"), enemies[0], "off"))
.toEqual([]);
expect(high.some(({ event: active }) => active.id === player.id)).toBe(true);
expect(high.at(-1)?.event.id).toBe(enemies.at(-1)?.id);
expect(new Set(high.map(({ key }) => key)).size).toBe(high.length);
});
it("keeps combat-significant enemy casts ahead of low-value friendly ticks", () => {
const partyTicks = Array.from({ length: COMBAT_EFFECT_SYSTEM_LIMIT.low }, (_, index) => event(index + 1, {
actorGroup: "party",
sourceActorId: `party-${index}`,
phase: "tick",
}));
const enemyCast = event(100, {
actorGroup: "enemy",
sourceActorId: "boss",
phase: "cast-start",
});
const effects = enqueue([...partyTicks, enemyCast], "low");
expect(effects).toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.low);
expect(effects.some(({ event: active }) => active.id === enemyCast.id)).toBe(true);
expect(effects.filter(({ event: active }) => active.phase === "tick"))
.toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.low - 1);
});
it("coalesces a single area burst but preserves projectile phases", () => {
const areaRelease = event(1, {
occurredAt: 100,
phase: "release",
delivery: "area",
sourceActorId: "caster",
});
const areaImpacts = Array.from({ length: 5 }, (_, index) => event(index + 2, {
occurredAt: 100,
phase: "impact",
delivery: "area",
sourceActorId: "caster",
targetActorId: `target-${index}`,
}));
const area = enqueue([areaRelease, ...areaImpacts], "high");
expect(area).toHaveLength(1);
expect(area[0].event.phase).toBe("impact");
const projectile = enqueue([
event(10, { occurredAt: 200, phase: "release", sourceActorId: "caster" }),
event(11, { occurredAt: 200, phase: "impact", sourceActorId: "caster" }),
], "high");
expect(projectile).toHaveLength(2);
});
});
+137
View File
@@ -0,0 +1,137 @@
import type {
CombatPresentationEvent,
CombatVfxQuality,
} from "../game/combatPresentation";
export interface ActiveCombatEffect {
readonly key: string;
readonly event: CombatPresentationEvent;
}
export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, number>> = {
off: 0,
low: 24,
high: 32,
};
export interface CombatEffectFrameState {
readonly elapsedMs: number;
readonly motionDeltaSeconds: number;
readonly progress: number;
readonly done: boolean;
}
export function advanceCombatEffectFrame(
elapsedMs: number,
rawDeltaSeconds: number,
durationMs: number,
): CombatEffectFrameState {
const safeElapsedMs = Number.isFinite(elapsedMs) ? Math.max(0, elapsedMs) : 0;
const deltaSeconds = Number.isFinite(rawDeltaSeconds) ? Math.max(0, rawDeltaSeconds) : 0;
const safeDurationMs = Number.isFinite(durationMs) ? Math.max(1, durationMs) : 1;
// Age follows wall time so a slow frame expires work instead of extending the
// overload. Motion remains bounded to avoid a single-frame visual jump.
const nextElapsedMs = safeElapsedMs + deltaSeconds * 1_000;
const progress = Math.min(1, nextElapsedMs / safeDurationMs);
return {
elapsedMs: nextElapsedMs,
motionDeltaSeconds: Math.min(0.05, deltaSeconds),
progress,
done: progress >= 1,
};
}
function actorPriority(event: CombatPresentationEvent): number {
const groupPriority = event.actorGroup === "player" ? 30 : event.actorGroup === "party" ? 20 : 10;
// Preserve readable combat beats before actor ownership. An enemy cast or
// impact is more useful than another periodic tick from any actor group.
const phasePriority = event.phase === "cast-start"
? 300
: event.phase === "release" || event.phase === "impact"
? 200
: event.phase === "tick"
? 0
: 100;
return groupPriority + phasePriority;
}
function areaBurstKey(event: CombatPresentationEvent): string | null {
if (event.delivery !== "area" || event.phase === "cast-start" || event.phase === "cast-cancel") {
return null;
}
return `${event.sessionId}:${event.sourceActorId}:${event.abilityId}:${event.occurredAt}`;
}
function effectKey(event: CombatPresentationEvent): string {
return `${event.sessionId}:${event.id}`;
}
function trimCombatEffects(
effects: readonly ActiveCombatEffect[],
limit: number,
): readonly ActiveCombatEffect[] {
if (effects.length <= limit) return effects;
const next = [...effects];
while (next.length > limit) {
let removalIndex = 0;
let removalPriority = actorPriority(next[0].event);
for (let index = 1; index < next.length; index += 1) {
const priority = actorPriority(next[index].event);
if (priority < removalPriority) {
removalIndex = index;
removalPriority = priority;
}
}
next.splice(removalIndex, 1);
}
return next;
}
export function capCombatEffects(
effects: readonly ActiveCombatEffect[],
quality: CombatVfxQuality,
): readonly ActiveCombatEffect[] {
const limit = COMBAT_EFFECT_SYSTEM_LIMIT[quality];
if (limit === 0) return effects.length === 0 ? effects : [];
return trimCombatEffects(effects, limit);
}
export function updateCombatEffectQueue(
current: readonly ActiveCombatEffect[],
event: CombatPresentationEvent,
quality: CombatVfxQuality,
): readonly ActiveCombatEffect[] {
const withoutCast = event.phase === "release" || event.phase === "cast-cancel"
? current.filter((effect) => !(
effect.event.phase === "cast-start"
&& effect.event.sourceActorId === event.sourceActorId
&& effect.event.abilityId === event.abilityId
))
: current;
if (quality === "off") return current.length === 0 ? current : [];
if (event.phase === "cast-cancel") {
return withoutCast.length === current.length ? current : withoutCast;
}
const burstKey = areaBurstKey(event);
if (burstKey) {
const existingIndex = withoutCast.findIndex((effect) => areaBurstKey(effect.event) === burstKey);
if (existingIndex >= 0) {
const existing = withoutCast[existingIndex];
if (existing.event.phase === event.phase) return withoutCast;
const next = [...withoutCast];
next[existingIndex] = { key: effectKey(event), event };
return capCombatEffects(next, quality);
}
}
const next = capCombatEffects(
[...withoutCast, { key: effectKey(event), event }],
quality,
);
if (
next.length === current.length
&& next.every((effect, index) => effect.key === current[index]?.key)
) return current;
return next;
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
createFrameRateGovernor,
resetFrameRateGovernor,
stepFrameRateGovernor,
} from "./frameRateGovernor";
function renderCount(targetFps: 30 | 60, sourceFps: number, durationMs = 1_000): number {
let state = createFrameRateGovernor(targetFps);
let renders = 0;
for (let timestamp = 0; timestamp <= durationMs; timestamp += 1_000 / sourceFps) {
const step = stepFrameRateGovernor(state, timestamp);
state = step.state;
if (step.shouldRender) renders += 1;
}
return renders;
}
describe("frame-rate governor", () => {
it("holds capped modes near their target on high-refresh displays", () => {
expect(renderCount(30, 144)).toBeGreaterThanOrEqual(30);
expect(renderCount(30, 144)).toBeLessThanOrEqual(31);
expect(renderCount(60, 240)).toBeGreaterThanOrEqual(60);
expect(renderCount(60, 240)).toBeLessThanOrEqual(61);
});
it("renders every source callback in display mode", () => {
let state = createFrameRateGovernor("display", 4);
for (const timestamp of [1_000, 1_008, 1_016]) {
const step = stepFrameRateGovernor(state, timestamp);
state = step.state;
expect(step.shouldRender).toBe(true);
}
expect(state.elapsedSeconds).toBeCloseTo(4.016);
});
it("skips missed deadlines without emitting a catch-up burst", () => {
let state = createFrameRateGovernor(60);
state = stepFrameRateGovernor(state, 0).state;
const delayed = stepFrameRateGovernor(state, 100);
expect(delayed.shouldRender).toBe(true);
expect(delayed.state.nextDeadlineMs).toBeGreaterThan(100);
const sameTimestamp = stepFrameRateGovernor(delayed.state, 100);
expect(sameTimestamp.shouldRender).toBe(false);
});
it("preserves elapsed time while resetting the wall-clock baseline", () => {
let state = createFrameRateGovernor(30, 2);
state = stepFrameRateGovernor(state, 1_000).state;
state = stepFrameRateGovernor(state, 1_034).state;
const reset = resetFrameRateGovernor(state, 60);
const resumed = stepFrameRateGovernor(reset, 90_000);
expect(resumed.shouldRender).toBe(true);
expect(resumed.elapsedSeconds).toBeCloseTo(state.elapsedSeconds);
});
it("never moves elapsed time backwards", () => {
let state = createFrameRateGovernor(60);
state = stepFrameRateGovernor(state, 20).state;
state = stepFrameRateGovernor(state, 40).state;
const backwards = stepFrameRateGovernor(state, 10);
expect(backwards.elapsedSeconds).toBe(state.elapsedSeconds);
});
});
+86
View File
@@ -0,0 +1,86 @@
export type FrameRateGovernorMode = number | "display";
export interface FrameRateGovernorState {
readonly mode: FrameRateGovernorMode;
readonly lastTimestampMs: number | null;
readonly nextDeadlineMs: number | null;
readonly elapsedSeconds: number;
}
export interface FrameRateGovernorStep {
readonly state: FrameRateGovernorState;
readonly shouldRender: boolean;
readonly elapsedSeconds: number;
}
function frameIntervalMs(mode: FrameRateGovernorMode): number | null {
if (mode === "display") return null;
return Number.isFinite(mode) && mode > 0 ? 1_000 / mode : 1_000 / 60;
}
export function createFrameRateGovernor(
mode: FrameRateGovernorMode,
elapsedSeconds = 0,
): FrameRateGovernorState {
return {
mode,
lastTimestampMs: null,
nextDeadlineMs: null,
elapsedSeconds: Math.max(0, elapsedSeconds),
};
}
export function resetFrameRateGovernor(
state: FrameRateGovernorState,
mode: FrameRateGovernorMode = state.mode,
): FrameRateGovernorState {
return createFrameRateGovernor(mode, state.elapsedSeconds);
}
export function stepFrameRateGovernor(
state: FrameRateGovernorState,
rawTimestampMs: number,
): FrameRateGovernorStep {
const fallbackTimestamp = state.lastTimestampMs ?? 0;
const finiteTimestamp = Number.isFinite(rawTimestampMs) ? rawTimestampMs : fallbackTimestamp;
const timestampMs = Math.max(fallbackTimestamp, finiteTimestamp);
const intervalMs = frameIntervalMs(state.mode);
if (state.lastTimestampMs === null) {
const nextState: FrameRateGovernorState = {
...state,
lastTimestampMs: timestampMs,
nextDeadlineMs: intervalMs === null ? null : timestampMs + intervalMs,
};
return { state: nextState, shouldRender: true, elapsedSeconds: nextState.elapsedSeconds };
}
const elapsedSeconds = state.elapsedSeconds + (timestampMs - state.lastTimestampMs) / 1_000;
if (intervalMs === null) {
const nextState: FrameRateGovernorState = {
...state,
lastTimestampMs: timestampMs,
elapsedSeconds,
};
return { state: nextState, shouldRender: true, elapsedSeconds };
}
const deadlineMs = state.nextDeadlineMs ?? timestampMs;
if (timestampMs < deadlineMs) {
const nextState: FrameRateGovernorState = {
...state,
lastTimestampMs: timestampMs,
elapsedSeconds,
};
return { state: nextState, shouldRender: false, elapsedSeconds };
}
const missedIntervals = Math.floor(Math.max(0, timestampMs - deadlineMs) / intervalMs);
const nextState: FrameRateGovernorState = {
...state,
lastTimestampMs: timestampMs,
nextDeadlineMs: deadlineMs + (missedIntervals + 1) * intervalMs,
elapsedSeconds,
};
return { state: nextState, shouldRender: true, elapsedSeconds };
}
+9 -4
View File
@@ -28,13 +28,18 @@ describe("R3F scene lifecycle", () => {
paused: false,
mapOpen: false,
companionOpen: false,
documentVisible: true,
frameRateLimit: 60 as const,
};
expect(sceneFrameLoop({ ...readyWorld, paused: true })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, mapOpen: true })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, companionOpen: true })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, paused: true, sharedOnline: true })).toBe("always");
expect(sceneFrameLoop({ ...readyWorld, paused: true, worldReady: false })).toBe("always");
expect(sceneFrameLoop({ ...readyWorld, paused: true, graphicsRecovering: true })).toBe("always");
expect(sceneFrameLoop(readyWorld)).toBe("always");
expect(sceneFrameLoop({ ...readyWorld, paused: true, sharedOnline: true })).toBe("never");
expect(sceneFrameLoop({ ...readyWorld, paused: true, worldReady: false })).toBe("never");
expect(sceneFrameLoop({ ...readyWorld, paused: true, graphicsRecovering: true })).toBe("never");
expect(sceneFrameLoop(readyWorld)).toBe("never");
expect(sceneFrameLoop({ ...readyWorld, frameRateLimit: "display" })).toBe("always");
expect(sceneFrameLoop({ ...readyWorld, documentVisible: false })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, documentVisible: false, sharedOnline: true })).toBe("never");
});
});
+6 -1
View File
@@ -1,5 +1,6 @@
import type { DungeonId } from "../game/dungeonRegistry";
import type { Frameloop } from "@react-three/fiber";
import type { GameplayFrameRateLimit } from "../game/frameRateSettings";
/**
* Encounter transitions must never remount the Canvas: R3F intentionally loses
@@ -41,10 +42,14 @@ export function sceneFrameLoop(options: {
readonly paused: boolean;
readonly mapOpen: boolean;
readonly companionOpen: boolean;
readonly documentVisible: boolean;
readonly frameRateLimit: GameplayFrameRateLimit;
}): Frameloop {
if (!options.documentVisible && !options.sharedOnline) return "demand";
const locallyPaused = options.worldReady
&& !options.graphicsRecovering
&& !options.sharedOnline
&& (options.paused || options.mapOpen || options.companionOpen);
return locallyPaused ? "demand" : "always";
if (locallyPaused) return "demand";
return options.frameRateLimit === "display" ? "always" : "never";
}