From d3978c40677bd946c7ac962cb5ba7f2455465ff6 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 31 Mar 2026 05:49:27 +0200 Subject: [PATCH] Add entity-instances.ts for managing game entities --- src/entities/entity-instances.ts | 361 +++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 src/entities/entity-instances.ts diff --git a/src/entities/entity-instances.ts b/src/entities/entity-instances.ts new file mode 100644 index 00000000..3e5f1e86 --- /dev/null +++ b/src/entities/entity-instances.ts @@ -0,0 +1,361 @@ +import { createOpaqueId } from "../core/ids"; +import type { Vec3 } from "../core/vector"; + +export interface PlayerStartEntity { + id: string; + kind: "playerStart"; + position: Vec3; + yawDegrees: number; +} + +export interface SoundEmitterEntity { + id: string; + kind: "soundEmitter"; + position: Vec3; + radius: number; + gain: number; + autoplay: boolean; + loop: boolean; +} + +export interface TriggerVolumeEntity { + id: string; + kind: "triggerVolume"; + position: Vec3; + size: Vec3; + triggerOnEnter: boolean; + triggerOnExit: boolean; +} + +export interface TeleportTargetEntity { + id: string; + kind: "teleportTarget"; + position: Vec3; + yawDegrees: number; +} + +export interface InteractableEntity { + id: string; + kind: "interactable"; + position: Vec3; + radius: number; + prompt: string; + enabled: boolean; +} + +export type EntityInstance = + | PlayerStartEntity + | SoundEmitterEntity + | TriggerVolumeEntity + | TeleportTargetEntity + | InteractableEntity; + +export type EntityKind = EntityInstance["kind"]; + +export interface EntityRegistryEntry { + kind: T["kind"]; + label: string; + description: string; + createDefaultEntity(overrides?: Partial): T; +} + +export const ENTITY_KIND_ORDER = ["playerStart", "soundEmitter", "triggerVolume", "teleportTarget", "interactable"] as const; + +export const DEFAULT_ENTITY_POSITION: Vec3 = { + x: 0, + y: 0, + z: 0 +}; + +export const DEFAULT_PLAYER_START_POSITION = DEFAULT_ENTITY_POSITION; +export const DEFAULT_PLAYER_START_YAW_DEGREES = 0; +export const DEFAULT_SOUND_EMITTER_RADIUS = 6; +export const DEFAULT_SOUND_EMITTER_GAIN = 1; +export const DEFAULT_TRIGGER_VOLUME_SIZE: Vec3 = { + x: 2, + y: 2, + z: 2 +}; +export const DEFAULT_TELEPORT_TARGET_YAW_DEGREES = 0; +export const DEFAULT_INTERACTABLE_RADIUS = 1.5; +export const DEFAULT_INTERACTABLE_PROMPT = "Use"; + +function cloneVec3(vector: Vec3): Vec3 { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} + +function assertFiniteVec3(vector: Vec3, label: string) { + if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) { + throw new Error(`${label} must be finite on every axis.`); + } +} + +function assertPositiveFiniteNumber(value: number, label: string) { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${label} must be a finite number greater than zero.`); + } +} + +function assertPositiveFiniteVec3(vector: Vec3, label: string) { + assertFiniteVec3(vector, label); + + if (vector.x <= 0 || vector.y <= 0 || vector.z <= 0) { + throw new Error(`${label} must remain positive on every axis.`); + } +} + +function assertNonNegativeFiniteNumber(value: number, label: string) { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${label} must be a finite number greater than or equal to zero.`); + } +} + +function assertBoolean(value: boolean, label: string) { + if (typeof value !== "boolean") { + throw new Error(`${label} must be a boolean.`); + } +} + +export function normalizeYawDegrees(yawDegrees: number): number { + const normalizedYaw = yawDegrees % 360; + return normalizedYaw < 0 ? normalizedYaw + 360 : normalizedYaw; +} + +export function normalizeInteractablePrompt(prompt: string): string { + const normalizedPrompt = prompt.trim(); + + if (normalizedPrompt.length === 0) { + throw new Error("Interactable prompt must be non-empty."); + } + + return normalizedPrompt; +} + +export function createPlayerStartEntity( + overrides: Partial> = {} +): PlayerStartEntity { + const position = cloneVec3(overrides.position ?? DEFAULT_PLAYER_START_POSITION); + const yawDegrees = overrides.yawDegrees ?? DEFAULT_PLAYER_START_YAW_DEGREES; + + assertFiniteVec3(position, "Player Start position"); + + if (!Number.isFinite(yawDegrees)) { + throw new Error("Player Start yaw must be a finite number."); + } + + return { + id: overrides.id ?? createOpaqueId("entity-player-start"), + kind: "playerStart", + position, + yawDegrees: normalizeYawDegrees(yawDegrees) + }; +} + +export function createSoundEmitterEntity( + overrides: Partial> = {} +): SoundEmitterEntity { + const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); + const radius = overrides.radius ?? DEFAULT_SOUND_EMITTER_RADIUS; + const gain = overrides.gain ?? DEFAULT_SOUND_EMITTER_GAIN; + const autoplay = overrides.autoplay ?? false; + const loop = overrides.loop ?? false; + + assertFiniteVec3(position, "Sound Emitter position"); + assertPositiveFiniteNumber(radius, "Sound Emitter radius"); + assertNonNegativeFiniteNumber(gain, "Sound Emitter gain"); + assertBoolean(autoplay, "Sound Emitter autoplay"); + assertBoolean(loop, "Sound Emitter loop"); + + return { + id: overrides.id ?? createOpaqueId("entity-sound-emitter"), + kind: "soundEmitter", + position, + radius, + gain, + autoplay, + loop + }; +} + +export function createTriggerVolumeEntity( + overrides: Partial> = {} +): TriggerVolumeEntity { + const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); + const size = cloneVec3(overrides.size ?? DEFAULT_TRIGGER_VOLUME_SIZE); + const triggerOnEnter = overrides.triggerOnEnter ?? true; + const triggerOnExit = overrides.triggerOnExit ?? false; + + assertFiniteVec3(position, "Trigger Volume position"); + assertPositiveFiniteVec3(size, "Trigger Volume size"); + assertBoolean(triggerOnEnter, "Trigger Volume triggerOnEnter"); + assertBoolean(triggerOnExit, "Trigger Volume triggerOnExit"); + + return { + id: overrides.id ?? createOpaqueId("entity-trigger-volume"), + kind: "triggerVolume", + position, + size, + triggerOnEnter, + triggerOnExit + }; +} + +export function createTeleportTargetEntity( + overrides: Partial> = {} +): TeleportTargetEntity { + const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); + const yawDegrees = overrides.yawDegrees ?? DEFAULT_TELEPORT_TARGET_YAW_DEGREES; + + assertFiniteVec3(position, "Teleport Target position"); + + if (!Number.isFinite(yawDegrees)) { + throw new Error("Teleport Target yaw must be a finite number."); + } + + return { + id: overrides.id ?? createOpaqueId("entity-teleport-target"), + kind: "teleportTarget", + position, + yawDegrees: normalizeYawDegrees(yawDegrees) + }; +} + +export function createInteractableEntity( + overrides: Partial> = {} +): InteractableEntity { + const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); + const radius = overrides.radius ?? DEFAULT_INTERACTABLE_RADIUS; + const prompt = normalizeInteractablePrompt(overrides.prompt ?? DEFAULT_INTERACTABLE_PROMPT); + const enabled = overrides.enabled ?? true; + + assertFiniteVec3(position, "Interactable position"); + assertPositiveFiniteNumber(radius, "Interactable radius"); + assertBoolean(enabled, "Interactable enabled"); + + return { + id: overrides.id ?? createOpaqueId("entity-interactable"), + kind: "interactable", + position, + radius, + prompt, + enabled + }; +} + +export const ENTITY_REGISTRY: { [K in EntityKind]: EntityRegistryEntry> } = { + playerStart: { + kind: "playerStart", + label: "Player Start", + description: "Primary authored spawn point for first-person runtime navigation.", + createDefaultEntity: createPlayerStartEntity + }, + soundEmitter: { + kind: "soundEmitter", + label: "Sound Emitter", + description: "Authored positional audio source placeholder for the later spatial-audio slice.", + createDefaultEntity: createSoundEmitterEntity + }, + triggerVolume: { + kind: "triggerVolume", + label: "Trigger Volume", + description: "Axis-aligned authored trigger volume for enter and exit events.", + createDefaultEntity: createTriggerVolumeEntity + }, + teleportTarget: { + kind: "teleportTarget", + label: "Teleport Target", + description: "Explicit authored teleport destination with a facing direction.", + createDefaultEntity: createTeleportTargetEntity + }, + interactable: { + kind: "interactable", + label: "Interactable", + description: "Explicit authored interaction point for later click and use behavior.", + createDefaultEntity: createInteractableEntity + } +}; + +export function isEntityKind(value: unknown): value is EntityKind { + return typeof value === "string" && Object.hasOwn(ENTITY_REGISTRY, value); +} + +export function getEntityRegistryEntry(kind: K): EntityRegistryEntry> { + return ENTITY_REGISTRY[kind]; +} + +export function createDefaultEntityInstance(kind: "playerStart", overrides?: Partial): PlayerStartEntity; +export function createDefaultEntityInstance(kind: "soundEmitter", overrides?: Partial): SoundEmitterEntity; +export function createDefaultEntityInstance(kind: "triggerVolume", overrides?: Partial): TriggerVolumeEntity; +export function createDefaultEntityInstance(kind: "teleportTarget", overrides?: Partial): TeleportTargetEntity; +export function createDefaultEntityInstance(kind: "interactable", overrides?: Partial): InteractableEntity; +export function createDefaultEntityInstance(kind: EntityKind, overrides: Partial = {}): EntityInstance { + switch (kind) { + case "playerStart": + return createPlayerStartEntity(overrides); + case "soundEmitter": + return createSoundEmitterEntity(overrides); + case "triggerVolume": + return createTriggerVolumeEntity(overrides); + case "teleportTarget": + return createTeleportTargetEntity(overrides); + case "interactable": + return createInteractableEntity(overrides); + } +} + +export function cloneEntityInstance(entity: EntityInstance): EntityInstance { + switch (entity.kind) { + case "playerStart": + return createPlayerStartEntity(entity); + case "soundEmitter": + return createSoundEmitterEntity(entity); + case "triggerVolume": + return createTriggerVolumeEntity(entity); + case "teleportTarget": + return createTeleportTargetEntity(entity); + case "interactable": + return createInteractableEntity(entity); + } +} + +export function cloneEntityRegistry(entities: Record): Record { + return Object.fromEntries(Object.entries(entities).map(([entityId, entity]) => [entityId, cloneEntityInstance(entity)])); +} + +export function compareEntityInstances(left: EntityInstance, right: EntityInstance): number { + const leftOrder = ENTITY_KIND_ORDER.indexOf(left.kind); + const rightOrder = ENTITY_KIND_ORDER.indexOf(right.kind); + + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + + return left.id.localeCompare(right.id); +} + +export function getEntityInstances(entities: Record): EntityInstance[] { + return Object.values(entities).sort(compareEntityInstances); +} + +export function getEntitiesOfKind( + entities: Record, + kind: K +): Extract[] { + return getEntityInstances(entities).filter((entity): entity is Extract => entity.kind === kind); +} + +export function getPlayerStartEntities(entities: Record): PlayerStartEntity[] { + return getEntitiesOfKind(entities, "playerStart"); +} + +export function getPrimaryPlayerStartEntity(entities: Record): PlayerStartEntity | null { + return getPlayerStartEntities(entities)[0] ?? null; +} + +export function getEntityKindLabel(kind: EntityKind): string { + return getEntityRegistryEntry(kind).label; +}