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

178 lines
5.8 KiB
TypeScript

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<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 = 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 <primitive object={visual} position={position} rotation={rotation} scale={scale} />;
}
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 (
<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]}
/>
))}
<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 preparedCollisions = useRef(new Set<string>());
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 (
<group name={`${definition.id}-production`}>
{assets.visual.map((url) => <VisualChunk key={url} url={url} definition={definition} />)}
{assets.collision.map((url) => (
<CollisionChunk
key={url}
url={url}
definition={definition}
registerPrepared={registerPrepared}
/>
))}
</group>
);
}