180 lines
6.2 KiB
TypeScript
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;
|
|
}
|