export const CONTROL_ENTITY_TARGET_KINDS = [ "pointLight", "spotLight", "soundEmitter" ] as const; export const CONTROL_INTERACTION_TARGET_KINDS = [ "interactable", "sceneExit" ] as const; export const CONTROL_CAPABILITY_KINDS = [ "animationPlayback", "soundPlayback", "interactionAvailability", "lightEnabled", "lightIntensity" ] as const; export type ControlEntityTargetKind = (typeof CONTROL_ENTITY_TARGET_KINDS)[number]; export type ControlInteractionTargetKind = (typeof CONTROL_INTERACTION_TARGET_KINDS)[number]; export type ControlCapabilityKind = (typeof CONTROL_CAPABILITY_KINDS)[number]; export interface ActorControlTargetRef { kind: "actor"; actorId: string; } export interface EntityControlTargetRef< TKind extends ControlEntityTargetKind = ControlEntityTargetKind > { kind: "entity"; entityId: string; entityKind: TKind; } export interface InteractionControlTargetRef< TKind extends ControlInteractionTargetKind = ControlInteractionTargetKind > { kind: "interaction"; entityId: string; interactionKind: TKind; } export interface SceneControlTargetRef { kind: "scene"; scope: "activeScene"; } export interface GlobalControlTargetRef { kind: "global"; scope: "project"; } export interface ModelInstanceControlTargetRef { kind: "modelInstance"; modelInstanceId: string; } export type LightControlTargetRef = EntityControlTargetRef< "pointLight" | "spotLight" >; export type SoundEmitterControlTargetRef = EntityControlTargetRef<"soundEmitter">; export type ControlTargetRef = | ActorControlTargetRef | EntityControlTargetRef | InteractionControlTargetRef | SceneControlTargetRef | GlobalControlTargetRef | ModelInstanceControlTargetRef; export interface ControlTargetDescriptor { target: ControlTargetRef; capabilities: ControlCapabilityKind[]; } export interface PlayModelAnimationControlEffect { type: "playModelAnimation"; target: ModelInstanceControlTargetRef; clipName: string; loop?: boolean; } export interface StopModelAnimationControlEffect { type: "stopModelAnimation"; target: ModelInstanceControlTargetRef; } export interface PlaySoundControlEffect { type: "playSound"; target: SoundEmitterControlTargetRef; } export interface StopSoundControlEffect { type: "stopSound"; target: SoundEmitterControlTargetRef; } export interface SetInteractionEnabledControlEffect { type: "setInteractionEnabled"; target: InteractionControlTargetRef; enabled: boolean; } export interface SetLightEnabledControlEffect { type: "setLightEnabled"; target: LightControlTargetRef; enabled: boolean; } export interface SetLightIntensityControlEffect { type: "setLightIntensity"; target: LightControlTargetRef; intensity: number; } export type ControlEffect = | PlayModelAnimationControlEffect | StopModelAnimationControlEffect | PlaySoundControlEffect | StopSoundControlEffect | SetInteractionEnabledControlEffect | SetLightEnabledControlEffect | SetLightIntensityControlEffect; export interface LightIntensityControlChannelDescriptor { channel: "light.intensity"; target: LightControlTargetRef; minValue: number; defaultValue: number; } export type ControlChannelDescriptor = LightIntensityControlChannelDescriptor; export interface DefaultResolvedControlSource { kind: "default"; } export interface InteractionLinkResolvedControlSource { kind: "interactionLink"; linkId: string; } export interface SchedulerResolvedControlSource { kind: "scheduler"; scheduleId: string; } export type RuntimeResolvedControlSource = | DefaultResolvedControlSource | InteractionLinkResolvedControlSource | SchedulerResolvedControlSource; export interface RuntimeResolvedLightEnabledState { type: "lightEnabled"; target: LightControlTargetRef; value: boolean; source: RuntimeResolvedControlSource; } export interface RuntimeResolvedInteractionEnabledState { type: "interactionEnabled"; target: InteractionControlTargetRef; value: boolean; source: RuntimeResolvedControlSource; } export type RuntimeResolvedDiscreteControlState = | RuntimeResolvedLightEnabledState | RuntimeResolvedInteractionEnabledState; export interface RuntimeResolvedLightIntensityChannelValue { type: "lightIntensity"; descriptor: LightIntensityControlChannelDescriptor; value: number; source: RuntimeResolvedControlSource; } export type RuntimeResolvedControlChannelValue = RuntimeResolvedLightIntensityChannelValue; export interface RuntimeResolvedControlState { discrete: RuntimeResolvedDiscreteControlState[]; channels: RuntimeResolvedControlChannelValue[]; } export interface RuntimeControlSurfaceDefinition { targets: ControlTargetDescriptor[]; channels: ControlChannelDescriptor[]; resolved: RuntimeResolvedControlState; } function assertNonEmptyString(value: string, label: string) { if (value.trim().length === 0) { throw new Error(`${label} must be non-empty.`); } } function assertNonNegativeFiniteNumber(value: number, label: string) { if (!Number.isFinite(value) || value < 0) { throw new Error(`${label} must be a finite number greater than or equal to zero.`); } } function assertBoolean(value: boolean, label: string) { if (typeof value !== "boolean") { throw new Error(`${label} must be a boolean.`); } } export function isControlEntityTargetKind( value: unknown ): value is ControlEntityTargetKind { return CONTROL_ENTITY_TARGET_KINDS.includes( value as ControlEntityTargetKind ); } export function isControlInteractionTargetKind( value: unknown ): value is ControlInteractionTargetKind { return CONTROL_INTERACTION_TARGET_KINDS.includes( value as ControlInteractionTargetKind ); } export function createActorControlTargetRef( actorId: string ): ActorControlTargetRef { assertNonEmptyString(actorId, "Control actor id"); return { kind: "actor", actorId }; } export function createEntityControlTargetRef< TKind extends ControlEntityTargetKind >(entityKind: TKind, entityId: string): EntityControlTargetRef { assertNonEmptyString(entityId, "Control entity id"); return { kind: "entity", entityId, entityKind }; } export function createInteractionControlTargetRef< TKind extends ControlInteractionTargetKind >( interactionKind: TKind, entityId: string ): InteractionControlTargetRef { assertNonEmptyString(entityId, "Control interaction entity id"); return { kind: "interaction", entityId, interactionKind }; } export function createActiveSceneControlTargetRef(): SceneControlTargetRef { return { kind: "scene", scope: "activeScene" }; } export function createProjectGlobalControlTargetRef(): GlobalControlTargetRef { return { kind: "global", scope: "project" }; } export function createModelInstanceControlTargetRef( modelInstanceId: string ): ModelInstanceControlTargetRef { assertNonEmptyString(modelInstanceId, "Control model instance id"); return { kind: "modelInstance", modelInstanceId }; } export function createLightControlTargetRef( lightKind: "pointLight" | "spotLight", entityId: string ): LightControlTargetRef { return createEntityControlTargetRef(lightKind, entityId); } export function createSoundEmitterControlTargetRef( entityId: string ): SoundEmitterControlTargetRef { return createEntityControlTargetRef("soundEmitter", entityId); } export function createControlTargetDescriptor( target: ControlTargetRef, capabilities: ControlCapabilityKind[] ): ControlTargetDescriptor { return { target: cloneControlTargetRef(target), capabilities: [...capabilities] }; } export function createPlayModelAnimationControlEffect(options: { target: ModelInstanceControlTargetRef; clipName: string; loop?: boolean; }): PlayModelAnimationControlEffect { assertNonEmptyString(options.clipName, "Control animation clip name"); return { type: "playModelAnimation", target: cloneControlTargetRef( options.target ) as ModelInstanceControlTargetRef, clipName: options.clipName, loop: options.loop }; } export function createStopModelAnimationControlEffect(options: { target: ModelInstanceControlTargetRef; }): StopModelAnimationControlEffect { return { type: "stopModelAnimation", target: cloneControlTargetRef( options.target ) as ModelInstanceControlTargetRef }; } export function createPlaySoundControlEffect(options: { target: SoundEmitterControlTargetRef; }): PlaySoundControlEffect { return { type: "playSound", target: cloneControlTargetRef( options.target ) as SoundEmitterControlTargetRef }; } export function createStopSoundControlEffect(options: { target: SoundEmitterControlTargetRef; }): StopSoundControlEffect { return { type: "stopSound", target: cloneControlTargetRef( options.target ) as SoundEmitterControlTargetRef }; } export function createSetInteractionEnabledControlEffect(options: { target: InteractionControlTargetRef; enabled: boolean; }): SetInteractionEnabledControlEffect { assertBoolean(options.enabled, "Control interaction enabled"); return { type: "setInteractionEnabled", target: cloneControlTargetRef( options.target ) as InteractionControlTargetRef, enabled: options.enabled }; } export function createSetLightEnabledControlEffect(options: { target: LightControlTargetRef; enabled: boolean; }): SetLightEnabledControlEffect { assertBoolean(options.enabled, "Control light enabled"); return { type: "setLightEnabled", target: cloneControlTargetRef(options.target) as LightControlTargetRef, enabled: options.enabled }; } export function createSetLightIntensityControlEffect(options: { target: LightControlTargetRef; intensity: number; }): SetLightIntensityControlEffect { assertNonNegativeFiniteNumber(options.intensity, "Control light intensity"); return { type: "setLightIntensity", target: cloneControlTargetRef(options.target) as LightControlTargetRef, intensity: options.intensity }; } export function createLightIntensityControlChannelDescriptor(options: { target: LightControlTargetRef; defaultValue: number; minValue?: number; }): LightIntensityControlChannelDescriptor { const minValue = options.minValue ?? 0; assertNonNegativeFiniteNumber( options.defaultValue, "Control light intensity default" ); assertNonNegativeFiniteNumber( minValue, "Control light intensity minimum" ); return { channel: "light.intensity", target: cloneControlTargetRef(options.target) as LightControlTargetRef, minValue, defaultValue: options.defaultValue }; } export function createDefaultResolvedControlSource(): DefaultResolvedControlSource { return { kind: "default" }; } export function createInteractionLinkResolvedControlSource( linkId: string ): InteractionLinkResolvedControlSource { assertNonEmptyString(linkId, "Resolved control interaction link id"); return { kind: "interactionLink", linkId }; } export function createResolvedLightEnabledState(options: { target: LightControlTargetRef; value: boolean; source: RuntimeResolvedControlSource; }): RuntimeResolvedLightEnabledState { assertBoolean(options.value, "Resolved control light enabled"); return { type: "lightEnabled", target: cloneControlTargetRef(options.target) as LightControlTargetRef, value: options.value, source: cloneResolvedControlSource(options.source) }; } export function createResolvedInteractionEnabledState(options: { target: InteractionControlTargetRef; value: boolean; source: RuntimeResolvedControlSource; }): RuntimeResolvedInteractionEnabledState { assertBoolean(options.value, "Resolved control interaction enabled"); return { type: "interactionEnabled", target: cloneControlTargetRef( options.target ) as InteractionControlTargetRef, value: options.value, source: cloneResolvedControlSource(options.source) }; } export function createResolvedLightIntensityChannelValue(options: { descriptor: LightIntensityControlChannelDescriptor; value: number; source: RuntimeResolvedControlSource; }): RuntimeResolvedLightIntensityChannelValue { assertNonNegativeFiniteNumber( options.value, "Resolved control light intensity" ); return { type: "lightIntensity", descriptor: cloneControlChannelDescriptor( options.descriptor ) as LightIntensityControlChannelDescriptor, value: options.value, source: cloneResolvedControlSource(options.source) }; } export function createEmptyRuntimeResolvedControlState(): RuntimeResolvedControlState { return { discrete: [], channels: [] }; } export function createEmptyRuntimeControlSurfaceDefinition(): RuntimeControlSurfaceDefinition { return { targets: [], channels: [], resolved: createEmptyRuntimeResolvedControlState() }; } export function createRuntimeControlSurfaceDefinition(options: { targets: ControlTargetDescriptor[]; channels?: ControlChannelDescriptor[]; resolved?: RuntimeResolvedControlState; }): RuntimeControlSurfaceDefinition { return { targets: options.targets.map(cloneControlTargetDescriptor), channels: (options.channels ?? []).map(cloneControlChannelDescriptor), resolved: cloneRuntimeResolvedControlState( options.resolved ?? createEmptyRuntimeResolvedControlState() ) }; } export function getControlTargetRefKey(target: ControlTargetRef): string { switch (target.kind) { case "actor": return `actor:${target.actorId}`; case "entity": return `entity:${target.entityKind}:${target.entityId}`; case "interaction": return `interaction:${target.interactionKind}:${target.entityId}`; case "scene": return `scene:${target.scope}`; case "global": return `global:${target.scope}`; case "modelInstance": return `modelInstance:${target.modelInstanceId}`; } } export function getControlChannelDescriptorKey( descriptor: ControlChannelDescriptor ): string { switch (descriptor.channel) { case "light.intensity": return `channel:${descriptor.channel}:${getControlTargetRefKey( descriptor.target )}`; } } function getResolvedDiscreteControlStateKey( state: RuntimeResolvedDiscreteControlState ): string { switch (state.type) { case "lightEnabled": return `state:${state.type}:${getControlTargetRefKey(state.target)}`; case "interactionEnabled": return `state:${state.type}:${getControlTargetRefKey(state.target)}`; } } export function cloneControlTargetRef( target: TTarget ): TTarget { switch (target.kind) { case "actor": return { kind: "actor", actorId: target.actorId } as TTarget; case "entity": return { kind: "entity", entityId: target.entityId, entityKind: target.entityKind } as TTarget; case "interaction": return { kind: "interaction", entityId: target.entityId, interactionKind: target.interactionKind } as TTarget; case "scene": return { kind: "scene", scope: target.scope } as TTarget; case "global": return { kind: "global", scope: target.scope } as TTarget; case "modelInstance": return { kind: "modelInstance", modelInstanceId: target.modelInstanceId } as TTarget; } } export function cloneControlTargetDescriptor( descriptor: ControlTargetDescriptor ): ControlTargetDescriptor { return { target: cloneControlTargetRef(descriptor.target), capabilities: [...descriptor.capabilities] }; } export function cloneResolvedControlSource( source: RuntimeResolvedControlSource ): RuntimeResolvedControlSource { switch (source.kind) { case "default": return { kind: "default" }; case "interactionLink": return { kind: "interactionLink", linkId: source.linkId }; case "scheduler": return { kind: "scheduler", scheduleId: source.scheduleId }; } } export function cloneControlEffect( effect: TEffect ): TEffect { switch (effect.type) { case "playModelAnimation": return { type: "playModelAnimation", target: cloneControlTargetRef(effect.target), clipName: effect.clipName, loop: effect.loop } as TEffect; case "stopModelAnimation": return { type: "stopModelAnimation", target: cloneControlTargetRef(effect.target) } as TEffect; case "playSound": return { type: "playSound", target: cloneControlTargetRef(effect.target) } as TEffect; case "stopSound": return { type: "stopSound", target: cloneControlTargetRef(effect.target) } as TEffect; case "setInteractionEnabled": return { type: "setInteractionEnabled", target: cloneControlTargetRef(effect.target), enabled: effect.enabled } as TEffect; case "setLightEnabled": return { type: "setLightEnabled", target: cloneControlTargetRef(effect.target), enabled: effect.enabled } as TEffect; case "setLightIntensity": return { type: "setLightIntensity", target: cloneControlTargetRef(effect.target), intensity: effect.intensity } as TEffect; } } export function cloneControlChannelDescriptor< TDescriptor extends ControlChannelDescriptor >(descriptor: TDescriptor): TDescriptor { switch (descriptor.channel) { case "light.intensity": return { channel: "light.intensity", target: cloneControlTargetRef(descriptor.target), minValue: descriptor.minValue, defaultValue: descriptor.defaultValue } as TDescriptor; } } export function cloneRuntimeResolvedDiscreteControlState< TState extends RuntimeResolvedDiscreteControlState >(state: TState): TState { switch (state.type) { case "lightEnabled": return createResolvedLightEnabledState({ target: state.target, value: state.value, source: state.source }) as TState; case "interactionEnabled": return createResolvedInteractionEnabledState({ target: state.target, value: state.value, source: state.source }) as TState; } } export function cloneRuntimeResolvedControlChannelValue< TValue extends RuntimeResolvedControlChannelValue >(value: TValue): TValue { switch (value.type) { case "lightIntensity": return createResolvedLightIntensityChannelValue({ descriptor: value.descriptor, value: value.value, source: value.source }) as TValue; } } export function cloneRuntimeResolvedControlState( state: RuntimeResolvedControlState ): RuntimeResolvedControlState { return { discrete: state.discrete.map(cloneRuntimeResolvedDiscreteControlState), channels: state.channels.map(cloneRuntimeResolvedControlChannelValue) }; } export function cloneRuntimeControlSurfaceDefinition( controlSurface: RuntimeControlSurfaceDefinition ): RuntimeControlSurfaceDefinition { return { targets: controlSurface.targets.map(cloneControlTargetDescriptor), channels: controlSurface.channels.map(cloneControlChannelDescriptor), resolved: cloneRuntimeResolvedControlState(controlSurface.resolved) }; } export function areControlEffectsEqual( left: ControlEffect, right: ControlEffect ): boolean { if (left.type !== right.type) { return false; } if (getControlTargetRefKey(left.target) !== getControlTargetRefKey(right.target)) { return false; } switch (left.type) { case "playModelAnimation": return ( left.clipName === (right as PlayModelAnimationControlEffect).clipName && left.loop === (right as PlayModelAnimationControlEffect).loop ); case "stopModelAnimation": case "playSound": case "stopSound": return true; case "setInteractionEnabled": return ( left.enabled === (right as SetInteractionEnabledControlEffect).enabled ); case "setLightEnabled": return left.enabled === (right as SetLightEnabledControlEffect).enabled; case "setLightIntensity": return ( left.intensity === (right as SetLightIntensityControlEffect).intensity ); } } export function getControlEffectLabel(effect: ControlEffect): string { switch (effect.type) { case "playModelAnimation": return "Play Animation"; case "stopModelAnimation": return "Stop Animation"; case "playSound": return "Play Sound"; case "stopSound": return "Stop Sound"; case "setInteractionEnabled": return "Set Interaction Enabled"; case "setLightEnabled": return "Set Light Enabled"; case "setLightIntensity": return "Set Light Intensity"; } } export function formatControlTargetRef(target: ControlTargetRef): string { switch (target.kind) { case "actor": return `Actor ${target.actorId}`; case "entity": return `${formatTargetKindLabel(target.entityKind)} ${target.entityId}`; case "interaction": return `${formatTargetKindLabel(target.interactionKind)} ${target.entityId}`; case "scene": return "Active Scene"; case "global": return "Project"; case "modelInstance": return `Model Instance ${target.modelInstanceId}`; } } function formatTargetKindLabel( kind: ControlEntityTargetKind | ControlInteractionTargetKind ): string { switch (kind) { case "pointLight": return "Point Light"; case "spotLight": return "Spot Light"; case "soundEmitter": return "Sound Emitter"; case "interactable": return "Interactable"; case "sceneExit": return "Scene Exit"; } } export function formatControlEffectValue(effect: ControlEffect): string { switch (effect.type) { case "playModelAnimation": return effect.loop === false ? `${effect.clipName} (Once)` : `${effect.clipName} (Loop)`; case "stopModelAnimation": return "Stop"; case "playSound": return "Play"; case "stopSound": return "Stop"; case "setInteractionEnabled": case "setLightEnabled": return effect.enabled ? "Enabled" : "Disabled"; case "setLightIntensity": return String(effect.intensity); } } export function applyControlEffectToResolvedState( resolved: RuntimeResolvedControlState, effect: ControlEffect, source: RuntimeResolvedControlSource ): RuntimeResolvedControlState { const nextResolved = cloneRuntimeResolvedControlState(resolved); switch (effect.type) { case "setLightEnabled": { const nextState = createResolvedLightEnabledState({ target: effect.target, value: effect.enabled, source }); const stateKey = getResolvedDiscreteControlStateKey(nextState); const existingIndex = nextResolved.discrete.findIndex( (candidate) => getResolvedDiscreteControlStateKey(candidate) === stateKey ); if (existingIndex >= 0) { nextResolved.discrete[existingIndex] = nextState; } else { nextResolved.discrete.push(nextState); } return nextResolved; } case "setInteractionEnabled": { const nextState = createResolvedInteractionEnabledState({ target: effect.target, value: effect.enabled, source }); const stateKey = getResolvedDiscreteControlStateKey(nextState); const existingIndex = nextResolved.discrete.findIndex( (candidate) => getResolvedDiscreteControlStateKey(candidate) === stateKey ); if (existingIndex >= 0) { nextResolved.discrete[existingIndex] = nextState; } else { nextResolved.discrete.push(nextState); } return nextResolved; } case "setLightIntensity": { const nextValue = createResolvedLightIntensityChannelValue({ descriptor: createLightIntensityControlChannelDescriptor({ target: effect.target, defaultValue: effect.intensity }), value: effect.intensity, source }); const descriptorKey = getControlChannelDescriptorKey(nextValue.descriptor); const existingIndex = nextResolved.channels.findIndex( (candidate) => getControlChannelDescriptorKey(candidate.descriptor) === descriptorKey ); if (existingIndex >= 0) { nextResolved.channels[existingIndex] = nextValue; } else { nextResolved.channels.push(nextValue); } return nextResolved; } case "playModelAnimation": case "stopModelAnimation": case "playSound": case "stopSound": return nextResolved; } }