440 lines
18 KiB
TypeScript
440 lines
18 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";
|
|
|
|
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 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}
|
|
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}
|
|
timeStep={1 / 60}
|
|
>
|
|
<DungeonEnvironment onReady={markEnvironmentReady} />
|
|
{worldReady && (
|
|
<>
|
|
<PlayerRig />
|
|
<OnlinePlayerPopulation active={!simulationBlocked} />
|
|
<PartyPopulation active={!simulationBlocked} simulate={!sharedOnline || onlineAuthority} />
|
|
<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>
|
|
);
|
|
}
|