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

331 lines
13 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Canvas } 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 { MobPopulation } from "./MobPopulation";
import { PartyPopulation } from "./PartyPopulation";
import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs";
import { PlayerRig } from "./PlayerRig";
import { useManastormStore } from "../game/manastormStore";
import { ManastormPortal } from "./ManastormPortal";
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 { GmPlacementMarkers } from "./GmPlacementMarkers";
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";
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);
const dungeon = requireDungeonDefinition(activeDungeonId);
const cameraFar = Math.max(
950,
Math.min(
10_000,
Math.hypot(
dungeon.bounds.max[0] - dungeon.bounds.min[0],
dungeon.bounds.max[2] - dungeon.bounds.min[2],
) * 1.35,
),
);
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 roamingPacks = manastormStageRoamingPacks(
dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask)),
gameMode === "manastorm" ? manastormEncounter : null,
);
const difficultySpawns = stagePopulation.staticSpawns.filter(
(spawn) => spawnMatchesDifficulty(spawn.spawnMask, difficultyMask),
);
const staticSpawns = activeDungeonId === "wailing-caverns" && gameMode === "dungeon"
? wailingStaticSpawnsForPhase(difficultySpawns, wailingPhase)
: difficultySpawns;
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);
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 markWorldReady = useCallback(() => {
setReadySessionRevision(sessionRevision);
setGraphicsRecovery((state) => state === "recovering" ? null : state);
}, [sessionRevision]);
const restartRenderer = useCallback(() => {
releasePointerLock();
useGameStore.getState().resetAtActiveSpawn();
setSafeGraphics(true);
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(() => {
useWailingEncounterStore.getState().reset();
}, [activeDungeonId, gameMode, sessionRevision]);
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",
}}
>
{frameloop === "never" && targetFps !== null && (
<CappedFrameLoop
active={documentVisible || sharedOnline}
targetFps={targetFps}
/>
)}
<GameGltfLoaderLifecycle>
{!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]} />
<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}
timeStep={1 / 60}
>
<DungeonEnvironment onReady={markWorldReady} />
{worldReady && (
<>
<PlayerRig />
<OnlinePlayerPopulation active={!simulationBlocked} />
<PartyPopulation active={!simulationBlocked} simulate={!sharedOnline || onlineAuthority} />
<GroundEffectTelegraphs />
<MobPopulation
active={!simulationBlocked}
entities={stagePopulation.entities}
roamingPacks={roamingPacks}
staticSpawns={staticSpawns}
allowedRuntimeIds={allowedRuntimeIds}
/>
<WailingNaralexEvent />
<ManastormPortal />
<GmPlacementMarkers />
<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>
);
}