Add interaction-links.ts and update scene-document.ts

This commit is contained in:
2026-04-14 01:33:18 +02:00
parent df941c2ec6
commit 72029da2c1
2 changed files with 480 additions and 1 deletions

View File

@@ -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;

View File

@@ -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<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
);
}