forked from phenom/healer-man
Release Healer Man 0.1.9
This commit is contained in:
1 parent
c245a199b6
commit
d060296ade
74 files changed
+5901
-334
No files matched your search
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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))}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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]} />
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user