import { useCallback, useEffect, useMemo, useRef } from "react"; import { RigidBody, TrimeshCollider } from "@react-three/rapier"; import { AdditiveBlending, DoubleSide, Mesh, NormalBlending, type InstancedMesh, type Material, type Object3D, } from "three"; import type { ResolvedDungeonAssets } from "../game/dungeonAssets"; import type { DungeonDefinition, DungeonMaterialRoles } from "../game/dungeonTypes"; import { useGameStore } from "../game/store"; import { collisionTrimeshes, prepareCollision } from "./productionDungeonCollision"; import { disposeObject3DResources } from "./threeResourceDisposal"; import { useGameGLTF } from "./useGameGLTF"; function matchesRole(name: string, patterns: readonly string[], names: readonly string[]): boolean { const lower = name.toLowerCase(); return names.some((candidate) => candidate.toLowerCase() === lower) || patterns.some((pattern) => new RegExp(pattern, "i").test(name)); } function prepareMaterial(source: Material, roles: DungeonMaterialRoles): Material { const material = source.clone(); const name = material.name; material.side = DoubleSide; if (matchesRole(name, roles.cutoutPatterns, roles.cutoutNames)) { material.transparent = false; material.opacity = 1; material.alphaTest = 0.5; material.depthWrite = true; material.blending = NormalBlending; } else if (matchesRole(name, roles.additivePatterns, roles.additiveNames)) { material.transparent = true; material.depthWrite = false; material.blending = AdditiveBlending; } else if (matchesRole(name, roles.blendPatterns, roles.blendNames)) { material.transparent = true; material.depthWrite = false; material.blending = NormalBlending; } else { material.transparent = false; material.opacity = 1; material.alphaTest = 0; material.depthWrite = true; material.blending = NormalBlending; } material.needsUpdate = true; return material; } function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D { 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 = prepareMaterial(source, roles); 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(); } }); return clone; } function VisualChunk({ url, definition }: { readonly url: string; readonly definition: DungeonDefinition }) { const gltf = useGameGLTF(url); const visual = useMemo( () => prepareVisual(gltf.scene, definition.presentation.materials), [definition.presentation.materials, gltf.scene], ); useEffect(() => () => { disposeObject3DResources(visual); }, [visual]); const { position, rotation, scale } = definition.transform; return ; } 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), [gltf.scene]); const trimeshes = useMemo(() => collisionTrimeshes(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) => ( ))} ); } 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 preparedCollisions = useRef(new Set()); const signalledFirstFrame = useRef(false); 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; useGameStore.getState().setAssetStatus("ready"); onReady?.(); }, [assets.collision.length, onReady]); return ( {assets.visual.map((url) => )} {assets.collision.map((url) => ( ))} ); }