import type { Vec3 } from "../core/vector"; import type { ControlEffect } from "../controls/control-surface"; import { type InteractionLink } from "../interactions/interaction-links"; import { getInteractionLinkSequenceSteps, type SequenceStep } from "../sequencer/project-sequence-steps"; import type { RuntimeInteractable, RuntimeNpc, RuntimeSceneDefinition, RuntimeSceneExit, RuntimeTeleportTarget, RuntimeTriggerVolume } from "./runtime-scene-build"; const DEFAULT_INTERACTABLE_TARGET_RADIUS = 0.75; const DEFAULT_NPC_DIALOGUE_TARGET_RADIUS = 1.5; export interface RuntimeDialogueStartSource { kind: "interactionLink" | "npc" | "direct"; sourceEntityId: string | null; linkId: string | null; trigger: InteractionLink["trigger"] | null; } export interface RuntimeInteractionDispatcher { teleportPlayer(target: RuntimeTeleportTarget, link: InteractionLink): void; activateSceneExit(sceneExit: RuntimeSceneExit): void; toggleBrushVisibility( brushId: string, visible: boolean | undefined, link: InteractionLink ): void; playAnimation( instanceId: string, clipName: string, loop: boolean | undefined, link: InteractionLink ): void; stopAnimation(instanceId: string, link: InteractionLink): void; playSound(soundEmitterId: string, link: InteractionLink): void; stopSound(soundEmitterId: string, link: InteractionLink): void; startDialogue(dialogueId: string, source?: RuntimeDialogueStartSource): void; dispatchControlEffect?(effect: ControlEffect, link: InteractionLink): void; } export interface RuntimeInteractionPrompt { sourceEntityId: string; prompt: string; distance: number; range: number; } export interface RuntimePlayerTriggerProbe { feetPosition: Vec3; eyePosition: Vec3; } function subtractVec3(left: Vec3, right: Vec3): Vec3 { return { x: left.x - right.x, y: left.y - right.y, z: left.z - right.z }; } function scaleVec3(vector: Vec3, scalar: number): Vec3 { return { x: vector.x * scalar, y: vector.y * scalar, z: vector.z * scalar }; } function dotVec3(left: Vec3, right: Vec3): number { return left.x * right.x + left.y * right.y + left.z * right.z; } function lengthSquaredVec3(vector: Vec3): number { return dotVec3(vector, vector); } function distanceBetweenVec3(left: Vec3, right: Vec3): number { return Math.sqrt(lengthSquaredVec3(subtractVec3(left, right))); } function normalizeVec3(vector: Vec3): Vec3 | null { const lengthSquared = lengthSquaredVec3(vector); if (lengthSquared <= Number.EPSILON) { return null; } return scaleVec3(vector, 1 / Math.sqrt(lengthSquared)); } 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 isVec3(value: Vec3 | RuntimePlayerTriggerProbe): value is Vec3 { return "x" in value; } function rayAxisAlignedBoxHitDistance( origin: Vec3, direction: Vec3, bounds: { min: Vec3; max: Vec3 } ): number | null { let near = Number.NEGATIVE_INFINITY; let far = Number.POSITIVE_INFINITY; for (const axis of ["x", "y", "z"] as const) { const axisOrigin = origin[axis]; const axisDirection = direction[axis]; const axisMin = bounds.min[axis]; const axisMax = bounds.max[axis]; if (Math.abs(axisDirection) <= Number.EPSILON) { if (axisOrigin < axisMin || axisOrigin > axisMax) { return null; } continue; } const inverseDirection = 1 / axisDirection; let entry = (axisMin - axisOrigin) * inverseDirection; let exit = (axisMax - axisOrigin) * inverseDirection; if (entry > exit) { const swap = entry; entry = exit; exit = swap; } near = Math.max(near, entry); far = Math.min(far, exit); if (near > far) { return null; } } if (far < 0) { return null; } return near >= 0 ? near : 0; } function distanceToAxisAlignedBox( position: Vec3, bounds: { min: Vec3; max: Vec3 } ): number { const dx = position.x < bounds.min.x ? bounds.min.x - position.x : position.x > bounds.max.x ? position.x - bounds.max.x : 0; const dy = position.y < bounds.min.y ? bounds.min.y - position.y : position.y > bounds.max.y ? position.y - bounds.max.y : 0; const dz = position.z < bounds.min.z ? bounds.min.z - position.z : position.z > bounds.max.z ? position.z - bounds.max.z : 0; return Math.sqrt(dx * dx + dy * dy + dz * dz); } function isPlayerInsideTriggerVolume( feetPosition: Vec3, eyePosition: Vec3, triggerVolume: RuntimeTriggerVolume ): boolean { if ( isPointInsideTriggerVolume(feetPosition, triggerVolume) || isPointInsideTriggerVolume(eyePosition, triggerVolume) ) { return true; } const halfSize = { x: triggerVolume.size.x * 0.5, y: triggerVolume.size.y * 0.5, z: triggerVolume.size.z * 0.5 }; return ( rayAxisAlignedBoxHitDistance( feetPosition, subtractVec3(eyePosition, feetPosition), { min: { x: triggerVolume.position.x - halfSize.x, y: triggerVolume.position.y - halfSize.y, z: triggerVolume.position.z - halfSize.z }, max: { x: triggerVolume.position.x + halfSize.x, y: triggerVolume.position.y + halfSize.y, z: triggerVolume.position.z + halfSize.z } } ) !== null ); } function raySphereHitDistance( origin: Vec3, direction: Vec3, center: Vec3, radius: number ): number | null { const offset = subtractVec3(origin, center); const halfB = dotVec3(offset, direction); const c = dotVec3(offset, offset) - radius * radius; const discriminant = halfB * halfB - c; if (discriminant < 0) { return null; } const discriminantRoot = Math.sqrt(discriminant); const nearestHit = -halfB - discriminantRoot; if (nearestHit >= 0) { return nearestHit; } const farHit = -halfB + discriminantRoot; return farHit >= 0 ? 0 : null; } function resolveTeleportTarget( runtimeScene: RuntimeSceneDefinition, entityId: string ): RuntimeTeleportTarget | null { return ( runtimeScene.entities.teleportTargets.find( (teleportTarget) => teleportTarget.entityId === entityId ) ?? null ); } function hasTriggerLinks( runtimeScene: RuntimeSceneDefinition, sourceEntityId: string, trigger: InteractionLink["trigger"] ): boolean { return runtimeScene.interactionLinks.some( (link) => link.sourceEntityId === sourceEntityId && resolveEffectiveInteractionTrigger(runtimeScene, link) === trigger ); } function resolveEffectiveInteractionTrigger( runtimeScene: RuntimeSceneDefinition, link: InteractionLink ): InteractionLink["trigger"] { if ( runtimeScene.entities.interactables.some( (entity) => entity.entityId === link.sourceEntityId ) || runtimeScene.entities.sceneExits.some( (entity) => entity.entityId === link.sourceEntityId ) || runtimeScene.entities.npcs.some((entity) => entity.entityId === link.sourceEntityId) ) { return "click"; } if ( runtimeScene.entities.triggerVolumes.some( (entity) => entity.entityId === link.sourceEntityId ) ) { return link.trigger === "click" ? "enter" : link.trigger; } return link.trigger; } function getInteractableTargetRadius( interactable: RuntimeInteractable ): number { return Math.min(DEFAULT_INTERACTABLE_TARGET_RADIUS, interactable.radius); } function getNpcDialoguePrompt( npc: RuntimeNpc, hasClickLinks: boolean ): string { const trimmedName = npc.name?.trim() ?? ""; if (npc.dialogueId !== null) { return trimmedName.length > 0 ? `Talk to ${trimmedName}` : "Talk"; } return trimmedName.length > 0 ? `Interact with ${trimmedName}` : hasClickLinks ? "Interact" : "Talk"; } function getNpcDialogueTargetBounds(npc: RuntimeNpc): { min: Vec3; max: Vec3; center: Vec3; range: number; } { switch (npc.collider.mode) { case "capsule": { return { min: { x: npc.position.x - npc.collider.radius, y: npc.position.y, z: npc.position.z - npc.collider.radius }, max: { x: npc.position.x + npc.collider.radius, y: npc.position.y + npc.collider.height, z: npc.position.z + npc.collider.radius }, center: { x: npc.position.x, y: npc.position.y + npc.collider.height * 0.5, z: npc.position.z }, range: Math.max( DEFAULT_NPC_DIALOGUE_TARGET_RADIUS, npc.collider.height * 0.5 ) }; } case "box": { return { min: { x: npc.position.x - npc.collider.size.x * 0.5, y: npc.position.y, z: npc.position.z - npc.collider.size.z * 0.5 }, max: { x: npc.position.x + npc.collider.size.x * 0.5, y: npc.position.y + npc.collider.size.y, z: npc.position.z + npc.collider.size.z * 0.5 }, center: { x: npc.position.x, y: npc.position.y + npc.collider.size.y * 0.5, z: npc.position.z }, range: Math.max( DEFAULT_NPC_DIALOGUE_TARGET_RADIUS, Math.max( npc.collider.size.x, npc.collider.size.y, npc.collider.size.z ) * 0.5 ) }; } case "none": return { min: { x: npc.position.x - DEFAULT_NPC_DIALOGUE_TARGET_RADIUS * 0.5, y: npc.position.y, z: npc.position.z - DEFAULT_NPC_DIALOGUE_TARGET_RADIUS * 0.5 }, max: { x: npc.position.x + DEFAULT_NPC_DIALOGUE_TARGET_RADIUS * 0.5, y: npc.position.y + 1.8, z: npc.position.z + DEFAULT_NPC_DIALOGUE_TARGET_RADIUS * 0.5 }, center: { x: npc.position.x, y: npc.position.y + 0.9, z: npc.position.z }, range: DEFAULT_NPC_DIALOGUE_TARGET_RADIUS }; } } function updateBestPrompt( currentBestPrompt: RuntimeInteractionPrompt | null, currentBestHitDistance: number, candidateEntityId: string, candidatePrompt: string, candidateDistance: number, candidateRange: number, candidateHitDistance: number ): { prompt: RuntimeInteractionPrompt | null; hitDistance: number } { const nextPrompt: RuntimeInteractionPrompt = { sourceEntityId: candidateEntityId, prompt: candidatePrompt, distance: candidateDistance, range: candidateRange }; if ( candidateHitDistance < currentBestHitDistance || (candidateHitDistance === currentBestHitDistance && (currentBestPrompt === null || candidateDistance < currentBestPrompt.distance || (candidateDistance === currentBestPrompt.distance && candidateEntityId.localeCompare(currentBestPrompt.sourceEntityId) < 0))) ) { return { prompt: nextPrompt, hitDistance: candidateHitDistance }; } return { prompt: currentBestPrompt, hitDistance: currentBestHitDistance }; } export class RuntimeInteractionSystem { private readonly occupiedTriggerVolumes = new Set(); reset() { this.occupiedTriggerVolumes.clear(); } updatePlayerPosition( playerProbe: Vec3 | RuntimePlayerTriggerProbe, runtimeScene: RuntimeSceneDefinition, dispatcher: RuntimeInteractionDispatcher ) { const feetPosition = isVec3(playerProbe) ? playerProbe : playerProbe.feetPosition; const eyePosition = isVec3(playerProbe) ? playerProbe : playerProbe.eyePosition; for (const triggerVolume of runtimeScene.entities.triggerVolumes) { const containsPlayer = isPlayerInsideTriggerVolume( feetPosition, eyePosition, triggerVolume ); const wasOccupied = this.occupiedTriggerVolumes.has( triggerVolume.entityId ); if ( !wasOccupied && containsPlayer && hasTriggerLinks(runtimeScene, triggerVolume.entityId, "enter") ) { this.dispatchLinks( triggerVolume.entityId, "enter", runtimeScene, dispatcher ); } else if ( wasOccupied && !containsPlayer && hasTriggerLinks(runtimeScene, triggerVolume.entityId, "exit") ) { this.dispatchLinks( triggerVolume.entityId, "exit", runtimeScene, dispatcher ); } if (containsPlayer) { this.occupiedTriggerVolumes.add(triggerVolume.entityId); } else { this.occupiedTriggerVolumes.delete(triggerVolume.entityId); } } } resolveClickInteractionPrompt( interactionOrigin: Vec3, rayOrigin: Vec3, rayDirection: Vec3, runtimeScene: RuntimeSceneDefinition ): RuntimeInteractionPrompt | null { const normalizedViewDirection = normalizeVec3(rayDirection); if (normalizedViewDirection === null) { return null; } let bestPrompt: RuntimeInteractionPrompt | null = null; let bestHitDistance = Number.POSITIVE_INFINITY; for (const interactable of runtimeScene.entities.interactables) { if ( !interactable.interactionEnabled || !hasTriggerLinks(runtimeScene, interactable.entityId, "click") ) { continue; } const distance = distanceBetweenVec3( interactionOrigin, interactable.position ); if (distance > interactable.radius) { continue; } const hitDistance = raySphereHitDistance( rayOrigin, normalizedViewDirection, interactable.position, getInteractableTargetRadius(interactable) ); if (hitDistance === null) { continue; } const next = updateBestPrompt( bestPrompt, bestHitDistance, interactable.entityId, interactable.prompt, distance, interactable.radius, hitDistance ); bestPrompt = next.prompt; bestHitDistance = next.hitDistance; } for (const sceneExit of runtimeScene.entities.sceneExits) { if (!sceneExit.interactionEnabled) { continue; } const distance = distanceBetweenVec3( interactionOrigin, sceneExit.position ); if (distance > sceneExit.radius) { continue; } const hitDistance = raySphereHitDistance( rayOrigin, normalizedViewDirection, sceneExit.position, Math.min(DEFAULT_INTERACTABLE_TARGET_RADIUS, sceneExit.radius) ); if (hitDistance === null) { continue; } const next = updateBestPrompt( bestPrompt, bestHitDistance, sceneExit.entityId, sceneExit.prompt, distance, sceneExit.radius, hitDistance ); bestPrompt = next.prompt; bestHitDistance = next.hitDistance; } for (const npc of runtimeScene.entities.npcs) { if (!npc.visible) { continue; } const hasClickLinks = hasTriggerLinks(runtimeScene, npc.entityId, "click"); if (!hasClickLinks && npc.dialogueId === null) { continue; } const bounds = getNpcDialogueTargetBounds(npc); const distance = distanceToAxisAlignedBox(interactionOrigin, bounds); if (distance > bounds.range) { continue; } const hitDistance = rayAxisAlignedBoxHitDistance( rayOrigin, normalizedViewDirection, bounds ); if (hitDistance === null) { continue; } const next = updateBestPrompt( bestPrompt, bestHitDistance, npc.entityId, getNpcDialoguePrompt(npc, hasClickLinks), distance, bounds.range, hitDistance ); bestPrompt = next.prompt; bestHitDistance = next.hitDistance; } return bestPrompt; } dispatchClickInteraction( sourceEntityId: string, runtimeScene: RuntimeSceneDefinition, dispatcher: RuntimeInteractionDispatcher ) { const sceneExit = runtimeScene.entities.sceneExits.find( (candidate) => candidate.entityId === sourceEntityId ) ?? null; if (sceneExit !== null) { dispatcher.activateSceneExit(sceneExit); return; } const npc = runtimeScene.entities.npcs.find( (candidate) => candidate.entityId === sourceEntityId ) ?? null; if (npc !== null) { if (hasTriggerLinks(runtimeScene, npc.entityId, "click")) { this.dispatchLinks(sourceEntityId, "click", runtimeScene, dispatcher); return; } if (npc.dialogueId !== null) { dispatcher.startDialogue(npc.dialogueId, { kind: "npc", sourceEntityId: npc.entityId, linkId: null, trigger: "click" }); return; } } this.dispatchLinks(sourceEntityId, "click", runtimeScene, dispatcher); } private dispatchLinks( sourceEntityId: string, trigger: InteractionLink["trigger"], runtimeScene: RuntimeSceneDefinition, dispatcher: RuntimeInteractionDispatcher ) { for (const link of runtimeScene.interactionLinks) { if ( link.sourceEntityId !== sourceEntityId || resolveEffectiveInteractionTrigger(runtimeScene, link) !== trigger ) { continue; } for (const step of getInteractionLinkSequenceSteps(link)) { this.dispatchSequenceStep(step, link, runtimeScene, dispatcher); } } } private dispatchSequenceStep( step: SequenceStep, link: InteractionLink, runtimeScene: RuntimeSceneDefinition, dispatcher: RuntimeInteractionDispatcher ) { switch (step.type) { case "controlEffect": if (dispatcher.dispatchControlEffect !== undefined) { dispatcher.dispatchControlEffect(step.effect, link); return; } switch (step.effect.type) { case "playModelAnimation": dispatcher.playAnimation( step.effect.target.modelInstanceId, step.effect.clipName, step.effect.loop, link ); return; case "stopModelAnimation": dispatcher.stopAnimation(step.effect.target.modelInstanceId, link); return; case "playSound": dispatcher.playSound(step.effect.target.entityId, link); return; case "stopSound": dispatcher.stopSound(step.effect.target.entityId, link); return; default: throw new Error( `Runtime control step ${step.effect.type} could not be dispatched because the runtime dispatcher does not support control effects.` ); } case "teleportPlayer": { const teleportTarget = resolveTeleportTarget( runtimeScene, step.targetEntityId ); if (teleportTarget !== null) { dispatcher.teleportPlayer(teleportTarget, link); } return; } case "toggleVisibility": dispatcher.toggleBrushVisibility( step.targetBrushId, step.visible, link ); return; case "startDialogue": dispatcher.startDialogue(step.dialogueId, { kind: "interactionLink", sourceEntityId: link.sourceEntityId, linkId: link.id, trigger: link.trigger }); return; } } }