Files
healer-man/src/game/dungeonWorldObjects.ts
T
2026-09-09 11:21:33 -04:00

180 lines
6.2 KiB
TypeScript

import type { MobVector3 } from "./mobPopulation";
export type DungeonWorldObjectKind =
| "door"
| "gate"
| "bridge"
| "switch"
| "key"
| "chest"
| "resource"
| "generic"
| "chair"
| "spell-focus"
| "text"
| "trap"
| "transport"
| "camera"
| "ritual"
| "mailbox"
| "fishing-hole"
| "aura-generator"
| "spellcaster"
| "destructible"
| "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 gameObjectType?: number;
readonly clientDisplayId?: number;
readonly clientModelPath?: string;
/** Unscaled client x/y/z display bounds retained for asset/collision review. */
readonly clientBounds?: {
readonly min: MobVector3;
readonly max: MobVector3;
};
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;
/** Source-adapted records stay hidden until their client display is reviewed. */
readonly hidden?: boolean;
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<string>();
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.`);
}
if (object.visual?.assetUrl && object.visual.hidden) {
throw new Error(`Dungeon world object ${object.id} cannot hide an authored visual asset.`);
}
if (object.source?.gameObjectType !== undefined && (
!Number.isInteger(object.source.gameObjectType)
|| object.source.gameObjectType < 0
|| object.source.gameObjectType > 35
)) {
throw new Error(`Dungeon world object ${object.id} has an invalid source gameobject type.`);
}
const clientBounds = object.source?.clientBounds;
if (clientBounds && (
!clientBounds.min.every(Number.isFinite)
|| !clientBounds.max.every(Number.isFinite)
|| clientBounds.min.some((value, axis) => value >= clientBounds.max[axis])
)) {
throw new Error(`Dungeon world object ${object.id} has invalid client display bounds.`);
}
}
export function nearestDungeonWorldInteraction(
objects: readonly DungeonWorldObjectDefinition[],
states: Readonly<Record<string, string>>,
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;
}