import type { MobVector3 } from "./mobPopulation"; export type DungeonWorldObjectKind = | "door" | "gate" | "bridge" | "switch" | "key" | "chest" | "npc" | "quest-object" | "trigger"; export type DungeonWorldObjectFidelity = | "source-exact" | "source-backed-adapted" | "healer-man-authored"; export interface DungeonWorldObjectSource { readonly fidelity: DungeonWorldObjectFidelity; readonly files: readonly string[]; readonly templateId?: number; readonly spawnId?: number; readonly note?: string; } export interface DungeonWorldObjectStateDefinition { readonly id: string; readonly label: string; readonly visible: boolean; readonly blocksMovement: boolean; readonly blocksSight?: boolean; readonly interactionEnabled?: boolean; } export interface DungeonWorldObjectInteraction { readonly prompt: string; readonly range: number; readonly signalId: string; readonly allowedStates?: readonly string[]; readonly oneShot?: boolean; readonly instructions?: string; } export interface DungeonWorldObjectVisual { /** Optional independently packaged GLB. Procedural geometry remains the safe fallback. */ readonly assetUrl?: string; readonly color?: string; readonly emissive?: string; readonly scale?: MobVector3; } export interface DungeonWorldObjectDefinition { readonly id: string; readonly name: string; readonly kind: DungeonWorldObjectKind; readonly position: MobVector3; readonly yaw?: number; /** Half-extents of the interaction/collision box in metres. */ readonly halfExtents: MobVector3; readonly initialState: string; readonly states: readonly DungeonWorldObjectStateDefinition[]; readonly interaction?: DungeonWorldObjectInteraction; readonly visual?: DungeonWorldObjectVisual; readonly source?: DungeonWorldObjectSource; } export interface DungeonWorldInteractionCandidate { readonly object: DungeonWorldObjectDefinition; readonly state: DungeonWorldObjectStateDefinition; readonly distance: number; } function finiteDistance(left: MobVector3, right: MobVector3): number { if (!left.every(Number.isFinite) || !right.every(Number.isFinite)) return Number.POSITIVE_INFINITY; return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]); } export function dungeonWorldObjectState( object: DungeonWorldObjectDefinition, stateId: string | undefined, ): DungeonWorldObjectStateDefinition { const requested = stateId ?? object.initialState; return object.states.find((state) => state.id === requested) ?? object.states.find((state) => state.id === object.initialState) ?? { id: object.initialState, label: object.initialState, visible: true, blocksMovement: false, interactionEnabled: false, }; } export function validateDungeonWorldObject(object: DungeonWorldObjectDefinition): void { if (!object.id.trim()) throw new Error("Dungeon world objects require a stable id."); if (!object.name.trim()) throw new Error(`Dungeon world object ${object.id} requires a name.`); if (!object.position.every(Number.isFinite) || !object.halfExtents.every((value) => Number.isFinite(value) && value > 0)) { throw new Error(`Dungeon world object ${object.id} has invalid geometry.`); } const stateIds = new Set(); for (const state of object.states) { if (!state.id.trim() || stateIds.has(state.id)) { throw new Error(`Dungeon world object ${object.id} has an invalid or duplicate state id.`); } stateIds.add(state.id); } if (!stateIds.has(object.initialState)) { throw new Error(`Dungeon world object ${object.id} does not define initial state ${object.initialState}.`); } if (object.interaction && (!object.interaction.signalId.trim() || object.interaction.range <= 0)) { throw new Error(`Dungeon world object ${object.id} has an invalid interaction.`); } } export function nearestDungeonWorldInteraction( objects: readonly DungeonWorldObjectDefinition[], states: Readonly>, origin: MobVector3, hasLineOfSight: (from: MobVector3, to: MobVector3) => boolean = () => true, ): DungeonWorldInteractionCandidate | null { let nearest: DungeonWorldInteractionCandidate | null = null; for (const object of objects) { const interaction = object.interaction; if (!interaction) continue; const state = dungeonWorldObjectState(object, states[object.id]); if (!state.visible) continue; if (state.interactionEnabled === false) continue; if (interaction.allowedStates?.length && !interaction.allowedStates.includes(state.id)) continue; const distance = finiteDistance(origin, object.position); if (distance > interaction.range || !hasLineOfSight(origin, object.position)) continue; if (!nearest || distance < nearest.distance || (distance === nearest.distance && object.id < nearest.object.id)) { nearest = { object, state, distance }; } } return nearest; }