Files
healer-man/src/scene/GameScene.tsx
T
2026-09-16 14:30:58 -04:00

450 lines
19 KiB
TypeScript

import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Canvas, useThree } from "@react-three/fiber";
import { Physics } from "@react-three/rapier";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { spawnMatchesDifficulty } from "../game/mobPopulation";
import {
beginMouseLookDrag,
endMouseLookDrag,
isDesktopCameraLookPointer,
releasePointerLock,
} from "../game/inputManager";
import { useGameStore } from "../game/store";
import { DungeonEnvironment } from "./DungeonEnvironment";
import { dungeonRenderDistance } from "./dungeonRenderDistance";
import { MobPopulation } from "./MobPopulation";
import { PartyPopulation } from "./PartyPopulation";
import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs";
import { PlayerRig } from "./PlayerRig";
import { useManastormStore } from "../game/manastormStore";
import {
encounterWorldIsReady,
encounterWorldKey,
rendererCanvasKey,
sceneFrameLoop,
} from "./sceneLifecycle";
import { manastormChaoticLinkRuntimeIds } from "../game/manastormChaoticLink";
import { manastormStagePopulation, manastormStageRoamingPacks } from "../game/manastormStagePopulation";
import { resolveStagePresentation } from "../game/manastormStagePresentation";
import {
useWailingEncounterStore,
wailingStaticSpawnsForPhase,
} from "../game/wailingCavernsEncounter";
import { WailingNaralexEvent } from "./WailingNaralexEvent";
import { GameGltfLoaderLifecycle } from "./useGameGLTF";
import { CombatEffects } from "./CombatEffects";
import { PLAYER_GRAVITY } from "../game/playerMovement";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
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";
import { selectDungeonSessionPopulation } from "../game/dungeonSpawnSelection";
import { DungeonWorldObjects } from "./DungeonWorldObjects";
import { ActivityAssetWarmup } from "./ActivityAssetWarmup";
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { ShelvedGameSceneFeatures } from "@shelvedGameScene";
import { shouldLoadPartyNavigation } from "../game/partyNavigationPolicy";
import {
activeDungeonCompletedObjectiveIds,
activeDungeonEncounterRevision,
activeDungeonEncounterSpawnGroupIds,
subscribeDungeonEncounterSnapshots,
} from "../game/dungeonEncounterRuntime";
import {
activeEncounterSpawnGroupSpawns,
suppressCompletedDungeonPopulation,
} from "../game/dungeonEncounterSpawns";
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { DungeonSpectatorCamera } from "./DungeonSpectatorCamera";
import { DungeonSimulationSceneBridge } from "./DungeonSimulationSceneBridge";
function DungeonCameraRange({ far }: { readonly far: number }) {
const camera = useThree((state) => state.camera);
useLayoutEffect(() => {
// Canvas preserves its camera across encounters; initial camera props alone
// do not update the clipping distance when switching dungeon packages.
camera.far = far;
camera.updateProjectionMatrix();
}, [camera, far]);
return null;
}
export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode);
const sessionRevision = useGameStore((state) => state.sessionRevision);
const manastormEncounter = useManastormStore((state) => state.currentEncounter);
const manastormPhase = useManastormStore((state) => state.phase);
const wailingPhase = useWailingEncounterStore((state) => state.phase);
useSyncExternalStore(
subscribeDungeonEncounterSnapshots,
activeDungeonEncounterRevision,
activeDungeonEncounterRevision,
);
const dungeon = requireDungeonDefinition(activeDungeonId);
const completedObjectiveIds = gameMode === "dungeon"
? activeDungeonCompletedObjectiveIds(activeDungeonId)
: [];
const cameraFar = dungeonRenderDistance(dungeon);
const stagePresentation = resolveStagePresentation(gameMode, manastormEncounter, {
title: dungeon.title,
mapId: dungeon.mapId,
areaName: dungeon.title,
});
const activeDifficultyId = useGameStore((state) => state.activeDifficultyId);
const difficulty = dungeon.difficultyVariants.find((variant) => variant.id === activeDifficultyId)
?? dungeon.difficultyVariants.find((variant) => variant.id === dungeon.defaultDifficultyId)
?? dungeon.difficultyVariants[0];
const difficultyMask = difficulty?.spawnMask ?? 1;
const stagePopulation = useMemo(
() => gameMode === "manastorm"
? manastormStagePopulation(dungeon.entities, dungeon.staticSpawns, manastormEncounter)
: { entities: dungeon.entities, staticSpawns: dungeon.staticSpawns },
[dungeon, gameMode, manastormEncounter],
);
const manastormAssetPackage = gameMode === "manastorm"
? activeManastormStageAssetPackage()
: null;
const activityAssets = useMemo(
() => manastormAssetPackage
? resolveManastormStageAssets(manastormAssetPackage)
: resolveDungeonAssets(dungeon),
[dungeon, manastormAssetPackage, sessionRevision],
);
const navigationUrl = activityAssets.navigation
&& shouldLoadPartyNavigation(gameMode, dungeon, manastormAssetPackage)
? activityAssets.navigation
: null;
const navigationLinks = manastormAssetPackage ? [] : dungeon.navigationLinks;
const activitySceneAssetUrls = useMemo(() => [...new Set([
...Object.values(stagePopulation.entities).flatMap((entity) => (
entity.visual.model?.url ? [entity.visual.model.url] : []
)),
...(navigationUrl ? [navigationUrl] : []),
])], [navigationUrl, stagePopulation.entities]);
const difficultyRoamingPacks = manastormStageRoamingPacks(
dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask)),
gameMode === "manastorm" ? manastormEncounter : null,
);
const difficultySpawns = stagePopulation.staticSpawns.filter(
(spawn) => spawnMatchesDifficulty(spawn.spawnMask, difficultyMask),
);
const persistentPopulation = suppressCompletedDungeonPopulation(
difficultySpawns,
difficultyRoamingPacks,
completedObjectiveIds,
);
const phasedStaticSpawns = activeDungeonId === "wailing-caverns" && gameMode === "dungeon"
? wailingStaticSpawnsForPhase(persistentPopulation.staticSpawns, wailingPhase)
: persistentPopulation.staticSpawns;
const sessionPopulation = selectDungeonSessionPopulation(
activeDungeonId,
sessionRevision,
phasedStaticSpawns,
persistentPopulation.roamingPacks,
);
const activeSpawnGroupIds = activeDungeonEncounterSpawnGroupIds();
const encounterSpawns = activeEncounterSpawnGroupSpawns(
dungeon.encounterSpawnGroups ?? [],
activeSpawnGroupIds,
difficultyMask,
sessionPopulation.staticSpawns,
).filter((spawn) => !completedObjectiveIds.includes(spawn.id));
const staticSpawns = [
...sessionPopulation.staticSpawns,
...encounterSpawns,
];
const roamingPacks = sessionPopulation.roamingPacks;
const paused = useGameStore((state) => state.paused);
const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen);
const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode);
const simulationRunState = useDungeonSimulationStore((state) => state.runState);
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 [environmentReadySessionRevision, setEnvironmentReadySessionRevision] = useState<number | null>(null);
const [activityAssetsReadySessionRevision, setActivityAssetsReadySessionRevision] = useState<number | null>(null);
const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
const proxyAssetStateRef = useRef<{ readonly revision: number; readonly message: string | null } | null>(null);
const [safeGraphics, setSafeGraphics] = useState(false);
const [adaptiveDpr, setAdaptiveDpr] = useState(1.5);
const [canvasRevision, setCanvasRevision] = useState(0);
const [graphicsRecovery, setGraphicsRecovery] = useState<"lost" | "recovering" | null>(null);
const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision);
const simulationBlocked = graphicsRecovery !== null
|| !worldReady
|| (!documentVisible && !sharedOnline)
|| (!sharedOnline && (paused || mapOpen || companionOpen));
const frameloop = sceneFrameLoop({
worldReady,
graphicsRecovering: graphicsRecovery !== null,
sharedOnline,
paused,
mapOpen,
companionOpen,
documentVisible,
frameRateLimit: gameplayFrameRate,
});
const targetFps = numericFrameRateLimit(gameplayFrameRate);
const allowedRuntimeIds = useMemo(
() => gameMode === "manastorm" && manastormEncounter
? manastormChaoticLinkRuntimeIds(manastormEncounter, manastormPhase)
: undefined,
[gameMode, manastormEncounter, manastormPhase],
);
const markEnvironmentReady = useCallback(() => {
const game = useGameStore.getState();
proxyAssetStateRef.current = game.assetStatus === "proxy"
? { revision: sessionRevision, message: game.assetMessage }
: null;
game.setAssetStatus("loading", "Preparing party, creatures, and navigation.");
setEnvironmentReadySessionRevision(sessionRevision);
setGraphicsRecovery((state) => state === "recovering" ? null : state);
}, [sessionRevision]);
const markActivityAssetsReady = useCallback(() => {
setActivityAssetsReadySessionRevision(sessionRevision);
}, [sessionRevision]);
useEffect(() => {
if (
environmentReadySessionRevision !== sessionRevision
|| activityAssetsReadySessionRevision !== sessionRevision
) return;
setReadySessionRevision(sessionRevision);
}, [activityAssetsReadySessionRevision, environmentReadySessionRevision, sessionRevision]);
useEffect(() => {
if (!worldReady) return;
const proxy = proxyAssetStateRef.current?.revision === sessionRevision
? proxyAssetStateRef.current
: null;
useGameStore.getState().setAssetStatus(proxy ? "proxy" : "ready", proxy?.message ?? null);
}, [sessionRevision, worldReady]);
const restartRenderer = useCallback(() => {
releasePointerLock();
useGameStore.getState().resetAtActiveSpawn();
setSafeGraphics(true);
setEnvironmentReadySessionRevision(null);
setActivityAssetsReadySessionRevision(null);
setReadySessionRevision(null);
setGraphicsRecovery("recovering");
setCanvasRevision((revision) => revision + 1);
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const handleContextLost = (rawEvent: Event) => {
const event = rawEvent as WebGLContextEvent;
console.error("Healer Man WebGL context lost", event.statusMessage);
releasePointerLock();
setGraphicsRecovery("lost");
};
const handleContextRestored = () => {
// Three reinitializes its renderer state and GPU resources on restore.
// Keep the existing Rapier world so recovery does not move the player.
console.info("Healer Man WebGL context restored");
setGraphicsRecovery(null);
};
canvas.addEventListener("webglcontextlost", handleContextLost);
canvas.addEventListener("webglcontextrestored", handleContextRestored);
return () => {
canvas.removeEventListener("webglcontextlost", handleContextLost);
canvas.removeEventListener("webglcontextrestored", handleContextRestored);
};
}, [canvasRevision]);
useEffect(() => {
setAdaptiveDpr(1.5);
}, [activeDungeonId, sessionRevision]);
const endMouseLook = useCallback(() => {
endMouseLookDrag();
}, []);
useEffect(() => {
const handleMouseUp = (event: MouseEvent) => {
if (isDesktopCameraLookPointer("mouse", event.button)) endMouseLook();
};
window.addEventListener("mouseup", handleMouseUp, true);
window.addEventListener("blur", endMouseLook);
return () => {
window.removeEventListener("mouseup", handleMouseUp, true);
window.removeEventListener("blur", endMouseLook);
endMouseLook();
};
}, [endMouseLook]);
const beginMouseLook = (event: ReactPointerEvent<HTMLDivElement>) => {
if (
simulationBlocked
|| !isDesktopCameraLookPointer(event.pointerType, event.button)
) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
beginMouseLookDrag(event.clientX, event.clientY);
};
return (
<div
className="scene-shell"
onPointerDown={beginMouseLook}
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)}
ref={canvasRef}
shadows={!safeGraphics}
dpr={safeGraphics ? 1 : adaptiveDpr}
frameloop={frameloop}
camera={{
fov: 62,
near: 0.08,
far: cameraFar,
position: [
dungeon.entrance.footPosition[0] + 4,
dungeon.entrance.footPosition[1] + 4,
dungeon.entrance.footPosition[2] + 4,
],
}}
gl={{
antialias: !safeGraphics,
powerPreference: safeGraphics ? "default" : "high-performance",
}}
>
<DungeonCameraRange far={cameraFar} />
{frameloop === "never" && targetFps !== null && (
<CappedFrameLoop
active={documentVisible || sharedOnline}
targetFps={targetFps}
/>
)}
<GameGltfLoaderLifecycle>
{!worldReady && (
<ActivityAssetWarmup
key={`activity-warmup:${sessionRevision}`}
navigationLinks={navigationLinks}
traversalRamps={manastormAssetPackage ? [] : dungeon.traversalRamps ?? []}
navigationUrl={navigationUrl}
sceneAssetUrls={activitySceneAssetUrls}
onReady={markActivityAssetsReady}
/>
)}
{!safeGraphics && worldReady && (
<AdaptiveGraphicsQuality
frameRateLimit={gameplayFrameRate}
onDprChange={setAdaptiveDpr}
/>
)}
{import.meta.env.DEV && <DevelopmentPerformanceProbe />}
<color attach="background" args={[dungeon.presentation.fog.color]} />
<fog attach="fog" args={[dungeon.presentation.fog.color, dungeon.presentation.fog.near, dungeon.presentation.fog.far]} />
<ambientLight intensity={dungeon.presentation.ambientLight.intensity} color={dungeon.presentation.ambientLight.color} />
<hemisphereLight args={[
dungeon.presentation.hemisphereLight.skyColor,
dungeon.presentation.hemisphereLight.groundColor,
dungeon.presentation.hemisphereLight.intensity,
]} />
<directionalLight
castShadow
color={dungeon.presentation.directionalLight.color}
intensity={dungeon.presentation.directionalLight.intensity}
position={[
dungeon.entrance.footPosition[0] + dungeon.presentation.directionalLight.offset[0],
dungeon.entrance.footPosition[1] + dungeon.presentation.directionalLight.offset[1],
dungeon.entrance.footPosition[2] + dungeon.presentation.directionalLight.offset[2],
]}
shadow-mapSize={[1024, 1024]}
shadow-bias={-0.00025}
/>
<Physics
key={encounterWorldKey(activeDungeonId, sessionRevision)}
gravity={[0, PLAYER_GRAVITY, 0]}
paused={simulationBlocked || (simulationSessionMode === "spectate-live" && simulationRunState !== "running")}
timeStep={1 / 60}
>
<DungeonEnvironment onReady={markEnvironmentReady} />
{worldReady && (
<>
{simulationSessionMode === "play" ? <PlayerRig /> : <DungeonSpectatorCamera />}
{simulationSessionMode === "play" && <OnlinePlayerPopulation active={!simulationBlocked} />}
<PartyPopulation
active={!simulationBlocked}
simulate={simulationSessionMode !== "spectate-replay" && (!sharedOnline || onlineAuthority)}
/>
<DungeonSimulationSceneBridge />
<GroundEffectTelegraphs />
{gameMode === "dungeon" && (
<DungeonWorldObjects definitions={dungeon.serverGameObjects} />
)}
<MobPopulation
active={!simulationBlocked}
entities={stagePopulation.entities}
roamingPacks={roamingPacks}
staticSpawns={staticSpawns}
allowedRuntimeIds={allowedRuntimeIds}
/>
<WailingNaralexEvent />
<ShelvedGameSceneFeatures />
<CombatEffects active={!simulationBlocked} safeGraphics={safeGraphics} />
</>
)}
</Physics>
</GameGltfLoaderLifecycle>
</Canvas>
{graphicsRecovery && (
<div
className="graphics-recovery"
role="alert"
onClick={(event) => event.stopPropagation()}
>
<p className="eyebrow">Graphics renderer interrupted</p>
<h1>
{graphicsRecovery === "recovering"
? `Rebuilding ${stagePresentation.title}...`
: "The 3D view stopped responding."}
</h1>
<p>
{graphicsRecovery === "recovering"
? "Restarting with reduced graphics settings."
: "Your interface is safe. If it does not recover automatically, restart at the entrance with reduced graphics."}
</p>
{graphicsRecovery === "lost" && (
<button type="button" className="button button--primary" onClick={restartRenderer}>
Restart at entrance
</button>
)}
</div>
)}
</div>
);
}