Files
webeditor3d/src/app/ProjectSequencesPanel.tsx
Victor Giers 1332b488cf auto-git:
[change] src/app/ProjectSequencesPanel.tsx
2026-04-15 11:13:38 +02:00

955 lines
38 KiB
TypeScript

import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { formatControlEffectValue, getControlTargetRefKey } from "../controls/control-surface";
import {
getProjectScheduleEffectOptionId,
getProjectScheduleTargetOptionByKey,
listProjectScheduleEffectOptions,
type ProjectScheduleEffectOptionId,
type ProjectScheduleTargetOption
} from "../scheduler/project-schedule-control-options";
import {
getSequenceEffectLabel,
type SequenceEffect,
type SequenceVisibilityMode
} from "../sequencer/project-sequence-steps";
import {
getProjectSequences,
type ProjectSequenceLibrary
} from "../sequencer/project-sequences";
interface ProjectSequencesPanelProps {
sequences: ProjectSequenceLibrary;
targetOptions: ProjectScheduleTargetOption[];
npcTalkTargetOptions: Array<{
npcEntityId: string;
label: string;
defaultDialogueId: string | null;
dialogues: Array<{
dialogueId: string;
label: string;
}>;
}>;
teleportTargetOptions: Array<{
entityId: string;
label: string;
}>;
sceneTransitionTargetOptions: Array<{
targetKey: string;
label: string;
}>;
visibilityTargetOptions: Array<{
targetKey: string;
label: string;
}>;
preferredControlTargetKey?: string | null;
selectedSequenceId: string | null;
onSelectSequence(sequenceId: string | null): void;
onAddSequence(): void;
onDeleteSequence(sequenceId: string): void;
onSetSequenceTitle(sequenceId: string, title: string): void;
onAddControlEffect(
sequenceId: string,
targetKey: string,
effectOptionId: ProjectScheduleEffectOptionId
): void;
onAddNpcTalkEffect(
sequenceId: string,
npcEntityId: string,
dialogueId: string | null
): void;
onAddTeleportStep(sequenceId: string, targetEntityId: string): void;
onAddSceneTransitionStep(sequenceId: string, targetKey: string): void;
onAddVisibilityStep(sequenceId: string, targetKey: 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;
onSetControlStepPathId(
sequenceId: string,
stepIndex: number,
pathId: string
): void;
onSetControlStepPathSpeed(
sequenceId: string,
stepIndex: number,
speed: number
): void;
onSetControlStepPathLoop(
sequenceId: string,
stepIndex: number,
loop: boolean
): void;
onSetControlStepPathSmooth(
sequenceId: string,
stepIndex: number,
smoothPath: boolean
): void;
onSetNpcTalkStepNpcEntityId(
sequenceId: string,
stepIndex: number,
npcEntityId: string
): void;
onSetNpcTalkStepDialogueId(
sequenceId: string,
stepIndex: number,
dialogueId: string | null
): void;
onSetTeleportStepTarget(
sequenceId: string,
stepIndex: number,
targetEntityId: string
): void;
onSetSceneTransitionStepTarget(
sequenceId: string,
stepIndex: number,
targetKey: string
): void;
onSetVisibilityStepTarget(
sequenceId: string,
stepIndex: number,
targetKey: string
): void;
onSetVisibilityStepMode(
sequenceId: string,
stepIndex: number,
mode: SequenceVisibilityMode
): 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,
targetOptions,
npcTalkTargetOptions,
teleportTargetOptions,
sceneTransitionTargetOptions,
visibilityTargetOptions,
preferredControlTargetKey = null,
selectedSequenceId,
onSelectSequence,
onAddSequence,
onDeleteSequence,
onSetSequenceTitle,
onAddControlEffect,
onAddNpcTalkEffect,
onAddTeleportStep,
onAddSceneTransitionStep,
onAddVisibilityStep,
onDeleteStep,
onSetControlStepTarget,
onSetControlStepEffectOption,
onSetControlStepNumericValue,
onSetControlStepColorValue,
onSetControlStepAnimationClip,
onSetControlStepAnimationLoop,
onSetControlStepPathId,
onSetControlStepPathSpeed,
onSetControlStepPathLoop,
onSetControlStepPathSmooth,
onSetNpcTalkStepNpcEntityId,
onSetNpcTalkStepDialogueId,
onSetTeleportStepTarget,
onSetSceneTransitionStepTarget,
onSetVisibilityStepTarget,
onSetVisibilityStepMode
}: ProjectSequencesPanelProps) {
const sequenceList = getProjectSequences(sequences);
const editableTargetOptions = targetOptions.filter(
(targetOption) => listProjectScheduleEffectOptions(targetOption).length > 0
);
const preferredControlTargetOption =
editableTargetOptions.find(
(targetOption) => targetOption.key === preferredControlTargetKey
) ?? null;
const addableControlTargetOptions =
preferredControlTargetOption === null
? editableTargetOptions
: [preferredControlTargetOption];
const addableControlEffects = addableControlTargetOptions.flatMap((targetOption) =>
listProjectScheduleEffectOptions(targetOption).map((effectOption) => ({
targetKey: targetOption.key,
effectOptionId: effectOption.id,
label:
preferredControlTargetOption === null
? `Add ${targetOption.label} ${effectOption.label} Effect`
: `Add ${effectOption.label} Effect`
}))
);
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"}
</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}>
Create 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. Some effects stay
active while a timeline placement is active. Others fire once when the
sequence starts.
</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 an effect to define what this sequence does.
</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 isSceneLightingEffect =
targetOption?.target.kind === "scene";
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>
) : (
<>
{isSceneLightingEffect ? (
<div className="material-summary">
This effect applies to the active scene lighting.
</div>
) : (
<>
<div className="vector-inputs vector-inputs--two">
<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
)
}
>
{editableTargetOptions.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" ||
effect.effect.type === "playActorAnimation" ? (
<>
<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
)
}
>
{(
effect.effect.type === "playActorAnimation"
? targetOption.defaults.actorAnimationClipNames
: 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}
{effect.effect.type === "followActorPath" ? (
<>
<label className="form-field">
<span className="label">Path</span>
<select
className="select-input"
value={effect.effect.pathId}
onChange={(event) =>
onSetControlStepPathId(
selectedSequence.id,
effectIndex,
event.currentTarget.value
)
}
>
{(targetOption.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={`${selectedSequence.id}-${effectIndex}-path-speed`}
className="text-input"
type="number"
min="0.01"
step="0.1"
defaultValue={effect.effect.speed}
onBlur={(event) =>
onSetControlStepPathSpeed(
selectedSequence.id,
effectIndex,
Number(event.currentTarget.value)
)
}
onKeyDown={(event) =>
commitOnEnter(event, () =>
onSetControlStepPathSpeed(
selectedSequence.id,
effectIndex,
Number(event.currentTarget.value)
)
)
}
/>
</label>
<label className="form-field form-field--inline">
<input
type="checkbox"
checked={effect.effect.loop}
onChange={(event) =>
onSetControlStepPathLoop(
selectedSequence.id,
effectIndex,
event.currentTarget.checked
)
}
/>
<span className="label">Loop</span>
</label>
<label className="form-field form-field--inline">
<input
type="checkbox"
checked={effect.effect.smoothPath}
onChange={(event) =>
onSetControlStepPathSmooth(
selectedSequence.id,
effectIndex,
event.currentTarget.checked
)
}
/>
<span className="label">Smooth Path</span>
</label>
</>
) : null}
</>
)}
</div>
);
}
if (effect.type === "makeNpcTalk") {
const selectedNpcOption =
npcTalkTargetOptions.find(
(option) => option.npcEntityId === effect.npcEntityId
) ?? null;
const dialogueOptions = selectedNpcOption?.dialogues ?? [];
const resolvedDialogueId =
effect.dialogueId !== null &&
dialogueOptions.some(
(option) => option.dialogueId === effect.dialogueId
)
? effect.dialogueId
: "";
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>
{selectedNpcOption === null ? (
<div className="material-summary">
The targeted NPC no longer exists or no longer exposes any
authored dialogues.
</div>
) : (
<>
<label className="form-field">
<span className="label">NPC</span>
<select
className="select-input"
value={effect.npcEntityId}
onChange={(event) =>
onSetNpcTalkStepNpcEntityId(
selectedSequence.id,
effectIndex,
event.currentTarget.value
)
}
>
{npcTalkTargetOptions.map((option) => (
<option key={option.npcEntityId} value={option.npcEntityId}>
{option.label}
</option>
))}
</select>
</label>
<label className="form-field">
<span className="label">Dialogue</span>
<select
className="select-input"
value={resolvedDialogueId}
onChange={(event) =>
onSetNpcTalkStepDialogueId(
selectedSequence.id,
effectIndex,
event.currentTarget.value.trim().length === 0
? null
: event.currentTarget.value
)
}
>
<option value="">Use NPC Default</option>
{dialogueOptions.map((dialogue) => (
<option
key={dialogue.dialogueId}
value={dialogue.dialogueId}
>
{dialogue.label}
</option>
))}
</select>
</label>
</>
)}
</div>
);
}
if (effect.type === "teleportPlayer") {
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">Teleport Target</span>
<select
className="select-input"
value={effect.targetEntityId}
onChange={(event) =>
onSetTeleportStepTarget(
selectedSequence.id,
effectIndex,
event.currentTarget.value
)
}
>
{teleportTargetOptions.map((target) => (
<option key={target.entityId} value={target.entityId}>
{target.label}
</option>
))}
</select>
</label>
</div>
);
}
if (effect.type === "startSceneTransition") {
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">Destination</span>
<select
className="select-input"
value={`${effect.targetSceneId}::${effect.targetEntryEntityId}`}
onChange={(event) =>
onSetSceneTransitionStepTarget(
selectedSequence.id,
effectIndex,
event.currentTarget.value
)
}
>
{sceneTransitionTargetOptions.map((target) => (
<option key={target.targetKey} value={target.targetKey}>
{target.label}
</option>
))}
</select>
</label>
</div>
);
}
if (effect.type === "setVisibility") {
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="vector-inputs vector-inputs--two">
<label className="form-field">
<span className="label">Target</span>
<select
className="select-input"
value={
effect.target.kind === "brush"
? `brush:${effect.target.brushId}`
: `modelInstance:${effect.target.modelInstanceId}`
}
onChange={(event) =>
onSetVisibilityStepTarget(
selectedSequence.id,
effectIndex,
event.currentTarget.value
)
}
>
{visibilityTargetOptions.map((target) => (
<option key={target.targetKey} value={target.targetKey}>
{target.label}
</option>
))}
</select>
</label>
<label className="form-field">
<span className="label">Mode</span>
<select
className="select-input"
value={effect.mode}
onChange={(event) =>
onSetVisibilityStepMode(
selectedSequence.id,
effectIndex,
event.currentTarget.value as SequenceVisibilityMode
)
}
>
<option value="toggle">Toggle</option>
<option value="show">Show</option>
<option value="hide">Hide</option>
</select>
</label>
</div>
</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 effect is preserved, but the current editor does not
expose direct editing for it yet.
</div>
</div>
);
})}
</div>
)}
<div className="inline-actions">
{addableControlEffects.map((effectButton) => (
<button
key={`${effectButton.targetKey}:${effectButton.effectOptionId}`}
className="toolbar__button toolbar__button--compact"
type="button"
onClick={() =>
onAddControlEffect(
selectedSequence.id,
effectButton.targetKey,
effectButton.effectOptionId
)
}
>
{effectButton.label}
</button>
))}
<button
className="toolbar__button toolbar__button--compact"
type="button"
disabled={npcTalkTargetOptions.length === 0}
onClick={() =>
onAddNpcTalkEffect(
selectedSequence.id,
npcTalkTargetOptions[0]?.npcEntityId ?? "",
npcTalkTargetOptions[0]?.defaultDialogueId ??
npcTalkTargetOptions[0]?.dialogues[0]?.dialogueId ??
null
)
}
>
Add Make NPC Talk Effect
</button>
<button
className="toolbar__button toolbar__button--compact"
type="button"
disabled={teleportTargetOptions.length === 0}
onClick={() =>
onAddTeleportStep(
selectedSequence.id,
teleportTargetOptions[0]?.entityId ?? ""
)
}
>
Add Teleport Effect
</button>
<button
className="toolbar__button toolbar__button--compact"
type="button"
disabled={sceneTransitionTargetOptions.length === 0}
onClick={() =>
onAddSceneTransitionStep(
selectedSequence.id,
sceneTransitionTargetOptions[0]?.targetKey ?? ""
)
}
>
Add Scene Transition Effect
</button>
<button
className="toolbar__button toolbar__button--compact"
type="button"
disabled={visibilityTargetOptions.length === 0}
onClick={() =>
onAddVisibilityStep(
selectedSequence.id,
visibilityTargetOptions[0]?.targetKey ?? ""
)
}
>
Add Visibility Effect
</button>
</div>
</div>
)}
</div>
);
}