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(); 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 ; } 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 ( {urls.map((url) => )} ); } 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 ( {trimeshes.map((trimesh) => ( ))} {sightBlockers.map((trimesh) => ( ))} ); } 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()); 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 ( {assets.visual.slice(0, loadedVisualCount).map((url) => ( ))} {assets.collision.slice(0, loadedCollisionCount).map((url) => ( ))} ); }