Add interaction-links.ts and update scene-document.ts
This commit is contained in:
@@ -20,7 +20,8 @@ import {
|
|||||||
} from "./project-time-settings";
|
} from "./project-time-settings";
|
||||||
import { type ScenePath } from "./paths";
|
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 NPC_PRESENCE_SCENE_DOCUMENT_VERSION = 44 as const;
|
||||||
export const PATH_FOUNDATION_SCENE_DOCUMENT_VERSION = 43 as const;
|
export const PATH_FOUNDATION_SCENE_DOCUMENT_VERSION = 43 as const;
|
||||||
export const NPC_COLLIDER_SCENE_DOCUMENT_VERSION = 42 as const;
|
export const NPC_COLLIDER_SCENE_DOCUMENT_VERSION = 42 as const;
|
||||||
|
|||||||
478
src/interactions/interaction-links.ts
Normal file
478
src/interactions/interaction-links.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user