Files
webeditor3d/src/runtime-three/runtime-interaction-system.ts

1072 lines
28 KiB
TypeScript
Raw Normal View History

2026-03-31 06:46:10 +02:00
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";
2026-03-31 06:46:10 +02:00
import type {
RuntimeInteractable,
RuntimeNpc,
RuntimeSceneDefinition,
RuntimeTeleportTarget,
RuntimeTriggerVolume
} from "./runtime-scene-build";
2026-03-31 06:46:10 +02:00
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;
}
2026-03-31 06:46:10 +02:00
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;
2026-03-31 06:46:10 +02:00
}
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;
}
2026-03-31 06:46:10 +02:00
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));
}
2026-03-31 06:46:10 +02:00
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 {
2026-03-31 06:46:10 +02:00
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 {
2026-03-31 06:46:10 +02:00
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
);
2026-03-31 06:46:10 +02:00
}
function hasTriggerLinks(
runtimeScene: RuntimeSceneDefinition,
sourceEntityId: string,
trigger: InteractionLink["trigger"]
): boolean {
return runtimeScene.interactionLinks.some(
(link) =>
link.sourceEntityId === sourceEntityId &&
resolveEffectiveInteractionTrigger(runtimeScene, link) === trigger
);
2026-03-31 06:46:10 +02:00
}
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 {
2026-03-31 06:46:10 +02:00
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 };
targetRadius?: number;
}
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 targetRadius = getInteractableTargetRadius(interactable);
const acquisitionRange = options.useTargetingReach
? TARGETING_ACQUISITION_REACH
: (options.interactionReachMeters ?? interactable.radius) +
targetRadius +
DEFAULT_INTERACTION_PROMPT_NEAR_FIELD_RADIUS;
if (distance > 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,
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 acquisitionRange = options.useTargetingReach
? TARGETING_ACQUISITION_REACH
: (options.interactionReachMeters ?? bounds.range) +
horizontalRadius +
DEFAULT_INTERACTION_PROMPT_NEAR_FIELD_RADIUS;
if (distance > 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;
}
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
};
}
2026-03-31 06:46:10 +02:00
export class RuntimeInteractionSystem {
private readonly occupiedTriggerVolumes = new Set<string>();
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;
2026-03-31 06:46:10 +02:00
for (const triggerVolume of runtimeScene.entities.triggerVolumes) {
const containsPlayer = isPlayerInsideTriggerVolume(
feetPosition,
eyePosition,
triggerVolume
);
const wasOccupied = this.occupiedTriggerVolumes.has(
triggerVolume.entityId
);
2026-03-31 06:46:10 +02:00
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
);
2026-03-31 06:46:10 +02:00
}
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 }
);
2026-03-31 06:46:10 +02:00
if (promptCandidates.length === 0) {
2026-03-31 06:46:10 +02:00
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;
2026-03-31 06:46:10 +02:00
}
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;
}
2026-03-31 06:46:10 +02:00
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
) {
2026-03-31 06:46:10 +02:00
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
);
2026-03-31 06:46:10 +02:00
if (teleportTarget !== null) {
dispatcher.teleportPlayer(teleportTarget, link);
2026-03-31 06:46:10 +02:00
}
return;
2026-03-31 06:46:10 +02:00
}
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;
2026-03-31 06:46:10 +02:00
}
}
}