Files
webeditor3d/src/app/ProjectSchedulePane.tsx

1103 lines
42 KiB
TypeScript

import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import {
HOURS_PER_DAY,
formatTimeOfDayHours
} from "../document/project-time-settings";
import type { ProjectDialogueLibrary } from "../dialogues/project-dialogues";
import { formatControlEffectValue, getControlTargetRefKey } from "../controls/control-surface";
import { ProjectSequencesPanel } from "./ProjectSequencesPanel";
import {
formatProjectScheduleDaySelection,
getProjectScheduleTimelineSegments,
type ProjectScheduler,
type ProjectScheduleRoutine
} from "../sequencer/project-sequencer";
import {
getProjectScheduleEffectOptionId,
getProjectScheduleTargetOptionForRoutine,
listProjectScheduleEffectOptions,
type ProjectScheduleEffectOptionId,
type ProjectScheduleTargetOption
} from "../sequencer/project-sequencer-control-options";
import {
findHeldSequenceControlEffect,
getProjectScheduleRoutineHeldSteps,
getProjectSequenceHeldSteps
} from "../sequencer/project-sequence-steps";
import {
getProjectSequences,
type ProjectSequenceLibrary
} from "../sequencer/project-sequences";
interface ProjectSequencerPaneProps {
mode: "timeline" | "sequence";
onSetMode(mode: "timeline" | "sequence"): void;
targetOptions: ProjectScheduleTargetOption[];
scheduler: ProjectScheduler;
sequences: ProjectSequenceLibrary;
dialogues: ProjectDialogueLibrary;
selectedRoutineId: string | null;
selectedSequenceId: string | null;
onSelectRoutine(routineId: string | null): void;
onSelectSequence(sequenceId: string | null): void;
onAddRoutine(targetKey: string): void;
onAddSequence(): void;
onDeleteRoutine(routineId: string): void;
onDeleteSequence(sequenceId: string): void;
onClose(): void;
onSetRoutineTarget(routineId: string, targetKey: string): void;
onSetRoutineTitle(routineId: string, title: string): void;
onSetRoutineEnabled(routineId: string, enabled: boolean): void;
onSetRoutineStartHour(routineId: string, startHour: number): void;
onSetRoutineEndHour(routineId: string, endHour: number): void;
onSetRoutinePriority(routineId: string, priority: number): void;
onSetRoutineSequenceId(routineId: string, sequenceId: string | null): void;
onSetRoutineEffectOption(
routineId: string,
effectOptionId: ProjectScheduleEffectOptionId
): void;
onSetRoutineNumericValue(routineId: string, value: number): void;
onSetRoutineColorValue(routineId: string, colorHex: string): void;
onSetRoutineAnimationClip(routineId: string, clipName: string): void;
onSetRoutineAnimationLoop(routineId: string, loop: boolean): void;
onSetActorRoutinePresence(routineId: string, active: boolean): void;
onSetActorRoutineAnimationClip(
routineId: string,
clipName: string | null
): void;
onSetActorRoutineAnimationLoop(routineId: string, loop: boolean): void;
onSetActorRoutinePath(routineId: string, pathId: string | null): void;
onSetActorRoutinePathSpeed(routineId: string, speed: number): void;
onSetActorRoutinePathLoop(routineId: string, loop: boolean): 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 handleCommitOnEnter(
event: ReactKeyboardEvent<HTMLInputElement>,
commit: () => void
) {
if (event.key !== "Enter") {
return;
}
event.currentTarget.blur();
commit();
}
function parseTimeOfDayInputHours(value: string, label: string): number {
const match = /^(?<hours>\d{1,2}):(?<minutes>\d{2})$/.exec(value.trim());
if (match?.groups === undefined) {
throw new Error(`${label} must use HH:MM.`);
}
const hours = Number(match.groups.hours);
const minutes = Number(match.groups.minutes);
if (
!Number.isFinite(hours) ||
!Number.isFinite(minutes) ||
hours < 0 ||
hours >= HOURS_PER_DAY ||
minutes < 0 ||
minutes >= 60
) {
throw new Error(`${label} must be a valid time of day.`);
}
return hours + minutes / 60;
}
function getRoutineSummary(
routine: ProjectScheduleRoutine,
sequences: ProjectSequenceLibrary
): string {
const summaryParts = [
formatProjectScheduleDaySelection(routine.days),
`${formatTimeOfDayHours(routine.startHour)}-${formatTimeOfDayHours(routine.endHour)}`
];
const heldSteps = getProjectScheduleRoutineHeldSteps(routine, sequences);
if (routine.target.kind === "actor") {
const presenceEffect = findHeldSequenceControlEffect(
heldSteps,
"setActorPresence"
);
const animationEffect = findHeldSequenceControlEffect(
heldSteps,
"playActorAnimation"
);
const pathEffect = findHeldSequenceControlEffect(
heldSteps,
"followActorPath"
);
summaryParts.push(presenceEffect?.active === false ? "Hidden" : "Present");
if (animationEffect !== null) {
summaryParts.push(formatControlEffectValue(animationEffect));
}
if (pathEffect !== null) {
summaryParts.push(formatControlEffectValue(pathEffect));
}
} else {
const effect = heldSteps[0];
if (effect?.type === "controlEffect") {
summaryParts.push(formatControlEffectValue(effect.effect));
}
}
summaryParts.push(`P${routine.priority}`);
return summaryParts.join(" · ");
}
function isRoutineEffectInactive(
routine: ProjectScheduleRoutine,
sequences: ProjectSequenceLibrary
): boolean {
const heldSteps = getProjectScheduleRoutineHeldSteps(routine, sequences);
if (routine.target.kind === "actor") {
return (
findHeldSequenceControlEffect(heldSteps, "setActorPresence")?.active === false
);
}
const effect =
heldSteps[0]?.type === "controlEffect" ? heldSteps[0].effect : undefined;
if (effect === undefined) {
return false;
}
switch (effect.type) {
case "stopModelAnimation":
case "stopSound":
return true;
case "setModelInstanceVisible":
return !effect.visible;
case "setInteractionEnabled":
case "setLightEnabled":
return !effect.enabled;
default:
return false;
}
}
function getRoutineNumericValue(routine: ProjectScheduleRoutine): number | null {
const effect = routine.effects[0];
if (effect === undefined) {
return null;
}
switch (effect.type) {
case "setSoundVolume":
return effect.volume;
case "setLightIntensity":
case "setAmbientLightIntensity":
case "setSunLightIntensity":
return effect.intensity;
default:
return null;
}
}
function getRoutineColorValue(routine: ProjectScheduleRoutine): string | null {
const effect = routine.effects[0];
if (effect === undefined) {
return null;
}
switch (effect.type) {
case "setLightColor":
case "setAmbientLightColor":
case "setSunLightColor":
return effect.colorHex;
default:
return null;
}
}
function groupTargetOptions(
targetOptions: ProjectScheduleTargetOption[]
): Array<{ groupLabel: string; options: ProjectScheduleTargetOption[] }> {
const grouped = new Map<string, ProjectScheduleTargetOption[]>();
for (const option of targetOptions) {
const entries = grouped.get(option.groupLabel) ?? [];
entries.push(option);
grouped.set(option.groupLabel, entries);
}
return [...grouped.entries()].map(([groupLabel, options]) => ({
groupLabel,
options
}));
}
export function ProjectSequencerPane({
mode,
onSetMode,
targetOptions,
scheduler,
sequences,
dialogues,
selectedRoutineId,
selectedSequenceId,
onSelectRoutine,
onSelectSequence,
onAddRoutine,
onAddSequence,
onDeleteRoutine,
onDeleteSequence,
onClose,
onSetRoutineTarget,
onSetRoutineTitle,
onSetRoutineEnabled,
onSetRoutineStartHour,
onSetRoutineEndHour,
onSetRoutinePriority,
onSetRoutineSequenceId,
onSetRoutineEffectOption,
onSetRoutineNumericValue,
onSetRoutineColorValue,
onSetRoutineAnimationClip,
onSetRoutineAnimationLoop,
onSetActorRoutinePresence,
onSetActorRoutineAnimationClip,
onSetActorRoutineAnimationLoop,
onSetActorRoutinePath,
onSetActorRoutinePathSpeed,
onSetActorRoutinePathLoop,
onSetSequenceTitle,
onAddHeldControlStep,
onAddImpulseControlStep,
onAddDialogueStep,
onDeleteStep,
onSetControlStepTarget,
onSetControlStepEffectOption,
onSetControlStepNumericValue,
onSetControlStepColorValue,
onSetControlStepAnimationClip,
onSetControlStepAnimationLoop,
onSetDialogueStepDialogueId
}: ProjectSequencerPaneProps) {
const selectedRoutine =
selectedRoutineId === null ? null : scheduler.routines[selectedRoutineId] ?? null;
const selectedRoutineHeldSteps =
selectedRoutine === null
? []
: getProjectScheduleRoutineHeldSteps(selectedRoutine, sequences);
const selectedTargetOption =
selectedRoutine === null
? null
: getProjectScheduleTargetOptionForRoutine(
targetOptions,
selectedRoutine.target
);
const selectedEffectOptionId =
selectedRoutine === null ||
selectedRoutine.target.kind === "actor" ||
selectedRoutine.sequenceId !== null
? null
: getProjectScheduleEffectOptionId(selectedRoutine.effects[0]!);
const selectedEffectOptions =
selectedTargetOption === null || selectedTargetOption.target.kind === "actor"
? []
: listProjectScheduleEffectOptions(selectedTargetOption);
const selectedActorPresenceEffect =
selectedRoutine === null || selectedRoutine.target.kind !== "actor"
? null
: findHeldSequenceControlEffect(selectedRoutineHeldSteps, "setActorPresence");
const selectedActorAnimationEffect =
selectedRoutine === null || selectedRoutine.target.kind !== "actor"
? null
: findHeldSequenceControlEffect(selectedRoutineHeldSteps, "playActorAnimation");
const selectedActorPathEffect =
selectedRoutine === null || selectedRoutine.target.kind !== "actor"
? null
: findHeldSequenceControlEffect(selectedRoutineHeldSteps, "followActorPath");
const compatibleHeldSequences =
selectedRoutine === null
? []
: getProjectSequences(sequences).filter((sequence) => {
const heldSteps = getProjectSequenceHeldSteps(sequence);
if (heldSteps.length === 0) {
return false;
}
return heldSteps.every(
(step) =>
step.type === "controlEffect" &&
getControlTargetRefKey(step.effect.target) ===
getControlTargetRefKey(selectedRoutine.target)
);
});
const hourTicks = Array.from({ length: HOURS_PER_DAY }, (_, hour) => hour);
return (
<section className="schedule-pane" data-testid="project-sequencer-pane">
<div className="schedule-pane__header">
<div>
<div className="label">Sequencer</div>
<div className="schedule-pane__summary">
{mode === "timeline"
? "Place sequences over global project time."
: "Compose reusable sequences from engine effects for timeline and interaction playback."}
</div>
</div>
<div className="schedule-pane__actions">
{mode !== "timeline" ? (
<button
className="toolbar__button toolbar__button--compact"
type="button"
onClick={() => onSetMode("timeline")}
>
Timeline
</button>
) : null}
{mode !== "sequence" ? (
<button
className="toolbar__button toolbar__button--compact"
type="button"
onClick={() => onSetMode("sequence")}
>
Sequence Editor
</button>
) : null}
<button
className="toolbar__button toolbar__button--compact"
type="button"
disabled={mode === "timeline" ? targetOptions.length === 0 : false}
onClick={() => {
if (mode === "timeline") {
onAddRoutine(selectedTargetOption?.key ?? targetOptions[0]?.key ?? "");
return;
}
onAddSequence();
}}
>
{mode === "timeline" ? "Add Sequence" : "Create Sequence"}
</button>
<button
className="toolbar__button toolbar__button--compact"
type="button"
onClick={onClose}
>
Close
</button>
</div>
</div>
<div className="schedule-pane__body">
{mode === "sequence" ? (
<div className="schedule-pane__editor" style={{ width: "100%" }}>
<ProjectSequencesPanel
sequences={sequences}
dialogues={dialogues}
targetOptions={targetOptions}
selectedSequenceId={selectedSequenceId}
onSelectSequence={onSelectSequence}
onAddSequence={onAddSequence}
onDeleteSequence={onDeleteSequence}
onSetSequenceTitle={onSetSequenceTitle}
onAddHeldControlStep={onAddHeldControlStep}
onAddImpulseControlStep={onAddImpulseControlStep}
onAddDialogueStep={onAddDialogueStep}
onDeleteStep={onDeleteStep}
onSetControlStepTarget={onSetControlStepTarget}
onSetControlStepEffectOption={onSetControlStepEffectOption}
onSetControlStepNumericValue={onSetControlStepNumericValue}
onSetControlStepColorValue={onSetControlStepColorValue}
onSetControlStepAnimationClip={onSetControlStepAnimationClip}
onSetControlStepAnimationLoop={onSetControlStepAnimationLoop}
onSetDialogueStepDialogueId={onSetDialogueStepDialogueId}
/>
</div>
) : (
<>
<div className="schedule-pane__timeline">
<div className="schedule-ruler">
<div className="schedule-ruler__label">Targets</div>
<div className="schedule-ruler__track">
{hourTicks.map((hour) => (
<div key={hour} className="schedule-ruler__tick">
<span>{String(hour).padStart(2, "0")}</span>
</div>
))}
</div>
</div>
{targetOptions.length === 0 ? (
<div className="schedule-pane__empty">
No sequencer-addressable control targets are authored in this
project yet.
</div>
) : (
targetOptions.map((targetOption) => {
const routines = Object.values(scheduler.routines)
.filter(
(routine) =>
getControlTargetRefKey(routine.target) === targetOption.key
)
.sort((left, right) => left.startHour - right.startHour);
return (
<div key={targetOption.key} className="schedule-row">
<div className="schedule-row__label">
<button
className="schedule-row__add"
type="button"
onClick={() => onAddRoutine(targetOption.key)}
>
+
</button>
<div className="schedule-row__meta">
<div className="schedule-row__title">{targetOption.label}</div>
<div className="schedule-row__subtitle">
{targetOption.groupLabel} · {targetOption.subtitle}
</div>
</div>
</div>
<div className="schedule-row__track">
<div className="schedule-row__grid" />
{routines.map((routine) =>
getProjectScheduleTimelineSegments(routine).map(
(segment) => (
<button
key={segment.key}
className={`schedule-block ${
selectedRoutineId === routine.id
? "schedule-block--selected"
: ""
} ${
isRoutineEffectInactive(routine, sequences)
? "schedule-block--inactive"
: ""
} ${
routine.enabled ? "" : "schedule-block--disabled"
}`.trim()}
type="button"
title={`${routine.title} · ${getRoutineSummary(routine, sequences)}`}
style={{
left: `${(segment.startHour / HOURS_PER_DAY) * 100}%`,
width: `${((segment.endHour - segment.startHour) / HOURS_PER_DAY) * 100}%`
}}
onClick={() => onSelectRoutine(routine.id)}
>
<span className="schedule-block__title">
{routine.title}
</span>
</button>
)
)
)}
</div>
</div>
);
})
)}
</div>
<aside className="schedule-pane__editor">
{selectedRoutine === null || selectedTargetOption === null ? (
<div className="schedule-pane__empty">
Select a sequence placement or create a new one.
</div>
) : (
<>
<div className="form-section">
<div className="label">Sequence Placement</div>
<label className="form-field">
<span className="label">Title</span>
<input
key={`${selectedRoutine.id}-title`}
className="text-input"
type="text"
defaultValue={selectedRoutine.title}
onBlur={(event) =>
onSetRoutineTitle(
selectedRoutine.id,
event.currentTarget.value
)
}
onKeyDown={(event) =>
handleCommitOnEnter(event, () =>
onSetRoutineTitle(
selectedRoutine.id,
event.currentTarget.value
)
)
}
/>
</label>
<label className="form-field">
<span className="label">Target</span>
<select
className="select-input"
value={selectedTargetOption.key}
onChange={(event) =>
onSetRoutineTarget(selectedRoutine.id, event.currentTarget.value)
}
>
{groupTargetOptions(targetOptions).map((group) => (
<optgroup key={group.groupLabel} label={group.groupLabel}>
{group.options.map((option) => (
<option key={option.key} value={option.key}>
{option.label} ({option.subtitle})
</option>
))}
</optgroup>
))}
</select>
</label>
<label className="form-field">
<span className="label">Sequence</span>
<select
className="select-input"
value={selectedRoutine.sequenceId ?? ""}
onChange={(event) =>
onSetRoutineSequenceId(
selectedRoutine.id,
event.currentTarget.value === ""
? null
: event.currentTarget.value
)
}
>
<option value="">This Placement (inline effects)</option>
{compatibleHeldSequences.map((sequence) => (
<option key={sequence.id} value={sequence.id}>
{sequence.title}
</option>
))}
</select>
</label>
<div className="inline-actions">
<button
className="toolbar__button toolbar__button--compact"
type="button"
onClick={() => {
onSelectSequence(selectedRoutine.sequenceId ?? selectedSequenceId);
onSetMode("sequence");
}}
>
Edit Sequences
</button>
</div>
{selectedRoutine.sequenceId !== null ? (
<div className="material-summary">
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 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 &&
selectedRoutine.target.kind === "actor" ? (
<>
<label className="form-field">
<span className="label">Presence</span>
<select
className="select-input"
value={
selectedActorPresenceEffect?.active === false
? "hidden"
: "present"
}
onChange={(event) =>
onSetActorRoutinePresence(
selectedRoutine.id,
event.currentTarget.value !== "hidden"
)
}
>
<option value="present">Present</option>
<option value="hidden">Hidden</option>
</select>
</label>
</>
) : selectedRoutine.sequenceId === null ? (
<label className="form-field">
<span className="label">Effect</span>
<select
className="select-input"
value={selectedEffectOptionId ?? ""}
onChange={(event) =>
onSetRoutineEffectOption(
selectedRoutine.id,
event.currentTarget.value as ProjectScheduleEffectOptionId
)
}
>
{selectedEffectOptions.map((effectOption) => (
<option key={effectOption.id} value={effectOption.id}>
{effectOption.label}
</option>
))}
</select>
</label>
) : null}
<label className="form-field form-field--inline">
<input
type="checkbox"
checked={selectedRoutine.enabled}
onChange={(event) =>
onSetRoutineEnabled(
selectedRoutine.id,
event.currentTarget.checked
)
}
/>
<span className="label">Enabled</span>
</label>
</div>
<div className="form-section">
<div className="label">Legacy Day Filter</div>
<div className="schedule-pane__summary">
{formatProjectScheduleDaySelection(selectedRoutine.days)}
</div>
<div className="material-summary">
Day-specific routine filters are preserved for compatibility.
New sequencer authoring should prefer sequence placements; a later
multi-day timeline will replace this legacy filter.
</div>
</div>
<div className="form-section">
<div className="label">Window</div>
<div className="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Start</span>
<input
key={`${selectedRoutine.id}-start`}
className="text-input"
type="time"
step="60"
defaultValue={formatTimeOfDayHours(selectedRoutine.startHour)}
onBlur={(event) =>
onSetRoutineStartHour(
selectedRoutine.id,
parseTimeOfDayInputHours(
event.currentTarget.value,
"Clip start"
)
)
}
onKeyDown={(event) =>
handleCommitOnEnter(event, () =>
onSetRoutineStartHour(
selectedRoutine.id,
parseTimeOfDayInputHours(
event.currentTarget.value,
"Clip start"
)
)
)
}
/>
</label>
<label className="form-field">
<span className="label">End</span>
<input
key={`${selectedRoutine.id}-end`}
className="text-input"
type="time"
step="60"
defaultValue={formatTimeOfDayHours(selectedRoutine.endHour)}
onBlur={(event) =>
onSetRoutineEndHour(
selectedRoutine.id,
parseTimeOfDayInputHours(
event.currentTarget.value,
"Clip end"
)
)
}
onKeyDown={(event) =>
handleCommitOnEnter(event, () =>
onSetRoutineEndHour(
selectedRoutine.id,
parseTimeOfDayInputHours(
event.currentTarget.value,
"Clip end"
)
)
)
}
/>
</label>
</div>
<label className="form-field">
<span className="label">Priority</span>
<input
key={`${selectedRoutine.id}-priority`}
className="text-input"
type="number"
step="1"
defaultValue={selectedRoutine.priority}
onBlur={(event) =>
onSetRoutinePriority(
selectedRoutine.id,
Number(event.currentTarget.value)
)
}
onKeyDown={(event) =>
handleCommitOnEnter(event, () =>
onSetRoutinePriority(
selectedRoutine.id,
Number(event.currentTarget.value)
)
)
}
/>
</label>
</div>
{selectedRoutine.sequenceId === null &&
selectedRoutine.target.kind === "actor" ? (
<>
<div className="form-section">
<div className="label">Animation</div>
<label className="form-field">
<span className="label">Clip</span>
<select
className="select-input"
value={selectedActorAnimationEffect?.clipName ?? ""}
onChange={(event) =>
onSetActorRoutineAnimationClip(
selectedRoutine.id,
event.currentTarget.value === ""
? null
: event.currentTarget.value
)
}
>
<option value="">None</option>
{(selectedTargetOption.defaults.actorAnimationClipNames ?? []).map(
(clipName) => (
<option key={clipName} value={clipName}>
{clipName}
</option>
)
)}
</select>
</label>
<label className="form-field form-field--inline">
<input
type="checkbox"
checked={selectedActorAnimationEffect?.loop !== false}
disabled={selectedActorAnimationEffect === null}
onChange={(event) =>
onSetActorRoutineAnimationLoop(
selectedRoutine.id,
event.currentTarget.checked
)
}
/>
<span className="label">Loop</span>
</label>
{(selectedTargetOption.defaults.actorAnimationClipNames ?? [])
.length === 0 ? (
<div className="schedule-pane__summary">
Animation clips are available only when this actor has one
uniquely bound NPC model with imported clips.
</div>
) : null}
</div>
<div className="form-section">
<div className="label">Path</div>
<label className="form-field">
<span className="label">Path</span>
<select
className="select-input"
value={selectedActorPathEffect?.pathId ?? ""}
onChange={(event) =>
onSetActorRoutinePath(
selectedRoutine.id,
event.currentTarget.value === ""
? null
: event.currentTarget.value
)
}
>
<option value="">None</option>
{(selectedTargetOption.defaults.actorPathOptions ?? []).map(
(pathOption) => (
<option key={pathOption.pathId} value={pathOption.pathId}>
{pathOption.label}
</option>
)
)}
</select>
</label>
<label className="form-field">
<span className="label">Speed</span>
<input
key={`${selectedRoutine.id}-actor-path-speed`}
className="text-input"
type="number"
min="0.01"
step="0.1"
defaultValue={
selectedActorPathEffect?.speed ??
selectedTargetOption.defaults.actorPathSpeed ??
1
}
disabled={selectedActorPathEffect === null}
onBlur={(event) =>
onSetActorRoutinePathSpeed(
selectedRoutine.id,
Number(event.currentTarget.value)
)
}
onKeyDown={(event) =>
handleCommitOnEnter(event, () =>
onSetActorRoutinePathSpeed(
selectedRoutine.id,
Number(event.currentTarget.value)
)
)
}
/>
</label>
<label className="form-field form-field--inline">
<input
type="checkbox"
checked={selectedActorPathEffect?.loop ?? false}
disabled={selectedActorPathEffect === null}
onChange={(event) =>
onSetActorRoutinePathLoop(
selectedRoutine.id,
event.currentTarget.checked
)
}
/>
<span className="label">Loop</span>
</label>
{(selectedTargetOption.defaults.actorPathOptions ?? []).length ===
0 ? (
<div className="schedule-pane__summary">
Paths are available only when this actor has one uniquely
bound NPC usage in a scene with enabled authored paths.
</div>
) : null}
</div>
</>
) : selectedRoutine.sequenceId === null ? (
<>
{selectedEffectOptions.find(
(effectOption) => effectOption.id === selectedEffectOptionId
)?.valueKind === "number" ? (
<div className="form-section">
<div className="label">Value</div>
<label className="form-field">
<span className="label">
{selectedEffectOptions.find(
(effectOption) =>
effectOption.id === selectedEffectOptionId
)?.valueLabel ?? "Value"}
</span>
<input
key={`${selectedRoutine.id}-numeric`}
className="text-input"
type="number"
min={
selectedEffectOptions.find(
(effectOption) =>
effectOption.id === selectedEffectOptionId
)?.min ?? 0
}
step={
selectedEffectOptions.find(
(effectOption) =>
effectOption.id === selectedEffectOptionId
)?.step ?? 0.1
}
defaultValue={getRoutineNumericValue(selectedRoutine) ?? 0}
onBlur={(event) =>
onSetRoutineNumericValue(
selectedRoutine.id,
Number(event.currentTarget.value)
)
}
onKeyDown={(event) =>
handleCommitOnEnter(event, () =>
onSetRoutineNumericValue(
selectedRoutine.id,
Number(event.currentTarget.value)
)
)
}
/>
</label>
</div>
) : null}
{selectedEffectOptions.find(
(effectOption) => effectOption.id === selectedEffectOptionId
)?.valueKind === "color" ? (
<div className="form-section">
<div className="label">Value</div>
<label className="form-field">
<span className="label">
{selectedEffectOptions.find(
(effectOption) =>
effectOption.id === selectedEffectOptionId
)?.valueLabel ?? "Color"}
</span>
<input
className="color-input"
type="color"
value={getRoutineColorValue(selectedRoutine) ?? "#ffffff"}
onChange={(event) =>
onSetRoutineColorValue(
selectedRoutine.id,
event.currentTarget.value
)
}
/>
</label>
</div>
) : null}
{selectedEffectOptions.find(
(effectOption) => effectOption.id === selectedEffectOptionId
)?.valueKind === "animation" ? (
<div className="form-section">
<div className="label">Animation</div>
<label className="form-field">
<span className="label">Clip</span>
<select
className="select-input"
value={
selectedRoutine.effects[0]?.type === "playModelAnimation"
? selectedRoutine.effects[0].clipName
: selectedTargetOption.defaults.animationClipNames?.[0] ??
""
}
onChange={(event) =>
onSetRoutineAnimationClip(
selectedRoutine.id,
event.currentTarget.value
)
}
>
{(selectedTargetOption.defaults.animationClipNames ?? []).map(
(clipName) => (
<option key={clipName} value={clipName}>
{clipName}
</option>
)
)}
</select>
</label>
<label className="form-field form-field--inline">
<input
type="checkbox"
checked={
selectedRoutine.effects[0]?.type ===
"playModelAnimation"
? selectedRoutine.effects[0].loop !== false
: true
}
onChange={(event) =>
onSetRoutineAnimationLoop(
selectedRoutine.id,
event.currentTarget.checked
)
}
/>
<span className="label">Loop</span>
</label>
</div>
) : null}
</>
) : null}
<div className="form-section">
<div className="label">Details</div>
<div className="schedule-pane__summary">
{selectedTargetOption.groupLabel} · {selectedTargetOption.label}
</div>
<div className="schedule-pane__summary">
{getRoutineSummary(selectedRoutine, sequences)}
</div>
</div>
<div className="form-section">
<button
className="toolbar__button toolbar__button--compact"
type="button"
onClick={() => onDeleteRoutine(selectedRoutine.id)}
>
Delete Clip
</button>
</div>
</>
)}
</aside>
</>
)}
</div>
</section>
);
}
export const ProjectSchedulePane = ProjectSequencerPane;