Files
healer-man/src/scene/DungeonWorldObjects.tsx
T
2026-09-09 11:21:33 -04:00

101 lines
3.3 KiB
TypeScript

import { CuboidCollider, RigidBody } from "@react-three/rapier";
import { useMemo, useSyncExternalStore } from "react";
import type { Object3D } from "three";
import {
activeDungeonEncounterRevision,
subscribeDungeonEncounterSnapshots,
} from "../game/dungeonEncounterRuntime";
import { activeDungeonWorldObjectStates } from "../game/dungeonWorldInteraction";
import {
dungeonWorldObjectState,
type DungeonWorldObjectDefinition,
} from "../game/dungeonWorldObjects";
import {
DUNGEON_PHYSICAL_COLLISION_GROUPS,
DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS,
} from "./dungeonLineOfSight";
import { useGameGLTF } from "./useGameGLTF";
function AssetObject({ definition }: { readonly definition: DungeonWorldObjectDefinition }) {
const gltf = useGameGLTF(definition.visual!.assetUrl!);
const visual = useMemo<Object3D>(() => gltf.scene.clone(true), [gltf.scene]);
return <primitive object={visual} scale={definition.visual?.scale ?? [1, 1, 1]} />;
}
function ProceduralObject({ definition }: { readonly definition: DungeonWorldObjectDefinition }) {
const [x, y, z] = definition.halfExtents;
const round = definition.kind === "trap" || definition.kind === "ritual" || definition.kind === "trigger";
return (
<mesh castShadow receiveShadow>
{round
? <cylinderGeometry args={[Math.max(x, z), Math.max(x, z), y * 2, 16]} />
: <boxGeometry args={[x * 2, y * 2, z * 2]} />}
<meshStandardMaterial
color={definition.visual?.color ?? (definition.kind === "switch" ? "#dca24a" : "#65584b")}
emissive={definition.visual?.emissive ?? "#000000"}
roughness={0.72}
/>
</mesh>
);
}
function WorldObject({
definition,
stateId,
}: {
readonly definition: DungeonWorldObjectDefinition;
readonly stateId?: string;
}) {
const state = dungeonWorldObjectState(definition, stateId);
if (!state.visible) return null;
const showVisual = definition.visual?.hidden !== true;
if (!showVisual && !state.blocksMovement && !state.blocksSight) return null;
return (
<RigidBody
key={`${definition.id}:${state.id}`}
type="fixed"
colliders={false}
position={definition.position}
rotation={[0, definition.yaw ?? 0, 0]}
name={`dungeon-object:${definition.id}:${state.id}`}
>
{state.blocksMovement && (
<CuboidCollider
args={[...definition.halfExtents]}
collisionGroups={DUNGEON_PHYSICAL_COLLISION_GROUPS}
/>
)}
{state.blocksSight && (
<CuboidCollider
args={[...definition.halfExtents]}
collisionGroups={DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS}
sensor
/>
)}
{showVisual && (definition.visual?.assetUrl
? <AssetObject definition={definition} />
: <ProceduralObject definition={definition} />)}
</RigidBody>
);
}
export function DungeonWorldObjects({
definitions,
}: {
readonly definitions: readonly DungeonWorldObjectDefinition[];
}) {
useSyncExternalStore(
subscribeDungeonEncounterSnapshots,
activeDungeonEncounterRevision,
activeDungeonEncounterRevision,
);
const states = activeDungeonWorldObjectStates();
return (
<group name="dungeon-world-objects">
{definitions.map((definition) => (
<WorldObject key={definition.id} definition={definition} stateId={states[definition.id]} />
))}
</group>
);
}