Files
healer-man/src/scene/ProductionDungeon.tsx
T
2026-09-12 15:14:56 -04:00

258 lines
8.4 KiB
TypeScript

import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RigidBody, TrimeshCollider } from "@react-three/rapier";
import { useThree } from "@react-three/fiber";
import {
Mesh,
type InstancedMesh,
type Material,
type Object3D,
} from "three";
import type { ResolvedDungeonAssets } from "../game/dungeonAssets";
import type { DungeonDefinition } from "../game/dungeonTypes";
import { useGameStore } from "../game/store";
import {
collisionLineOfSightTrimeshes,
collisionTrimeshes,
prepareCollision,
} from "./productionDungeonCollision";
import {
DUNGEON_PHYSICAL_COLLISION_GROUPS,
DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS,
} from "./dungeonLineOfSight";
import { disposeObject3DResources } from "./threeResourceDisposal";
import { cacheStaticMatrices, instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
import { createStaticVisibilityCells, registerStaticVisibility } from "./staticSceneVisibility";
import { useGameGLTF } from "./useGameGLTF";
import { prepareDungeonMaterial } from "./productionDungeonMaterials";
import {
DUNGEON_COLLISION_LOAD_BATCH_SIZE,
DUNGEON_VISUAL_LOAD_BATCH_SIZE,
advanceDungeonAssetCount,
} from "./dungeonLoadSequencing";
function prepareVisual(root: Object3D, definition: DungeonDefinition) {
const clone = root.clone(true);
const clonedMaterials = new Map<string, Material>();
clone.traverse((node) => {
const mesh = node as Mesh;
if (!mesh.isMesh) return;
const sources = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
const prepared = sources.map((source) => {
const cached = clonedMaterials.get(source.uuid);
if (cached) return cached;
const next = prepareDungeonMaterial(source, definition.presentation.materials, definition.environmentMode);
clonedMaterials.set(source.uuid, next);
return next;
});
mesh.material = Array.isArray(mesh.material) ? prepared : prepared[0];
mesh.castShadow = false;
mesh.receiveShadow = true;
const instanced = mesh as InstancedMesh;
if (instanced.isInstancedMesh) {
instanced.computeBoundingBox();
instanced.computeBoundingSphere();
}
});
instanceRepeatedStaticMeshes(clone);
const cells = createStaticVisibilityCells(clone);
cacheStaticMatrices(clone);
return { visual: clone, cells };
}
function VisualChunk({ url, definition }: { readonly url: string; readonly definition: DungeonDefinition }) {
const gltf = useGameGLTF(url);
const scene = useThree((state) => state.scene);
const { visual, cells } = useMemo(
() => prepareVisual(gltf.scene, definition),
[definition, gltf.scene],
);
useEffect(() => registerStaticVisibility(scene, cells), [scene, cells]);
useEffect(() => () => { disposeObject3DResources(visual); }, [visual]);
const { position, rotation, scale } = definition.transform;
return <primitive object={visual} position={position} rotation={rotation} scale={scale} />;
}
function AssetProbe({ url }: { readonly url: string }) {
useGameGLTF(url);
return null;
}
function CommitLoadedBatch({ onReady }: { readonly onReady: () => void }) {
useEffect(() => {
const frame = window.requestAnimationFrame(onReady);
return () => window.cancelAnimationFrame(frame);
}, [onReady]);
return null;
}
/**
* Suspends only the small batch currently being fetched. Previously prepared
* visuals and colliders stay mounted while the next batch enters the GLTF
* cache, avoiding one cold-start burst across the entire dungeon package.
*/
function AssetBatchLoader({
urls,
onReady,
}: {
readonly urls: readonly string[];
readonly onReady: () => void;
}) {
if (!urls.length) return null;
return (
<Suspense fallback={null}>
{urls.map((url) => <AssetProbe key={url} url={url} />)}
<CommitLoadedBatch onReady={onReady} />
</Suspense>
);
}
function CollisionChunk({
url,
definition,
registerPrepared,
}: {
readonly url: string;
readonly definition: DungeonDefinition;
readonly registerPrepared: (url: string, ready: boolean) => void;
}) {
const gltf = useGameGLTF(url);
const collision = useMemo(
() => prepareCollision(gltf.scene, definition.traversalRamps),
[definition.traversalRamps, gltf.scene],
);
const trimeshes = useMemo(() => collisionTrimeshes(collision), [collision]);
const sightBlockers = useMemo(() => collisionLineOfSightTrimeshes(collision), [collision]);
useEffect(() => () => {
disposeObject3DResources(collision, {
materials: false,
textures: false,
skeletons: false,
});
}, [collision]);
useEffect(() => {
registerPrepared(url, trimeshes.length > 0);
return () => registerPrepared(url, false);
}, [registerPrepared, trimeshes.length, url]);
const { position, rotation, scale } = definition.transform;
return (
<RigidBody
type="fixed"
colliders={false}
name={`${definition.id}-collision`}
position={position}
rotation={rotation}
scale={scale}
>
{trimeshes.map((trimesh) => (
<TrimeshCollider
key={trimesh.id}
args={[trimesh.vertices, trimesh.indices]}
collisionGroups={DUNGEON_PHYSICAL_COLLISION_GROUPS}
/>
))}
{sightBlockers.map((trimesh) => (
<TrimeshCollider
key={trimesh.id}
args={[trimesh.vertices, trimesh.indices]}
collisionGroups={DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS}
sensor
/>
))}
<primitive object={collision} />
</RigidBody>
);
}
export function ProductionDungeon({
definition,
assets,
onReady,
}: {
readonly definition: DungeonDefinition;
readonly assets: ResolvedDungeonAssets;
readonly onReady?: () => void;
}) {
if (!assets.visual.length || !assets.collision.length) {
throw new Error(`${definition.title} mounted without its required visual and collision GLBs.`);
}
const [loadedVisualCount, setLoadedVisualCount] = useState(0);
const [loadedCollisionCount, setLoadedCollisionCount] = useState(0);
const preparedCollisions = useRef(new Set<string>());
const signalledFirstFrame = useRef(false);
const pendingVisuals = assets.visual.slice(
loadedVisualCount,
advanceDungeonAssetCount(
loadedVisualCount,
assets.visual.length,
DUNGEON_VISUAL_LOAD_BATCH_SIZE,
),
);
const visualsLoaded = loadedVisualCount >= assets.visual.length;
const pendingCollisions = visualsLoaded
? assets.collision.slice(
loadedCollisionCount,
advanceDungeonAssetCount(
loadedCollisionCount,
assets.collision.length,
DUNGEON_COLLISION_LOAD_BATCH_SIZE,
),
)
: [];
const pendingUrls = pendingVisuals.length ? pendingVisuals : pendingCollisions;
const commitLoadedBatch = useCallback(() => {
if (!visualsLoaded) {
setLoadedVisualCount((current) => advanceDungeonAssetCount(
current,
assets.visual.length,
DUNGEON_VISUAL_LOAD_BATCH_SIZE,
));
return;
}
setLoadedCollisionCount((current) => advanceDungeonAssetCount(
current,
assets.collision.length,
DUNGEON_COLLISION_LOAD_BATCH_SIZE,
));
}, [assets.collision.length, assets.visual.length, visualsLoaded]);
useEffect(() => {
useGameStore.getState().setAssetStatus(
"loading",
`Preparing ${loadedVisualCount}/${assets.visual.length} visual and ${loadedCollisionCount}/${assets.collision.length} collision chunks.`,
);
}, [assets.collision.length, assets.visual.length, loadedCollisionCount, loadedVisualCount]);
const registerPrepared = useCallback((url: string, ready: boolean) => {
if (!ready) {
preparedCollisions.current.delete(url);
return;
}
preparedCollisions.current.add(url);
if (signalledFirstFrame.current) return;
if (preparedCollisions.current.size < assets.collision.length) return;
signalledFirstFrame.current = true;
onReady?.();
}, [assets.collision.length, onReady]);
return (
<group name={`${definition.id}-production`}>
{assets.visual.slice(0, loadedVisualCount).map((url) => (
<VisualChunk key={url} url={url} definition={definition} />
))}
{assets.collision.slice(0, loadedCollisionCount).map((url) => (
<CollisionChunk
key={url}
url={url}
definition={definition}
registerPrepared={registerPrepared}
/>
))}
<AssetBatchLoader
key={`${loadedVisualCount}:${loadedCollisionCount}:${pendingUrls.join("|")}`}
urls={pendingUrls}
onReady={commitLoadedBatch}
/>
</group>
);
}