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 { 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user