1071 lines
36 KiB
TypeScript
1071 lines
36 KiB
TypeScript
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<HTMLInputElement>,
|
|
commit: () => void
|
|
) {
|
|
if (event.key !== "Enter") {
|
|
return;
|
|
}
|
|
|
|
event.currentTarget.blur();
|
|
commit();
|
|
}
|
|
|
|
function parseTimeOfDayInputHours(value: string, label: string): number {
|
|
const match = /^(?<hours>\d{1,2}):(?<minutes>\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<HTMLElement>("[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<RoutineDragState | null>(
|
|
null
|
|
);
|
|
const routineDragStateRef = useRef<RoutineDragState | null>(null);
|
|
const routineDragCleanupRef = useRef<(() => void) | null>(null);
|
|
|
|
useEffect(() => {
|
|
routineDragStateRef.current = routineDragState;
|
|
}, [routineDragState]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
routineDragCleanupRef.current?.();
|
|
};
|
|
}, []);
|
|
|
|
const beginRoutineDrag = (
|
|
event: ReactMouseEvent<HTMLElement>,
|
|
routine: ProjectScheduleRoutine,
|
|
mode: RoutineDragState["mode"]
|
|
) => {
|
|
const trackElement = event.currentTarget.closest<HTMLElement>(
|
|
"[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 (
|
|
<section className="schedule-pane" data-testid="project-sequencer-pane">
|
|
<div className="schedule-pane__header">
|
|
<div>
|
|
<div className="label">Sequencer</div>
|
|
<div className="schedule-pane__summary">
|
|
{mode === "timeline"
|
|
? "Place sequences over global project time."
|
|
: "Compose reusable sequences from engine effects for timeline and interaction playback."}
|
|
</div>
|
|
</div>
|
|
<div className="schedule-pane__actions">
|
|
{mode !== "timeline" ? (
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={() => onSetMode("timeline")}
|
|
>
|
|
Timeline
|
|
</button>
|
|
) : null}
|
|
{mode !== "sequence" ? (
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={() => onSetMode("sequence")}
|
|
>
|
|
Sequence Editor
|
|
</button>
|
|
) : null}
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
disabled={mode === "timeline" ? targetOptions.length === 0 : false}
|
|
onClick={() => {
|
|
if (mode === "timeline") {
|
|
onAddRoutine(selectedTargetOption?.key ?? targetOptions[0]?.key ?? "");
|
|
return;
|
|
}
|
|
|
|
onAddSequence();
|
|
}}
|
|
>
|
|
{mode === "timeline" ? "Add Sequence" : "Create Sequence"}
|
|
</button>
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={onClose}
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="schedule-pane__body">
|
|
{mode === "sequence" ? (
|
|
<div className="schedule-pane__editor" style={{ width: "100%" }}>
|
|
<ProjectSequencesPanel
|
|
sequences={sequences}
|
|
targetOptions={targetOptions}
|
|
npcTalkTargetOptions={npcTalkTargetOptions}
|
|
teleportTargetOptions={teleportTargetOptions}
|
|
sceneTransitionTargetOptions={sceneTransitionTargetOptions}
|
|
visibilityTargetOptions={visibilityTargetOptions}
|
|
preferredControlTargetKey={
|
|
selectedRoutine?.sequenceId === selectedSequenceId &&
|
|
selectedRoutine.target.kind !== "global"
|
|
? getControlTargetRefKey(selectedRoutine.target)
|
|
: null
|
|
}
|
|
selectedSequenceId={selectedSequenceId}
|
|
onSelectSequence={onSelectSequence}
|
|
onAddSequence={onAddSequence}
|
|
onDeleteSequence={onDeleteSequence}
|
|
onSetSequenceTitle={onSetSequenceTitle}
|
|
onAddControlEffect={onAddControlEffect}
|
|
onAddNpcTalkEffect={onAddNpcTalkEffect}
|
|
onAddTeleportStep={onAddTeleportStep}
|
|
onAddSceneTransitionStep={onAddSceneTransitionStep}
|
|
onAddVisibilityStep={onAddVisibilityStep}
|
|
onDeleteStep={onDeleteStep}
|
|
onSetControlStepTarget={onSetControlStepTarget}
|
|
onSetControlStepEffectOption={onSetControlStepEffectOption}
|
|
onSetControlStepNumericValue={onSetControlStepNumericValue}
|
|
onSetControlStepColorValue={onSetControlStepColorValue}
|
|
onSetControlStepAnimationClip={onSetControlStepAnimationClip}
|
|
onSetControlStepAnimationLoop={onSetControlStepAnimationLoop}
|
|
onSetControlStepPathId={onSetControlStepPathId}
|
|
onSetControlStepPathSpeed={onSetControlStepPathSpeed}
|
|
onSetControlStepPathLoop={onSetControlStepPathLoop}
|
|
onSetControlStepPathSmooth={onSetControlStepPathSmooth}
|
|
onSetNpcTalkStepNpcEntityId={onSetNpcTalkStepNpcEntityId}
|
|
onSetNpcTalkStepDialogueId={onSetNpcTalkStepDialogueId}
|
|
onSetTeleportStepTarget={onSetTeleportStepTarget}
|
|
onSetSceneTransitionStepTarget={onSetSceneTransitionStepTarget}
|
|
onSetVisibilityStepTarget={onSetVisibilityStepTarget}
|
|
onSetVisibilityStepMode={onSetVisibilityStepMode}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="schedule-pane__timeline">
|
|
<div className="schedule-ruler">
|
|
<div className="schedule-ruler__label">Targets</div>
|
|
<div className="schedule-ruler__track">
|
|
{hourTicks.map((hour) => (
|
|
<div key={hour} className="schedule-ruler__tick">
|
|
<span>{String(hour).padStart(2, "0")}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{targetOptions.length === 0 ? (
|
|
<div className="schedule-pane__empty">
|
|
No sequencer-addressable control targets are authored in this
|
|
project yet.
|
|
</div>
|
|
) : (
|
|
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 (
|
|
<div key={targetOption.key} className="schedule-row">
|
|
<div className="schedule-row__label">
|
|
<button
|
|
className="schedule-row__add"
|
|
type="button"
|
|
onClick={() => onAddRoutine(targetOption.key)}
|
|
>
|
|
+
|
|
</button>
|
|
<div className="schedule-row__meta">
|
|
<div className="schedule-row__title">{targetOption.label}</div>
|
|
<div className="schedule-row__subtitle">
|
|
{targetOption.groupLabel} · {targetOption.subtitle}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="schedule-row__track"
|
|
data-sequencer-target-key={targetOption.key}
|
|
data-sequencer-track="true"
|
|
>
|
|
<div className="schedule-row__grid" />
|
|
{routines.map((routine) => {
|
|
const renderedRoutine = getRenderedRoutine(routine);
|
|
|
|
return getProjectScheduleTimelineSegments(renderedRoutine).map(
|
|
(segment) => (
|
|
<button
|
|
key={segment.key}
|
|
className={`schedule-block ${
|
|
selectedRoutineId === routine.id
|
|
? "schedule-block--selected"
|
|
: ""
|
|
} ${
|
|
isRoutineEffectInactive(routine, sequences)
|
|
? "schedule-block--inactive"
|
|
: ""
|
|
} ${
|
|
routine.enabled ? "" : "schedule-block--disabled"
|
|
} ${
|
|
routineDragState?.routineId === routine.id
|
|
? "schedule-block--dragging"
|
|
: ""
|
|
}`.trim()}
|
|
type="button"
|
|
title={`${routine.title} · ${getRoutineSummary(routine, sequences)}`}
|
|
style={{
|
|
left: `${(segment.startHour / HOURS_PER_DAY) * 100}%`,
|
|
width: `${((segment.endHour - segment.startHour) / HOURS_PER_DAY) * 100}%`
|
|
}}
|
|
onMouseDown={(event) =>
|
|
beginRoutineDrag(event, routine, "move")
|
|
}
|
|
onClick={() => onSelectRoutine(routine.id)}
|
|
>
|
|
<span
|
|
className="schedule-block__resize-handle schedule-block__resize-handle--start"
|
|
aria-label={`Resize start of ${routine.title}`}
|
|
onMouseDown={(event) =>
|
|
beginRoutineDrag(event, routine, "resize-start")
|
|
}
|
|
/>
|
|
<span className="schedule-block__title">
|
|
{routine.title}
|
|
</span>
|
|
<span
|
|
className="schedule-block__resize-handle schedule-block__resize-handle--end"
|
|
aria-label={`Resize end of ${routine.title}`}
|
|
onMouseDown={(event) =>
|
|
beginRoutineDrag(event, routine, "resize-end")
|
|
}
|
|
/>
|
|
</button>
|
|
)
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
<aside className="schedule-pane__editor">
|
|
{selectedRoutine === null || selectedTargetOption === null ? (
|
|
<div className="schedule-pane__empty">
|
|
Select a sequence placement or create a new one.
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="form-section">
|
|
<div className="label">Sequence Placement</div>
|
|
<label className="form-field">
|
|
<span className="label">Title</span>
|
|
<input
|
|
key={`${selectedRoutine.id}-title`}
|
|
className="text-input"
|
|
type="text"
|
|
defaultValue={selectedRoutine.title}
|
|
onBlur={(event) =>
|
|
onSetRoutineTitle(
|
|
selectedRoutine.id,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleCommitOnEnter(event, () =>
|
|
onSetRoutineTitle(
|
|
selectedRoutine.id,
|
|
event.currentTarget.value
|
|
)
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Sequence</span>
|
|
<select
|
|
className="select-input"
|
|
value={selectedRoutine.sequenceId ?? ""}
|
|
onChange={(event) =>
|
|
onSetRoutineSequenceId(
|
|
selectedRoutine.id,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{selectedRoutine.sequenceId === null ? (
|
|
<option value="" disabled>
|
|
Select Sequence
|
|
</option>
|
|
) : null}
|
|
{selectableSequences.map((sequence) => (
|
|
<option key={sequence.id} value={sequence.id}>
|
|
{sequence.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="inline-actions">
|
|
{selectedRoutine.sequenceId === null ? (
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={() => onCreateRoutineSequence(selectedRoutine.id)}
|
|
>
|
|
Create Attached Sequence
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={() => {
|
|
onSelectSequence(
|
|
selectedRoutine.sequenceId ?? selectedSequenceId
|
|
);
|
|
onSetMode("sequence");
|
|
}}
|
|
>
|
|
Edit Sequence
|
|
</button>
|
|
)}
|
|
</div>
|
|
{selectedAttachedSequence !== null ? (
|
|
<div className="material-summary">
|
|
This placement runs <strong>{selectedAttachedSequence.title}</strong>.
|
|
Edit animation, path, lighting, audio, dialogue, or other
|
|
engine effects in the Sequence Editor.
|
|
</div>
|
|
) : null}
|
|
{selectedRoutine.sequenceId === null ? (
|
|
<div className="material-summary">
|
|
{selectedRoutine.target.kind === "global"
|
|
? "Project event placements run attached sequences only. Create a sequence with start effects like scene transitions, dialogue starts, or teleports."
|
|
: "This placement has no attached sequence yet. Create one, then author animation, path, lighting, sound, dialogue, or other engine effects inside the Sequence Editor."}
|
|
</div>
|
|
) : null}
|
|
<label className="form-field form-field--inline">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedRoutine.enabled}
|
|
onChange={(event) =>
|
|
onSetRoutineEnabled(
|
|
selectedRoutine.id,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
<span className="label">Enabled</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Window</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Start</span>
|
|
<input
|
|
key={`${selectedRoutine.id}-start`}
|
|
className="text-input"
|
|
type="time"
|
|
step="60"
|
|
defaultValue={formatTimeOfDayHours(selectedRoutine.startHour)}
|
|
onBlur={(event) =>
|
|
onSetRoutineStartHour(
|
|
selectedRoutine.id,
|
|
parseTimeOfDayInputHours(
|
|
event.currentTarget.value,
|
|
"Clip start"
|
|
)
|
|
)
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleCommitOnEnter(event, () =>
|
|
onSetRoutineStartHour(
|
|
selectedRoutine.id,
|
|
parseTimeOfDayInputHours(
|
|
event.currentTarget.value,
|
|
"Clip start"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">End</span>
|
|
<input
|
|
key={`${selectedRoutine.id}-end`}
|
|
className="text-input"
|
|
type="time"
|
|
step="60"
|
|
defaultValue={formatTimeOfDayHours(selectedRoutine.endHour)}
|
|
onBlur={(event) =>
|
|
onSetRoutineEndHour(
|
|
selectedRoutine.id,
|
|
parseTimeOfDayInputHours(
|
|
event.currentTarget.value,
|
|
"Clip end"
|
|
)
|
|
)
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleCommitOnEnter(event, () =>
|
|
onSetRoutineEndHour(
|
|
selectedRoutine.id,
|
|
parseTimeOfDayInputHours(
|
|
event.currentTarget.value,
|
|
"Clip end"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="form-field">
|
|
<span className="label">Priority</span>
|
|
<input
|
|
key={`${selectedRoutine.id}-priority`}
|
|
className="text-input"
|
|
type="number"
|
|
step="1"
|
|
defaultValue={selectedRoutine.priority}
|
|
onBlur={(event) =>
|
|
onSetRoutinePriority(
|
|
selectedRoutine.id,
|
|
Number(event.currentTarget.value)
|
|
)
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleCommitOnEnter(event, () =>
|
|
onSetRoutinePriority(
|
|
selectedRoutine.id,
|
|
Number(event.currentTarget.value)
|
|
)
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Details</div>
|
|
<div className="schedule-pane__summary">
|
|
{selectedTargetOption.groupLabel} · {selectedTargetOption.label}
|
|
</div>
|
|
<div className="schedule-pane__summary">
|
|
{getRoutineSummary(selectedRoutine, sequences)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={() => onDeleteRoutine(selectedRoutine.id)}
|
|
>
|
|
Delete Clip
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</aside>
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export const ProjectSchedulePane = ProjectSequencerPane;
|