import { createOpaqueId } from "../core/ids"; import { areControlEffectsEqual, cloneControlEffect, createModelInstanceControlTargetRef, createPlayModelAnimationControlEffect, createPlaySoundControlEffect, createSoundEmitterControlTargetRef, createStopModelAnimationControlEffect, createStopSoundControlEffect, getControlEffectLabel, type ControlEffect } from "../controls/control-surface"; export const INTERACTION_TRIGGER_KINDS = ["enter", "exit", "click"] as const; export type InteractionTriggerKind = (typeof INTERACTION_TRIGGER_KINDS)[number]; export interface TeleportPlayerAction { type: "teleportPlayer"; targetEntityId: string; } export interface ToggleVisibilityAction { type: "toggleVisibility"; targetBrushId: string; visible?: boolean; } export interface PlayAnimationAction { type: "playAnimation"; targetModelInstanceId: string; clipName: string; loop?: boolean; } export interface StopAnimationAction { type: "stopAnimation"; targetModelInstanceId: string; } export interface PlaySoundAction { type: "playSound"; targetSoundEmitterId: string; } export interface StopSoundAction { type: "stopSound"; targetSoundEmitterId: string; } export interface RunSequenceAction { type: "runSequence"; sequenceId: string; } export interface ControlInteractionAction { type: "control"; effect: ControlEffect; } export type InteractionAction = | TeleportPlayerAction | ToggleVisibilityAction | PlayAnimationAction | StopAnimationAction | PlaySoundAction | StopSoundAction | RunSequenceAction | ControlInteractionAction; export interface InteractionLink { id: string; sourceEntityId: string; trigger: InteractionTriggerKind; action: InteractionAction; } export interface CreateTeleportPlayerInteractionLinkOptions { id?: string; sourceEntityId: string; trigger?: InteractionTriggerKind; targetEntityId: string; } export interface CreateToggleVisibilityInteractionLinkOptions { id?: string; sourceEntityId: string; trigger?: InteractionTriggerKind; targetBrushId: string; visible?: boolean; } export interface CreatePlayAnimationInteractionLinkOptions { id?: string; sourceEntityId: string; trigger?: InteractionTriggerKind; targetModelInstanceId: string; clipName: string; loop?: boolean; } export interface CreateStopAnimationInteractionLinkOptions { id?: string; sourceEntityId: string; trigger?: InteractionTriggerKind; targetModelInstanceId: string; } export interface CreatePlaySoundInteractionLinkOptions { id?: string; sourceEntityId: string; trigger?: InteractionTriggerKind; targetSoundEmitterId: string; } export interface CreateStopSoundInteractionLinkOptions { id?: string; sourceEntityId: string; trigger?: InteractionTriggerKind; targetSoundEmitterId: string; } export interface CreateControlInteractionLinkOptions { id?: string; sourceEntityId: string; trigger?: InteractionTriggerKind; effect: ControlEffect; } export interface CreateRunSequenceInteractionLinkOptions { id?: string; sourceEntityId: string; trigger?: InteractionTriggerKind; sequenceId: string; } function assertNonEmptyString(value: string, label: string) { if (value.trim().length === 0) { throw new Error(`${label} must be non-empty.`); } } function cloneAction(action: InteractionAction): InteractionAction { switch (action.type) { case "teleportPlayer": return { type: "teleportPlayer", targetEntityId: action.targetEntityId }; case "toggleVisibility": return { type: "toggleVisibility", targetBrushId: action.targetBrushId, visible: action.visible }; case "playAnimation": return { type: "playAnimation", targetModelInstanceId: action.targetModelInstanceId, clipName: action.clipName, loop: action.loop }; case "stopAnimation": return { type: "stopAnimation", targetModelInstanceId: action.targetModelInstanceId }; case "playSound": return { type: "playSound", targetSoundEmitterId: action.targetSoundEmitterId }; case "stopSound": return { type: "stopSound", targetSoundEmitterId: action.targetSoundEmitterId }; case "runSequence": return { type: "runSequence", sequenceId: action.sequenceId }; case "control": return { type: "control", effect: cloneControlEffect(action.effect) }; } } export function isInteractionTriggerKind( value: unknown ): value is InteractionTriggerKind { return value === "enter" || value === "exit" || value === "click"; } export function isControlInteractionAction( action: InteractionAction ): action is ControlInteractionAction { return action.type === "control"; } export function createTeleportPlayerInteractionLink( options: CreateTeleportPlayerInteractionLinkOptions ): InteractionLink { assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); assertNonEmptyString(options.targetEntityId, "Teleport target entity id"); return { id: options.id ?? createOpaqueId("interaction-link"), sourceEntityId: options.sourceEntityId, trigger: options.trigger ?? "enter", action: { type: "teleportPlayer", targetEntityId: options.targetEntityId } }; } export function createToggleVisibilityInteractionLink( options: CreateToggleVisibilityInteractionLinkOptions ): InteractionLink { assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); assertNonEmptyString(options.targetBrushId, "Visibility target brush id"); if (options.visible !== undefined && typeof options.visible !== "boolean") { throw new Error( "Visibility action visible must be a boolean when authored." ); } return { id: options.id ?? createOpaqueId("interaction-link"), sourceEntityId: options.sourceEntityId, trigger: options.trigger ?? "enter", action: { type: "toggleVisibility", targetBrushId: options.targetBrushId, visible: options.visible } }; } export function createPlayAnimationInteractionLink( options: CreatePlayAnimationInteractionLinkOptions ): InteractionLink { assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); assertNonEmptyString( options.targetModelInstanceId, "Play animation target model instance id" ); assertNonEmptyString(options.clipName, "Play animation clip name"); return { id: options.id ?? createOpaqueId("interaction-link"), sourceEntityId: options.sourceEntityId, trigger: options.trigger ?? "enter", action: { type: "playAnimation", targetModelInstanceId: options.targetModelInstanceId, clipName: options.clipName, loop: options.loop } }; } export function createStopAnimationInteractionLink( options: CreateStopAnimationInteractionLinkOptions ): InteractionLink { assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); assertNonEmptyString( options.targetModelInstanceId, "Stop animation target model instance id" ); return { id: options.id ?? createOpaqueId("interaction-link"), sourceEntityId: options.sourceEntityId, trigger: options.trigger ?? "enter", action: { type: "stopAnimation", targetModelInstanceId: options.targetModelInstanceId } }; } export function createPlaySoundInteractionLink( options: CreatePlaySoundInteractionLinkOptions ): InteractionLink { assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); assertNonEmptyString( options.targetSoundEmitterId, "Play sound target sound emitter id" ); return { id: options.id ?? createOpaqueId("interaction-link"), sourceEntityId: options.sourceEntityId, trigger: options.trigger ?? "enter", action: { type: "playSound", targetSoundEmitterId: options.targetSoundEmitterId } }; } export function createStopSoundInteractionLink( options: CreateStopSoundInteractionLinkOptions ): InteractionLink { assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); assertNonEmptyString( options.targetSoundEmitterId, "Stop sound target sound emitter id" ); return { id: options.id ?? createOpaqueId("interaction-link"), sourceEntityId: options.sourceEntityId, trigger: options.trigger ?? "enter", action: { type: "stopSound", targetSoundEmitterId: options.targetSoundEmitterId } }; } export function createControlInteractionLink( options: CreateControlInteractionLinkOptions ): InteractionLink { assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); return { id: options.id ?? createOpaqueId("interaction-link"), sourceEntityId: options.sourceEntityId, trigger: options.trigger ?? "enter", action: { type: "control", effect: cloneControlEffect(options.effect) } }; } export function createRunSequenceInteractionLink( options: CreateRunSequenceInteractionLinkOptions ): InteractionLink { assertNonEmptyString(options.sourceEntityId, "Interaction source entity id"); assertNonEmptyString(options.sequenceId, "Sequence id"); return { id: options.id ?? createOpaqueId("interaction-link"), sourceEntityId: options.sourceEntityId, trigger: options.trigger ?? "enter", action: { type: "runSequence", sequenceId: options.sequenceId } }; } export function getInteractionActionControlEffect( action: InteractionAction ): ControlEffect | null { switch (action.type) { case "playAnimation": return createPlayModelAnimationControlEffect({ target: createModelInstanceControlTargetRef( action.targetModelInstanceId ), clipName: action.clipName, loop: action.loop }); case "stopAnimation": return createStopModelAnimationControlEffect({ target: createModelInstanceControlTargetRef( action.targetModelInstanceId ) }); case "playSound": return createPlaySoundControlEffect({ target: createSoundEmitterControlTargetRef(action.targetSoundEmitterId) }); case "stopSound": return createStopSoundControlEffect({ target: createSoundEmitterControlTargetRef(action.targetSoundEmitterId) }); case "runSequence": return null; case "control": return cloneControlEffect(action.effect); case "teleportPlayer": case "toggleVisibility": return null; } } export function getInteractionActionLabel(action: InteractionAction): string { switch (action.type) { case "teleportPlayer": return "Teleport Player"; case "toggleVisibility": return "Toggle Visibility"; case "playAnimation": return "Play Animation"; case "stopAnimation": return "Stop Animation"; case "playSound": return "Play Sound"; case "stopSound": return "Stop Sound"; case "runSequence": return "Run Sequence"; case "control": return getControlEffectLabel(action.effect); } } export function cloneInteractionLink(link: InteractionLink): InteractionLink { return { id: link.id, sourceEntityId: link.sourceEntityId, trigger: link.trigger, action: cloneAction(link.action) }; } export function areInteractionLinksEqual( left: InteractionLink, right: InteractionLink ): boolean { if ( left.id !== right.id || left.sourceEntityId !== right.sourceEntityId || left.trigger !== right.trigger ) { return false; } if (left.action.type !== right.action.type) { return false; } switch (left.action.type) { case "teleportPlayer": return ( left.action.targetEntityId === (right.action as TeleportPlayerAction).targetEntityId ); case "toggleVisibility": return ( left.action.targetBrushId === (right.action as ToggleVisibilityAction).targetBrushId && left.action.visible === (right.action as ToggleVisibilityAction).visible ); case "playAnimation": return ( left.action.targetModelInstanceId === (right.action as PlayAnimationAction).targetModelInstanceId && left.action.clipName === (right.action as PlayAnimationAction).clipName && left.action.loop === (right.action as PlayAnimationAction).loop ); case "stopAnimation": return ( left.action.targetModelInstanceId === (right.action as StopAnimationAction).targetModelInstanceId ); case "playSound": return ( left.action.targetSoundEmitterId === (right.action as PlaySoundAction).targetSoundEmitterId ); case "stopSound": return ( left.action.targetSoundEmitterId === (right.action as StopSoundAction).targetSoundEmitterId ); case "runSequence": return ( left.action.sequenceId === (right.action as RunSequenceAction).sequenceId ); case "control": return areControlEffectsEqual( left.action.effect, (right.action as ControlInteractionAction).effect ); } } export function cloneInteractionLinkRegistry( links: Record ): Record { return Object.fromEntries( Object.entries(links).map(([linkId, link]) => [ linkId, cloneInteractionLink(link) ]) ); } export function compareInteractionLinks( left: InteractionLink, right: InteractionLink ): number { if (left.sourceEntityId !== right.sourceEntityId) { return left.sourceEntityId.localeCompare(right.sourceEntityId); } if (left.trigger !== right.trigger) { return left.trigger.localeCompare(right.trigger); } return left.id.localeCompare(right.id); } export function getInteractionLinks( links: Record ): InteractionLink[] { return Object.values(links).sort(compareInteractionLinks); } export function getInteractionLinksForSource( links: Record, sourceEntityId: string ): InteractionLink[] { return getInteractionLinks(links).filter( (link) => link.sourceEntityId === sourceEntityId ); }