forked from phenom/healer-man
255 lines
8.4 KiB
TypeScript
255 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), [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>
|
|
);
|
|
}
|