Refactor sequence handling and add new sequences panel

This commit is contained in:
2026-04-15 01:13:32 +02:00
parent 4cab97d785
commit 36f81d74b0
8 changed files with 585 additions and 450 deletions

View File

@@ -369,10 +369,6 @@ import { Panel } from "../shared-ui/Panel";
import { ProjectDialoguesPanel } from "./ProjectDialoguesPanel"; import { ProjectDialoguesPanel } from "./ProjectDialoguesPanel";
import { ProjectSequencerPane } from "./ProjectSequencerPane"; import { ProjectSequencerPane } from "./ProjectSequencerPane";
import { import {
DEFAULT_HELD_SEQUENCE_CLIP_DURATION_MINUTES,
DEFAULT_IMPULSE_SEQUENCE_CLIP_DURATION_MINUTES,
DEFAULT_PROJECT_SEQUENCE_DURATION_MINUTES,
getNextSequenceClipLane,
getProjectSequenceImpulseSteps getProjectSequenceImpulseSteps
} from "../sequencer/project-sequence-steps"; } from "../sequencer/project-sequence-steps";
import { import {
@@ -4246,7 +4242,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
editorState.projectDocument.scheduler.routines[routineId] ?? null; editorState.projectDocument.scheduler.routines[routineId] ?? null;
if (currentRoutine === null) { if (currentRoutine === null) {
setStatusMessage("Selected sequencer clip no longer exists."); setStatusMessage("Selected sequence placement no longer exists.");
return; return;
} }
@@ -4291,7 +4287,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
if (targetOption === null) { if (targetOption === null) {
setStatusMessage( setStatusMessage(
"Author a sequencer-addressable control target before creating a clip." "Author a sequencer-addressable control target before creating a sequence placement."
); );
return; return;
} }
@@ -4324,8 +4320,8 @@ export function App({ store, initialStatusMessage }: AppProps) {
applyProjectScheduler( applyProjectScheduler(
nextScheduler, nextScheduler,
"Create project sequencer clip", "Create sequence placement",
`Created sequencer clip for ${targetOption.label}.` `Created a sequence placement for ${targetOption.label}.`
); );
setSchedulePaneOpen(true); setSchedulePaneOpen(true);
setSequencerMode("timeline"); setSequencerMode("timeline");
@@ -4343,8 +4339,8 @@ export function App({ store, initialStatusMessage }: AppProps) {
applyProjectScheduler( applyProjectScheduler(
nextScheduler, nextScheduler,
"Delete project sequencer clip", "Delete sequence placement",
"Deleted sequencer clip." "Deleted sequence placement."
); );
if (selectedScheduleRoutineId === routineId) { if (selectedScheduleRoutineId === routineId) {
setSelectedScheduleRoutineId(null); setSelectedScheduleRoutineId(null);
@@ -4384,11 +4380,8 @@ export function App({ store, initialStatusMessage }: AppProps) {
const createDefaultProjectSequenceControlStep = ( const createDefaultProjectSequenceControlStep = (
stepClass: "held" | "impulse", stepClass: "held" | "impulse",
targetKey: string, targetKey: string,
previousStep?: Extract<ProjectSequence["clips"][number], { type: "controlEffect" }> | null, previousStep?: Extract<ProjectSequence["effects"][number], { type: "controlEffect" }> | null
timing?: Partial< ): Extract<ProjectSequence["effects"][number], { type: "controlEffect" }> => {
Pick<ProjectSequence["clips"][number], "startMinute" | "durationMinutes" | "lane">
>
): Extract<ProjectSequence["clips"][number], { type: "controlEffect" }> => {
const targetOption = resolveSequenceControlTargetOption(targetKey); const targetOption = resolveSequenceControlTargetOption(targetKey);
if (targetOption === null) { if (targetOption === null) {
@@ -4404,13 +4397,6 @@ export function App({ store, initialStatusMessage }: AppProps) {
} }
return { return {
startMinute: timing?.startMinute ?? 0,
durationMinutes:
timing?.durationMinutes ??
(stepClass === "held"
? DEFAULT_HELD_SEQUENCE_CLIP_DURATION_MINUTES
: DEFAULT_IMPULSE_SEQUENCE_CLIP_DURATION_MINUTES),
lane: timing?.lane ?? 0,
stepClass, stepClass,
type: "controlEffect", type: "controlEffect",
effect: createProjectScheduleEffectFromOption({ effect: createProjectScheduleEffectFromOption({
@@ -4421,9 +4407,6 @@ export function App({ store, initialStatusMessage }: AppProps) {
}; };
}; };
const getNextProjectSequenceClipLane = (sequence: ProjectSequence): number =>
getNextSequenceClipLane(sequence.clips);
const updateProjectSequence = ( const updateProjectSequence = (
sequenceId: string, sequenceId: string,
label: string, label: string,
@@ -4446,13 +4429,13 @@ export function App({ store, initialStatusMessage }: AppProps) {
stepIndex: number, stepIndex: number,
label: string, label: string,
successMessage: string, successMessage: string,
mutate: (step: ProjectSequence["clips"][number]) => void mutate: (step: ProjectSequence["effects"][number]) => void
) => { ) => {
updateProjectSequence(sequenceId, label, successMessage, (sequence) => { updateProjectSequence(sequenceId, label, successMessage, (sequence) => {
const step = sequence.clips[stepIndex]; const step = sequence.effects[stepIndex];
if (step === undefined) { if (step === undefined) {
throw new Error("Selected project sequence clip no longer exists."); throw new Error("Selected project sequence effect no longer exists.");
} }
mutate(step); mutate(step);
@@ -4460,9 +4443,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
}; };
const handleAddProjectSequence = () => { const handleAddProjectSequence = () => {
const nextSequence = createProjectSequence({ const nextSequence = createProjectSequence();
durationMinutes: DEFAULT_PROJECT_SEQUENCE_DURATION_MINUTES
});
updateProjectSequences( updateProjectSequences(
"Add project sequence", "Add project sequence",
@@ -4540,21 +4521,14 @@ export function App({ store, initialStatusMessage }: AppProps) {
updateProjectSequence( updateProjectSequence(
sequenceId, sequenceId,
stepClass === "held" stepClass === "held"
? "Add held project sequence control clip" ? "Add held project sequence effect"
: "Add impulse project sequence control clip", : "Add impulse project sequence effect",
stepClass === "held" stepClass === "held"
? "Added held control clip." ? "Added held effect."
: "Added impulse control clip.", : "Added impulse effect.",
(sequence) => { (sequence) => {
sequence.clips.push( sequence.effects.push(
createDefaultProjectSequenceControlStep(stepClass, targetKey, null, { createDefaultProjectSequenceControlStep(stepClass, targetKey)
startMinute: 0,
durationMinutes:
stepClass === "held"
? DEFAULT_HELD_SEQUENCE_CLIP_DURATION_MINUTES
: DEFAULT_IMPULSE_SEQUENCE_CLIP_DURATION_MINUTES,
lane: getNextProjectSequenceClipLane(sequence)
})
); );
} }
); );
@@ -4567,12 +4541,9 @@ export function App({ store, initialStatusMessage }: AppProps) {
updateProjectSequence( updateProjectSequence(
sequenceId, sequenceId,
"Add project sequence dialogue clip", "Add project sequence dialogue clip",
"Added dialogue clip.", "Added dialogue effect.",
(sequence) => { (sequence) => {
sequence.clips.push({ sequence.effects.push({
startMinute: 0,
durationMinutes: DEFAULT_IMPULSE_SEQUENCE_CLIP_DURATION_MINUTES,
lane: getNextProjectSequenceClipLane(sequence),
stepClass: "impulse", stepClass: "impulse",
type: "startDialogue", type: "startDialogue",
dialogueId dialogueId
@@ -4587,10 +4558,10 @@ export function App({ store, initialStatusMessage }: AppProps) {
) => { ) => {
updateProjectSequence( updateProjectSequence(
sequenceId, sequenceId,
"Delete project sequence clip", "Delete project sequence effect",
"Deleted sequence clip.", "Deleted sequence effect.",
(sequence) => { (sequence) => {
sequence.clips.splice(stepIndex, 1); sequence.effects.splice(stepIndex, 1);
} }
); );
}; };
@@ -4794,74 +4765,6 @@ export function App({ store, initialStatusMessage }: AppProps) {
); );
}; };
const updateProjectSequenceDurationMinutes = (
sequenceId: string,
durationMinutes: number
) => {
updateProjectSequence(
sequenceId,
"Set project sequence duration",
"Updated sequence duration.",
(sequence) => {
if (!Number.isFinite(durationMinutes) || durationMinutes < 1) {
throw new Error(
"Sequence duration must be a finite number of minutes greater than zero."
);
}
sequence.durationMinutes = Math.max(1, Math.trunc(durationMinutes));
for (const clip of sequence.clips) {
if (clip.startMinute >= sequence.durationMinutes) {
clip.startMinute = Math.max(0, sequence.durationMinutes - 1);
}
if (clip.startMinute + clip.durationMinutes > sequence.durationMinutes) {
clip.durationMinutes = Math.max(
1,
sequence.durationMinutes - clip.startMinute
);
}
}
}
);
};
const updateProjectSequenceClipTiming = (
sequenceId: string,
stepIndex: number,
timing: {
startMinute: number;
durationMinutes: number;
lane: number;
}
) => {
updateProjectSequence(sequenceId, "Set project sequence clip timing", "Updated sequence clip timing.", (sequence) => {
const clip = sequence.clips[stepIndex];
if (clip === undefined) {
throw new Error("Selected project sequence clip no longer exists.");
}
if (
!Number.isFinite(timing.startMinute) ||
!Number.isFinite(timing.durationMinutes) ||
!Number.isFinite(timing.lane)
) {
throw new Error("Sequence clip timing must use finite minute values.");
}
clip.startMinute = Math.min(
Math.max(0, Math.trunc(timing.startMinute)),
Math.max(0, sequence.durationMinutes - 1)
);
clip.durationMinutes = Math.min(
Math.max(1, Math.trunc(timing.durationMinutes)),
Math.max(1, sequence.durationMinutes - clip.startMinute)
);
clip.lane = Math.max(0, Math.trunc(timing.lane));
});
};
const updateWorldTimeOfDaySettings = ( const updateWorldTimeOfDaySettings = (
label: string, label: string,
@@ -7473,7 +7376,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
if (defaultSequence === null) { if (defaultSequence === null) {
openSequencerSequenceEditor(); openSequencerSequenceEditor();
setStatusMessage( setStatusMessage(
"Open the Sequencer sequence editor and author a sequence with at least one impulse clip before adding a sequence link." "Open the Sequencer sequence editor and author a sequence with at least one impulse effect before adding a sequence link."
); );
return; return;
} }
@@ -8008,7 +7911,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
if (defaultSequence === null) { if (defaultSequence === null) {
openSequencerSequenceEditor(); openSequencerSequenceEditor();
setStatusMessage( setStatusMessage(
"Open the Sequencer sequence editor and author a sequence with at least one impulse clip before switching this link to run sequence." "Open the Sequencer sequence editor and author a sequence with at least one impulse effect before switching this link to run sequence."
); );
return; return;
} }
@@ -8787,7 +8690,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
</div> </div>
<div className="material-summary"> <div className="material-summary">
Run Sequence links can only reference sequences that Run Sequence links can only reference sequences that
contain at least one impulse clip. contain at least one impulse effect.
</div> </div>
</div> </div>
); );
@@ -11431,7 +11334,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
updateProjectScheduleRoutine( updateProjectScheduleRoutine(
routineId, routineId,
"Set project sequencer target", "Set project sequencer target",
"Retargeted sequencer clip.", "Retargeted sequence placement.",
(routine) => { (routine) => {
const targetOption = const targetOption =
resolveProjectScheduleTargetOption(targetKey); resolveProjectScheduleTargetOption(targetKey);
@@ -11484,8 +11387,8 @@ export function App({ store, initialStatusMessage }: AppProps) {
onSetRoutineTitle={(routineId, title) => onSetRoutineTitle={(routineId, title) =>
updateProjectScheduleRoutine( updateProjectScheduleRoutine(
routineId, routineId,
"Rename project sequencer clip", "Rename sequence placement",
"Updated sequencer clip title.", "Updated sequence placement title.",
(routine) => { (routine) => {
routine.title = title.trim(); routine.title = title.trim();
} }
@@ -11494,10 +11397,10 @@ export function App({ store, initialStatusMessage }: AppProps) {
onSetRoutineEnabled={(routineId, enabled) => onSetRoutineEnabled={(routineId, enabled) =>
updateProjectScheduleRoutine( updateProjectScheduleRoutine(
routineId, routineId,
"Toggle project sequencer clip", "Toggle sequence placement",
enabled enabled
? "Enabled sequencer clip." ? "Enabled sequence placement."
: "Disabled sequencer clip.", : "Disabled sequence placement.",
(routine) => { (routine) => {
routine.enabled = enabled; routine.enabled = enabled;
} }
@@ -11507,7 +11410,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
updateProjectScheduleRoutine( updateProjectScheduleRoutine(
routineId, routineId,
"Set project sequencer start time", "Set project sequencer start time",
"Updated sequencer clip start time.", "Updated sequence placement start time.",
(routine) => { (routine) => {
routine.startHour = normalizeTimeOfDayHours(startHour); routine.startHour = normalizeTimeOfDayHours(startHour);
if (routine.startHour === routine.endHour) { if (routine.startHour === routine.endHour) {
@@ -11522,7 +11425,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
updateProjectScheduleRoutine( updateProjectScheduleRoutine(
routineId, routineId,
"Set project sequencer end time", "Set project sequencer end time",
"Updated sequencer clip end time.", "Updated sequence placement end time.",
(routine) => { (routine) => {
routine.endHour = normalizeTimeOfDayHours(endHour); routine.endHour = normalizeTimeOfDayHours(endHour);
if (routine.startHour === routine.endHour) { if (routine.startHour === routine.endHour) {
@@ -11537,7 +11440,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
updateProjectScheduleRoutine( updateProjectScheduleRoutine(
routineId, routineId,
"Set project sequencer priority", "Set project sequencer priority",
"Updated sequencer clip priority.", "Updated sequence placement priority.",
(routine) => { (routine) => {
if (!Number.isFinite(priority)) { if (!Number.isFinite(priority)) {
throw new Error( throw new Error(
@@ -11554,7 +11457,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
routineId, routineId,
"Set project sequencer sequence", "Set project sequencer sequence",
sequenceId === null sequenceId === null
? "Timeline clip now uses inline held clips." ? "Sequence placement now uses inline held effects."
: "Timeline clip now resolves a project sequence.", : "Timeline clip now resolves a project sequence.",
(routine) => { (routine) => {
routine.sequenceId = sequenceId; routine.sequenceId = sequenceId;
@@ -11565,7 +11468,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
updateProjectScheduleRoutine( updateProjectScheduleRoutine(
routineId, routineId,
"Set project sequencer effect", "Set project sequencer effect",
"Updated sequencer clip effect.", "Updated sequence placement effect.",
(routine) => { (routine) => {
if (routine.target.kind === "actor") { if (routine.target.kind === "actor") {
throw new Error( throw new Error(
@@ -11598,7 +11501,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
updateProjectScheduleRoutine( updateProjectScheduleRoutine(
routineId, routineId,
"Set project sequencer numeric value", "Set project sequencer numeric value",
"Updated sequencer clip value.", "Updated sequence placement value.",
(routine) => { (routine) => {
if (!Number.isFinite(value) || value < 0) { if (!Number.isFinite(value) || value < 0) {
throw new Error( throw new Error(
@@ -11610,7 +11513,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
if (effect === undefined) { if (effect === undefined) {
throw new Error( throw new Error(
"The current sequencer clip does not expose a numeric value." "The current sequence placement does not expose a numeric value."
); );
} }
@@ -11635,13 +11538,13 @@ export function App({ store, initialStatusMessage }: AppProps) {
updateProjectScheduleRoutine( updateProjectScheduleRoutine(
routineId, routineId,
"Set project sequencer color", "Set project sequencer color",
"Updated sequencer clip color.", "Updated sequence placement color.",
(routine) => { (routine) => {
const effect = routine.effects[0]; const effect = routine.effects[0];
if (effect === undefined) { if (effect === undefined) {
throw new Error( throw new Error(
"The current sequencer clip does not expose a color value." "The current sequence placement does not expose a color value."
); );
} }
@@ -11888,7 +11791,6 @@ export function App({ store, initialStatusMessage }: AppProps) {
} }
) )
} }
onSetSequenceDurationMinutes={updateProjectSequenceDurationMinutes}
onAddHeldControlStep={(sequenceId, targetKey) => onAddHeldControlStep={(sequenceId, targetKey) =>
handleAddProjectSequenceControlStep( handleAddProjectSequenceControlStep(
sequenceId, sequenceId,
@@ -11905,7 +11807,6 @@ export function App({ store, initialStatusMessage }: AppProps) {
} }
onAddDialogueStep={handleAddProjectSequenceDialogueStep} onAddDialogueStep={handleAddProjectSequenceDialogueStep}
onDeleteStep={handleDeleteProjectSequenceStep} onDeleteStep={handleDeleteProjectSequenceStep}
onSetClipTiming={updateProjectSequenceClipTiming}
onSetControlStepTarget={updateProjectSequenceControlStepTarget} onSetControlStepTarget={updateProjectSequenceControlStepTarget}
onSetControlStepEffectOption={ onSetControlStepEffectOption={
updateProjectSequenceControlStepEffectOption updateProjectSequenceControlStepEffectOption

View File

@@ -71,20 +71,10 @@ interface ProjectSequencerPaneProps {
onSetActorRoutinePathSpeed(routineId: string, speed: number): void; onSetActorRoutinePathSpeed(routineId: string, speed: number): void;
onSetActorRoutinePathLoop(routineId: string, loop: boolean): void; onSetActorRoutinePathLoop(routineId: string, loop: boolean): void;
onSetSequenceTitle(sequenceId: string, title: string): void; onSetSequenceTitle(sequenceId: string, title: string): void;
onSetSequenceDurationMinutes(sequenceId: string, durationMinutes: number): void;
onAddHeldControlStep(sequenceId: string, targetKey: string): void; onAddHeldControlStep(sequenceId: string, targetKey: string): void;
onAddImpulseControlStep(sequenceId: string, targetKey: string): void; onAddImpulseControlStep(sequenceId: string, targetKey: string): void;
onAddDialogueStep(sequenceId: string, dialogueId: string): void; onAddDialogueStep(sequenceId: string, dialogueId: string): void;
onDeleteStep(sequenceId: string, stepIndex: number): void; onDeleteStep(sequenceId: string, stepIndex: number): void;
onSetClipTiming(
sequenceId: string,
stepIndex: number,
timing: {
startMinute: number;
durationMinutes: number;
lane: number;
}
): void;
onSetControlStepTarget( onSetControlStepTarget(
sequenceId: string, sequenceId: string,
stepIndex: number, stepIndex: number,
@@ -324,12 +314,10 @@ export function ProjectSequencerPane({
onSetActorRoutinePathSpeed, onSetActorRoutinePathSpeed,
onSetActorRoutinePathLoop, onSetActorRoutinePathLoop,
onSetSequenceTitle, onSetSequenceTitle,
onSetSequenceDurationMinutes,
onAddHeldControlStep, onAddHeldControlStep,
onAddImpulseControlStep, onAddImpulseControlStep,
onAddDialogueStep, onAddDialogueStep,
onDeleteStep, onDeleteStep,
onSetClipTiming,
onSetControlStepTarget, onSetControlStepTarget,
onSetControlStepEffectOption, onSetControlStepEffectOption,
onSetControlStepNumericValue, onSetControlStepNumericValue,
@@ -399,8 +387,8 @@ export function ProjectSequencerPane({
<div className="label">Sequencer</div> <div className="label">Sequencer</div>
<div className="schedule-pane__summary"> <div className="schedule-pane__summary">
{mode === "timeline" {mode === "timeline"
? "Place sequence clips over global project time." ? "Place sequences over global project time."
: "Compose reusable sequences from clips for timeline and interaction playback."} : "Compose reusable sequences from engine effects for timeline and interaction playback."}
</div> </div>
</div> </div>
<div className="schedule-pane__actions"> <div className="schedule-pane__actions">
@@ -459,12 +447,10 @@ export function ProjectSequencerPane({
onAddSequence={onAddSequence} onAddSequence={onAddSequence}
onDeleteSequence={onDeleteSequence} onDeleteSequence={onDeleteSequence}
onSetSequenceTitle={onSetSequenceTitle} onSetSequenceTitle={onSetSequenceTitle}
onSetSequenceDurationMinutes={onSetSequenceDurationMinutes}
onAddHeldControlStep={onAddHeldControlStep} onAddHeldControlStep={onAddHeldControlStep}
onAddImpulseControlStep={onAddImpulseControlStep} onAddImpulseControlStep={onAddImpulseControlStep}
onAddDialogueStep={onAddDialogueStep} onAddDialogueStep={onAddDialogueStep}
onDeleteStep={onDeleteStep} onDeleteStep={onDeleteStep}
onSetClipTiming={onSetClipTiming}
onSetControlStepTarget={onSetControlStepTarget} onSetControlStepTarget={onSetControlStepTarget}
onSetControlStepEffectOption={onSetControlStepEffectOption} onSetControlStepEffectOption={onSetControlStepEffectOption}
onSetControlStepNumericValue={onSetControlStepNumericValue} onSetControlStepNumericValue={onSetControlStepNumericValue}
@@ -562,12 +548,12 @@ export function ProjectSequencerPane({
<aside className="schedule-pane__editor"> <aside className="schedule-pane__editor">
{selectedRoutine === null || selectedTargetOption === null ? ( {selectedRoutine === null || selectedTargetOption === null ? (
<div className="schedule-pane__empty"> <div className="schedule-pane__empty">
Select a clip block or create a new sequencer clip. Select a sequence placement or create a new one.
</div> </div>
) : ( ) : (
<> <>
<div className="form-section"> <div className="form-section">
<div className="label">Timeline Clip</div> <div className="label">Sequence Placement</div>
<label className="form-field"> <label className="form-field">
<span className="label">Title</span> <span className="label">Title</span>
<input <input
@@ -625,7 +611,7 @@ export function ProjectSequencerPane({
) )
} }
> >
<option value="">This Clip (inline held clips)</option> <option value="">This Placement (inline effects)</option>
{compatibleHeldSequences.map((sequence) => ( {compatibleHeldSequences.map((sequence) => (
<option key={sequence.id} value={sequence.id}> <option key={sequence.id} value={sequence.id}>
{sequence.title} {sequence.title}
@@ -647,17 +633,17 @@ export function ProjectSequencerPane({
</div> </div>
{selectedRoutine.sequenceId !== null ? ( {selectedRoutine.sequenceId !== null ? (
<div className="material-summary"> <div className="material-summary">
This timeline clip resolves held clips from the selected This placement resolves held effects from the selected
project sequence. Inline clip controls stay preserved as sequence. Inline effects stay preserved as fallback, but are
fallback, but are not edited while a sequence is attached. not edited while a sequence is attached.
</div> </div>
) : null} ) : null}
{selectedRoutine.sequenceId === null && {selectedRoutine.sequenceId === null &&
compatibleHeldSequences.length === 0 ? ( compatibleHeldSequences.length === 0 ? (
<div className="material-summary"> <div className="material-summary">
No compatible project sequence with held clips is authored No compatible sequence with held effects is authored for this
for this target yet. Use Sequence Editor to author clips, or target yet. Use Sequence Editor to author effects, or keep
keep this placement on its own inline held clips. this placement on its own inline effects.
</div> </div>
) : null} ) : null}
{selectedRoutine.sequenceId === null && {selectedRoutine.sequenceId === null &&
@@ -727,7 +713,7 @@ export function ProjectSequencerPane({
</div> </div>
<div className="material-summary"> <div className="material-summary">
Day-specific routine filters are preserved for compatibility. Day-specific routine filters are preserved for compatibility.
New sequencer authoring should prefer timeline clips; a later New sequencer authoring should prefer sequence placements; a later
multi-day timeline will replace this legacy filter. multi-day timeline will replace this legacy filter.
</div> </div>
</div> </div>

View File

@@ -0,0 +1,528 @@
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { formatControlEffectValue, getControlTargetRefKey } from "../controls/control-surface";
import { type ProjectDialogueLibrary, getProjectDialogues } from "../dialogues/project-dialogues";
import {
getProjectScheduleEffectOptionId,
getProjectScheduleTargetOptionByKey,
listProjectScheduleEffectOptions,
type ProjectScheduleEffectOptionId,
type ProjectScheduleTargetOption
} from "../scheduler/project-schedule-control-options";
import {
getProjectSequenceHeldSteps,
getProjectSequenceImpulseSteps,
getSequenceEffectLabel,
type SequenceEffect
} from "../sequencer/project-sequence-steps";
import {
getProjectSequences,
type ProjectSequenceLibrary
} from "../sequencer/project-sequences";
interface ProjectSequencesPanelProps {
sequences: ProjectSequenceLibrary;
dialogues: ProjectDialogueLibrary;
targetOptions: ProjectScheduleTargetOption[];
selectedSequenceId: string | null;
onSelectSequence(sequenceId: string | null): void;
onAddSequence(): void;
onDeleteSequence(sequenceId: string): void;
onSetSequenceTitle(sequenceId: string, title: string): void;
onAddHeldControlStep(sequenceId: string, targetKey: string): void;
onAddImpulseControlStep(sequenceId: string, targetKey: string): void;
onAddDialogueStep(sequenceId: string, dialogueId: 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;
onSetDialogueStepDialogueId(
sequenceId: string,
stepIndex: number,
dialogueId: string
): void;
}
function commitOnEnter(
event: ReactKeyboardEvent<HTMLInputElement>,
commit: () => void
) {
if (event.key !== "Enter") {
return;
}
event.currentTarget.blur();
commit();
}
function getControlEffectNumericValue(
effect: Extract<SequenceEffect, { type: "controlEffect" }>
): number | null {
switch (effect.effect.type) {
case "setSoundVolume":
return effect.effect.volume;
case "setLightIntensity":
case "setAmbientLightIntensity":
case "setSunLightIntensity":
return effect.effect.intensity;
default:
return null;
}
}
function getControlEffectColorValue(
effect: Extract<SequenceEffect, { type: "controlEffect" }>
): string | null {
switch (effect.effect.type) {
case "setLightColor":
case "setAmbientLightColor":
case "setSunLightColor":
return effect.effect.colorHex;
default:
return null;
}
}
export function ProjectSequencesPanel({
sequences,
dialogues,
targetOptions,
selectedSequenceId,
onSelectSequence,
onAddSequence,
onDeleteSequence,
onSetSequenceTitle,
onAddHeldControlStep,
onAddImpulseControlStep,
onAddDialogueStep,
onDeleteStep,
onSetControlStepTarget,
onSetControlStepEffectOption,
onSetControlStepNumericValue,
onSetControlStepColorValue,
onSetControlStepAnimationClip,
onSetControlStepAnimationLoop,
onSetDialogueStepDialogueId
}: ProjectSequencesPanelProps) {
const sequenceList = getProjectSequences(sequences);
const dialogueList = getProjectDialogues(dialogues);
const selectedSequence =
selectedSequenceId === null
? null
: sequences.sequences[selectedSequenceId] ?? null;
return (
<div className="form-section">
<div className="label">Sequences</div>
{sequenceList.length === 0 ? (
<div className="outliner-empty">No project sequences authored yet.</div>
) : (
<div className="outliner-list">
{sequenceList.map((sequence) => (
<div
key={sequence.id}
className={`outliner-item outliner-item--compact ${
selectedSequence?.id === sequence.id
? "outliner-item--selected"
: ""
}`.trim()}
>
<div className="outliner-item__row">
<button
className="outliner-item__select"
type="button"
onClick={() => onSelectSequence(sequence.id)}
>
<span className="outliner-item__title">{sequence.title}</span>
<span className="outliner-item__meta">
{sequence.effects.length} effect
{sequence.effects.length === 1 ? "" : "s"} ·{" "}
{getProjectSequenceHeldSteps(sequence).length} held ·{" "}
{getProjectSequenceImpulseSteps(sequence).length} impulse
</span>
</button>
<button
className="outliner-item__delete"
type="button"
aria-label={`Delete ${sequence.title}`}
onClick={() => onDeleteSequence(sequence.id)}
>
x
</button>
</div>
</div>
))}
</div>
)}
<div className="inline-actions">
<button className="toolbar__button" type="button" onClick={onAddSequence}>
Add Sequence
</button>
</div>
{selectedSequence === null ? (
<div className="outliner-empty">
Select a sequence to edit its title and effects.
</div>
) : (
<div className="form-section">
<div className="material-summary">
A sequence is a reusable bundle of engine effects. Held effects stay
active for the whole scheduled window. Impulse effects fire when the
sequence is started from an interaction.
</div>
<label className="form-field">
<span className="label">Title</span>
<input
className="text-input"
type="text"
defaultValue={selectedSequence.title}
onBlur={(event) =>
onSetSequenceTitle(selectedSequence.id, event.currentTarget.value)
}
onKeyDown={(event) =>
commitOnEnter(event, () =>
onSetSequenceTitle(selectedSequence.id, event.currentTarget.value)
)
}
/>
</label>
<div className="label">Effects</div>
{selectedSequence.effects.length === 0 ? (
<div className="outliner-empty">
Add held control, impulse control, or dialogue effects.
</div>
) : (
<div className="outliner-list">
{selectedSequence.effects.map((effect, effectIndex) => {
if (effect.type === "controlEffect") {
const targetKey = getControlTargetRefKey(effect.effect.target);
const targetOption =
getProjectScheduleTargetOptionByKey(targetOptions, targetKey);
const effectOptions =
targetOption === null
? []
: listProjectScheduleEffectOptions(targetOption);
const effectOptionId =
targetOption === null
? null
: (() => {
try {
return getProjectScheduleEffectOptionId(effect.effect);
} catch {
return null;
}
})();
return (
<div key={`${selectedSequence.id}-${effectIndex}`} className="outliner-item">
<div className="outliner-item__row">
<div className="outliner-item__meta">
{getSequenceEffectLabel(effect)}
</div>
<button
className="outliner-item__delete"
type="button"
onClick={() => onDeleteStep(selectedSequence.id, effectIndex)}
>
x
</button>
</div>
{targetOption === null || effectOptionId === null ? (
<div className="material-summary">
{formatControlEffectValue(effect.effect)}. This effect is
preserved, but the current editor can only edit targets
and effects exposed through the existing control catalog.
</div>
) : (
<>
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Class</span>
<input
className="text-input"
type="text"
value={effect.stepClass === "held" ? "Held" : "Impulse"}
readOnly
/>
</label>
<label className="form-field">
<span className="label">Target</span>
<select
className="select-input"
value={targetOption.key}
onChange={(event) =>
onSetControlStepTarget(
selectedSequence.id,
effectIndex,
event.currentTarget.value
)
}
>
{targetOptions.map((option) => (
<option key={option.key} value={option.key}>
{option.groupLabel} · {option.label}
</option>
))}
</select>
</label>
</div>
<label className="form-field">
<span className="label">Effect</span>
<select
className="select-input"
value={effectOptionId}
onChange={(event) =>
onSetControlStepEffectOption(
selectedSequence.id,
effectIndex,
event.currentTarget.value as ProjectScheduleEffectOptionId
)
}
>
{effectOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
{effectOptions.find((option) => option.id === effectOptionId)
?.valueKind === "number" ? (
<label className="form-field">
<span className="label">
{effectOptions.find((option) => option.id === effectOptionId)
?.valueLabel ?? "Value"}
</span>
<input
key={`${selectedSequence.id}-${effectIndex}-numeric`}
className="text-input"
type="number"
min={
effectOptions.find((option) => option.id === effectOptionId)
?.min ?? 0
}
step={
effectOptions.find((option) => option.id === effectOptionId)
?.step ?? 0.1
}
defaultValue={getControlEffectNumericValue(effect) ?? 0}
onBlur={(event) =>
onSetControlStepNumericValue(
selectedSequence.id,
effectIndex,
Number(event.currentTarget.value)
)
}
onKeyDown={(event) =>
commitOnEnter(event, () =>
onSetControlStepNumericValue(
selectedSequence.id,
effectIndex,
Number(event.currentTarget.value)
)
)
}
/>
</label>
) : null}
{effectOptions.find((option) => option.id === effectOptionId)
?.valueKind === "color" ? (
<label className="form-field">
<span className="label">
{effectOptions.find((option) => option.id === effectOptionId)
?.valueLabel ?? "Color"}
</span>
<input
className="color-input"
type="color"
value={getControlEffectColorValue(effect) ?? "#ffffff"}
onChange={(event) =>
onSetControlStepColorValue(
selectedSequence.id,
effectIndex,
event.currentTarget.value
)
}
/>
</label>
) : null}
{effect.effect.type === "playModelAnimation" ? (
<>
<label className="form-field">
<span className="label">Clip</span>
<select
className="select-input"
value={effect.effect.clipName}
onChange={(event) =>
onSetControlStepAnimationClip(
selectedSequence.id,
effectIndex,
event.currentTarget.value
)
}
>
{(targetOption.defaults.animationClipNames ?? []).map(
(clipName) => (
<option key={clipName} value={clipName}>
{clipName}
</option>
)
)}
</select>
</label>
<label className="form-field form-field--inline">
<input
type="checkbox"
checked={effect.effect.loop !== false}
onChange={(event) =>
onSetControlStepAnimationLoop(
selectedSequence.id,
effectIndex,
event.currentTarget.checked
)
}
/>
<span className="label">Loop</span>
</label>
</>
) : null}
</>
)}
</div>
);
}
if (effect.type === "startDialogue") {
return (
<div key={`${selectedSequence.id}-${effectIndex}`} className="outliner-item">
<div className="outliner-item__row">
<div className="outliner-item__meta">
{getSequenceEffectLabel(effect)}
</div>
<button
className="outliner-item__delete"
type="button"
onClick={() => onDeleteStep(selectedSequence.id, effectIndex)}
>
x
</button>
</div>
<label className="form-field">
<span className="label">Dialogue</span>
<select
className="select-input"
value={effect.dialogueId}
onChange={(event) =>
onSetDialogueStepDialogueId(
selectedSequence.id,
effectIndex,
event.currentTarget.value
)
}
>
{dialogueList.map((dialogue) => (
<option key={dialogue.id} value={dialogue.id}>
{dialogue.title}
</option>
))}
</select>
</label>
</div>
);
}
return (
<div key={`${selectedSequence.id}-${effectIndex}`} className="outliner-item">
<div className="outliner-item__row">
<div className="outliner-item__meta">
{getSequenceEffectLabel(effect)}
</div>
<button
className="outliner-item__delete"
type="button"
onClick={() => onDeleteStep(selectedSequence.id, effectIndex)}
>
x
</button>
</div>
<div className="material-summary">
This impulse effect is preserved, but the current editor only
exposes direct editing for dialogue and control effects.
</div>
</div>
);
})}
</div>
)}
<div className="inline-actions">
<button
className="toolbar__button toolbar__button--compact"
type="button"
disabled={targetOptions.length === 0}
onClick={() =>
onAddHeldControlStep(selectedSequence.id, targetOptions[0]?.key ?? "")
}
>
Add Held Effect
</button>
<button
className="toolbar__button toolbar__button--compact"
type="button"
disabled={targetOptions.length === 0}
onClick={() =>
onAddImpulseControlStep(selectedSequence.id, targetOptions[0]?.key ?? "")
}
>
Add Impulse Effect
</button>
<button
className="toolbar__button toolbar__button--compact"
type="button"
disabled={dialogueList.length === 0}
onClick={() =>
onAddDialogueStep(selectedSequence.id, dialogueList[0]?.id ?? "")
}
>
Add Dialogue Effect
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1119,149 +1119,6 @@ button:disabled {
white-space: nowrap; white-space: nowrap;
} }
.sequence-timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.sequence-timeline__summary {
color: var(--color-muted);
font-size: 0.8rem;
}
.sequence-timeline__ruler {
position: relative;
height: 24px;
margin-left: 72px;
}
.sequence-timeline__hour {
position: absolute;
top: 0;
transform: translateX(-50%);
color: var(--color-muted);
font-size: 0.7rem;
white-space: nowrap;
}
.sequence-timeline__track {
position: relative;
margin-left: 72px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.01)),
rgba(9, 13, 18, 0.9);
overflow: hidden;
}
.sequence-timeline__lane {
position: absolute;
left: 0;
right: 0;
height: 52px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.sequence-timeline__lane:first-child {
border-top: 0;
}
.sequence-timeline__lane-label {
position: absolute;
left: -68px;
top: 50%;
width: 56px;
transform: translateY(-50%);
color: var(--color-muted);
font-size: 0.72rem;
text-align: right;
}
.sequence-timeline__minor-grid,
.sequence-timeline__major-grid {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
pointer-events: none;
}
.sequence-timeline__minor-grid {
background: rgba(255, 255, 255, 0.04);
}
.sequence-timeline__major-grid {
background: rgba(255, 255, 255, 0.11);
}
.sequence-timeline__clip {
position: absolute;
min-width: 18px;
height: 36px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
cursor: grab;
overflow: hidden;
}
.sequence-timeline__clip:active {
cursor: grabbing;
}
.sequence-timeline__clip--held {
background: linear-gradient(135deg, #f5b66f, #d17f4a);
color: #24150d;
}
.sequence-timeline__clip--impulse {
background: linear-gradient(135deg, #8ec7d9, #5d8aa1);
color: #0d1a22;
}
.sequence-timeline__clip--selected {
box-shadow: 0 0 0 2px rgba(255, 250, 240, 0.35);
}
.sequence-timeline__clip-title {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
font-size: 0.76rem;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.sequence-timeline__clip-time {
font-size: 0.68rem;
opacity: 0.8;
white-space: nowrap;
}
.sequence-timeline__clip-handle {
position: absolute;
top: 4px;
bottom: 4px;
width: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.45);
cursor: ew-resize;
}
.sequence-timeline__clip-handle--start {
left: 4px;
}
.sequence-timeline__clip-handle--end {
right: 4px;
}
.schedule-days { .schedule-days {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -3541,7 +3541,7 @@ function readProjectSequenceEffect(value: unknown, label: string): SequenceClip
}; };
case "startDialogue": case "startDialogue":
if (stepClass !== "impulse") { if (stepClass !== "impulse") {
throw new Error(`${label}.startDialogue clips must use the impulse class.`); throw new Error(`${label}.startDialogue effects must use the impulse class.`);
} }
return { return {
@@ -3551,7 +3551,7 @@ function readProjectSequenceEffect(value: unknown, label: string): SequenceClip
}; };
case "teleportPlayer": case "teleportPlayer":
if (stepClass !== "impulse") { if (stepClass !== "impulse") {
throw new Error(`${label}.teleportPlayer clips must use the impulse class.`); throw new Error(`${label}.teleportPlayer effects must use the impulse class.`);
} }
return { return {
@@ -3564,7 +3564,7 @@ function readProjectSequenceEffect(value: unknown, label: string): SequenceClip
}; };
case "toggleVisibility": case "toggleVisibility":
if (stepClass !== "impulse") { if (stepClass !== "impulse") {
throw new Error(`${label}.toggleVisibility clips must use the impulse class.`); throw new Error(`${label}.toggleVisibility effects must use the impulse class.`);
} }
return { return {

View File

@@ -649,9 +649,6 @@ describe("RuntimeInteractionSystem", () => {
title: "Console Dialogue Sequence", title: "Console Dialogue Sequence",
steps: [ steps: [
{ {
startMinute: 0,
durationMinutes: 1,
lane: 0,
stepClass: "impulse", stepClass: "impulse",
type: "startDialogue", type: "startDialogue",
dialogueId: "dialogue-sequence" dialogueId: "dialogue-sequence"

View File

@@ -22,8 +22,7 @@ import {
} from "../../src/sequencer/project-sequences"; } from "../../src/sequencer/project-sequences";
import { import {
applyRuntimeProjectScheduleToControlState, applyRuntimeProjectScheduleToControlState,
resolveRuntimeProjectScheduleState, resolveRuntimeProjectScheduleState
type RuntimeProjectSchedulePathDefinition
} from "../../src/runtime-three/runtime-project-scheduler"; } from "../../src/runtime-three/runtime-project-scheduler";
describe("runtime project scheduler", () => { describe("runtime project scheduler", () => {
@@ -180,9 +179,6 @@ describe("runtime project scheduler", () => {
title: "Vendor Open Sequence", title: "Vendor Open Sequence",
steps: [ steps: [
{ {
startMinute: 0,
durationMinutes: 480,
lane: 0,
stepClass: "held", stepClass: "held",
type: "controlEffect", type: "controlEffect",
effect: createSetActorPresenceControlEffect({ effect: createSetActorPresenceControlEffect({
@@ -191,9 +187,6 @@ describe("runtime project scheduler", () => {
}) })
}, },
{ {
startMinute: 0,
durationMinutes: 480,
lane: 1,
stepClass: "held", stepClass: "held",
type: "controlEffect", type: "controlEffect",
effect: createPlayActorAnimationControlEffect({ effect: createPlayActorAnimationControlEffect({
@@ -240,130 +233,6 @@ describe("runtime project scheduler", () => {
); );
}); });
it("resolves timed held sequence clips against local routine minutes", () => {
const actorTarget = createActorControlTargetRef("actor-timed-sequence");
const scheduler = createEmptyProjectScheduler();
const sequences = createEmptyProjectSequenceLibrary();
sequences.sequences["sequence-timed-open"] = createProjectSequence({
id: "sequence-timed-open",
title: "Timed Open Sequence",
durationMinutes: 180,
clips: [
{
startMinute: 30,
durationMinutes: 60,
lane: 0,
stepClass: "held",
type: "controlEffect",
effect: createSetActorPresenceControlEffect({
target: actorTarget,
active: true
})
},
{
startMinute: 60,
durationMinutes: 90,
lane: 1,
stepClass: "held",
type: "controlEffect",
effect: createFollowActorPathControlEffect({
target: actorTarget,
pathId: "path-market",
speed: 2,
loop: false,
progressMode: "deriveFromTime"
})
}
]
});
scheduler.routines["routine-timed-open"] = createProjectScheduleRoutine({
id: "routine-timed-open",
title: "Timed Open",
target: actorTarget,
startHour: 9,
endHour: 12,
sequenceId: "sequence-timed-open",
effects: [
createSetActorPresenceControlEffect({
target: actorTarget,
active: false
})
]
});
const pathsById = new Map<string, RuntimeProjectSchedulePathDefinition>([
[
"path-market",
{
id: "path-market",
loop: false,
points: [
{ position: { x: 0, y: 0, z: 0 } },
{ position: { x: 4, y: 0, z: 0 } }
],
segments: [
{
start: { x: 0, y: 0, z: 0 },
end: { x: 4, y: 0, z: 0 },
length: 4,
distanceStart: 0,
distanceEnd: 4,
tangent: { x: 1, y: 0, z: 0 }
}
],
totalLength: 4
}
]
]);
const beforePresence = resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: ["actor-timed-sequence"],
dayNumber: 1,
timeOfDayHours: 9.25,
pathsById
});
const duringPresence = resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: ["actor-timed-sequence"],
dayNumber: 1,
timeOfDayHours: 9.75,
pathsById
});
const duringPath = resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: ["actor-timed-sequence"],
dayNumber: 1,
timeOfDayHours: 10.5,
pathsById
});
expect(beforePresence.actors[0]).toEqual(
expect.objectContaining({
active: false
})
);
expect(duringPresence.actors[0]).toEqual(
expect.objectContaining({
active: true
})
);
expect(duringPath.actors[0].pathEffect).toEqual(
expect.objectContaining({
type: "followActorPath",
pathId: "path-market"
})
);
expect(duringPath.actors[0].resolvedPath).toEqual(
expect.objectContaining({
elapsedHours: 0.5,
distance: 1
})
);
});
it("applies non-actor scheduler effects over baseline control state and restores defaults when inactive", () => { it("applies non-actor scheduler effects over baseline control state and restores defaults when inactive", () => {
const lightTarget = createLightControlTargetRef( const lightTarget = createLightControlTargetRef(
"pointLight", "pointLight",

View File

@@ -267,9 +267,6 @@ describe("validateSceneDocument", () => {
title: "Held Only", title: "Held Only",
steps: [ steps: [
{ {
startMinute: 0,
durationMinutes: 120,
lane: 0,
stepClass: "held", stepClass: "held",
type: "controlEffect", type: "controlEffect",
effect: createSetSoundVolumeControlEffect({ effect: createSetSoundVolumeControlEffect({