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 { 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

View File

@@ -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>

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;
}
.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;

View File

@@ -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 {

View File

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

View File

@@ -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",

View File

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