import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; import { HOURS_PER_DAY, formatTimeOfDayHours } from "../document/project-time-settings"; import { formatControlEffectValue, getControlTargetRefKey } from "../controls/control-surface"; import { ProjectSequencesPanel } from "./ProjectSequencesPanel"; import { getProjectScheduleTimelineSegments, type ProjectScheduler, type ProjectScheduleRoutine } from "../sequencer/project-sequencer"; import { getProjectScheduleTargetOptionForRoutine, type ProjectScheduleEffectOptionId, type ProjectScheduleTargetOption } from "../sequencer/project-sequencer-control-options"; import { findHeldSequenceControlEffect, getProjectScheduleRoutineHeldSteps, getProjectSequenceImpulseSteps } from "../sequencer/project-sequence-steps"; import { getProjectSequences, type ProjectSequenceLibrary } from "../sequencer/project-sequences"; const MINUTES_PER_DAY = HOURS_PER_DAY * 60; const MINIMUM_SEQUENCE_PLACEMENT_DURATION_MINUTES = 1; interface RoutineDragState { routineId: string; mode: "move" | "resize-start" | "resize-end"; originStartMinutes: number; originEndMinutes: number; originTargetKey: string; pointerStartX: number; trackWidth: number; draftStartMinutes: number; draftEndMinutes: number; draftTargetKey: string; } interface ProjectSequencerPaneProps { mode: "timeline" | "sequence"; onSetMode(mode: "timeline" | "sequence"): void; targetOptions: ProjectScheduleTargetOption[]; teleportTargetOptions: Array<{ entityId: string; label: string; }>; sceneTransitionTargetOptions: Array<{ targetKey: string; label: string; }>; visibilityTargetOptions: Array<{ targetKey: string; label: string; }>; scheduler: ProjectScheduler; sequences: ProjectSequenceLibrary; npcTalkTargetOptions: Array<{ npcEntityId: string; label: string; defaultDialogueId: string | null; dialogues: Array<{ dialogueId: string; label: string; }>; }>; selectedRoutineId: string | null; selectedSequenceId: string | null; onSelectRoutine(routineId: string | null): void; onSelectSequence(sequenceId: string | null): void; onAddRoutine(targetKey: string): void; onAddSequence(): void; onDeleteRoutine(routineId: string): void; onDeleteSequence(sequenceId: string): void; onClose(): void; onCreateRoutineSequence(routineId: string): void; onSetRoutineTarget(routineId: string, targetKey: string): void; onSetRoutineTitle(routineId: string, title: string): void; onSetRoutineEnabled(routineId: string, enabled: boolean): void; onSetRoutineStartHour(routineId: string, startHour: number): void; onSetRoutineEndHour(routineId: string, endHour: number): void; onSetRoutinePriority(routineId: string, priority: number): void; onSetRoutineSequenceId(routineId: string, sequenceId: string | null): void; onSetSequenceTitle(sequenceId: string, title: string): void; onAddControlEffect( sequenceId: string, targetKey: string, effectOptionId: ProjectScheduleEffectOptionId ): void; onAddNpcTalkEffect( sequenceId: string, npcEntityId: string, dialogueId: string | null ): void; onAddTeleportStep(sequenceId: string, targetEntityId: string): void; onAddSceneTransitionStep(sequenceId: string, targetKey: string): void; onAddVisibilityStep(sequenceId: string, targetKey: string): void; onDeleteStep(sequenceId: string, stepIndex: number): void; onSetControlStepTarget( sequenceId: string, stepIndex: number, targetKey: string ): void; onSetControlStepEffectOption( sequenceId: string, stepIndex: number, effectOptionId: ProjectScheduleEffectOptionId ): void; onSetControlStepNumericValue( sequenceId: string, stepIndex: number, value: number ): void; onSetControlStepColorValue( sequenceId: string, stepIndex: number, colorHex: string ): void; onSetControlStepAnimationClip( sequenceId: string, stepIndex: number, clipName: string ): void; onSetControlStepAnimationLoop( sequenceId: string, stepIndex: number, loop: boolean ): void; onSetControlStepPathId( sequenceId: string, stepIndex: number, pathId: string ): void; onSetControlStepPathSpeed( sequenceId: string, stepIndex: number, speed: number ): void; onSetControlStepPathLoop( sequenceId: string, stepIndex: number, loop: boolean ): void; onSetControlStepPathSmooth( sequenceId: string, stepIndex: number, smoothPath: boolean ): void; onSetNpcTalkStepNpcEntityId( sequenceId: string, stepIndex: number, npcEntityId: string ): void; onSetNpcTalkStepDialogueId( sequenceId: string, stepIndex: number, dialogueId: string | null ): void; onSetTeleportStepTarget( sequenceId: string, stepIndex: number, targetEntityId: string ): void; onSetSceneTransitionStepTarget( sequenceId: string, stepIndex: number, targetKey: string ): void; onSetVisibilityStepTarget( sequenceId: string, stepIndex: number, targetKey: string ): void; onSetVisibilityStepMode( sequenceId: string, stepIndex: number, mode: "toggle" | "show" | "hide" ): void; } function handleCommitOnEnter( event: ReactKeyboardEvent, commit: () => void ) { if (event.key !== "Enter") { return; } event.currentTarget.blur(); commit(); } function parseTimeOfDayInputHours(value: string, label: string): number { const match = /^(?\d{1,2}):(?\d{2})$/.exec(value.trim()); if (match?.groups === undefined) { throw new Error(`${label} must use HH:MM.`); } const hours = Number(match.groups.hours); const minutes = Number(match.groups.minutes); if ( !Number.isFinite(hours) || !Number.isFinite(minutes) || hours < 0 || hours >= HOURS_PER_DAY || minutes < 0 || minutes >= 60 ) { throw new Error(`${label} must be a valid time of day.`); } return hours + minutes / 60; } function normalizeMinuteOfDay(minute: number): number { const wrappedMinute = minute % MINUTES_PER_DAY; return wrappedMinute < 0 ? wrappedMinute + MINUTES_PER_DAY : wrappedMinute; } function convertHoursToMinuteOfDay(hours: number): number { return normalizeMinuteOfDay(Math.round(hours * 60)); } function convertMinuteOfDayToHours(minute: number): number { return normalizeMinuteOfDay(minute) / 60; } function getMinuteDistance(startMinutes: number, endMinutes: number): number { return endMinutes > startMinutes ? endMinutes - startMinutes : MINUTES_PER_DAY - startMinutes + endMinutes; } function resolveRoutineDragState( dragState: RoutineDragState, clientX: number, clientY: number ): RoutineDragState { const resolvedClientX = Number.isFinite(clientX) ? clientX : dragState.pointerStartX; const resolvedClientY = Number.isFinite(clientY) ? clientY : 0; const deltaMinutes = Math.round( ((resolvedClientX - dragState.pointerStartX) / dragState.trackWidth) * MINUTES_PER_DAY ); switch (dragState.mode) { case "move": { const durationMinutes = getMinuteDistance( dragState.originStartMinutes, dragState.originEndMinutes ); const draftStartMinutes = normalizeMinuteOfDay( dragState.originStartMinutes + deltaMinutes ); const draftEndMinutes = normalizeMinuteOfDay( draftStartMinutes + durationMinutes ); const pointerTargetElement = document.elementFromPoint( resolvedClientX, resolvedClientY ); const draftTargetKey = pointerTargetElement instanceof HTMLElement ? pointerTargetElement .closest("[data-sequencer-target-key]") ?.dataset.sequencerTargetKey ?? dragState.originTargetKey : dragState.originTargetKey; return { ...dragState, draftStartMinutes, draftEndMinutes, draftTargetKey }; } case "resize-start": { let draftStartMinutes = normalizeMinuteOfDay( dragState.originStartMinutes + deltaMinutes ); if ( getMinuteDistance(draftStartMinutes, dragState.originEndMinutes) < MINIMUM_SEQUENCE_PLACEMENT_DURATION_MINUTES ) { draftStartMinutes = normalizeMinuteOfDay( dragState.originEndMinutes - MINIMUM_SEQUENCE_PLACEMENT_DURATION_MINUTES ); } return { ...dragState, draftStartMinutes }; } case "resize-end": { let draftEndMinutes = normalizeMinuteOfDay( dragState.originEndMinutes + deltaMinutes ); if ( getMinuteDistance(dragState.originStartMinutes, draftEndMinutes) < MINIMUM_SEQUENCE_PLACEMENT_DURATION_MINUTES ) { draftEndMinutes = normalizeMinuteOfDay( dragState.originStartMinutes + MINIMUM_SEQUENCE_PLACEMENT_DURATION_MINUTES ); } return { ...dragState, draftEndMinutes }; } } } function getRoutineSummary( routine: ProjectScheduleRoutine, sequences: ProjectSequenceLibrary ): string { const summaryParts = [ `${formatTimeOfDayHours(routine.startHour)}-${formatTimeOfDayHours(routine.endHour)}` ]; const heldSteps = getProjectScheduleRoutineHeldSteps(routine, sequences); if (routine.target.kind === "actor") { const animationEffect = findHeldSequenceControlEffect( heldSteps, "playActorAnimation" ); const pathEffect = findHeldSequenceControlEffect( heldSteps, "followActorPath" ); if (animationEffect !== null) { summaryParts.push(formatControlEffectValue(animationEffect)); } if (pathEffect !== null) { summaryParts.push(formatControlEffectValue(pathEffect)); } } else { const effect = heldSteps[0]; if (routine.target.kind === "global" && routine.sequenceId !== null) { summaryParts.push("Start Sequence"); } else if (effect?.type === "controlEffect") { summaryParts.push(formatControlEffectValue(effect.effect)); } } summaryParts.push(`P${routine.priority}`); return summaryParts.join(" · "); } function isRoutineEffectInactive( routine: ProjectScheduleRoutine, sequences: ProjectSequenceLibrary ): boolean { const heldSteps = getProjectScheduleRoutineHeldSteps(routine, sequences); if (routine.target.kind === "actor") { return false; } const effect = heldSteps[0]?.type === "controlEffect" ? heldSteps[0].effect : undefined; if (effect === undefined) { return false; } switch (effect.type) { case "stopModelAnimation": case "stopSound": return true; case "setModelInstanceVisible": return !effect.visible; case "setInteractionEnabled": case "setLightEnabled": return !effect.enabled; default: return false; } } export function ProjectSequencerPane({ mode, onSetMode, targetOptions, teleportTargetOptions, sceneTransitionTargetOptions, visibilityTargetOptions, scheduler, sequences, npcTalkTargetOptions, selectedRoutineId, selectedSequenceId, onSelectRoutine, onSelectSequence, onAddRoutine, onAddSequence, onDeleteRoutine, onDeleteSequence, onClose, onCreateRoutineSequence, onSetRoutineTarget, onSetRoutineTitle, onSetRoutineEnabled, onSetRoutineStartHour, onSetRoutineEndHour, onSetRoutinePriority, onSetRoutineSequenceId, onSetSequenceTitle, onAddControlEffect, onAddNpcTalkEffect, onAddTeleportStep, onAddSceneTransitionStep, onAddVisibilityStep, onDeleteStep, onSetControlStepTarget, onSetControlStepEffectOption, onSetControlStepNumericValue, onSetControlStepColorValue, onSetControlStepAnimationClip, onSetControlStepAnimationLoop, onSetControlStepPathId, onSetControlStepPathSpeed, onSetControlStepPathLoop, onSetControlStepPathSmooth, onSetNpcTalkStepNpcEntityId, onSetNpcTalkStepDialogueId, onSetTeleportStepTarget, onSetSceneTransitionStepTarget, onSetVisibilityStepTarget, onSetVisibilityStepMode }: ProjectSequencerPaneProps) { const [routineDragState, setRoutineDragState] = useState( null ); const routineDragStateRef = useRef(null); const routineDragCleanupRef = useRef<(() => void) | null>(null); useEffect(() => { routineDragStateRef.current = routineDragState; }, [routineDragState]); useEffect(() => { return () => { routineDragCleanupRef.current?.(); }; }, []); const beginRoutineDrag = ( event: ReactMouseEvent, routine: ProjectScheduleRoutine, mode: RoutineDragState["mode"] ) => { const trackElement = event.currentTarget.closest( "[data-sequencer-track='true']" ); if (trackElement === null) { return; } event.preventDefault(); event.stopPropagation(); onSelectRoutine(routine.id); const nextDragState: RoutineDragState = { routineId: routine.id, mode, originStartMinutes: convertHoursToMinuteOfDay(routine.startHour), originEndMinutes: convertHoursToMinuteOfDay(routine.endHour), originTargetKey: getControlTargetRefKey(routine.target), pointerStartX: Number.isFinite(event.clientX) ? event.clientX : 0, trackWidth: Math.max(trackElement.getBoundingClientRect().width, 1), draftStartMinutes: convertHoursToMinuteOfDay(routine.startHour), draftEndMinutes: convertHoursToMinuteOfDay(routine.endHour), draftTargetKey: getControlTargetRefKey(routine.target) }; routineDragCleanupRef.current?.(); const previousUserSelect = document.body.style.userSelect; document.body.style.userSelect = "none"; const handlePointerMove = (pointerEvent: MouseEvent) => { const currentDragState = routineDragStateRef.current; if (currentDragState === null) { return; } const updatedDragState = resolveRoutineDragState( currentDragState, pointerEvent.clientX, pointerEvent.clientY ); routineDragStateRef.current = updatedDragState; setRoutineDragState(updatedDragState); }; const handlePointerUp = () => { const finalDragState = routineDragStateRef.current; document.body.style.userSelect = previousUserSelect; window.removeEventListener("mousemove", handlePointerMove); window.removeEventListener("mouseup", handlePointerUp); routineDragCleanupRef.current = null; setRoutineDragState(null); routineDragStateRef.current = null; if (finalDragState === null) { return; } if (finalDragState.draftTargetKey !== finalDragState.originTargetKey) { onSetRoutineTarget(finalDragState.routineId, finalDragState.draftTargetKey); } if (finalDragState.draftStartMinutes !== finalDragState.originStartMinutes) { onSetRoutineStartHour( finalDragState.routineId, convertMinuteOfDayToHours(finalDragState.draftStartMinutes) ); } if (finalDragState.draftEndMinutes !== finalDragState.originEndMinutes) { onSetRoutineEndHour( finalDragState.routineId, convertMinuteOfDayToHours(finalDragState.draftEndMinutes) ); } }; routineDragCleanupRef.current = () => { document.body.style.userSelect = previousUserSelect; window.removeEventListener("mousemove", handlePointerMove); window.removeEventListener("mouseup", handlePointerUp); routineDragCleanupRef.current = null; }; window.addEventListener("mousemove", handlePointerMove); window.addEventListener("mouseup", handlePointerUp); routineDragStateRef.current = nextDragState; setRoutineDragState(nextDragState); }; const getRenderedRoutineTargetKey = (routine: ProjectScheduleRoutine): string => routineDragState?.routineId === routine.id ? routineDragState.draftTargetKey : getControlTargetRefKey(routine.target); const getRenderedRoutine = ( routine: ProjectScheduleRoutine ): ProjectScheduleRoutine => routineDragState?.routineId === routine.id ? { ...routine, startHour: convertMinuteOfDayToHours(routineDragState.draftStartMinutes), endHour: convertMinuteOfDayToHours(routineDragState.draftEndMinutes) } : routine; const selectedRoutine = selectedRoutineId === null ? null : scheduler.routines[selectedRoutineId] ?? null; const selectedTargetOption = selectedRoutine === null ? null : getProjectScheduleTargetOptionForRoutine( targetOptions, selectedRoutine.target ); const compatibleHeldSequences = selectedRoutine === null ? [] : selectedRoutine.target.kind === "global" ? getProjectSequences(sequences).filter( (sequence) => getProjectSequenceImpulseSteps(sequence).length > 0 ) : getProjectSequences(sequences).filter((sequence) => { const controlSteps = sequence.effects.filter( ( effect ): effect is Extract< (typeof sequence.effects)[number], { type: "controlEffect" } > => effect.type === "controlEffect" ); return controlSteps.every( (step) => getControlTargetRefKey(step.effect.target) === getControlTargetRefKey(selectedRoutine.target) ); }); const selectedAttachedSequence = selectedRoutine?.sequenceId === null || selectedRoutine?.sequenceId === undefined ? null : sequences.sequences[selectedRoutine.sequenceId] ?? null; const selectableSequences = selectedAttachedSequence === null || compatibleHeldSequences.some((sequence) => sequence.id === selectedAttachedSequence.id) ? compatibleHeldSequences : [selectedAttachedSequence, ...compatibleHeldSequences]; const hourTicks = Array.from({ length: HOURS_PER_DAY }, (_, hour) => hour); return (
Sequencer
{mode === "timeline" ? "Place sequences over global project time." : "Compose reusable sequences from engine effects for timeline and interaction playback."}
{mode !== "timeline" ? ( ) : null} {mode !== "sequence" ? ( ) : null}
{mode === "sequence" ? (
) : ( <>
Targets
{hourTicks.map((hour) => (
{String(hour).padStart(2, "0")}
))}
{targetOptions.length === 0 ? (
No sequencer-addressable control targets are authored in this project yet.
) : ( targetOptions.map((targetOption) => { const routines = Object.values(scheduler.routines) .filter( (routine) => getRenderedRoutineTargetKey(routine) === targetOption.key ) .sort( (left, right) => getRenderedRoutine(left).startHour - getRenderedRoutine(right).startHour ); return (
{targetOption.label}
{targetOption.groupLabel} · {targetOption.subtitle}
{routines.map((routine) => { const renderedRoutine = getRenderedRoutine(routine); return getProjectScheduleTimelineSegments(renderedRoutine).map( (segment) => ( ) ); })}
); }) )}
)}
); } export const ProjectSchedulePane = ProjectSequencerPane;