forked from phenom/healer-man
178 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|