diff --git a/src/runtime-three/runtime-interaction-system.ts b/src/runtime-three/runtime-interaction-system.ts new file mode 100644 index 00000000..c3cf4bb6 --- /dev/null +++ b/src/runtime-three/runtime-interaction-system.ts @@ -0,0 +1,84 @@ +import type { Vec3 } from "../core/vector"; +import type { InteractionLink } from "../interactions/interaction-links"; + +import type { RuntimeSceneDefinition, RuntimeTeleportTarget, RuntimeTriggerVolume } from "./runtime-scene-build"; + +export interface RuntimeInteractionDispatcher { + teleportPlayer(target: RuntimeTeleportTarget, link: InteractionLink): void; + toggleBrushVisibility(brushId: string, visible: boolean | undefined, link: InteractionLink): void; +} + +function isPointInsideTriggerVolume(position: Vec3, triggerVolume: RuntimeTriggerVolume): boolean { + const halfSize = { + x: triggerVolume.size.x * 0.5, + y: triggerVolume.size.y * 0.5, + z: triggerVolume.size.z * 0.5 + }; + + return ( + position.x >= triggerVolume.position.x - halfSize.x && + position.x <= triggerVolume.position.x + halfSize.x && + position.y >= triggerVolume.position.y - halfSize.y && + position.y <= triggerVolume.position.y + halfSize.y && + position.z >= triggerVolume.position.z - halfSize.z && + position.z <= triggerVolume.position.z + halfSize.z + ); +} + +function resolveTeleportTarget(runtimeScene: RuntimeSceneDefinition, entityId: string): RuntimeTeleportTarget | null { + return runtimeScene.entities.teleportTargets.find((teleportTarget) => teleportTarget.entityId === entityId) ?? null; +} + +export class RuntimeInteractionSystem { + private readonly occupiedTriggerVolumes = new Set(); + + reset() { + this.occupiedTriggerVolumes.clear(); + } + + updatePlayerPosition(feetPosition: Vec3, runtimeScene: RuntimeSceneDefinition, dispatcher: RuntimeInteractionDispatcher) { + for (const triggerVolume of runtimeScene.entities.triggerVolumes) { + const containsPlayer = isPointInsideTriggerVolume(feetPosition, triggerVolume); + const wasOccupied = this.occupiedTriggerVolumes.has(triggerVolume.entityId); + + if (!wasOccupied && containsPlayer && triggerVolume.triggerOnEnter) { + this.dispatchLinks(triggerVolume.entityId, "enter", runtimeScene, dispatcher); + } else if (wasOccupied && !containsPlayer && triggerVolume.triggerOnExit) { + this.dispatchLinks(triggerVolume.entityId, "exit", runtimeScene, dispatcher); + } + + if (containsPlayer) { + this.occupiedTriggerVolumes.add(triggerVolume.entityId); + } else { + this.occupiedTriggerVolumes.delete(triggerVolume.entityId); + } + } + } + + private dispatchLinks( + sourceEntityId: string, + trigger: InteractionLink["trigger"], + runtimeScene: RuntimeSceneDefinition, + dispatcher: RuntimeInteractionDispatcher + ) { + for (const link of runtimeScene.interactionLinks) { + if (link.sourceEntityId !== sourceEntityId || link.trigger !== trigger) { + continue; + } + + switch (link.action.type) { + case "teleportPlayer": { + const teleportTarget = resolveTeleportTarget(runtimeScene, link.action.targetEntityId); + + if (teleportTarget !== null) { + dispatcher.teleportPlayer(teleportTarget, link); + } + break; + } + case "toggleVisibility": + dispatcher.toggleBrushVisibility(link.action.targetBrushId, link.action.visible, link); + break; + } + } + } +} diff --git a/src/runtime-three/runtime-scene-build.ts b/src/runtime-three/runtime-scene-build.ts index 90d921c4..c23adc21 100644 --- a/src/runtime-three/runtime-scene-build.ts +++ b/src/runtime-three/runtime-scene-build.ts @@ -4,6 +4,7 @@ import type { SceneDocument, WorldSettings } from "../document/scene-document"; import { cloneWorldSettings } from "../document/world-settings"; import { getEntityInstances, getPrimaryPlayerStartEntity, type EntityInstance } from "../entities/entity-instances"; import { getBoxBrushBounds } from "../geometry/box-brush"; +import { cloneInteractionLink, getInteractionLinks, type InteractionLink } from "../interactions/interaction-links"; import { cloneMaterialDef, type MaterialDef } from "../materials/starter-material-library"; import { cloneFaceUvState } from "../document/brushes"; import { assertRuntimeSceneBuildable } from "./runtime-scene-validation"; @@ -96,6 +97,7 @@ export interface RuntimeSceneDefinition { colliders: RuntimeBoxCollider[]; sceneBounds: RuntimeSceneBounds | null; entities: RuntimeEntityCollection; + interactionLinks: InteractionLink[]; playerStart: RuntimePlayerStart | null; spawn: RuntimeSpawnPoint; } @@ -309,6 +311,7 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options: const colliders = Object.values(document.brushes).map((brush) => buildRuntimeCollider(brush)); const sceneBounds = combineColliderBounds(colliders); const entities = buildRuntimeEntityCollection(document); + const interactionLinks = getInteractionLinks(document.interactionLinks).map((link) => cloneInteractionLink(link)); const playerStartEntity = getPrimaryPlayerStartEntity(document.entities); const playerStart = playerStartEntity === null @@ -325,6 +328,7 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options: colliders, sceneBounds, entities, + interactionLinks, playerStart, spawn: playerStart === null