import type { Vec3 } from "../core/vector"; import type { ControlEffect } from "../controls/control-surface"; import { type InteractionLink } from "../interactions/interaction-links"; import { getInteractionLinkImpulseSteps, type ImpulseSequenceStep, type SequenceVisibilityMode, type SequenceVisibilityTarget } from "../sequencer/project-sequence-steps"; import type { RuntimeInteractable, RuntimeNpc, RuntimeSceneDefinition, RuntimeTeleportTarget, RuntimeTriggerVolume } from "./runtime-scene-build"; const DEFAULT_INTERACTABLE_TARGET_RADIUS = 0.75; const DEFAULT_NPC_DIALOGUE_TARGET_RADIUS = 1.5; const DEFAULT_INTERACTION_PROMPT_NEAR_FIELD_RADIUS = 0.2; const TARGETING_ACQUISITION_REACH = 15; const TARGETING_MIN_VIEW_DOT = 0.1; 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; startSceneTransition( request: { sourceEntityId: string | null; targetSceneId: string; targetEntryEntityId: string; }, link: InteractionLink | null ): void; toggleBrushVisibility( brushId: string, visible: boolean | undefined, link: InteractionLink ): void; setVisibility?( target: SequenceVisibilityTarget, mode: SequenceVisibilityMode, 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; startNpcDialogue?( npcEntityId: string, dialogueId: string | null, source?: RuntimeDialogueStartSource ): void; dispatchControlEffect?(effect: ControlEffect, link: InteractionLink): void; } export interface RuntimeInteractionPrompt { sourceEntityId: string; prompt: string; distance: number; range: number; } export type RuntimeTargetCandidateKind = "npc" | "interactable"; export interface RuntimeTargetReference { kind: RuntimeTargetCandidateKind; entityId: string; } export interface RuntimeTargetCandidate extends RuntimeTargetReference { prompt: string; position: Vec3; center: Vec3; distance: number; range: number; viewDot: number; score: number; } export interface RuntimeResolvedTarget extends RuntimeTargetReference { prompt: string; position: Vec3; center: Vec3; 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 clampUnitInterval(value: number): number { return Math.max(0, Math.min(1, value)); } function distanceBetweenVec3(left: Vec3, right: Vec3): number { return Math.sqrt(lengthSquaredVec3(subtractVec3(left, right))); } function distanceBetweenVec2( left: { x: number; y: number }, right: { x: number; y: number } ): number { const dx = left.x - right.x; const dy = left.y - right.y; return Math.sqrt(dx * dx + dy * dy); } 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 distanceToHorizontalAxisAlignedBounds( 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 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 + 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 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.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() ?? ""; const hasNpcDialogue = npc.defaultDialogueId !== null || npc.dialogues.length > 0; if (!hasClickLinks) { return trimmedName.length > 0 ? `Talk to ${trimmedName}` : "Talk"; } if (hasNpcDialogue) { 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 }; } } interface RuntimeInteractionTargetSource { kind: RuntimeTargetCandidateKind; entityId: string; prompt: string; position: Vec3; center: Vec3; distance: number; range: number; acquisitionRange: number; horizontalRadius: number; bounds?: { min: Vec3; max: Vec3 }; } interface Vec2 { x: number; y: number; } function lengthSquaredVec2(vector: Vec2): number { return vector.x * vector.x + vector.y * vector.y; } function normalizeVec2(vector: Vec2): Vec2 | null { const lengthSquared = lengthSquaredVec2(vector); if (lengthSquared <= Number.EPSILON) { return null; } const inverseLength = 1 / Math.sqrt(lengthSquared); return { x: vector.x * inverseLength, y: vector.y * inverseLength }; } function getNpcHorizontalTargetRadius( npc: RuntimeNpc, bounds: { min: Vec3; max: Vec3 } ): number { switch (npc.collider.mode) { case "capsule": return npc.collider.radius; case "box": return Math.max(npc.collider.size.x, npc.collider.size.z) * 0.5; case "none": return Math.max( bounds.max.x - bounds.min.x, bounds.max.z - bounds.min.z ) * 0.5; } } function collectRuntimeInteractionTargetSources( interactionOrigin: Vec3, runtimeScene: RuntimeSceneDefinition, options: { useTargetingReach?: boolean; interactionReachMeters?: number; } = {} ): RuntimeInteractionTargetSource[] { const candidates: RuntimeInteractionTargetSource[] = []; for (const interactable of runtimeScene.entities.interactables) { if ( !interactable.interactionEnabled || !hasTriggerLinks(runtimeScene, interactable.entityId, "click") ) { continue; } const distance = distanceBetweenVec3(interactionOrigin, interactable.position); const horizontalDistance = distanceBetweenVec2( { x: interactionOrigin.x, y: interactionOrigin.z }, { x: interactable.position.x, y: interactable.position.z } ); const targetRadius = getInteractableTargetRadius(interactable); const acquisitionRange = options.useTargetingReach ? TARGETING_ACQUISITION_REACH : (options.interactionReachMeters ?? interactable.radius) + targetRadius + DEFAULT_INTERACTION_PROMPT_NEAR_FIELD_RADIUS; if ( (options.useTargetingReach ? distance : horizontalDistance) > acquisitionRange ) { continue; } candidates.push({ kind: "interactable", entityId: interactable.entityId, prompt: interactable.prompt, position: interactable.position, center: interactable.position, distance, range: interactable.radius, acquisitionRange, horizontalRadius: targetRadius }); } for (const npc of runtimeScene.entities.npcs) { if (!npc.visible) { continue; } const hasClickLinks = hasTriggerLinks(runtimeScene, npc.entityId, "click"); if (!hasClickLinks) { continue; } const bounds = getNpcDialogueTargetBounds(npc); const horizontalRadius = getNpcHorizontalTargetRadius(npc, bounds); const distance = distanceToAxisAlignedBox(interactionOrigin, bounds); const horizontalDistance = distanceToHorizontalAxisAlignedBounds( interactionOrigin, bounds ); const acquisitionRange = options.useTargetingReach ? TARGETING_ACQUISITION_REACH : (options.interactionReachMeters ?? bounds.range) + horizontalRadius + DEFAULT_INTERACTION_PROMPT_NEAR_FIELD_RADIUS; if ( (options.useTargetingReach ? distance : horizontalDistance) > acquisitionRange ) { continue; } candidates.push({ kind: "npc", entityId: npc.entityId, prompt: getNpcDialoguePrompt(npc, hasClickLinks), position: npc.position, center: bounds.center, distance, range: bounds.range, acquisitionRange, horizontalRadius, bounds }); } return candidates; } export function resolveRuntimeTargetCandidates(options: { interactionOrigin: Vec3; cameraPosition: Vec3; cameraForward: Vec3; runtimeScene: RuntimeSceneDefinition; previousProposedTargetEntityId?: string | null; }): RuntimeTargetCandidate[] { const normalizedViewDirection = normalizeVec3(options.cameraForward); if (normalizedViewDirection === null) { return []; } const previousId = options.previousProposedTargetEntityId ?? null; const candidates: RuntimeTargetCandidate[] = []; for (const source of collectRuntimeInteractionTargetSources( options.interactionOrigin, options.runtimeScene, { useTargetingReach: true } )) { const toTarget = subtractVec3(source.center, options.cameraPosition); const cameraDistanceSquared = lengthSquaredVec3(toTarget); if (cameraDistanceSquared <= Number.EPSILON) { continue; } const cameraDistance = Math.sqrt(cameraDistanceSquared); const viewDirection = scaleVec3(toTarget, 1 / cameraDistance); const viewDot = dotVec3(viewDirection, normalizedViewDirection); if (viewDot <= TARGETING_MIN_VIEW_DOT) { continue; } const interactionDistanceScore = 1 / (1 + source.distance / Math.max(source.range, 0.001)); const acquisitionDistanceScore = 1 - clampUnitInterval(source.distance / Math.max(source.acquisitionRange, 0.001)); const cameraDistanceScore = 1 / (1 + cameraDistance * 0.12); const stabilityBonus = source.entityId === previousId ? 0.12 : 0; const score = viewDot * 2.2 + interactionDistanceScore * 0.45 + acquisitionDistanceScore * 0.5 + cameraDistanceScore * 0.25 + stabilityBonus; candidates.push({ kind: source.kind, entityId: source.entityId, prompt: source.prompt, position: source.position, center: source.center, distance: source.distance, range: source.range, viewDot, score }); } candidates.sort( (a, b) => b.score - a.score || a.distance - b.distance || a.entityId.localeCompare(b.entityId) ); return candidates; } export function resolveRuntimeTargetReference( runtimeScene: RuntimeSceneDefinition, reference: RuntimeTargetReference ): RuntimeResolvedTarget | null { switch (reference.kind) { case "interactable": { const interactable = runtimeScene.entities.interactables.find( (candidate) => candidate.entityId === reference.entityId ) ?? null; if ( interactable === null || !interactable.interactionEnabled || !hasTriggerLinks(runtimeScene, interactable.entityId, "click") ) { return null; } return { kind: "interactable", entityId: interactable.entityId, prompt: interactable.prompt, position: interactable.position, center: interactable.position, range: interactable.radius }; } case "npc": { const npc = runtimeScene.entities.npcs.find( (candidate) => candidate.entityId === reference.entityId ) ?? null; if ( npc === null || !npc.visible || !hasTriggerLinks(runtimeScene, npc.entityId, "click") ) { return null; } const bounds = getNpcDialogueTargetBounds(npc); return { kind: "npc", entityId: npc.entityId, prompt: getNpcDialoguePrompt(npc, true), position: npc.position, center: bounds.center, range: bounds.range }; } } } export function resolveStableRuntimeTargetProposal( candidates: RuntimeTargetCandidate[], previousProposedTargetEntityId: string | null, minScoreLead = 0.12 ): RuntimeTargetCandidate | null { const best = candidates[0] ?? null; if ( best === null || previousProposedTargetEntityId === null || best.entityId === previousProposedTargetEntityId ) { return best; } const previous = candidates.find( (candidate) => candidate.entityId === previousProposedTargetEntityId ) ?? null; if (previous !== null && best.score < previous.score + minScoreLead) { return previous; } return best; } 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, viewDirection: Vec3, interactionReachMeters: number, interactionAngleDegrees: number, runtimeScene: RuntimeSceneDefinition ): RuntimeInteractionPrompt | null { const horizontalViewDirection = normalizeVec2({ x: viewDirection.x, y: viewDirection.z }); if (horizontalViewDirection === null) { return null; } const halfAngleRadians = (interactionAngleDegrees * Math.PI) / 360; const coneSlope = Math.tan(halfAngleRadians); const promptCandidates = collectRuntimeInteractionTargetSources( interactionOrigin, runtimeScene, { interactionReachMeters } ); if (promptCandidates.length === 0) { return null; } let bestPrompt: RuntimeInteractionPrompt | null = null; let bestAngularOffset = Number.POSITIVE_INFINITY; let bestForwardDistance = Number.POSITIVE_INFINITY; for (const candidate of promptCandidates) { const offsetX = candidate.center.x - interactionOrigin.x; const offsetZ = candidate.center.z - interactionOrigin.z; const forwardDistance = offsetX * horizontalViewDirection.x + offsetZ * horizontalViewDirection.y; const lateralDistance = offsetX * -horizontalViewDirection.y + offsetZ * horizontalViewDirection.x; const absoluteLateralDistance = Math.abs(lateralDistance); const paddedForwardDistance = Math.max(forwardDistance, 0); const allowedLateralDistance = DEFAULT_INTERACTION_PROMPT_NEAR_FIELD_RADIUS + coneSlope * paddedForwardDistance + candidate.horizontalRadius; const minForwardDistance = -DEFAULT_INTERACTION_PROMPT_NEAR_FIELD_RADIUS - candidate.horizontalRadius; const maxForwardDistance = interactionReachMeters + DEFAULT_INTERACTION_PROMPT_NEAR_FIELD_RADIUS + candidate.horizontalRadius; if ( forwardDistance < minForwardDistance || forwardDistance > maxForwardDistance || absoluteLateralDistance > allowedLateralDistance ) { continue; } const angularOffset = Math.atan2( absoluteLateralDistance, Math.max( paddedForwardDistance + DEFAULT_INTERACTION_PROMPT_NEAR_FIELD_RADIUS, Number.EPSILON ) ); if ( angularOffset < bestAngularOffset || (angularOffset === bestAngularOffset && (forwardDistance < bestForwardDistance || (forwardDistance === bestForwardDistance && (bestPrompt === null || candidate.distance < bestPrompt.distance || (candidate.distance === bestPrompt.distance && candidate.entityId.localeCompare(bestPrompt.sourceEntityId) < 0))))) ) { bestPrompt = { sourceEntityId: candidate.entityId, prompt: candidate.prompt, distance: candidate.distance, range: interactionReachMeters }; bestAngularOffset = angularOffset; bestForwardDistance = forwardDistance; } } return bestPrompt; } dispatchClickInteraction( sourceEntityId: string, runtimeScene: RuntimeSceneDefinition, dispatcher: RuntimeInteractionDispatcher ) { const npc = runtimeScene.entities.npcs.find( (candidate) => candidate.entityId === sourceEntityId ) ?? null; if (npc !== null) { this.dispatchLinks(sourceEntityId, "click", runtimeScene, dispatcher); 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 getInteractionLinkImpulseSteps(link, runtimeScene.sequences)) { this.dispatchSequenceStep(step, link, runtimeScene, dispatcher); } } } private dispatchSequenceStep( step: ImpulseSequenceStep, 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 "startSceneTransition": dispatcher.startSceneTransition( { sourceEntityId: link.sourceEntityId, targetSceneId: step.targetSceneId, targetEntryEntityId: step.targetEntryEntityId }, link ); return; case "setVisibility": if (dispatcher.setVisibility !== undefined) { dispatcher.setVisibility(step.target, step.mode, link); return; } if (step.target.kind === "brush") { dispatcher.toggleBrushVisibility( step.target.brushId, step.mode === "toggle" ? undefined : step.mode === "show", link ); return; } throw new Error( "Runtime visibility steps targeting model instances require dispatcher.setVisibility support." ); case "makeNpcTalk": dispatcher.startNpcDialogue?.(step.npcEntityId, step.dialogueId, { kind: "interactionLink", sourceEntityId: link.sourceEntityId, linkId: link.id, trigger: link.trigger }); return; } } }