diff --git a/src/document/scene-document.ts b/src/document/scene-document.ts index 3c267443..2346ef40 100644 --- a/src/document/scene-document.ts +++ b/src/document/scene-document.ts @@ -20,7 +20,8 @@ import { } from "./project-time-settings"; import { type ScenePath } from "./paths"; -export const SCENE_DOCUMENT_VERSION = 44 as const; +export const SCENE_DOCUMENT_VERSION = 45 as const; +export const CONTROL_SURFACE_FOUNDATION_SCENE_DOCUMENT_VERSION = 45 as const; export const NPC_PRESENCE_SCENE_DOCUMENT_VERSION = 44 as const; export const PATH_FOUNDATION_SCENE_DOCUMENT_VERSION = 43 as const; export const NPC_COLLIDER_SCENE_DOCUMENT_VERSION = 42 as const; diff --git a/src/interactions/interaction-links.ts b/src/interactions/interaction-links.ts new file mode 100644 index 00000000..9fe9268e --- /dev/null +++ b/src/interactions/interaction-links.ts @@ -0,0 +1,478 @@ +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 ControlInteractionAction { + type: "control"; + effect: ControlEffect; +} + +export type InteractionAction = + | TeleportPlayerAction + | ToggleVisibilityAction + | PlayAnimationAction + | StopAnimationAction + | PlaySoundAction + | StopSoundAction + | 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; +} + +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 "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 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 "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 "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 "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 + ); +}