diff --git a/src/document/scene-document.ts b/src/document/scene-document.ts index e7aed5da..112751cf 100644 --- a/src/document/scene-document.ts +++ b/src/document/scene-document.ts @@ -32,7 +32,8 @@ import { type ProjectSequenceLibrary } from "../sequencer/project-sequences"; -export const SCENE_DOCUMENT_VERSION = 55 as const; +export const SCENE_DOCUMENT_VERSION = 56 as const; +export const PROJECT_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION = 56 as const; export const PROJECT_SEQUENCE_TIMING_SCENE_DOCUMENT_VERSION = 55 as const; export const PROJECT_SEQUENCE_CLIPS_SCENE_DOCUMENT_VERSION = 54 as const; export const PROJECT_SEQUENCE_LIBRARY_SCENE_DOCUMENT_VERSION = 53 as const; diff --git a/src/runtime-three/runtime-project-scheduler.ts b/src/runtime-three/runtime-project-scheduler.ts index 170ee47d..fc12dbee 100644 --- a/src/runtime-three/runtime-project-scheduler.ts +++ b/src/runtime-three/runtime-project-scheduler.ts @@ -28,8 +28,8 @@ import { } from "../sequencer/project-sequencer"; import { findHeldSequenceControlEffect, - getProjectScheduleRoutineHeldSteps, - getProjectScheduleRoutineResolvedHeldControlEffectsAtMinute + getHeldSequenceControlEffects, + getProjectScheduleRoutineHeldSteps } from "../sequencer/project-sequence-steps"; import type { ProjectSequenceLibrary } from "../sequencer/project-sequences"; @@ -431,42 +431,23 @@ export function resolveRuntimeActorScheduleState(options: { activeRoutine, options.sequences ); - const elapsedHours = getProjectScheduleRoutineElapsedHoursAt( - activeRoutine, - options.dayNumber, - options.timeOfDayHours - ); - const resolvedHeldEffects = - elapsedHours === null - ? [] - : getProjectScheduleRoutineResolvedHeldControlEffectsAtMinute( - activeRoutine, - options.sequences, - elapsedHours * 60 - ); - const presenceEffect = - (resolvedHeldEffects.find( - (entry): entry is typeof entry & { effect: SetActorPresenceControlEffect } => - entry.effect.type === "setActorPresence" - )?.effect ?? - findHeldSequenceControlEffect(heldSteps, "setActorPresence")) ?? + findHeldSequenceControlEffect(heldSteps, "setActorPresence") ?? createSetActorPresenceControlEffect({ target: createActorControlTargetRef(options.actorId), active: true }); const animationEffect = - resolvedHeldEffects.find( - (entry): entry is typeof entry & { effect: PlayActorAnimationControlEffect } => - entry.effect.type === "playActorAnimation" - )?.effect ?? findHeldSequenceControlEffect(heldSteps, "playActorAnimation"); - const pathEffectEntry = resolvedHeldEffects.find( - (entry): entry is typeof entry & { effect: FollowActorPathControlEffect } => - entry.effect.type === "followActorPath" + findHeldSequenceControlEffect(heldSteps, "playActorAnimation"); + const pathEffect = findHeldSequenceControlEffect( + heldSteps, + "followActorPath" + ); + const elapsedHours = getProjectScheduleRoutineElapsedHoursAt( + activeRoutine, + options.dayNumber, + options.timeOfDayHours ); - const pathEffect = - pathEffectEntry?.effect ?? - findHeldSequenceControlEffect(heldSteps, "followActorPath"); return { actorId: options.actorId, @@ -483,10 +464,7 @@ export function resolveRuntimeActorScheduleState(options: { ? null : resolveActorSchedulePathState({ effect: pathEffect, - elapsedHours: - pathEffectEntry === undefined - ? elapsedHours - : pathEffectEntry.elapsedMinutes / 60, + elapsedHours, path: options.pathsById?.get(pathEffect.pathId) ?? null }) }; @@ -540,18 +518,9 @@ function resolveRuntimeScheduledControlRoutines(options: { continue; } - const elapsedHours = getProjectScheduleRoutineElapsedHoursAt( - routine, - options.dayNumber, - options.timeOfDayHours - ); - const effects = getProjectScheduleRoutineResolvedHeldControlEffectsAtMinute( - routine, - options.sequences, - elapsedHours === null ? null : elapsedHours * 60 - ).map((entry) => cloneControlEffect(entry.effect)); - - for (const effect of effects) { + for (const effect of getHeldSequenceControlEffects( + getProjectScheduleRoutineHeldSteps(routine, options.sequences) + )) { const resolutionKey = getControlEffectResolutionKey(effect); if (seenResolutionKeys.has(resolutionKey)) { diff --git a/src/sequencer/project-sequence-steps.ts b/src/sequencer/project-sequence-steps.ts new file mode 100644 index 00000000..13d70a15 --- /dev/null +++ b/src/sequencer/project-sequence-steps.ts @@ -0,0 +1,378 @@ +import { + cloneControlEffect, + getControlEffectLabel, + type ControlEffect +} from "../controls/control-surface"; +import { + getInteractionActionControlEffect, + type InteractionLink +} from "../interactions/interaction-links"; +import type { ProjectScheduleRoutine } from "../scheduler/project-scheduler"; + +export interface HeldControlSequenceEffect { + stepClass: "held"; + type: "controlEffect"; + effect: ControlEffect; +} + +export interface ImpulseControlSequenceEffect { + stepClass: "impulse"; + type: "controlEffect"; + effect: ControlEffect; +} + +export interface StartDialogueSequenceEffect { + stepClass: "impulse"; + type: "startDialogue"; + dialogueId: string; +} + +export interface TeleportPlayerSequenceEffect { + stepClass: "impulse"; + type: "teleportPlayer"; + targetEntityId: string; +} + +export interface ToggleVisibilitySequenceEffect { + stepClass: "impulse"; + type: "toggleVisibility"; + targetBrushId: string; + visible?: boolean; +} + +type SequenceDefinitionLike = { + id: string; + effects: SequenceEffect[]; +}; + +type SequenceLibraryLike = { + sequences: Record; +}; + +export type HeldSequenceStep = HeldControlSequenceEffect; + +export type ImpulseSequenceStep = + | ImpulseControlSequenceEffect + | StartDialogueSequenceEffect + | TeleportPlayerSequenceEffect + | ToggleVisibilitySequenceEffect; + +export type SequenceEffect = HeldSequenceStep | ImpulseSequenceStep; +export type SequenceClip = SequenceEffect; +export type SequenceStep = SequenceEffect; + +export function cloneSequenceEffect(effect: SequenceEffect): SequenceEffect { + switch (effect.type) { + case "controlEffect": + return { + stepClass: effect.stepClass, + type: "controlEffect", + effect: cloneControlEffect(effect.effect) + }; + case "startDialogue": + return { + stepClass: "impulse", + type: "startDialogue", + dialogueId: effect.dialogueId + }; + case "teleportPlayer": + return { + stepClass: "impulse", + type: "teleportPlayer", + targetEntityId: effect.targetEntityId + }; + case "toggleVisibility": + return { + stepClass: "impulse", + type: "toggleVisibility", + targetBrushId: effect.targetBrushId, + visible: effect.visible + }; + } +} + +export function cloneSequenceClip(clip: SequenceClip): SequenceClip { + return cloneSequenceEffect(clip); +} + +export function cloneSequenceStep(step: SequenceStep): SequenceStep { + return cloneSequenceEffect(step); +} + +export function cloneSequenceEffects(effects: SequenceEffect[]): SequenceEffect[] { + return effects.map(cloneSequenceEffect); +} + +export function cloneSequenceClips(clips: SequenceClip[]): SequenceClip[] { + return cloneSequenceEffects(clips); +} + +export function cloneSequenceSteps(steps: SequenceStep[]): SequenceStep[] { + return cloneSequenceEffects(steps); +} + +export function getSequenceEffectLabel(effect: SequenceEffect): string { + switch (effect.type) { + case "controlEffect": + return `${effect.stepClass === "held" ? "Held" : "Impulse"}: ${getControlEffectLabel(effect.effect)}`; + case "startDialogue": + return "Impulse: Start Dialogue"; + case "teleportPlayer": + return "Impulse: Teleport Player"; + case "toggleVisibility": + return "Impulse: Toggle Visibility"; + } +} + +export function getSequenceClipLabel(clip: SequenceClip): string { + return getSequenceEffectLabel(clip); +} + +export function getSequenceStepLabel(step: SequenceStep): string { + return getSequenceEffectLabel(step); +} + +export function getHeldSequenceEffects( + effects: readonly SequenceEffect[] +): HeldSequenceStep[] { + return effects + .filter((effect): effect is HeldSequenceStep => effect.stepClass === "held") + .map(cloneSequenceEffect) as HeldSequenceStep[]; +} + +export function getHeldSequenceClips( + clips: readonly SequenceClip[] +): HeldSequenceStep[] { + return getHeldSequenceEffects(clips); +} + +export function getHeldSequenceSteps( + steps: readonly SequenceStep[] +): HeldSequenceStep[] { + return getHeldSequenceEffects(steps); +} + +export function getImpulseSequenceEffects( + effects: readonly SequenceEffect[] +): ImpulseSequenceStep[] { + return effects + .filter((effect): effect is ImpulseSequenceStep => effect.stepClass === "impulse") + .map(cloneSequenceEffect) as ImpulseSequenceStep[]; +} + +export function getImpulseSequenceClips( + clips: readonly SequenceClip[] +): ImpulseSequenceStep[] { + return getImpulseSequenceEffects(clips); +} + +export function getImpulseSequenceSteps( + steps: readonly SequenceStep[] +): ImpulseSequenceStep[] { + return getImpulseSequenceEffects(steps); +} + +export function getProjectSequenceHeldEffects( + sequence: SequenceDefinitionLike +): HeldSequenceStep[] { + return getHeldSequenceEffects(sequence.effects); +} + +export function getProjectSequenceHeldClips( + sequence: SequenceDefinitionLike +): HeldSequenceStep[] { + return getProjectSequenceHeldEffects(sequence); +} + +export function getProjectSequenceHeldSteps( + sequence: SequenceDefinitionLike +): HeldSequenceStep[] { + return getProjectSequenceHeldEffects(sequence); +} + +export function getProjectSequenceImpulseEffects( + sequence: SequenceDefinitionLike +): ImpulseSequenceStep[] { + return getImpulseSequenceEffects(sequence.effects); +} + +export function getProjectSequenceImpulseClips( + sequence: SequenceDefinitionLike +): ImpulseSequenceStep[] { + return getProjectSequenceImpulseEffects(sequence); +} + +export function getProjectSequenceImpulseSteps( + sequence: SequenceDefinitionLike +): ImpulseSequenceStep[] { + return getProjectSequenceImpulseEffects(sequence); +} + +export function getInteractionLinkImpulseEffects( + link: InteractionLink, + sequenceLibrary?: SequenceLibraryLike | null +): ImpulseSequenceStep[] { + const controlEffect = getInteractionActionControlEffect(link.action); + + if (controlEffect !== null) { + return [ + { + stepClass: "impulse", + type: "controlEffect", + effect: controlEffect + } + ]; + } + + switch (link.action.type) { + case "teleportPlayer": + return [ + { + stepClass: "impulse", + type: "teleportPlayer", + targetEntityId: link.action.targetEntityId + } + ]; + case "toggleVisibility": + return [ + { + stepClass: "impulse", + type: "toggleVisibility", + targetBrushId: link.action.targetBrushId, + visible: link.action.visible + } + ]; + case "startDialogue": + return [ + { + stepClass: "impulse", + type: "startDialogue", + dialogueId: link.action.dialogueId + } + ]; + case "runSequence": { + const sequence = + sequenceLibrary?.sequences[link.action.sequenceId] ?? null; + return sequence === null ? [] : getProjectSequenceImpulseEffects(sequence); + } + case "playAnimation": + case "stopAnimation": + case "playSound": + case "stopSound": + case "control": + throw new Error( + `Interaction action ${link.action.type} should have normalized to a controlEffect sequence effect.` + ); + } +} + +export function getInteractionLinkImpulseClips( + link: InteractionLink, + sequenceLibrary?: SequenceLibraryLike | null +): ImpulseSequenceStep[] { + return getInteractionLinkImpulseEffects(link, sequenceLibrary); +} + +export function getInteractionLinkImpulseSteps( + link: InteractionLink, + sequenceLibrary?: SequenceLibraryLike | null +): ImpulseSequenceStep[] { + return getInteractionLinkImpulseEffects(link, sequenceLibrary); +} + +export function getInteractionLinkSequenceEffects( + link: InteractionLink, + sequenceLibrary?: SequenceLibraryLike | null +): SequenceEffect[] { + return getInteractionLinkImpulseEffects(link, sequenceLibrary); +} + +export function getInteractionLinkSequenceClips( + link: InteractionLink, + sequenceLibrary?: SequenceLibraryLike | null +): SequenceClip[] { + return getInteractionLinkSequenceEffects(link, sequenceLibrary); +} + +export function getInteractionLinkSequenceSteps( + link: InteractionLink, + sequenceLibrary?: SequenceLibraryLike | null +): SequenceStep[] { + return getInteractionLinkSequenceEffects(link, sequenceLibrary); +} + +export function getProjectScheduleRoutineHeldEffects( + routine: ProjectScheduleRoutine, + sequenceLibrary?: SequenceLibraryLike | null +): HeldSequenceStep[] { + if (routine.sequenceId !== null) { + const sequence = sequenceLibrary?.sequences[routine.sequenceId] ?? null; + + if (sequence !== null) { + return getProjectSequenceHeldEffects(sequence); + } + } + + return routine.effects.map((effect) => ({ + stepClass: "held" as const, + type: "controlEffect" as const, + effect: cloneControlEffect(effect) + })); +} + +export function getProjectScheduleRoutineHeldClips( + routine: ProjectScheduleRoutine, + sequenceLibrary?: SequenceLibraryLike | null +): HeldSequenceStep[] { + return getProjectScheduleRoutineHeldEffects(routine, sequenceLibrary); +} + +export function getProjectScheduleRoutineHeldSteps( + routine: ProjectScheduleRoutine, + sequenceLibrary?: SequenceLibraryLike | null +): HeldSequenceStep[] { + return getProjectScheduleRoutineHeldEffects(routine, sequenceLibrary); +} + +export function getProjectScheduleRoutineSequenceEffects( + routine: ProjectScheduleRoutine, + sequenceLibrary?: SequenceLibraryLike | null +): SequenceEffect[] { + return getProjectScheduleRoutineHeldEffects(routine, sequenceLibrary); +} + +export function getProjectScheduleRoutineSequenceClips( + routine: ProjectScheduleRoutine, + sequenceLibrary?: SequenceLibraryLike | null +): SequenceClip[] { + return getProjectScheduleRoutineSequenceEffects(routine, sequenceLibrary); +} + +export function getProjectScheduleRoutineSequenceSteps( + routine: ProjectScheduleRoutine, + sequenceLibrary?: SequenceLibraryLike | null +): SequenceStep[] { + return getProjectScheduleRoutineSequenceEffects(routine, sequenceLibrary); +} + +export function getHeldSequenceControlEffects( + steps: readonly HeldSequenceStep[] +): ControlEffect[] { + return steps + .filter((step): step is HeldControlSequenceEffect => step.type === "controlEffect") + .map((step) => cloneControlEffect(step.effect)); +} + +export function findHeldSequenceControlEffect< + TType extends ControlEffect["type"] +>( + steps: readonly HeldSequenceStep[], + type: TType +): Extract | null { + return ( + getHeldSequenceControlEffects(steps).find( + (effect): effect is Extract => + effect.type === type + ) ?? null + ); +} diff --git a/src/sequencer/project-sequences.ts b/src/sequencer/project-sequences.ts index 3579c452..f4b25268 100644 --- a/src/sequencer/project-sequences.ts +++ b/src/sequencer/project-sequences.ts @@ -1,18 +1,16 @@ import { createOpaqueId } from "../core/ids"; import { - cloneSequenceClip, - DEFAULT_PROJECT_SEQUENCE_DURATION_MINUTES, - getProjectSequenceDurationMinutes, + cloneSequenceEffect, type SequenceClip, + type SequenceEffect, type SequenceStep } from "./project-sequence-steps"; export interface ProjectSequence { id: string; title: string; - durationMinutes: number; - clips: SequenceClip[]; + effects: SequenceEffect[]; } export interface ProjectSequenceLibrary { @@ -29,16 +27,6 @@ function normalizeProjectSequenceTitle(title: string | undefined): string { return normalizedTitle; } -function normalizeProjectSequenceDurationMinutes( - value: number | undefined -): number { - if (value === undefined || !Number.isFinite(value)) { - return DEFAULT_PROJECT_SEQUENCE_DURATION_MINUTES; - } - - return Math.max(1, Math.trunc(value)); -} - export function createEmptyProjectSequenceLibrary(): ProjectSequenceLibrary { return { sequences: {} @@ -46,21 +34,18 @@ export function createEmptyProjectSequenceLibrary(): ProjectSequenceLibrary { } export function createProjectSequence( - overrides: Partial> & { + overrides: Partial> & { + effects?: SequenceEffect[]; clips?: SequenceClip[]; steps?: SequenceStep[]; } = {} ): ProjectSequence { - const clips = (overrides.clips ?? overrides.steps)?.map(cloneSequenceClip) ?? []; - return { id: overrides.id ?? createOpaqueId("sequence"), title: normalizeProjectSequenceTitle(overrides.title ?? "Sequence"), - durationMinutes: normalizeProjectSequenceDurationMinutes( - overrides.durationMinutes ?? - (clips.length > 0 ? getProjectSequenceDurationMinutes({ id: "sequence", clips }) : undefined) - ), - clips + effects: (overrides.effects ?? overrides.clips ?? overrides.steps)?.map( + cloneSequenceEffect + ) ?? [] }; } @@ -70,8 +55,7 @@ export function cloneProjectSequence( return { id: sequence.id, title: sequence.title, - durationMinutes: sequence.durationMinutes, - clips: sequence.clips.map(cloneSequenceClip) + effects: sequence.effects.map(cloneSequenceEffect) }; } @@ -95,20 +79,19 @@ export function areProjectSequencesEqual( if ( left.id !== right.id || left.title !== right.title || - left.durationMinutes !== right.durationMinutes || - left.clips.length !== right.clips.length + left.effects.length !== right.effects.length ) { return false; } - return left.clips.every((clip, index) => { - const rightClip = right.clips[index]; + return left.effects.every((effect, index) => { + const rightEffect = right.effects[index]; - if (rightClip === undefined) { + if (rightEffect === undefined) { return false; } - return JSON.stringify(clip) === JSON.stringify(rightClip); + return JSON.stringify(effect) === JSON.stringify(rightEffect); }); }