diff --git a/src/controls/control-surface.ts b/src/controls/control-surface.ts new file mode 100644 index 00000000..3b8852b6 --- /dev/null +++ b/src/controls/control-surface.ts @@ -0,0 +1,1811 @@ +import { isHexColorString } from "../document/world-settings"; + +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 = [ + "actorPresence", + "animationPlayback", + "modelVisibility", + "soundPlayback", + "soundVolume", + "interactionAvailability", + "lightEnabled", + "lightIntensity", + "lightColor", + "ambientLightIntensity", + "ambientLightColor", + "sunLightIntensity", + "sunLightColor" +] 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 SetActorPresenceControlEffect { + type: "setActorPresence"; + target: ActorControlTargetRef; + active: boolean; +} + +export interface PlayModelAnimationControlEffect { + type: "playModelAnimation"; + target: ModelInstanceControlTargetRef; + clipName: string; + loop?: boolean; +} + +export interface StopModelAnimationControlEffect { + type: "stopModelAnimation"; + target: ModelInstanceControlTargetRef; +} + +export interface SetModelInstanceVisibleControlEffect { + type: "setModelInstanceVisible"; + target: ModelInstanceControlTargetRef; + visible: boolean; +} + +export interface PlaySoundControlEffect { + type: "playSound"; + target: SoundEmitterControlTargetRef; +} + +export interface StopSoundControlEffect { + type: "stopSound"; + target: SoundEmitterControlTargetRef; +} + +export interface SetSoundVolumeControlEffect { + type: "setSoundVolume"; + target: SoundEmitterControlTargetRef; + volume: number; +} + +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 interface SetLightColorControlEffect { + type: "setLightColor"; + target: LightControlTargetRef; + colorHex: string; +} + +export interface SetAmbientLightIntensityControlEffect { + type: "setAmbientLightIntensity"; + target: SceneControlTargetRef; + intensity: number; +} + +export interface SetAmbientLightColorControlEffect { + type: "setAmbientLightColor"; + target: SceneControlTargetRef; + colorHex: string; +} + +export interface SetSunLightIntensityControlEffect { + type: "setSunLightIntensity"; + target: SceneControlTargetRef; + intensity: number; +} + +export interface SetSunLightColorControlEffect { + type: "setSunLightColor"; + target: SceneControlTargetRef; + colorHex: string; +} + +export type ControlEffect = + | SetActorPresenceControlEffect + | PlayModelAnimationControlEffect + | StopModelAnimationControlEffect + | SetModelInstanceVisibleControlEffect + | PlaySoundControlEffect + | StopSoundControlEffect + | SetSoundVolumeControlEffect + | SetInteractionEnabledControlEffect + | SetLightEnabledControlEffect + | SetLightIntensityControlEffect + | SetLightColorControlEffect + | SetAmbientLightIntensityControlEffect + | SetAmbientLightColorControlEffect + | SetSunLightIntensityControlEffect + | SetSunLightColorControlEffect; + +export interface LightIntensityControlChannelDescriptor { + channel: "light.intensity"; + target: LightControlTargetRef; + minValue: number; + defaultValue: number; +} + +export interface SoundVolumeControlChannelDescriptor { + channel: "sound.volume"; + target: SoundEmitterControlTargetRef; + minValue: number; + defaultValue: number; +} + +export interface AmbientLightIntensityControlChannelDescriptor { + channel: "ambientLight.intensity"; + target: SceneControlTargetRef; + minValue: number; + defaultValue: number; +} + +export interface SunLightIntensityControlChannelDescriptor { + channel: "sunLight.intensity"; + target: SceneControlTargetRef; + minValue: number; + defaultValue: number; +} + +export type ControlChannelDescriptor = + | LightIntensityControlChannelDescriptor + | SoundVolumeControlChannelDescriptor + | AmbientLightIntensityControlChannelDescriptor + | SunLightIntensityControlChannelDescriptor; + +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 RuntimeResolvedActorPresenceState { + type: "actorPresence"; + target: ActorControlTargetRef; + value: boolean; + source: RuntimeResolvedControlSource; +} + +export interface RuntimeResolvedInteractionEnabledState { + type: "interactionEnabled"; + target: InteractionControlTargetRef; + value: boolean; + source: RuntimeResolvedControlSource; +} + +export interface RuntimeResolvedModelInstanceVisibilityState { + type: "modelVisibility"; + target: ModelInstanceControlTargetRef; + value: boolean; + source: RuntimeResolvedControlSource; +} + +export interface RuntimeResolvedSoundPlaybackState { + type: "soundPlayback"; + target: SoundEmitterControlTargetRef; + value: boolean; + source: RuntimeResolvedControlSource; +} + +export interface RuntimeResolvedModelAnimationPlaybackState { + type: "modelAnimationPlayback"; + target: ModelInstanceControlTargetRef; + clipName: string | null; + loop: boolean | undefined; + source: RuntimeResolvedControlSource; +} + +export interface RuntimeResolvedLightColorState { + type: "lightColor"; + target: LightControlTargetRef; + value: string; + source: RuntimeResolvedControlSource; +} + +export interface RuntimeResolvedAmbientLightColorState { + type: "ambientLightColor"; + target: SceneControlTargetRef; + value: string; + source: RuntimeResolvedControlSource; +} + +export interface RuntimeResolvedSunLightColorState { + type: "sunLightColor"; + target: SceneControlTargetRef; + value: string; + source: RuntimeResolvedControlSource; +} + +export type RuntimeResolvedDiscreteControlState = + | RuntimeResolvedActorPresenceState + | RuntimeResolvedLightEnabledState + | RuntimeResolvedInteractionEnabledState + | RuntimeResolvedModelInstanceVisibilityState + | RuntimeResolvedSoundPlaybackState + | RuntimeResolvedModelAnimationPlaybackState + | RuntimeResolvedLightColorState + | RuntimeResolvedAmbientLightColorState + | RuntimeResolvedSunLightColorState; + +export interface RuntimeResolvedLightIntensityChannelValue { + type: "lightIntensity"; + descriptor: LightIntensityControlChannelDescriptor; + value: number; + source: RuntimeResolvedControlSource; +} + +export interface RuntimeResolvedSoundVolumeChannelValue { + type: "soundVolume"; + descriptor: SoundVolumeControlChannelDescriptor; + value: number; + source: RuntimeResolvedControlSource; +} + +export interface RuntimeResolvedAmbientLightIntensityChannelValue { + type: "ambientLightIntensity"; + descriptor: AmbientLightIntensityControlChannelDescriptor; + value: number; + source: RuntimeResolvedControlSource; +} + +export interface RuntimeResolvedSunLightIntensityChannelValue { + type: "sunLightIntensity"; + descriptor: SunLightIntensityControlChannelDescriptor; + value: number; + source: RuntimeResolvedControlSource; +} + +export type RuntimeResolvedControlChannelValue = + | RuntimeResolvedLightIntensityChannelValue + | RuntimeResolvedSoundVolumeChannelValue + | RuntimeResolvedAmbientLightIntensityChannelValue + | RuntimeResolvedSunLightIntensityChannelValue; + +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.`); + } +} + +function assertHexColor(value: string, label: string) { + if (!isHexColorString(value)) { + throw new Error(`${label} must be a valid hex color string.`); + } +} + +function upsertResolvedDiscreteState( + resolved: RuntimeResolvedControlState, + state: RuntimeResolvedDiscreteControlState +) { + const stateKey = getResolvedDiscreteControlStateKey(state); + const existingIndex = resolved.discrete.findIndex( + (candidate) => getResolvedDiscreteControlStateKey(candidate) === stateKey + ); + + if (existingIndex >= 0) { + resolved.discrete[existingIndex] = state; + } else { + resolved.discrete.push(state); + } +} + +function upsertResolvedChannelValue( + resolved: RuntimeResolvedControlState, + value: RuntimeResolvedControlChannelValue +) { + const channelKey = getControlChannelDescriptorKey(value.descriptor); + const existingIndex = resolved.channels.findIndex( + (candidate) => + getControlChannelDescriptorKey(candidate.descriptor) === channelKey + ); + + if (existingIndex >= 0) { + resolved.channels[existingIndex] = value; + } else { + resolved.channels.push(value); + } +} + +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 createSetActorPresenceControlEffect(options: { + target: ActorControlTargetRef; + active: boolean; +}): SetActorPresenceControlEffect { + assertBoolean(options.active, "Control actor active"); + + return { + type: "setActorPresence", + target: cloneControlTargetRef(options.target) as ActorControlTargetRef, + active: options.active + }; +} + +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 createSetModelInstanceVisibleControlEffect(options: { + target: ModelInstanceControlTargetRef; + visible: boolean; +}): SetModelInstanceVisibleControlEffect { + assertBoolean(options.visible, "Control model instance visible"); + + return { + type: "setModelInstanceVisible", + target: cloneControlTargetRef( + options.target + ) as ModelInstanceControlTargetRef, + visible: options.visible + }; +} + +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 createSetSoundVolumeControlEffect(options: { + target: SoundEmitterControlTargetRef; + volume: number; +}): SetSoundVolumeControlEffect { + assertNonNegativeFiniteNumber(options.volume, "Control sound volume"); + + return { + type: "setSoundVolume", + target: cloneControlTargetRef( + options.target + ) as SoundEmitterControlTargetRef, + volume: options.volume + }; +} + +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 createSetLightColorControlEffect(options: { + target: LightControlTargetRef; + colorHex: string; +}): SetLightColorControlEffect { + assertHexColor(options.colorHex, "Control light color"); + + return { + type: "setLightColor", + target: cloneControlTargetRef(options.target) as LightControlTargetRef, + colorHex: options.colorHex + }; +} + +export function createSetAmbientLightIntensityControlEffect(options: { + target: SceneControlTargetRef; + intensity: number; +}): SetAmbientLightIntensityControlEffect { + assertNonNegativeFiniteNumber(options.intensity, "Control ambient light intensity"); + + return { + type: "setAmbientLightIntensity", + target: cloneControlTargetRef(options.target) as SceneControlTargetRef, + intensity: options.intensity + }; +} + +export function createSetAmbientLightColorControlEffect(options: { + target: SceneControlTargetRef; + colorHex: string; +}): SetAmbientLightColorControlEffect { + assertHexColor(options.colorHex, "Control ambient light color"); + + return { + type: "setAmbientLightColor", + target: cloneControlTargetRef(options.target) as SceneControlTargetRef, + colorHex: options.colorHex + }; +} + +export function createSetSunLightIntensityControlEffect(options: { + target: SceneControlTargetRef; + intensity: number; +}): SetSunLightIntensityControlEffect { + assertNonNegativeFiniteNumber(options.intensity, "Control sun light intensity"); + + return { + type: "setSunLightIntensity", + target: cloneControlTargetRef(options.target) as SceneControlTargetRef, + intensity: options.intensity + }; +} + +export function createSetSunLightColorControlEffect(options: { + target: SceneControlTargetRef; + colorHex: string; +}): SetSunLightColorControlEffect { + assertHexColor(options.colorHex, "Control sun light color"); + + return { + type: "setSunLightColor", + target: cloneControlTargetRef(options.target) as SceneControlTargetRef, + colorHex: options.colorHex + }; +} + +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 createSoundVolumeControlChannelDescriptor(options: { + target: SoundEmitterControlTargetRef; + defaultValue: number; + minValue?: number; +}): SoundVolumeControlChannelDescriptor { + const minValue = options.minValue ?? 0; + assertNonNegativeFiniteNumber(options.defaultValue, "Control sound volume default"); + assertNonNegativeFiniteNumber(minValue, "Control sound volume minimum"); + + return { + channel: "sound.volume", + target: cloneControlTargetRef(options.target) as SoundEmitterControlTargetRef, + minValue, + defaultValue: options.defaultValue + }; +} + +export function createAmbientLightIntensityControlChannelDescriptor(options: { + target: SceneControlTargetRef; + defaultValue: number; + minValue?: number; +}): AmbientLightIntensityControlChannelDescriptor { + const minValue = options.minValue ?? 0; + assertNonNegativeFiniteNumber( + options.defaultValue, + "Control ambient light intensity default" + ); + assertNonNegativeFiniteNumber( + minValue, + "Control ambient light intensity minimum" + ); + + return { + channel: "ambientLight.intensity", + target: cloneControlTargetRef(options.target) as SceneControlTargetRef, + minValue, + defaultValue: options.defaultValue + }; +} + +export function createSunLightIntensityControlChannelDescriptor(options: { + target: SceneControlTargetRef; + defaultValue: number; + minValue?: number; +}): SunLightIntensityControlChannelDescriptor { + const minValue = options.minValue ?? 0; + assertNonNegativeFiniteNumber( + options.defaultValue, + "Control sun light intensity default" + ); + assertNonNegativeFiniteNumber(minValue, "Control sun light intensity minimum"); + + return { + channel: "sunLight.intensity", + target: cloneControlTargetRef(options.target) as SceneControlTargetRef, + minValue, + defaultValue: options.defaultValue + }; +} + +export function createDefaultResolvedControlSource(): DefaultResolvedControlSource { + return { + kind: "default" + }; +} + +export function createSchedulerResolvedControlSource( + scheduleId: string +): SchedulerResolvedControlSource { + assertNonEmptyString(scheduleId, "Resolved control scheduler id"); + + return { + kind: "scheduler", + scheduleId + }; +} + +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 createResolvedActorPresenceState(options: { + target: ActorControlTargetRef; + value: boolean; + source: RuntimeResolvedControlSource; +}): RuntimeResolvedActorPresenceState { + assertBoolean(options.value, "Resolved control actor active"); + + return { + type: "actorPresence", + target: cloneControlTargetRef(options.target) as ActorControlTargetRef, + 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 createResolvedModelInstanceVisibilityState(options: { + target: ModelInstanceControlTargetRef; + value: boolean; + source: RuntimeResolvedControlSource; +}): RuntimeResolvedModelInstanceVisibilityState { + assertBoolean(options.value, "Resolved control model instance visible"); + + return { + type: "modelVisibility", + target: cloneControlTargetRef( + options.target + ) as ModelInstanceControlTargetRef, + value: options.value, + source: cloneResolvedControlSource(options.source) + }; +} + +export function createResolvedSoundPlaybackState(options: { + target: SoundEmitterControlTargetRef; + value: boolean; + source: RuntimeResolvedControlSource; +}): RuntimeResolvedSoundPlaybackState { + assertBoolean(options.value, "Resolved control sound playback"); + + return { + type: "soundPlayback", + target: cloneControlTargetRef(options.target) as SoundEmitterControlTargetRef, + value: options.value, + source: cloneResolvedControlSource(options.source) + }; +} + +export function createResolvedModelAnimationPlaybackState(options: { + target: ModelInstanceControlTargetRef; + clipName: string | null; + loop: boolean | undefined; + source: RuntimeResolvedControlSource; +}): RuntimeResolvedModelAnimationPlaybackState { + if (options.clipName !== null) { + assertNonEmptyString(options.clipName, "Resolved control animation clip"); + } + + return { + type: "modelAnimationPlayback", + target: cloneControlTargetRef( + options.target + ) as ModelInstanceControlTargetRef, + clipName: options.clipName, + loop: options.loop, + source: cloneResolvedControlSource(options.source) + }; +} + +export function createResolvedLightColorState(options: { + target: LightControlTargetRef; + value: string; + source: RuntimeResolvedControlSource; +}): RuntimeResolvedLightColorState { + assertHexColor(options.value, "Resolved control light color"); + + return { + type: "lightColor", + target: cloneControlTargetRef(options.target) as LightControlTargetRef, + value: options.value, + source: cloneResolvedControlSource(options.source) + }; +} + +export function createResolvedAmbientLightColorState(options: { + target: SceneControlTargetRef; + value: string; + source: RuntimeResolvedControlSource; +}): RuntimeResolvedAmbientLightColorState { + assertHexColor(options.value, "Resolved control ambient light color"); + + return { + type: "ambientLightColor", + target: cloneControlTargetRef(options.target) as SceneControlTargetRef, + value: options.value, + source: cloneResolvedControlSource(options.source) + }; +} + +export function createResolvedSunLightColorState(options: { + target: SceneControlTargetRef; + value: string; + source: RuntimeResolvedControlSource; +}): RuntimeResolvedSunLightColorState { + assertHexColor(options.value, "Resolved control sun light color"); + + return { + type: "sunLightColor", + target: cloneControlTargetRef(options.target) as SceneControlTargetRef, + 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 createResolvedSoundVolumeChannelValue(options: { + descriptor: SoundVolumeControlChannelDescriptor; + value: number; + source: RuntimeResolvedControlSource; +}): RuntimeResolvedSoundVolumeChannelValue { + assertNonNegativeFiniteNumber(options.value, "Resolved control sound volume"); + + return { + type: "soundVolume", + descriptor: cloneControlChannelDescriptor( + options.descriptor + ) as SoundVolumeControlChannelDescriptor, + value: options.value, + source: cloneResolvedControlSource(options.source) + }; +} + +export function createResolvedAmbientLightIntensityChannelValue(options: { + descriptor: AmbientLightIntensityControlChannelDescriptor; + value: number; + source: RuntimeResolvedControlSource; +}): RuntimeResolvedAmbientLightIntensityChannelValue { + assertNonNegativeFiniteNumber( + options.value, + "Resolved control ambient light intensity" + ); + + return { + type: "ambientLightIntensity", + descriptor: cloneControlChannelDescriptor( + options.descriptor + ) as AmbientLightIntensityControlChannelDescriptor, + value: options.value, + source: cloneResolvedControlSource(options.source) + }; +} + +export function createResolvedSunLightIntensityChannelValue(options: { + descriptor: SunLightIntensityControlChannelDescriptor; + value: number; + source: RuntimeResolvedControlSource; +}): RuntimeResolvedSunLightIntensityChannelValue { + assertNonNegativeFiniteNumber( + options.value, + "Resolved control sun light intensity" + ); + + return { + type: "sunLightIntensity", + descriptor: cloneControlChannelDescriptor( + options.descriptor + ) as SunLightIntensityControlChannelDescriptor, + 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 { + return `channel:${descriptor.channel}:${getControlTargetRefKey( + descriptor.target + )}`; +} + +function getResolvedDiscreteControlStateKey( + state: RuntimeResolvedDiscreteControlState +): string { + 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 "setActorPresence": + return { + type: "setActorPresence", + target: cloneControlTargetRef(effect.target), + active: effect.active + } as TEffect; + 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 "setModelInstanceVisible": + return { + type: "setModelInstanceVisible", + target: cloneControlTargetRef(effect.target), + visible: effect.visible + } 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 "setSoundVolume": + return { + type: "setSoundVolume", + target: cloneControlTargetRef(effect.target), + volume: effect.volume + } 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; + case "setLightColor": + return { + type: "setLightColor", + target: cloneControlTargetRef(effect.target), + colorHex: effect.colorHex + } as TEffect; + case "setAmbientLightIntensity": + return { + type: "setAmbientLightIntensity", + target: cloneControlTargetRef(effect.target), + intensity: effect.intensity + } as TEffect; + case "setAmbientLightColor": + return { + type: "setAmbientLightColor", + target: cloneControlTargetRef(effect.target), + colorHex: effect.colorHex + } as TEffect; + case "setSunLightIntensity": + return { + type: "setSunLightIntensity", + target: cloneControlTargetRef(effect.target), + intensity: effect.intensity + } as TEffect; + case "setSunLightColor": + return { + type: "setSunLightColor", + target: cloneControlTargetRef(effect.target), + colorHex: effect.colorHex + } 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; + case "sound.volume": + return { + channel: "sound.volume", + target: cloneControlTargetRef(descriptor.target), + minValue: descriptor.minValue, + defaultValue: descriptor.defaultValue + } as TDescriptor; + case "ambientLight.intensity": + return { + channel: "ambientLight.intensity", + target: cloneControlTargetRef(descriptor.target), + minValue: descriptor.minValue, + defaultValue: descriptor.defaultValue + } as TDescriptor; + case "sunLight.intensity": + return { + channel: "sunLight.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 "actorPresence": + return createResolvedActorPresenceState({ + target: state.target, + value: state.value, + source: state.source + }) as TState; + 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; + case "modelVisibility": + return createResolvedModelInstanceVisibilityState({ + target: state.target, + value: state.value, + source: state.source + }) as TState; + case "soundPlayback": + return createResolvedSoundPlaybackState({ + target: state.target, + value: state.value, + source: state.source + }) as TState; + case "modelAnimationPlayback": + return createResolvedModelAnimationPlaybackState({ + target: state.target, + clipName: state.clipName, + loop: state.loop, + source: state.source + }) as TState; + case "lightColor": + return createResolvedLightColorState({ + target: state.target, + value: state.value, + source: state.source + }) as TState; + case "ambientLightColor": + return createResolvedAmbientLightColorState({ + target: state.target, + value: state.value, + source: state.source + }) as TState; + case "sunLightColor": + return createResolvedSunLightColorState({ + 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; + case "soundVolume": + return createResolvedSoundVolumeChannelValue({ + descriptor: value.descriptor, + value: value.value, + source: value.source + }) as TValue; + case "ambientLightIntensity": + return createResolvedAmbientLightIntensityChannelValue({ + descriptor: value.descriptor, + value: value.value, + source: value.source + }) as TValue; + case "sunLightIntensity": + return createResolvedSunLightIntensityChannelValue({ + 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 "setActorPresence": + return left.active === (right as SetActorPresenceControlEffect).active; + case "playModelAnimation": + return ( + left.clipName === (right as PlayModelAnimationControlEffect).clipName && + left.loop === (right as PlayModelAnimationControlEffect).loop + ); + case "stopModelAnimation": + case "playSound": + case "stopSound": + return true; + case "setModelInstanceVisible": + return ( + left.visible === + (right as SetModelInstanceVisibleControlEffect).visible + ); + case "setSoundVolume": + return left.volume === (right as SetSoundVolumeControlEffect).volume; + 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 + ); + case "setLightColor": + return left.colorHex === (right as SetLightColorControlEffect).colorHex; + case "setAmbientLightIntensity": + return ( + left.intensity === + (right as SetAmbientLightIntensityControlEffect).intensity + ); + case "setAmbientLightColor": + return ( + left.colorHex === + (right as SetAmbientLightColorControlEffect).colorHex + ); + case "setSunLightIntensity": + return ( + left.intensity === (right as SetSunLightIntensityControlEffect).intensity + ); + case "setSunLightColor": + return left.colorHex === (right as SetSunLightColorControlEffect).colorHex; + } +} + +export function getControlEffectLabel(effect: ControlEffect): string { + switch (effect.type) { + case "setActorPresence": + return "Set Actor Presence"; + case "playModelAnimation": + return "Play Animation"; + case "stopModelAnimation": + return "Stop Animation"; + case "setModelInstanceVisible": + return "Set Model Visibility"; + case "playSound": + return "Play Sound"; + case "stopSound": + return "Stop Sound"; + case "setSoundVolume": + return "Set Sound Volume"; + case "setInteractionEnabled": + return "Set Interaction Enabled"; + case "setLightEnabled": + return "Set Light Enabled"; + case "setLightIntensity": + return "Set Light Intensity"; + case "setLightColor": + return "Set Light Color"; + case "setAmbientLightIntensity": + return "Set Ambient Light Intensity"; + case "setAmbientLightColor": + return "Set Ambient Light Color"; + case "setSunLightIntensity": + return "Set Sun Light Intensity"; + case "setSunLightColor": + return "Set Sun Light Color"; + } +} + +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 "setActorPresence": + return effect.active ? "Present" : "Hidden"; + case "playModelAnimation": + return effect.loop === false + ? `${effect.clipName} (Once)` + : `${effect.clipName} (Loop)`; + case "stopModelAnimation": + return "Stop"; + case "setModelInstanceVisible": + return effect.visible ? "Visible" : "Hidden"; + case "playSound": + return "Play"; + case "stopSound": + return "Stop"; + case "setSoundVolume": + return String(effect.volume); + case "setInteractionEnabled": + case "setLightEnabled": + return effect.enabled ? "Enabled" : "Disabled"; + case "setLightIntensity": + case "setAmbientLightIntensity": + case "setSunLightIntensity": + return String(effect.intensity); + case "setLightColor": + case "setAmbientLightColor": + case "setSunLightColor": + return effect.colorHex; + } +} + +export function applyControlEffectToResolvedState( + resolved: RuntimeResolvedControlState, + effect: ControlEffect, + source: RuntimeResolvedControlSource +): RuntimeResolvedControlState { + const nextResolved = cloneRuntimeResolvedControlState(resolved); + + switch (effect.type) { + case "setActorPresence": + upsertResolvedDiscreteState( + nextResolved, + createResolvedActorPresenceState({ + target: effect.target, + value: effect.active, + source + }) + ); + return nextResolved; + case "playModelAnimation": + upsertResolvedDiscreteState( + nextResolved, + createResolvedModelAnimationPlaybackState({ + target: effect.target, + clipName: effect.clipName, + loop: effect.loop, + source + }) + ); + return nextResolved; + case "stopModelAnimation": + upsertResolvedDiscreteState( + nextResolved, + createResolvedModelAnimationPlaybackState({ + target: effect.target, + clipName: null, + loop: undefined, + source + }) + ); + return nextResolved; + case "setModelInstanceVisible": + upsertResolvedDiscreteState( + nextResolved, + createResolvedModelInstanceVisibilityState({ + target: effect.target, + value: effect.visible, + source + }) + ); + return nextResolved; + case "playSound": + upsertResolvedDiscreteState( + nextResolved, + createResolvedSoundPlaybackState({ + target: effect.target, + value: true, + source + }) + ); + return nextResolved; + case "stopSound": + upsertResolvedDiscreteState( + nextResolved, + createResolvedSoundPlaybackState({ + target: effect.target, + value: false, + source + }) + ); + return nextResolved; + case "setSoundVolume": { + const descriptor = + nextResolved.channels.find( + ( + candidate + ): candidate is RuntimeResolvedSoundVolumeChannelValue => + candidate.type === "soundVolume" && + getControlTargetRefKey(candidate.descriptor.target) === + getControlTargetRefKey(effect.target) + )?.descriptor ?? + createSoundVolumeControlChannelDescriptor({ + target: effect.target, + defaultValue: effect.volume + }); + + upsertResolvedChannelValue( + nextResolved, + createResolvedSoundVolumeChannelValue({ + descriptor, + value: effect.volume, + source + }) + ); + return nextResolved; + } + case "setInteractionEnabled": + upsertResolvedDiscreteState( + nextResolved, + createResolvedInteractionEnabledState({ + target: effect.target, + value: effect.enabled, + source + }) + ); + return nextResolved; + case "setLightEnabled": + upsertResolvedDiscreteState( + nextResolved, + createResolvedLightEnabledState({ + target: effect.target, + value: effect.enabled, + source + }) + ); + return nextResolved; + case "setLightIntensity": { + const descriptor = + nextResolved.channels.find( + ( + candidate + ): candidate is RuntimeResolvedLightIntensityChannelValue => + candidate.type === "lightIntensity" && + getControlTargetRefKey(candidate.descriptor.target) === + getControlTargetRefKey(effect.target) + )?.descriptor ?? + createLightIntensityControlChannelDescriptor({ + target: effect.target, + defaultValue: effect.intensity + }); + + upsertResolvedChannelValue( + nextResolved, + createResolvedLightIntensityChannelValue({ + descriptor, + value: effect.intensity, + source + }) + ); + return nextResolved; + } + case "setLightColor": + upsertResolvedDiscreteState( + nextResolved, + createResolvedLightColorState({ + target: effect.target, + value: effect.colorHex, + source + }) + ); + return nextResolved; + case "setAmbientLightIntensity": { + const descriptor = + nextResolved.channels.find( + ( + candidate + ): candidate is RuntimeResolvedAmbientLightIntensityChannelValue => + candidate.type === "ambientLightIntensity" && + getControlTargetRefKey(candidate.descriptor.target) === + getControlTargetRefKey(effect.target) + )?.descriptor ?? + createAmbientLightIntensityControlChannelDescriptor({ + target: effect.target, + defaultValue: effect.intensity + }); + + upsertResolvedChannelValue( + nextResolved, + createResolvedAmbientLightIntensityChannelValue({ + descriptor, + value: effect.intensity, + source + }) + ); + return nextResolved; + } + case "setAmbientLightColor": + upsertResolvedDiscreteState( + nextResolved, + createResolvedAmbientLightColorState({ + target: effect.target, + value: effect.colorHex, + source + }) + ); + return nextResolved; + case "setSunLightIntensity": { + const descriptor = + nextResolved.channels.find( + ( + candidate + ): candidate is RuntimeResolvedSunLightIntensityChannelValue => + candidate.type === "sunLightIntensity" && + getControlTargetRefKey(candidate.descriptor.target) === + getControlTargetRefKey(effect.target) + )?.descriptor ?? + createSunLightIntensityControlChannelDescriptor({ + target: effect.target, + defaultValue: effect.intensity + }); + + upsertResolvedChannelValue( + nextResolved, + createResolvedSunLightIntensityChannelValue({ + descriptor, + value: effect.intensity, + source + }) + ); + return nextResolved; + } + case "setSunLightColor": + upsertResolvedDiscreteState( + nextResolved, + createResolvedSunLightColorState({ + target: effect.target, + value: effect.colorHex, + source + }) + ); + return nextResolved; + } +}