Files
webeditor3d/src/interactions/interaction-links.ts

329 lines
10 KiB
TypeScript

import { createOpaqueId } from "../core/ids";
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 type InteractionAction =
| TeleportPlayerAction
| ToggleVisibilityAction
| PlayAnimationAction
| StopAnimationAction
| PlaySoundAction
| StopSoundAction;
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 CreatePlaySoundInteractionLinkOptions {
id?: string;
sourceEntityId: string;
trigger?: InteractionTriggerKind;
targetSoundEmitterId: string;
}
export interface CreateStopSoundInteractionLinkOptions {
id?: string;
sourceEntityId: string;
trigger?: InteractionTriggerKind;
targetSoundEmitterId: 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
};
}
}
export function isInteractionTriggerKind(value: unknown): value is InteractionTriggerKind {
return value === "enter" || value === "exit" || value === "click";
}
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 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 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 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;
default: {
// Exhaustive check — TypeScript should never reach here
const _exhaustive: never = left.action;
void _exhaustive;
return false;
}
}
}
export function cloneInteractionLinkRegistry(links: Record<string, InteractionLink>): Record<string, InteractionLink> {
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<string, InteractionLink>): InteractionLink[] {
return Object.values(links).sort(compareInteractionLinks);
}
export function getInteractionLinksForSource(links: Record<string, InteractionLink>, sourceEntityId: string): InteractionLink[] {
return getInteractionLinks(links).filter((link) => link.sourceEntityId === sourceEntityId);
}