Refactor sequence handling and add new sequences panel
This commit is contained in:
181
src/app/App.tsx
181
src/app/App.tsx
@@ -369,10 +369,6 @@ import { Panel } from "../shared-ui/Panel";
|
||||
import { ProjectDialoguesPanel } from "./ProjectDialoguesPanel";
|
||||
import { ProjectSequencerPane } from "./ProjectSequencerPane";
|
||||
import {
|
||||
DEFAULT_HELD_SEQUENCE_CLIP_DURATION_MINUTES,
|
||||
DEFAULT_IMPULSE_SEQUENCE_CLIP_DURATION_MINUTES,
|
||||
DEFAULT_PROJECT_SEQUENCE_DURATION_MINUTES,
|
||||
getNextSequenceClipLane,
|
||||
getProjectSequenceImpulseSteps
|
||||
} from "../sequencer/project-sequence-steps";
|
||||
import {
|
||||
@@ -4246,7 +4242,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
editorState.projectDocument.scheduler.routines[routineId] ?? null;
|
||||
|
||||
if (currentRoutine === null) {
|
||||
setStatusMessage("Selected sequencer clip no longer exists.");
|
||||
setStatusMessage("Selected sequence placement no longer exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4291,7 +4287,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
|
||||
if (targetOption === null) {
|
||||
setStatusMessage(
|
||||
"Author a sequencer-addressable control target before creating a clip."
|
||||
"Author a sequencer-addressable control target before creating a sequence placement."
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -4324,8 +4320,8 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
|
||||
applyProjectScheduler(
|
||||
nextScheduler,
|
||||
"Create project sequencer clip",
|
||||
`Created sequencer clip for ${targetOption.label}.`
|
||||
"Create sequence placement",
|
||||
`Created a sequence placement for ${targetOption.label}.`
|
||||
);
|
||||
setSchedulePaneOpen(true);
|
||||
setSequencerMode("timeline");
|
||||
@@ -4343,8 +4339,8 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
|
||||
applyProjectScheduler(
|
||||
nextScheduler,
|
||||
"Delete project sequencer clip",
|
||||
"Deleted sequencer clip."
|
||||
"Delete sequence placement",
|
||||
"Deleted sequence placement."
|
||||
);
|
||||
if (selectedScheduleRoutineId === routineId) {
|
||||
setSelectedScheduleRoutineId(null);
|
||||
@@ -4384,11 +4380,8 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
const createDefaultProjectSequenceControlStep = (
|
||||
stepClass: "held" | "impulse",
|
||||
targetKey: string,
|
||||
previousStep?: Extract<ProjectSequence["clips"][number], { type: "controlEffect" }> | null,
|
||||
timing?: Partial<
|
||||
Pick<ProjectSequence["clips"][number], "startMinute" | "durationMinutes" | "lane">
|
||||
>
|
||||
): Extract<ProjectSequence["clips"][number], { type: "controlEffect" }> => {
|
||||
previousStep?: Extract<ProjectSequence["effects"][number], { type: "controlEffect" }> | null
|
||||
): Extract<ProjectSequence["effects"][number], { type: "controlEffect" }> => {
|
||||
const targetOption = resolveSequenceControlTargetOption(targetKey);
|
||||
|
||||
if (targetOption === null) {
|
||||
@@ -4404,13 +4397,6 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
}
|
||||
|
||||
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,
|
||||
type: "controlEffect",
|
||||
effect: createProjectScheduleEffectFromOption({
|
||||
@@ -4421,9 +4407,6 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
};
|
||||
};
|
||||
|
||||
const getNextProjectSequenceClipLane = (sequence: ProjectSequence): number =>
|
||||
getNextSequenceClipLane(sequence.clips);
|
||||
|
||||
const updateProjectSequence = (
|
||||
sequenceId: string,
|
||||
label: string,
|
||||
@@ -4446,13 +4429,13 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
stepIndex: number,
|
||||
label: string,
|
||||
successMessage: string,
|
||||
mutate: (step: ProjectSequence["clips"][number]) => void
|
||||
mutate: (step: ProjectSequence["effects"][number]) => void
|
||||
) => {
|
||||
updateProjectSequence(sequenceId, label, successMessage, (sequence) => {
|
||||
const step = sequence.clips[stepIndex];
|
||||
const step = sequence.effects[stepIndex];
|
||||
|
||||
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);
|
||||
@@ -4460,9 +4443,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
};
|
||||
|
||||
const handleAddProjectSequence = () => {
|
||||
const nextSequence = createProjectSequence({
|
||||
durationMinutes: DEFAULT_PROJECT_SEQUENCE_DURATION_MINUTES
|
||||
});
|
||||
const nextSequence = createProjectSequence();
|
||||
|
||||
updateProjectSequences(
|
||||
"Add project sequence",
|
||||
@@ -4540,21 +4521,14 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
updateProjectSequence(
|
||||
sequenceId,
|
||||
stepClass === "held"
|
||||
? "Add held project sequence control clip"
|
||||
: "Add impulse project sequence control clip",
|
||||
? "Add held project sequence effect"
|
||||
: "Add impulse project sequence effect",
|
||||
stepClass === "held"
|
||||
? "Added held control clip."
|
||||
: "Added impulse control clip.",
|
||||
? "Added held effect."
|
||||
: "Added impulse effect.",
|
||||
(sequence) => {
|
||||
sequence.clips.push(
|
||||
createDefaultProjectSequenceControlStep(stepClass, targetKey, null, {
|
||||
startMinute: 0,
|
||||
durationMinutes:
|
||||
stepClass === "held"
|
||||
? DEFAULT_HELD_SEQUENCE_CLIP_DURATION_MINUTES
|
||||
: DEFAULT_IMPULSE_SEQUENCE_CLIP_DURATION_MINUTES,
|
||||
lane: getNextProjectSequenceClipLane(sequence)
|
||||
})
|
||||
sequence.effects.push(
|
||||
createDefaultProjectSequenceControlStep(stepClass, targetKey)
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -4567,12 +4541,9 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
updateProjectSequence(
|
||||
sequenceId,
|
||||
"Add project sequence dialogue clip",
|
||||
"Added dialogue clip.",
|
||||
"Added dialogue effect.",
|
||||
(sequence) => {
|
||||
sequence.clips.push({
|
||||
startMinute: 0,
|
||||
durationMinutes: DEFAULT_IMPULSE_SEQUENCE_CLIP_DURATION_MINUTES,
|
||||
lane: getNextProjectSequenceClipLane(sequence),
|
||||
sequence.effects.push({
|
||||
stepClass: "impulse",
|
||||
type: "startDialogue",
|
||||
dialogueId
|
||||
@@ -4587,10 +4558,10 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
) => {
|
||||
updateProjectSequence(
|
||||
sequenceId,
|
||||
"Delete project sequence clip",
|
||||
"Deleted sequence clip.",
|
||||
"Delete project sequence effect",
|
||||
"Deleted sequence effect.",
|
||||
(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 = (
|
||||
label: string,
|
||||
@@ -7473,7 +7376,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
if (defaultSequence === null) {
|
||||
openSequencerSequenceEditor();
|
||||
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;
|
||||
}
|
||||
@@ -8008,7 +7911,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
if (defaultSequence === null) {
|
||||
openSequencerSequenceEditor();
|
||||
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;
|
||||
}
|
||||
@@ -8787,7 +8690,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
</div>
|
||||
<div className="material-summary">
|
||||
Run Sequence links can only reference sequences that
|
||||
contain at least one impulse clip.
|
||||
contain at least one impulse effect.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -11431,7 +11334,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
updateProjectScheduleRoutine(
|
||||
routineId,
|
||||
"Set project sequencer target",
|
||||
"Retargeted sequencer clip.",
|
||||
"Retargeted sequence placement.",
|
||||
(routine) => {
|
||||
const targetOption =
|
||||
resolveProjectScheduleTargetOption(targetKey);
|
||||
@@ -11484,8 +11387,8 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
onSetRoutineTitle={(routineId, title) =>
|
||||
updateProjectScheduleRoutine(
|
||||
routineId,
|
||||
"Rename project sequencer clip",
|
||||
"Updated sequencer clip title.",
|
||||
"Rename sequence placement",
|
||||
"Updated sequence placement title.",
|
||||
(routine) => {
|
||||
routine.title = title.trim();
|
||||
}
|
||||
@@ -11494,10 +11397,10 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
onSetRoutineEnabled={(routineId, enabled) =>
|
||||
updateProjectScheduleRoutine(
|
||||
routineId,
|
||||
"Toggle project sequencer clip",
|
||||
"Toggle sequence placement",
|
||||
enabled
|
||||
? "Enabled sequencer clip."
|
||||
: "Disabled sequencer clip.",
|
||||
? "Enabled sequence placement."
|
||||
: "Disabled sequence placement.",
|
||||
(routine) => {
|
||||
routine.enabled = enabled;
|
||||
}
|
||||
@@ -11507,7 +11410,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
updateProjectScheduleRoutine(
|
||||
routineId,
|
||||
"Set project sequencer start time",
|
||||
"Updated sequencer clip start time.",
|
||||
"Updated sequence placement start time.",
|
||||
(routine) => {
|
||||
routine.startHour = normalizeTimeOfDayHours(startHour);
|
||||
if (routine.startHour === routine.endHour) {
|
||||
@@ -11522,7 +11425,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
updateProjectScheduleRoutine(
|
||||
routineId,
|
||||
"Set project sequencer end time",
|
||||
"Updated sequencer clip end time.",
|
||||
"Updated sequence placement end time.",
|
||||
(routine) => {
|
||||
routine.endHour = normalizeTimeOfDayHours(endHour);
|
||||
if (routine.startHour === routine.endHour) {
|
||||
@@ -11537,7 +11440,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
updateProjectScheduleRoutine(
|
||||
routineId,
|
||||
"Set project sequencer priority",
|
||||
"Updated sequencer clip priority.",
|
||||
"Updated sequence placement priority.",
|
||||
(routine) => {
|
||||
if (!Number.isFinite(priority)) {
|
||||
throw new Error(
|
||||
@@ -11554,7 +11457,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
routineId,
|
||||
"Set project sequencer sequence",
|
||||
sequenceId === null
|
||||
? "Timeline clip now uses inline held clips."
|
||||
? "Sequence placement now uses inline held effects."
|
||||
: "Timeline clip now resolves a project sequence.",
|
||||
(routine) => {
|
||||
routine.sequenceId = sequenceId;
|
||||
@@ -11565,7 +11468,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
updateProjectScheduleRoutine(
|
||||
routineId,
|
||||
"Set project sequencer effect",
|
||||
"Updated sequencer clip effect.",
|
||||
"Updated sequence placement effect.",
|
||||
(routine) => {
|
||||
if (routine.target.kind === "actor") {
|
||||
throw new Error(
|
||||
@@ -11598,7 +11501,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
updateProjectScheduleRoutine(
|
||||
routineId,
|
||||
"Set project sequencer numeric value",
|
||||
"Updated sequencer clip value.",
|
||||
"Updated sequence placement value.",
|
||||
(routine) => {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new Error(
|
||||
@@ -11610,7 +11513,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
|
||||
if (effect === undefined) {
|
||||
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(
|
||||
routineId,
|
||||
"Set project sequencer color",
|
||||
"Updated sequencer clip color.",
|
||||
"Updated sequence placement color.",
|
||||
(routine) => {
|
||||
const effect = routine.effects[0];
|
||||
|
||||
if (effect === undefined) {
|
||||
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) =>
|
||||
handleAddProjectSequenceControlStep(
|
||||
sequenceId,
|
||||
@@ -11905,7 +11807,6 @@ export function App({ store, initialStatusMessage }: AppProps) {
|
||||
}
|
||||
onAddDialogueStep={handleAddProjectSequenceDialogueStep}
|
||||
onDeleteStep={handleDeleteProjectSequenceStep}
|
||||
onSetClipTiming={updateProjectSequenceClipTiming}
|
||||
onSetControlStepTarget={updateProjectSequenceControlStepTarget}
|
||||
onSetControlStepEffectOption={
|
||||
updateProjectSequenceControlStepEffectOption
|
||||
|
||||
@@ -71,20 +71,10 @@ interface ProjectSequencerPaneProps {
|
||||
onSetActorRoutinePathSpeed(routineId: string, speed: number): void;
|
||||
onSetActorRoutinePathLoop(routineId: string, loop: boolean): void;
|
||||
onSetSequenceTitle(sequenceId: string, title: string): void;
|
||||
onSetSequenceDurationMinutes(sequenceId: string, durationMinutes: number): 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;
|
||||
onSetClipTiming(
|
||||
sequenceId: string,
|
||||
stepIndex: number,
|
||||
timing: {
|
||||
startMinute: number;
|
||||
durationMinutes: number;
|
||||
lane: number;
|
||||
}
|
||||
): void;
|
||||
onSetControlStepTarget(
|
||||
sequenceId: string,
|
||||
stepIndex: number,
|
||||
@@ -324,12 +314,10 @@ export function ProjectSequencerPane({
|
||||
onSetActorRoutinePathSpeed,
|
||||
onSetActorRoutinePathLoop,
|
||||
onSetSequenceTitle,
|
||||
onSetSequenceDurationMinutes,
|
||||
onAddHeldControlStep,
|
||||
onAddImpulseControlStep,
|
||||
onAddDialogueStep,
|
||||
onDeleteStep,
|
||||
onSetClipTiming,
|
||||
onSetControlStepTarget,
|
||||
onSetControlStepEffectOption,
|
||||
onSetControlStepNumericValue,
|
||||
@@ -399,8 +387,8 @@ export function ProjectSequencerPane({
|
||||
<div className="label">Sequencer</div>
|
||||
<div className="schedule-pane__summary">
|
||||
{mode === "timeline"
|
||||
? "Place sequence clips over global project time."
|
||||
: "Compose reusable sequences from clips for timeline and interaction playback."}
|
||||
? "Place sequences over global project time."
|
||||
: "Compose reusable sequences from engine effects for timeline and interaction playback."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="schedule-pane__actions">
|
||||
@@ -459,12 +447,10 @@ export function ProjectSequencerPane({
|
||||
onAddSequence={onAddSequence}
|
||||
onDeleteSequence={onDeleteSequence}
|
||||
onSetSequenceTitle={onSetSequenceTitle}
|
||||
onSetSequenceDurationMinutes={onSetSequenceDurationMinutes}
|
||||
onAddHeldControlStep={onAddHeldControlStep}
|
||||
onAddImpulseControlStep={onAddImpulseControlStep}
|
||||
onAddDialogueStep={onAddDialogueStep}
|
||||
onDeleteStep={onDeleteStep}
|
||||
onSetClipTiming={onSetClipTiming}
|
||||
onSetControlStepTarget={onSetControlStepTarget}
|
||||
onSetControlStepEffectOption={onSetControlStepEffectOption}
|
||||
onSetControlStepNumericValue={onSetControlStepNumericValue}
|
||||
@@ -562,12 +548,12 @@ export function ProjectSequencerPane({
|
||||
<aside className="schedule-pane__editor">
|
||||
{selectedRoutine === null || selectedTargetOption === null ? (
|
||||
<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 className="form-section">
|
||||
<div className="label">Timeline Clip</div>
|
||||
<div className="label">Sequence Placement</div>
|
||||
<label className="form-field">
|
||||
<span className="label">Title</span>
|
||||
<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) => (
|
||||
<option key={sequence.id} value={sequence.id}>
|
||||
{sequence.title}
|
||||
@@ -647,17 +633,17 @@ export function ProjectSequencerPane({
|
||||
</div>
|
||||
{selectedRoutine.sequenceId !== null ? (
|
||||
<div className="material-summary">
|
||||
This timeline clip resolves held clips from the selected
|
||||
project sequence. Inline clip controls stay preserved as
|
||||
fallback, but are not edited while a sequence is attached.
|
||||
This placement resolves held effects from the selected
|
||||
sequence. Inline effects stay preserved as fallback, but are
|
||||
not edited while a sequence is attached.
|
||||
</div>
|
||||
) : null}
|
||||
{selectedRoutine.sequenceId === null &&
|
||||
compatibleHeldSequences.length === 0 ? (
|
||||
<div className="material-summary">
|
||||
No compatible project sequence with held clips is authored
|
||||
for this target yet. Use Sequence Editor to author clips, or
|
||||
keep this placement on its own inline held clips.
|
||||
No compatible sequence with held effects is authored for this
|
||||
target yet. Use Sequence Editor to author effects, or keep
|
||||
this placement on its own inline effects.
|
||||
</div>
|
||||
) : null}
|
||||
{selectedRoutine.sequenceId === null &&
|
||||
@@ -727,7 +713,7 @@ export function ProjectSequencerPane({
|
||||
</div>
|
||||
<div className="material-summary">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
528
src/app/ProjectSequencesPanel.tsx
Normal file
528
src/app/ProjectSequencesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
src/app/app.css
143
src/app/app.css
@@ -1119,149 +1119,6 @@ button:disabled {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -3541,7 +3541,7 @@ function readProjectSequenceEffect(value: unknown, label: string): SequenceClip
|
||||
};
|
||||
case "startDialogue":
|
||||
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 {
|
||||
@@ -3551,7 +3551,7 @@ function readProjectSequenceEffect(value: unknown, label: string): SequenceClip
|
||||
};
|
||||
case "teleportPlayer":
|
||||
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 {
|
||||
@@ -3564,7 +3564,7 @@ function readProjectSequenceEffect(value: unknown, label: string): SequenceClip
|
||||
};
|
||||
case "toggleVisibility":
|
||||
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 {
|
||||
|
||||
@@ -649,9 +649,6 @@ describe("RuntimeInteractionSystem", () => {
|
||||
title: "Console Dialogue Sequence",
|
||||
steps: [
|
||||
{
|
||||
startMinute: 0,
|
||||
durationMinutes: 1,
|
||||
lane: 0,
|
||||
stepClass: "impulse",
|
||||
type: "startDialogue",
|
||||
dialogueId: "dialogue-sequence"
|
||||
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
} from "../../src/sequencer/project-sequences";
|
||||
import {
|
||||
applyRuntimeProjectScheduleToControlState,
|
||||
resolveRuntimeProjectScheduleState,
|
||||
type RuntimeProjectSchedulePathDefinition
|
||||
resolveRuntimeProjectScheduleState
|
||||
} from "../../src/runtime-three/runtime-project-scheduler";
|
||||
|
||||
describe("runtime project scheduler", () => {
|
||||
@@ -180,9 +179,6 @@ describe("runtime project scheduler", () => {
|
||||
title: "Vendor Open Sequence",
|
||||
steps: [
|
||||
{
|
||||
startMinute: 0,
|
||||
durationMinutes: 480,
|
||||
lane: 0,
|
||||
stepClass: "held",
|
||||
type: "controlEffect",
|
||||
effect: createSetActorPresenceControlEffect({
|
||||
@@ -191,9 +187,6 @@ describe("runtime project scheduler", () => {
|
||||
})
|
||||
},
|
||||
{
|
||||
startMinute: 0,
|
||||
durationMinutes: 480,
|
||||
lane: 1,
|
||||
stepClass: "held",
|
||||
type: "controlEffect",
|
||||
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", () => {
|
||||
const lightTarget = createLightControlTargetRef(
|
||||
"pointLight",
|
||||
|
||||
@@ -267,9 +267,6 @@ describe("validateSceneDocument", () => {
|
||||
title: "Held Only",
|
||||
steps: [
|
||||
{
|
||||
startMinute: 0,
|
||||
durationMinutes: 120,
|
||||
lane: 0,
|
||||
stepClass: "held",
|
||||
type: "controlEffect",
|
||||
effect: createSetSoundVolumeControlEffect({
|
||||
|
||||
Reference in New Issue
Block a user