diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index ff72a237..08325251 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -171,6 +171,21 @@ export function targetAssetOf( return object ? findAsset(project, object.assetId) : undefined; } +/** How the timeline names what a row aims at: the object (or the root itself) and the property. */ +export function trackLabel(project: Project, animation: AnimationAsset, track: AnimationTrack): string { + const root = findAsset(project, animation.root); + const object = track.target === '' ? undefined : rootObjects(root).find((o) => o.id === track.target); + const aimed = targetAssetOf(project, animation, track.target); + const who = + track.target === '' + ? (root?.name ?? '?') + : object + ? object.name || findAsset(project, object.assetId)?.name || '?' + : '(missing)'; + const def = aimed && animatableDefs(aimed.type).find((d) => d.key === track.property); + return `${who} · ${def?.label ?? track.property}`; +} + /** Whether a value names something in the project – what deleting that something has to count. */ export function referencesAny( track: AnimationTrack, diff --git a/src/editor/animation/AnimationEditor.ts b/src/editor/animation/AnimationEditor.ts index 4c8d1d0a..eb525be2 100644 --- a/src/editor/animation/AnimationEditor.ts +++ b/src/editor/animation/AnimationEditor.ts @@ -1,56 +1,47 @@ -import { animationDuration, fpsOf, frameCount } from '../../core/model/animation'; +import { SetAnimationRootCommand } from '../../core/commands/animationCommands'; +import { ANIMATION_ROOT_TYPES, animationDuration, fpsOf, frameCount } from '../../core/model/animation'; import type { AnimationAsset, Asset } from '../../core/model/Asset'; import { findAsset } from '../../core/model/Project'; -import { formatNumber, Label, Theme, VStack } from '../../ui'; -import { AssetTabEditor, type TabCanvas } from '../AssetTabEditor'; +import { Button, Dropdown, formatNumber, Menu, type MenuItem } from '../../ui'; +import { AssetTabEditor } from '../AssetTabEditor'; import type { EditorState } from '../state'; +import { openRowDialog, RowDialog } from './RowDialog'; +import { TimelineCanvas } from './TimelineCanvas'; /** Room kept for the "3 rows · 24 frames · 10 fps · 2.4 s" readout. */ const INFO_WIDTH = 220; +const ROOT_WIDTH = 160; /** - * Stands where the timeline goes (#97): says what the animation animates and holds the tab's - * subject, so picking, deleting and the readout already work on the new model. + * The Animation tab (#97): the header picks what the animation animates and adds rows, the + * canvas below is the timeline. `fps` and `playback` are properties of the asset, edited in the + * inspector like any other. */ -export class TimelinePlaceholder extends VStack implements TabCanvas { - private assetId = ''; - private readonly note = new Label('', { style: 'muted' }); +export class AnimationEditor extends AssetTabEditor { + /** Public for the check scripts. */ + readonly rootPicker: Dropdown; + readonly addRowButton: Button; + readonly playButton: Button; - constructor(private readonly state: EditorState) { - super({ padding: Theme.padding }); - this.add(this.note); - } - - get currentAssetId(): string { - return this.assetId; - } - - show(assetId: string): void { - this.assetId = assetId; - const asset = findAsset(this.state.project, assetId); - const root = asset?.type === 'animation' ? findAsset(this.state.project, asset.root) : undefined; - this.note.setText( - asset?.type !== 'animation' - ? '' - : `Animates ${root ? root.name : 'nothing yet'} – the timeline editor is being built.`, - ); - } - - fit(): void {} -} - -export class AnimationEditor extends AssetTabEditor { constructor(state: EditorState) { super(state, { noun: 'Animation', infoWidth: INFO_WIDTH }); - this.mount(new TimelinePlaceholder(state)); + this.rootPicker = new Dropdown('—', { width: ROOT_WIDTH, onClick: () => this.openRootPicker() }); + this.addRowButton = new Button('+ Row', { onClick: () => this.openRowDialog() }); + this.playButton = new Button('Play', { width: 64, onClick: () => this.togglePlay() }); + this.mount(new TimelineCanvas(state), [this.rootPicker, this.addRowButton, this.playButton]); } - /** Only called while the tab is visible; the timeline brings its own preview clock. */ - tick(_dt: number): void {} + /** Only called while the tab is visible. */ + tick(dt: number): void { + if (!this.canvas.isPlaying) return; + this.canvas.tick(dt); + // A finished `once` pass pauses the canvas; the button has to say so. + this.syncPlayButton(); + } /** Whether the preview is running – a moving picture needs a frame without an event. */ get isPlaying(): boolean { - return false; + return this.canvas.isPlaying; } protected override selectedId(): string { @@ -61,11 +52,6 @@ export class AnimationEditor extends AssetTabEditor 0)); + this.syncPlayButton(); + } + + private syncPlayButton(): void { + this.playButton.setText(this.canvas.isPlaying ? 'Pause' : 'Play'); + } + + private togglePlay(): void { + if (this.canvas.isPlaying) this.canvas.pause(); + else this.canvas.play(); + this.syncPlayButton(); + } + + /** What may be animated: a sprite, text, container or scene – never another animation. */ + private openRootPicker(): void { + const asset = this.asset(); + if (!asset) return; + const items: MenuItem[] = this.state.project.assets + .filter((a) => ANIMATION_ROOT_TYPES.includes(a.type)) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((candidate) => ({ + label: `${candidate.id === asset.root ? '● ' : ''}${candidate.name} (${candidate.type})`, + onSelect: () => this.state.execute(new SetAnimationRootCommand(asset.id, candidate.id)), + })); + if (items.length === 0) items.push({ label: 'Nothing to animate yet', onSelect: () => undefined }); + new Menu(items, { searchable: true }).openBelow(this.rootPicker); + } + + private openRowDialog(): void { + const asset = this.asset(); + if (asset) openRowDialog(new RowDialog(this.state, asset)); + } } diff --git a/src/editor/animation/RowDialog.ts b/src/editor/animation/RowDialog.ts new file mode 100644 index 00000000..98a05ad8 --- /dev/null +++ b/src/editor/animation/RowDialog.ts @@ -0,0 +1,214 @@ +import { + AddAnimationTrackCommand, + RetargetAnimationTrackCommand, +} from '../../core/commands/animationCommands'; +import { animatableDefs, animatedKind, rootObjects, targetAssetOf } from '../../core/model/animation'; +import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; +import { treeOrder } from '../../core/model/hierarchy'; +import { findAsset } from '../../core/model/Project'; +import type { PropertyDef } from '../../core/properties/PropertySchema'; +import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Popup, Spacer, Theme } from '../../ui'; +import type { EditorState } from '../state'; + +/** Matches the inspector's label column. */ +const LABEL_WIDTH = 76; +const PICKER_WIDTH = 240; + +let current: RowDialog | null = null; + +/** The open row dialog, for the check scripts. */ +export function currentRowDialog(): RowDialog | null { + return current; +} + +/** Opens the dialog centred, closing whichever one was open before it. */ +export function openRowDialog(dialog: RowDialog): RowDialog { + current?.close(); + current = dialog; + dialog.openCentered(); + return dialog; +} + +/** One choice of the object picker: the root itself (`''`) or an object inside it. */ +interface TargetChoice { + id: string; + label: string; +} + +/** + * Adds a row to the timeline, or aims an existing one elsewhere: first the object – the animated + * asset itself, or one placed inside it when that is a container or scene – then a property of + * it. Retargeting offers only properties of the row's kind, since its keys stay (#97). + */ +export class RowDialog extends Popup { + private target: string; + private property: string; + private readonly targetButton: Dropdown; + private readonly propertyButton: Dropdown; + private readonly summary: Label; + private readonly confirm: Button; + + constructor( + private readonly state: EditorState, + private readonly asset: AnimationAsset, + private readonly track: AnimationTrack | null = null, + ) { + super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap }); + this.minWidth = 360; + this.target = track?.target ?? ''; + this.property = track?.property ?? ''; + const root = findAsset(state.project, asset.root); + this.content.add( + new Label(track ? 'Change what the row animates' : `Add a row to "${asset.name}"`, { + style: 'bodyBold', + }), + ); + this.content.add(new Label(`Animates ${root ? `"${root.name}"` : 'nothing yet'}`, { style: 'muted' })); + + this.targetButton = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openTargets() }); + this.content.add(this.pair('Object', this.targetButton)); + this.propertyButton = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openProperties() }); + this.content.add(this.pair('Property', this.propertyButton)); + + this.summary = new Label('', { style: 'muted' }); + this.content.add(this.summary); + + const buttons = new HStack({ gap: Theme.gap, align: 'center' }); + buttons.add(new Spacer(), 1); + const cancel = new Button('Cancel'); + cancel.onClick = this.closeThen(); + buttons.add(cancel); + this.confirm = new Button(track ? 'Change' : 'Add row', { variant: 'primary' }); + this.confirm.onClick = this.closeThen(() => this.apply()); + buttons.add(this.confirm); + this.content.add(buttons); + + // Without a property yet, the first one the object offers is as good a start as any. + if (!this.property) this.property = this.properties()[0]?.key ?? ''; + this.refresh(); + } + + override close(): void { + super.close(); + if (current === this) current = null; + } + + /** What the dialog offers and holds, for the check scripts. */ + debugInfo(): { target: string; property: string; targets: string[]; properties: string[]; ok: boolean } { + return { + target: this.target, + property: this.property, + targets: this.targets().map((t) => t.label), + properties: this.properties().map((d) => d.label), + ok: this.confirm.enabled, + }; + } + + /** Picks by id / key rather than by click, for the check scripts. */ + pick(target: string, property: string): void { + this.target = target; + this.property = property; + this.refresh(); + } + + private pair(text: string, control: Dropdown): HStack { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + const label = new Label(text, { style: 'muted' }); + label.minWidth = LABEL_WIDTH; + row.add(label); + row.add(control); + row.add(new Spacer(), 1); + return row; + } + + private targets(): TargetChoice[] { + const root = findAsset(this.state.project, this.asset.root); + if (!root) return []; + const choices: TargetChoice[] = [{ id: '', label: `${root.name} (itself)` }]; + if (root.type === 'container' || root.type === 'scene') + for (const { object, depth } of treeOrder(root)) + choices.push({ + id: object.id, + label: `${' '.repeat(depth)}${object.name || findAsset(this.state.project, object.assetId)?.name || '?'}`, + }); + return choices; + } + + /** The properties the picked object offers, less those another row already animates. */ + private properties(): PropertyDef[] { + const aimed = targetAssetOf(this.state.project, this.asset, this.target); + if (!aimed) return []; + const was = this.trackKind(); + return animatableDefs(aimed.type).filter((def) => { + if (was !== null && def.kind !== was) return false; + return !this.asset.tracks.some( + (t) => t.id !== this.track?.id && t.target === this.target && t.property === def.key, + ); + }); + } + + /** The kind the row being changed has; null when its target is gone, so it may go anywhere. */ + private trackKind(): string | null { + if (!this.track) return null; + const aimed = targetAssetOf(this.state.project, this.asset, this.track.target); + return aimed ? (animatedKind(aimed.type, this.track.property) ?? null) : null; + } + + private openTargets(): void { + const items: MenuItem[] = this.targets().map((choice) => ({ + label: choice.id === this.target ? `● ${choice.label}` : choice.label, + onSelect: () => { + this.target = choice.id; + // Another object may not have the property; fall back to its first. + if (!this.properties().some((d) => d.key === this.property)) + this.property = this.properties()[0]?.key ?? ''; + this.refresh(); + }, + })); + new Menu(items, { + searchable: rootObjects(findAsset(this.state.project, this.asset.root)).length > 8, + }).openBelow(this.targetButton); + } + + private openProperties(): void { + const items: MenuItem[] = this.properties().map((def) => ({ + label: def.key === this.property ? `● ${def.label}` : def.label, + onSelect: () => { + this.property = def.key; + this.refresh(); + }, + })); + if (items.length === 0) items.push({ label: 'Nothing left to animate here', onSelect: () => undefined }); + new Menu(items, { searchable: true }).openBelow(this.propertyButton); + } + + private refresh(): void { + const target = this.targets().find((t) => t.id === this.target); + this.targetButton.setText(target ? target.label.trim() : this.target === '' ? '—' : '(missing)'); + const def = this.properties().find((d) => d.key === this.property); + this.propertyButton.setText(def ? def.label : '—'); + const ok = Boolean(target && def); + this.summary.setText( + !target + ? 'Pick the animated asset first.' + : !def + ? 'Pick a property.' + : `Keys on this row set ${def.label} (${def.kind}) of ${target.label.trim()}.`, + ); + this.confirm.setEnabled(ok); + this.refreshLayout(); + } + + private apply(): void { + if (this.track) { + this.state.execute( + new RetargetAnimationTrackCommand(this.asset.id, this.track.id, this.target, this.property), + ); + return; + } + const command = new AddAnimationTrackCommand(this.asset.id, this.target, this.property); + if (this.state.execute(command)) + this.state.select({ kind: 'track', assetId: this.asset.id, trackId: command.trackId, keys: [] }); + } +} diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts new file mode 100644 index 00000000..70ac1a5c --- /dev/null +++ b/src/editor/animation/TimelineCanvas.ts @@ -0,0 +1,553 @@ +import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; +import { + type KeyRef, + MoveAnimationKeysCommand, + SetAnimationKeyCommand, +} from '../../core/commands/animationCommands'; +import { + animatedKind, + animationPlayheadAt, + frameCount, + targetAssetOf, + trackLabel, +} from '../../core/model/animation'; +import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; +import type { Vec2 } from '../../core/model/Properties'; +import { findAssetOf } from '../../core/model/Project'; +import { drawBand, pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui'; +import { type EditorState, trackSelection } from '../state'; +import { + clampMove, + clampPan, + DEFAULT_VIEW, + frameAt, + frameCentreX, + frameToX, + hasKey, + HEADER_WIDTH, + KEY_SIZE, + keyAt, + keysWithin, + ROW_HEIGHT, + rowAt, + rowCentreY, + rowTop, + RULER_HEIGHT, + rulerStep, + sameKey, + type TimelineView, + zoomCells, +} from './timelineView'; + +/** Pixels the pointer must travel before a press becomes a drag rather than a click. */ +const DRAG_THRESHOLD = 3; +/** Inset of a row's label from the header's left edge. */ +const LABEL_INSET = 8; + +/** Keys being dragged: what was picked, and how far the pointer has taken them, in whole frames. */ +interface KeyDrag { + keys: KeyRef[]; + pointer: Vec2; + delta: number; + moved: boolean; +} + +/** A band being spanned over the cells, in widget-local pixels. */ +interface Band { + start: Vec2; + current: Vec2; + active: boolean; +} + +/** + * The Animation tab's canvas (#97): row headers on the left, a frame ruler on top, a cell per row + * and frame below it. A double click in a cell sets a key, keys are picked, banded, dragged and + * deleted, the ruler scrubs the playhead. Everything is drawn in widget pixels: keys and labels + * keep their size whatever the horizontal zoom. + */ +export class TimelineCanvas extends Widget { + private readonly bg = new Graphics(); + private readonly cells = new Graphics(); + private readonly overlay = new Graphics(); + private readonly labelLayer = new Container(); + private readonly rulerLayer = new Container(); + private readonly labels = new Map(); + private readonly rulerTexts: Text[] = []; + private readonly hint = new Text({ text: '', style: textStyle('muted') }); + private readonly clip = new Graphics(); + private view: TimelineView = { ...DEFAULT_VIEW }; + private assetId = ''; + /** Where the playhead stands, in frames – fractional while playing. */ + playhead = 0; + /** Seconds since play began; the playhead is a function of it (see `animationPlayheadAt`). */ + private time = 0; + private playingNow = false; + private panning: { pointer: Vec2; panX: number } | null = null; + private dragging: KeyDrag | null = null; + private band: Band | null = null; + private scrubbing = false; + + constructor(private readonly state: EditorState) { + super(); + this.addChild(this.bg, this.cells, this.overlay, this.labelLayer, this.rulerLayer, this.hint, this.clip); + this.cells.mask = this.clip; + this.overlay.mask = this.clip; + this.rulerLayer.mask = this.clip; + this.hint.eventMode = 'none'; + this.focusable = true; + + this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); + this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); + this.on('pointerup', () => this.onPointerUp()); + this.on('pointerupoutside', () => this.onPointerUp()); + this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); + + state.on('project', () => this.redraw()); + state.on('selection', () => this.redraw()); + state.on('load', () => { + this.view = { ...DEFAULT_VIEW }; + this.redraw(); + }); + } + + override measure(): Size { + return { w: Math.max(this.minWidth, 300), h: Math.max(this.minHeight, 160) }; + } + + /** Switching resets view and playhead: the next animation is a new subject. */ + show(assetId: string): void { + if (this.assetId === assetId) return; + this.assetId = assetId; + this.view = { ...DEFAULT_VIEW }; + this.stop(); + this.redraw(); + } + + get currentAssetId(): string { + return this.assetId; + } + + /** Back to frame 0 at the default zoom. */ + fit(): void { + this.view = { ...DEFAULT_VIEW }; + this.redraw(); + } + + get isPlaying(): boolean { + return this.playingNow; + } + + /** Runs from where the playhead stands; at the end of a finished pass it starts over. */ + play(): void { + const asset = this.asset(); + if (!asset) return; + const count = frameCount(asset); + if (count > 0 && this.playhead >= count) this.playhead = 0; + this.time = this.timeOf(asset, this.playhead); + this.playingNow = true; + this.redraw(); + } + + pause(): void { + this.playingNow = false; + this.redraw(); + } + + /** Pauses and puts the playhead back to frame 0. */ + stop(): void { + this.playingNow = false; + this.playhead = 0; + this.time = 0; + } + + /** Moves the playhead `dt` seconds on; a finished `once` pass pauses at the end. */ + tick(dt: number): void { + const asset = this.asset(); + if (!this.playingNow || !asset) return; + this.time += dt; + const at = animationPlayheadAt(asset, this.time); + this.playhead = at.frame; + if (at.finished) this.playingNow = false; + this.redraw(); + } + + /** Puts the playhead on a frame – what the ruler does, and what a check does without a pointer. */ + scrub(frame: number): void { + this.playhead = Math.max(0, frame); + const asset = this.asset(); + if (asset) this.time = this.timeOf(asset, this.playhead); + this.redraw(); + } + + /** The seconds at which a loop's playhead first reaches `frame` – so play resumes where it was scrubbed to. */ + private timeOf(asset: AnimationAsset, frame: number): number { + const fps = Number(asset.props.fps); + return fps > 0 ? frame / fps : 0; + } + + // --- for the check scripts ------------------------------------------------------------------- + + /** Where a cell's centre sits on screen. */ + cellGlobal(trackId: string, frame: number): Vec2 | null { + const row = this.rowOf(trackId); + if (row < 0) return null; + const origin = this.getGlobalPosition(); + return { x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame), y: origin.y + rowCentreY(row) }; + } + + /** Where a row's header sits on screen. */ + headerGlobal(trackId: string): Vec2 | null { + const row = this.rowOf(trackId); + if (row < 0) return null; + const origin = this.getGlobalPosition(); + return { x: origin.x + HEADER_WIDTH / 2, y: origin.y + rowCentreY(row) }; + } + + /** Where a frame sits on the ruler. */ + rulerGlobal(frame: number): Vec2 { + const origin = this.getGlobalPosition(); + return { x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame), y: origin.y + RULER_HEIGHT / 2 }; + } + + /** The keys as drawn, with whether each is picked. */ + keysDrawn(): { trackId: string; frame: number; selected: boolean }[] { + const picked = this.state.selectedKeys; + const out: { trackId: string; frame: number; selected: boolean }[] = []; + for (const track of this.asset()?.tracks ?? []) + for (const key of track.keys) + out.push({ + trackId: track.id, + frame: key.frame, + selected: hasKey(picked, { trackId: track.id, frame: key.frame }), + }); + return out; + } + + /** The row labels as drawn, top to bottom. */ + rowsDrawn(): string[] { + const asset = this.asset(); + return asset ? asset.tracks.map((t) => trackLabel(this.state.project, asset, t)) : []; + } + + viewInfo(): TimelineView & { playhead: number; playing: boolean } { + return { ...this.view, playhead: this.playhead, playing: this.playingNow }; + } + + // --- pointer --------------------------------------------------------------------------------- + + private asset(): AnimationAsset | null { + return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'animation') ?? null) : null; + } + + private rowOf(trackId: string): number { + return this.asset()?.tracks.findIndex((t) => t.id === trackId) ?? -1; + } + + private onPointerDown(e: FederatedPointerEvent): void { + const asset = this.asset(); + if (!asset) return; + const local = this.toLocal(e.global); + const point = { x: local.x, y: local.y }; + if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) { + this.panning = { pointer: point, panX: this.view.panX }; + e.stopPropagation(); + return; + } + if (e.button !== 0) return; + ui().focus.focus(this); + e.stopPropagation(); + + if (local.x < HEADER_WIDTH) { + const row = rowAt(local.y, asset.tracks.length); + const track = row >= 0 ? asset.tracks[row] : undefined; + if (track) this.state.select({ kind: 'track', assetId: asset.id, trackId: track.id, keys: [] }); + return; + } + const cell = { x: local.x - HEADER_WIDTH, y: local.y }; + if (local.y < RULER_HEIGHT) { + this.scrubbing = true; + this.pause(); + this.scrub(frameAt(this.view, cell.x)); + return; + } + const picked = this.state.selectedKeys; + const hit = keyAt(this.view, asset.tracks, cell); + if (hit) { + // A picked key is taken as it is, so a drag moves the whole pick; an unpicked one replaces it. + const keys = hasKey(picked, hit) ? picked : [hit]; + if (!hasKey(picked, hit)) this.state.select(trackSelection(asset.id, keys, hit.trackId)); + this.dragging = { keys: [...keys], pointer: point, delta: 0, moved: false }; + return; + } + const row = rowAt(cell.y, asset.tracks.length); + const track = row >= 0 ? asset.tracks[row] : undefined; + if (track && pressCount(e, this) === 2) { + this.setKey(asset, track, frameAt(this.view, cell.x)); + return; + } + this.band = { start: point, current: point, active: false }; + } + + /** A double click: a key with the value the row has there, picked so the inspector shows it. */ + private setKey(asset: AnimationAsset, track: AnimationTrack, frame: number): void { + const aimed = targetAssetOf(this.state.project, asset, track.target); + if (!aimed || !animatedKind(aimed.type, track.property)) { + this.state.setNotice('This row aims at something that is gone – change its target first.'); + return; + } + if (this.state.execute(new SetAnimationKeyCommand(asset.id, track.id, frame))) + this.state.select({ + kind: 'track', + assetId: asset.id, + trackId: track.id, + keys: [{ trackId: track.id, frame }], + }); + } + + private onPointerMove(e: FederatedPointerEvent): void { + const local = this.toLocal(e.global); + if (this.panning) { + this.view = clampPan({ ...this.view, panX: this.panning.panX + (local.x - this.panning.pointer.x) }); + this.redraw(); + return; + } + if (this.scrubbing) { + this.scrub(frameAt(this.view, local.x - HEADER_WIDTH)); + return; + } + if (this.dragging) { + const dx = local.x - this.dragging.pointer.x; + if (!this.dragging.moved && Math.abs(dx) < DRAG_THRESHOLD) return; + this.dragging.moved = true; + this.dragging.delta = clampMove(this.dragging.keys, Math.round(dx / this.view.cellWidth)); + this.redraw(); + return; + } + if (this.band) { + this.band.current = { x: local.x, y: local.y }; + const span = Math.max(Math.abs(local.x - this.band.start.x), Math.abs(local.y - this.band.start.y)); + if (!this.band.active && span < DRAG_THRESHOLD) return; + this.band.active = true; + this.redraw(); + } + } + + private onPointerUp(): void { + this.panning = null; + this.scrubbing = false; + const drag = this.dragging; + const band = this.band; + this.dragging = null; + this.band = null; + const asset = this.asset(); + if (!asset) return; + + if (drag) { + if (drag.moved && drag.delta !== 0) { + const moved = drag.keys.map((k) => ({ trackId: k.trackId, frame: k.frame + drag.delta })); + const primary = this.state.selection?.kind === 'track' ? this.state.selection.trackId : undefined; + if (this.state.execute(new MoveAnimationKeysCommand(asset.id, drag.keys, drag.delta))) + this.state.select(trackSelection(asset.id, moved, primary)); + } + this.redraw(); + return; + } + if (!band) return; + if (!band.active) { + // A click on empty cells clears the pick but keeps the animation selected, like everywhere else. + this.state.select({ kind: 'asset', assetId: asset.id }); + this.redraw(); + return; + } + const a = { x: band.start.x - HEADER_WIDTH, y: band.start.y }; + const b = { x: band.current.x - HEADER_WIDTH, y: band.current.y }; + const caught = keysWithin(this.view, asset.tracks, a, b); + this.state.select(trackSelection(asset.id, caught) ?? { kind: 'asset', assetId: asset.id }); + this.redraw(); + } + + private onWheel(e: FederatedWheelEvent): void { + const local = this.toLocal(e.global); + if (e.shiftKey) { + this.view = clampPan({ ...this.view, panX: this.view.panX - e.deltaY }); + } else { + this.view = clampPan(zoomCells(this.view, zoomFactor(e.deltaY), local.x - HEADER_WIDTH)); + } + this.redraw(); + e.preventDefault(); + } + + // --- drawing --------------------------------------------------------------------------------- + + override redraw(): void { + const c = Theme.colors; + this.bg.clear().rect(0, 0, this.w, this.h).fill(c.viewport); + this.clip + .clear() + .rect(HEADER_WIDTH, 0, Math.max(0, this.w - HEADER_WIDTH), this.h) + .fill(0xffffff); + this.cells.clear(); + this.overlay.clear(); + const asset = this.asset(); + const tracks = asset?.tracks ?? []; + const selectedRow = this.state.selection?.kind === 'track' ? this.state.selection.trackId : ''; + + // Header column and ruler chrome. + this.bg.rect(0, 0, HEADER_WIDTH, this.h).fill(c.panel); + this.bg.rect(0, 0, this.w, RULER_HEIGHT).fill(c.panelHeader); + this.bg.rect(HEADER_WIDTH - 1, 0, 1, this.h).fill(c.border); + this.bg.rect(0, RULER_HEIGHT - 1, this.w, 1).fill(c.border); + + if (asset) { + this.drawRows(asset, tracks, selectedRow); + this.drawRuler(asset); + this.drawKeys(tracks); + this.drawPlayhead(); + } + if (this.band?.active) drawBand(this.overlay, this.band.start, this.band.current); + this.syncLabels(asset, tracks, selectedRow); + this.syncHint(asset); + } + + private drawRows(asset: AnimationAsset, tracks: readonly AnimationTrack[], selectedRow: string): void { + const c = Theme.colors; + const count = frameCount(asset); + const x0 = HEADER_WIDTH + frameToX(this.view, 0); + const x1 = HEADER_WIDTH + frameToX(this.view, count); + tracks.forEach((track, row) => { + const y = rowTop(row); + const selected = track.id === selectedRow; + if (selected) this.bg.rect(0, y, HEADER_WIDTH, ROW_HEIGHT).fill({ color: c.selection, alpha: 0.6 }); + this.cells.rect(HEADER_WIDTH, y, this.w - HEADER_WIDTH, ROW_HEIGHT).fill({ + color: 0xffffff, + alpha: row % 2 === 0 ? 0.02 : 0.04, + }); + // The frames the animation covers read lighter than the void after the last key. + if (count > 0) + this.cells + .rect(Math.max(HEADER_WIDTH, x0), y, Math.max(0, x1 - Math.max(HEADER_WIDTH, x0)), ROW_HEIGHT) + .fill({ color: 0xffffff, alpha: 0.03 }); + this.cells + .rect(HEADER_WIDTH, y + ROW_HEIGHT - 1, this.w - HEADER_WIDTH, 1) + .fill({ color: c.border, alpha: 0.6 }); + }); + // Cell borders only when they have room to be seen. + if (this.view.cellWidth >= 8 && tracks.length > 0) { + const bottom = rowTop(tracks.length); + const first = frameAt(this.view, 0); + for (let frame = first; ; frame++) { + const x = HEADER_WIDTH + frameToX(this.view, frame); + if (x > this.w) break; + this.cells.rect(x, RULER_HEIGHT, 1, bottom - RULER_HEIGHT).fill({ color: c.border, alpha: 0.35 }); + } + } + } + + private drawRuler(asset: AnimationAsset): void { + const c = Theme.colors; + const step = rulerStep(this.view); + const count = frameCount(asset); + if (count > 0) { + const x0 = HEADER_WIDTH + frameToX(this.view, 0); + const x1 = HEADER_WIDTH + frameToX(this.view, count); + this.cells.rect(x0, 0, x1 - x0, RULER_HEIGHT - 1).fill({ color: c.accent, alpha: 0.15 }); + } + let used = 0; + const first = frameAt(this.view, 0); + for (let frame = Math.floor(first / step) * step; ; frame += step) { + const x = HEADER_WIDTH + frameToX(this.view, frame); + if (x > this.w) break; + if (x < HEADER_WIDTH) continue; + this.cells.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted); + let text = this.rulerTexts[used]; + if (!text) { + text = new Text({ style: textStyle('small') }); + this.rulerTexts.push(text); + this.rulerLayer.addChild(text); + } + text.visible = true; + if (text.text !== String(frame)) text.text = String(frame); + text.position.set(x + 3, 3); + used += 1; + } + for (let i = used; i < this.rulerTexts.length; i++) this.rulerTexts[i]!.visible = false; + } + + private drawKeys(tracks: readonly AnimationTrack[]): void { + const c = Theme.colors; + const picked = this.state.selectedKeys; + const drag = this.dragging?.moved ? this.dragging : null; + const half = KEY_SIZE / 2; + tracks.forEach((track, row) => { + const y = rowCentreY(row); + for (const key of track.keys) { + const ref = { trackId: track.id, frame: key.frame }; + const selected = hasKey(picked, ref); + const shifted = drag && drag.keys.some((k) => sameKey(k, ref)) ? drag.delta : 0; + const x = HEADER_WIDTH + frameCentreX(this.view, key.frame + shifted); + if (x < HEADER_WIDTH - half || x > this.w + half) continue; + this.overlay + .moveTo(x, y - half) + .lineTo(x + half, y) + .lineTo(x, y + half) + .lineTo(x - half, y) + .closePath() + .fill(selected ? c.accent : c.text) + .stroke({ width: 1, color: selected ? c.accentText : c.viewport }); + } + }); + } + + private drawPlayhead(): void { + const x = HEADER_WIDTH + frameCentreX(this.view, this.playhead); + if (x < HEADER_WIDTH || x > this.w) return; + this.overlay.rect(x - 0.5, 0, 1, this.h).fill(Theme.colors.hot); + this.overlay + .moveTo(x - 5, 0) + .lineTo(x + 5, 0) + .lineTo(x, 7) + .closePath() + .fill(Theme.colors.hot); + } + + private syncLabels( + asset: AnimationAsset | null, + tracks: readonly AnimationTrack[], + selectedRow: string, + ): void { + const alive = new Set(); + tracks.forEach((track, row) => { + alive.add(track.id); + let label = this.labels.get(track.id); + if (!label) { + label = new Text({ style: textStyle('body') }); + this.labels.set(track.id, label); + this.labelLayer.addChild(label); + } + const missing = !asset || !targetAssetOf(this.state.project, asset, track.target); + const text = asset ? trackLabel(this.state.project, asset, track) : ''; + if (label.text !== text) label.text = text; + label.style = textStyle(missing ? 'warning' : track.id === selectedRow ? 'accent' : 'body'); + label.position.set(LABEL_INSET, rowTop(row) + (ROW_HEIGHT - label.height) / 2); + label.visible = rowTop(row) + ROW_HEIGHT <= this.h; + }); + for (const [id, label] of this.labels) { + if (alive.has(id)) continue; + label.destroy(); + this.labels.delete(id); + } + } + + private syncHint(asset: AnimationAsset | null): void { + const text = !asset + ? 'No animation selected – pick one in the Assets panel or create one.' + : !asset.root + ? 'Pick what this animation animates in the header.' + : asset.tracks.length === 0 + ? 'Add a row with "+ Row", then double-click a cell to set a key.' + : ''; + this.hint.text = text; + this.hint.visible = text !== ''; + this.hint.position.set(HEADER_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2); + } +} diff --git a/src/editor/animation/timelineView.ts b/src/editor/animation/timelineView.ts new file mode 100644 index 00000000..2cc8e3d7 --- /dev/null +++ b/src/editor/animation/timelineView.ts @@ -0,0 +1,148 @@ +import type { KeyRef } from '../../core/commands/animationCommands'; +import type { AnimationTrack } from '../../core/model/Asset'; +import type { Vec2 } from '../../core/model/Properties'; +import { clamp } from '../../ui'; + +/** + * The timeline's geometry, apart from Pixi so a test can drive it: a column of row headers on the + * left, a frame ruler on top, and a grid of cells below it – one cell per row and frame. The view + * pans and zooms horizontally only; rows have a fixed height and are never scrolled away. + */ +export interface TimelineView { + /** Screen pixels the cell of frame 0 starts at, measured from the left edge of the cell area. */ + panX: number; + /** Width of one frame's cell in screen pixels. */ + cellWidth: number; +} + +/** Width of the row header column, in pixels. */ +export const HEADER_WIDTH = 220; +/** Height of the frame ruler above the rows. */ +export const RULER_HEIGHT = 22; +export const ROW_HEIGHT = 26; +export const MIN_CELL_WIDTH = 4; +export const MAX_CELL_WIDTH = 48; +export const DEFAULT_CELL_WIDTH = 16; +/** Side of a key's diamond, in pixels – constant, so it stays grabbable when the cells are narrow. */ +export const KEY_SIZE = 10; +/** Every `n`-th frame gets a number on the ruler; chosen so the numbers never overlap. */ +const RULER_LABEL_MIN_PX = 40; + +export const DEFAULT_VIEW: TimelineView = { panX: 0, cellWidth: DEFAULT_CELL_WIDTH }; + +/** The x of the left edge of `frame`'s cell, in cell-area pixels. */ +export function frameToX(view: TimelineView, frame: number): number { + return view.panX + frame * view.cellWidth; +} + +/** The centre of `frame`'s cell – where its key is drawn. */ +export function frameCentreX(view: TimelineView, frame: number): number { + return frameToX(view, frame) + view.cellWidth / 2; +} + +/** The frame whose cell holds `x`, fractional; -1 and below for a point left of frame 0. */ +export function xToFrame(view: TimelineView, x: number): number { + return (x - view.panX) / view.cellWidth; +} + +/** The whole frame under `x`, never below 0. */ +export function frameAt(view: TimelineView, x: number): number { + return Math.max(0, Math.floor(xToFrame(view, x))); +} + +/** The row under `y` (measured from the top of the ruler), or -1 above the rows and past the last one. */ +export function rowAt(y: number, rowCount: number): number { + if (y < RULER_HEIGHT) return -1; + const row = Math.floor((y - RULER_HEIGHT) / ROW_HEIGHT); + return row < rowCount ? row : -1; +} + +export function rowTop(row: number): number { + return RULER_HEIGHT + row * ROW_HEIGHT; +} + +export function rowCentreY(row: number): number { + return rowTop(row) + ROW_HEIGHT / 2; +} + +/** Zooms the cells about `x` (cell-area pixels), so the frame under the pointer stays under it. */ +export function zoomCells(view: TimelineView, factor: number, x: number): TimelineView { + const cellWidth = clamp(view.cellWidth * factor, MIN_CELL_WIDTH, MAX_CELL_WIDTH); + const frame = xToFrame(view, x); + return { panX: x - frame * cellWidth, cellWidth }; +} + +/** + * Keeps frame 0 from drifting off to the right: the timeline starts at 0, and empty space left + * of it is nothing to look at. The left may still be panned past when the animation is long. + */ +export function clampPan(view: TimelineView): TimelineView { + return view.panX > 0 ? { ...view, panX: 0 } : view; +} + +/** How many frames the cell area shows at its width. */ +export function visibleFrames(view: TimelineView, width: number): number { + return Math.ceil(width / view.cellWidth); +} + +/** Every `n`-th frame gets a number on the ruler; a round step so the numbers read like a scale. */ +export function rulerStep(view: TimelineView): number { + const steps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]; + return steps.find((step) => step * view.cellWidth >= RULER_LABEL_MIN_PX) ?? 1000; +} + +/** + * The key under a point, tested against the diamond drawn at the cell's centre rather than the + * whole cell: at a wide zoom a click into the empty part of a keyed cell is a click on nothing. + */ +export function keyAt(view: TimelineView, tracks: readonly AnimationTrack[], point: Vec2): KeyRef | null { + const row = rowAt(point.y, tracks.length); + if (row < 0) return null; + const track = tracks[row]!; + const reach = Math.max(KEY_SIZE / 2, view.cellWidth / 2); + for (const key of track.keys) { + if (Math.abs(frameCentreX(view, key.frame) - point.x) <= reach) + return { trackId: track.id, frame: key.frame }; + } + return null; +} + +/** The keys whose diamond centre lies inside the band spanned between two points. */ +export function keysWithin( + view: TimelineView, + tracks: readonly AnimationTrack[], + a: Vec2, + b: Vec2, +): KeyRef[] { + const left = Math.min(a.x, b.x); + const right = Math.max(a.x, b.x); + const top = Math.min(a.y, b.y); + const bottom = Math.max(a.y, b.y); + const out: KeyRef[] = []; + tracks.forEach((track, row) => { + const y = rowCentreY(row); + if (y < top || y > bottom) return; + for (const key of track.keys) { + const x = frameCentreX(view, key.frame); + if (x >= left && x <= right) out.push({ trackId: track.id, frame: key.frame }); + } + }); + return out; +} + +export function sameKey(a: KeyRef, b: KeyRef): boolean { + return a.trackId === b.trackId && a.frame === b.frame; +} + +export function hasKey(keys: readonly KeyRef[], key: KeyRef): boolean { + return keys.some((k) => sameKey(k, key)); +} + +/** + * The frames a drag may move the picked keys by: never before frame 0. `delta` is what the pointer + * asked for, in whole frames. + */ +export function clampMove(keys: readonly KeyRef[], delta: number): number { + const first = Math.min(...keys.map((k) => k.frame)); + return Number.isFinite(first) ? Math.max(delta, -first) : 0; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index cde49630..80a957b8 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -19,6 +19,7 @@ import { particleLibrary } from '../viewport/ParticleLibrary'; import { shaderLibrary } from '../viewport/ShaderLibrary'; import { soundLibrary } from '../viewport/SoundLibrary'; import { textureLibrary } from '../viewport/TextureLibrary'; +import { currentRowDialog } from './animation/RowDialog'; import type { CentreTab, Editor } from './Editor'; import { currentFontPicker } from './font/FontPicker'; import { currentNodePalette } from './graph/NodePalette'; @@ -463,6 +464,23 @@ export function installDebugHook(app: Application, state: EditorState, editor: E animation: { current: () => editor.animationEditor.currentAssetId, info: () => editor.animationEditor.shownInfo, + rootPickerAt: () => centreOf(editor.animationEditor.rootPicker), + addRowAt: () => centreOf(editor.animationEditor.addRowButton), + playAt: () => ({ + ...centreOf(editor.animationEditor.playButton), + text: editor.animationEditor.playButton.text, + }), + /** Where a cell, a row's header and a frame on the ruler sit – no text names a cell. */ + cellAt: (trackId: string, frame: number) => editor.animationEditor.canvas.cellGlobal(trackId, frame), + headerAt: (trackId: string) => editor.animationEditor.canvas.headerGlobal(trackId), + rulerAt: (frame: number) => editor.animationEditor.canvas.rulerGlobal(frame), + keys: () => editor.animationEditor.canvas.keysDrawn(), + rows: () => editor.animationEditor.canvas.rowsDrawn(), + view: () => editor.animationEditor.canvas.viewInfo(), + scrub: (frame: number) => editor.animationEditor.canvas.scrub(frame), + /** The open row dialog: what it offers and holds, and a way to pick without clicking through menus. */ + rowDialog: () => currentRowDialog()?.debugInfo() ?? null, + rowDialogPick: (target: string, property: string) => currentRowDialog()?.pick(target, property), }, container: { current: () => containerEditor.currentAssetId, diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index e263bc57..fa4a30c3 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -12,6 +12,7 @@ import { objectSignature, refreshObjectSection, } from './objectSection'; +import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection'; /** * Shows the properties of the current selection. The rows are generated from the PropertySchema @@ -90,6 +91,7 @@ export class Inspector extends Panel { else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId); else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId); else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId); + else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys); else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds); else buildObjectSection(this.host, s.holderId, s.objectId); this.scroll.setContent(this.host.body); @@ -102,6 +104,7 @@ export class Inspector extends Panel { if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId); if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId); if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId); + if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys); return objectSignature(this.state.project, s.holderId, s.objectIds, s.objectId); } @@ -129,7 +132,9 @@ export class Inspector extends Panel { ? refreshFrameSection(this.host, s.assetId, s.frameId) : s.kind === 'annotation' ? refreshAnnotationSection(this.host, s.assetId, s.annotationId) - : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); + : s.kind === 'track' + ? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys) + : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); if (!ok) this.rebuild(); } } diff --git a/src/editor/inspector/trackSection.ts b/src/editor/inspector/trackSection.ts new file mode 100644 index 00000000..88eadd3a --- /dev/null +++ b/src/editor/inspector/trackSection.ts @@ -0,0 +1,152 @@ +import { + RemoveAnimationKeysCommand, + RemoveAnimationTracksCommand, + SetAnimationKeyCommand, + SetTrackRelativeCommand, +} from '../../core/commands/animationCommands'; +import { animatableDefs, targetAssetOf, trackLabel } from '../../core/model/animation'; +import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; +import { findAssetOf, type Project } from '../../core/model/Project'; +import { Button } from '../../ui'; +import { openRowDialog, RowDialog } from '../animation/RowDialog'; +import { createFieldRow } from './fieldFactory'; +import { addGap, addNote, addTitle, type InspectorHost } from './host'; + +/** + * Structure-defining facts of a track selection: which row, how many keys and of what kind – a + * key's value field is built for its kind, and a colour key needs another widget than a number. + */ +export function trackSignature( + project: Project, + assetId: string, + trackId: string, + keys: readonly { trackId: string; frame: number }[], +): string { + const asset = findAssetOf(project, assetId, 'animation'); + const track = asset?.tracks.find((t) => t.id === trackId); + if (!asset || !track) return 'missing'; + const kind = kindOf(project, asset, track) ?? 'missing'; + const key = keys.length === 1 ? `${keys[0]!.trackId}@${keys[0]!.frame}` : String(keys.length); + return `${track.id}|${track.target}|${track.property}|${kind}|${track.relative ? 'rel' : 'abs'}|${key}`; +} + +/** + * A row of the timeline: what it aims at, whether it is relative, and – with one key picked – the + * key's value. Several keys show their count and the way to delete them at once. + */ +export function buildTrackSection( + host: InspectorHost, + assetId: string, + trackId: string, + keys: readonly { trackId: string; frame: number }[], +): void { + const asset = findAssetOf(host.state.project, assetId, 'animation'); + const track = asset?.tracks.find((t) => t.id === trackId); + if (!asset || !track) return; + const kind = kindOf(host.state.project, asset, track); + + addTitle(host, 'TIMELINE ROW'); + const label = createFieldRow( + host, + { key: 'row', label: 'Row', kind: 'text', default: '' }, + trackLabel(host.state.project, asset, track), + { onInput: () => {}, onChange: () => {} }, + ); + host.body.add(label.row); + if (!kind) + addNote(host, 'The object or property this row aims at is gone; it is skipped when played.', 'warning'); + else addNote(host, `A ${kind} row of "${asset.name}".`); + + host.body.add( + new Button('Change target…', { + onClick: () => { + const current = findAssetOf(host.state.project, assetId, 'animation'); + const row = current?.tracks.find((t) => t.id === trackId); + if (current && row) openRowDialog(new RowDialog(host.state, current, row)); + }, + }), + ); + + if (kind === 'number') { + const relative = createFieldRow( + host, + { key: 'relative', label: 'Relative', kind: 'boolean', default: false }, + Boolean(track.relative), + { + onInput: () => {}, + onChange: (value) => + host.state.execute(new SetTrackRelativeCommand(assetId, trackId, Boolean(value))), + }, + ); + host.fields.set('relative', relative); + host.body.add(relative.row); + addNote(host, 'Relative: the keys add to where the target stood when the animation started.', 'small'); + } + + if (keys.length === 1) buildKey(host, asset, keys[0]!); + else if (keys.length > 1) { + addGap(host); + addTitle(host, `${keys.length} KEYS`); + host.body.add( + new Button('Delete keys', { + variant: 'danger', + onClick: () => host.state.execute(new RemoveAnimationKeysCommand(assetId, [...keys])), + }), + ); + } + + addGap(host); + host.body.add( + new Button('Delete row', { + variant: 'danger', + onClick: () => host.state.execute(new RemoveAnimationTracksCommand(assetId, [trackId])), + }), + ); +} + +/** The value of the one picked key, editable in the widget its kind calls for. */ +function buildKey(host: InspectorHost, asset: AnimationAsset, ref: { trackId: string; frame: number }): void { + const track = asset.tracks.find((t) => t.id === ref.trackId); + const key = track?.keys.find((k) => k.frame === ref.frame); + const def = track && defOf(host.state.project, asset, track); + if (!track || !key || !def) return; + addGap(host); + addTitle(host, `KEY AT FRAME ${ref.frame}`); + const row = createFieldRow(host, { ...def, key: 'value', label: 'Value' }, key.value, { + onInput: () => {}, + onChange: (value) => + host.state.execute(new SetAnimationKeyCommand(asset.id, ref.trackId, ref.frame, value)), + }); + host.fields.set('value', row); + host.body.add(row.row); + if (track.relative) addNote(host, 'An offset, since the row is relative.', 'small'); +} + +/** Pushes the current values into the rows. False when the row is gone (rebuild). */ +export function refreshTrackSection( + host: InspectorHost, + assetId: string, + trackId: string, + keys: readonly { trackId: string; frame: number }[], +): boolean { + const asset = findAssetOf(host.state.project, assetId, 'animation'); + const track = asset?.tracks.find((t) => t.id === trackId); + if (!asset || !track) return false; + host.fields.get('relative')?.setValue(Boolean(track.relative)); + const ref = keys.length === 1 ? keys[0] : undefined; + if (ref) { + const key = asset.tracks.find((t) => t.id === ref.trackId)?.keys.find((k) => k.frame === ref.frame); + if (!key) return false; + host.fields.get('value')?.setValue(key.value); + } + return true; +} + +function defOf(project: Project, asset: AnimationAsset, track: AnimationTrack) { + const aimed = targetAssetOf(project, asset, track.target); + return aimed ? animatableDefs(aimed.type).find((d) => d.key === track.property) : undefined; +} + +function kindOf(project: Project, asset: AnimationAsset, track: AnimationTrack): string | undefined { + return defOf(project, asset, track)?.kind; +} diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index e31585e3..cb2aef14 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -1,3 +1,4 @@ +import { RemoveAnimationKeysCommand, RemoveAnimationTracksCommand } from '../core/commands/animationCommands'; import { RemoveFrameCommand } from '../core/commands/frameCommands'; import { ui } from '../ui'; import { confirmDeleteAsset, createContainer, removeGraphSelection, removeObjects } from './actions'; @@ -41,6 +42,13 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions): else if (s?.kind === 'node') removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds); else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId)); else if (s?.kind === 'annotation') removeGraphSelection(state, s.assetId, [], s.annotationIds); + // The keys when some are picked, else the row itself – like a frame of a texture. + else if (s?.kind === 'track') + state.execute( + s.keys.length > 0 + ? new RemoveAnimationKeysCommand(s.assetId, s.keys) + : new RemoveAnimationTracksCommand(s.assetId, [s.trackId]), + ); else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId); return s !== null; } diff --git a/src/editor/state.ts b/src/editor/state.ts index 42157727..fe5bde5e 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -1,3 +1,4 @@ +import type { KeyRef } from '../core/commands/animationCommands'; import { type Command, CommandError } from '../core/commands/Command'; import { CommandStack } from '../core/commands/CommandStack'; import type { Asset, SceneAsset } from '../core/model/Asset'; @@ -37,6 +38,12 @@ export type Selection = | { kind: 'frame'; assetId: string; frameId: string } /** One or more groups or comments of a graph, `annotationId` being the primary one. */ | { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[] } + /** + * A row of an animation's timeline (the Animation tab), and the keys picked on it or on other + * rows: `keys` is empty when the row itself was clicked. `trackId` is the row the inspector + * describes – of several keys, the one picked last. + */ + | { kind: 'track'; assetId: string; trackId: string; keys: KeyRef[] } | null; /** @@ -113,6 +120,19 @@ export function graphSelection( ); } +/** + * Builds a track selection. Without keys it is the row itself; with keys the row is the one of the + * key picked last, unless `trackId` says otherwise. Duplicate keys collapse to one. + */ +export function trackSelection(assetId: string, keys: readonly KeyRef[], trackId?: string): Selection { + const unique: KeyRef[] = []; + for (const key of keys) + if (!unique.some((k) => k.trackId === key.trackId && k.frame === key.frame)) unique.push(key); + const last = unique[unique.length - 1]; + const row = trackId ?? last?.trackId; + return row ? { kind: 'track', assetId, trackId: row, keys: unique } : null; +} + function pickIds(input: readonly string[], primary?: string): { ids: string[]; primary: string } | null { const ids = [...new Set(input)]; if (ids.length === 0) return null; @@ -377,6 +397,10 @@ export class EditorState { selection.annotationIds ?? [selection.annotationId], selection.annotationId, ); + // The short form `{ trackId }` names a row without keys. + if (selection?.kind === 'track') + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + selection = trackSelection(selection.assetId, selection.keys ?? [], selection.trackId); if (sameSelection(this.selection, selection)) return; this.selection = selection; this.emit('selection'); @@ -464,6 +488,24 @@ export class EditorState { else if (!asset.frames.some((f) => f.id === s.frameId)) this.select({ kind: 'asset', assetId: s.assetId }); } + if (s.kind === 'track') { + const asset = findAssetOf(this.project, s.assetId, 'animation'); + if (!asset) this.select(null); + else { + // A key that moved or died leaves the selection; a row that died takes its keys with it. + const alive = s.keys.filter((key) => + asset.tracks.some((t) => t.id === key.trackId && t.keys.some((k) => k.frame === key.frame)), + ); + const rowAlive = asset.tracks.some((t) => t.id === s.trackId); + if (alive.length !== s.keys.length || !rowAlive) + this.select( + trackSelection(s.assetId, alive, rowAlive ? s.trackId : undefined) ?? { + kind: 'asset', + assetId: s.assetId, + }, + ); + } + } } /** The texture asset the current selection refers to (the asset itself or one of its frames). */ @@ -474,9 +516,16 @@ export class EditorState { return findAssetOf(this.project, s.assetId, 'texture')?.id ?? null; } + /** The animation the selection refers to: the asset, or a row or key of its timeline. */ get selectedAnimationId(): string | null { const s = this.selection; - return s?.kind === 'asset' ? (findAssetOf(this.project, s.assetId, 'animation')?.id ?? null) : null; + if (s?.kind !== 'asset' && s?.kind !== 'track') return null; + return findAssetOf(this.project, s.assetId, 'animation')?.id ?? null; + } + + /** The keys picked in the Animation tab, none when the selection is about something else. */ + get selectedKeys(): KeyRef[] { + return this.selection?.kind === 'track' ? this.selection.keys : []; } /** The behavior asset the current selection refers to (asset, placed object or node), if any. */ @@ -571,6 +620,13 @@ export function sameSelection(a: Selection, b: Selection): boolean { ); } if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId; + if (a.kind === 'track' && b.kind === 'track') + return ( + a.assetId === b.assetId && + a.trackId === b.trackId && + a.keys.length === b.keys.length && + a.keys.every((key, i) => key.trackId === b.keys[i]!.trackId && key.frame === b.keys[i]!.frame) + ); if (a.kind === 'annotation' && b.kind === 'annotation') return ( a.assetId === b.assetId && diff --git a/tests/editor/timeline-view.test.ts b/tests/editor/timeline-view.test.ts new file mode 100644 index 00000000..d1e37fc1 --- /dev/null +++ b/tests/editor/timeline-view.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import type { AnimationTrack } from '../../src/core/model/Asset'; +import { + clampMove, + clampPan, + DEFAULT_VIEW, + frameAt, + frameCentreX, + frameToX, + keyAt, + keysWithin, + MAX_CELL_WIDTH, + MIN_CELL_WIDTH, + ROW_HEIGHT, + rowAt, + rowCentreY, + RULER_HEIGHT, + rulerStep, + type TimelineView, + xToFrame, + zoomCells, +} from '../../src/editor/animation/timelineView'; + +const TRACKS: AnimationTrack[] = [ + { + id: 'a', + target: '', + property: 'x', + keys: [ + { frame: 0, value: 0 }, + { frame: 4, value: 40 }, + ], + }, + { id: 'b', target: '', property: 'y', keys: [{ frame: 2, value: 0 }] }, +]; + +describe('timeline geometry', () => { + it('maps frames to cells and back', () => { + const view: TimelineView = { panX: 10, cellWidth: 20 }; + expect(frameToX(view, 0)).toBe(10); + expect(frameToX(view, 3)).toBe(70); + expect(frameCentreX(view, 3)).toBe(80); + expect(xToFrame(view, 80)).toBe(3.5); + expect(frameAt(view, 89)).toBe(3); + expect(frameAt(view, 90)).toBe(4); + }); + + it('never answers a frame below 0', () => { + expect(frameAt({ panX: 0, cellWidth: 16 }, -40)).toBe(0); + }); + + it('finds rows below the ruler and nothing beyond the last', () => { + expect(rowAt(RULER_HEIGHT - 1, 2)).toBe(-1); + expect(rowAt(RULER_HEIGHT, 2)).toBe(0); + expect(rowAt(RULER_HEIGHT + ROW_HEIGHT, 2)).toBe(1); + expect(rowAt(RULER_HEIGHT + 2 * ROW_HEIGHT, 2)).toBe(-1); + }); + + it('zooms about the pointer, so the frame under it stays put', () => { + const view: TimelineView = { panX: -100, cellWidth: 16 }; + const x = 200; + const before = xToFrame(view, x); + const zoomed = zoomCells(view, 1.5, x); + expect(zoomed.cellWidth).toBe(24); + expect(xToFrame(zoomed, x)).toBeCloseTo(before); + }); + + it('keeps the cell width within its bounds', () => { + expect(zoomCells(DEFAULT_VIEW, 100, 0).cellWidth).toBe(MAX_CELL_WIDTH); + expect(zoomCells(DEFAULT_VIEW, 0.001, 0).cellWidth).toBe(MIN_CELL_WIDTH); + }); + + it('does not let frame 0 drift right of the left edge', () => { + expect(clampPan({ panX: 30, cellWidth: 16 }).panX).toBe(0); + expect(clampPan({ panX: -30, cellWidth: 16 }).panX).toBe(-30); + }); + + it('labels the ruler with a step that keeps the numbers apart', () => { + expect(rulerStep({ panX: 0, cellWidth: 48 })).toBe(1); + expect(rulerStep({ panX: 0, cellWidth: 16 })).toBe(5); + expect(rulerStep({ panX: 0, cellWidth: 4 })).toBe(10); + }); +}); + +describe('picking keys', () => { + const view: TimelineView = { panX: 0, cellWidth: 16 }; + + it('hits the key drawn at the centre of its cell', () => { + expect(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toEqual({ + trackId: 'a', + frame: 4, + }); + expect(keyAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(1) })).toEqual({ + trackId: 'b', + frame: 2, + }); + }); + + it('misses an empty cell and the ruler', () => { + expect(keyAt(view, TRACKS, { x: frameCentreX(view, 2), y: rowCentreY(0) })).toBeNull(); + expect(keyAt(view, TRACKS, { x: frameCentreX(view, 4), y: RULER_HEIGHT / 2 })).toBeNull(); + }); + + it('a band catches every key whose diamond centre it spans, across rows', () => { + const a = { x: frameToX(view, 1), y: RULER_HEIGHT }; + const b = { x: frameToX(view, 5), y: RULER_HEIGHT + 2 * ROW_HEIGHT }; + expect(keysWithin(view, TRACKS, a, b)).toEqual([ + { trackId: 'a', frame: 4 }, + { trackId: 'b', frame: 2 }, + ]); + // Spanned the other way round it is the same band. + expect(keysWithin(view, TRACKS, b, a)).toHaveLength(2); + }); + + it('a band over one row leaves the other alone', () => { + const a = { x: frameToX(view, 0), y: RULER_HEIGHT }; + const b = { x: frameToX(view, 10), y: RULER_HEIGHT + ROW_HEIGHT - 1 }; + expect(keysWithin(view, TRACKS, a, b).map((k) => k.trackId)).toEqual(['a', 'a']); + }); + + it('a move never takes a key before frame 0', () => { + const keys = [ + { trackId: 'a', frame: 2 }, + { trackId: 'b', frame: 5 }, + ]; + expect(clampMove(keys, -5)).toBe(-2); + expect(clampMove(keys, 3)).toBe(3); + expect(clampMove([], -3)).toBe(0); + }); +}); diff --git a/tests/editor/track-selection.test.ts b/tests/editor/track-selection.test.ts new file mode 100644 index 00000000..605585c8 --- /dev/null +++ b/tests/editor/track-selection.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + AddAnimationTrackCommand, + MoveAnimationKeysCommand, + RemoveAnimationKeysCommand, + RemoveAnimationTracksCommand, + SetAnimationKeyCommand, + SetAnimationRootCommand, +} from '../../src/core/commands/animationCommands'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import type { AnimationAsset } from '../../src/core/model/Asset'; +import { createEmptyProject } from '../../src/core/model/Project'; +import { EditorState, trackSelection } from '../../src/editor/state'; + +/** A sprite, an animation over it, one row on `x` with keys on 0 and 4, and a second row on `y`. */ +function setup(): { state: EditorState; animation: AnimationAsset; x: string; y: string } { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const sprite = new CreateAssetCommand('sprite', 'Dancer'); + expect(state.execute(sprite)).toBe(true); + const create = new CreateAssetCommand('animation', 'Walk'); + expect(state.execute(create)).toBe(true); + const animation = create.asset as AnimationAsset; + expect(state.execute(new SetAnimationRootCommand(animation.id, sprite.asset.id))).toBe(true); + const x = new AddAnimationTrackCommand(animation.id, '', 'x'); + const y = new AddAnimationTrackCommand(animation.id, '', 'y'); + expect(state.execute(x)).toBe(true); + expect(state.execute(y)).toBe(true); + expect(state.execute(new SetAnimationKeyCommand(animation.id, x.trackId, 0, 0))).toBe(true); + expect(state.execute(new SetAnimationKeyCommand(animation.id, x.trackId, 4, 40))).toBe(true); + return { state, animation, x: x.trackId, y: y.trackId }; +} + +describe('selecting rows and keys of a timeline', () => { + let f: ReturnType; + beforeEach(() => { + f = setup(); + }); + + it('a row alone is a track selection without keys, and keeps the animation on the tab', () => { + f.state.select({ kind: 'track', assetId: f.animation.id, trackId: f.x, keys: [] }); + expect(f.state.selectedKeys).toEqual([]); + expect(f.state.selectedAnimationId).toBe(f.animation.id); + }); + + it('the row of the key picked last is the one the inspector describes', () => { + const keys = [ + { trackId: f.x, frame: 0 }, + { trackId: f.x, frame: 4 }, + ]; + f.state.select(trackSelection(f.animation.id, keys)); + expect(f.state.selection).toMatchObject({ kind: 'track', trackId: f.x, keys }); + // The same key twice is one key. + f.state.select(trackSelection(f.animation.id, [...keys, keys[0]!])); + expect(f.state.selectedKeys).toHaveLength(2); + }); + + it('accepts the short form a test hook passes, without keys', () => { + f.state.select({ kind: 'track', assetId: f.animation.id, trackId: f.y } as never); + expect(f.state.selection).toEqual({ kind: 'track', assetId: f.animation.id, trackId: f.y, keys: [] }); + }); + + it('drops a key that was deleted and keeps the rest', () => { + f.state.select( + trackSelection(f.animation.id, [ + { trackId: f.x, frame: 0 }, + { trackId: f.x, frame: 4 }, + ]), + ); + f.state.execute(new RemoveAnimationKeysCommand(f.animation.id, [{ trackId: f.x, frame: 4 }])); + expect(f.state.selectedKeys).toEqual([{ trackId: f.x, frame: 0 }]); + }); + + it('a moved key leaves the selection – the frame it was picked at holds no key any more', () => { + f.state.select(trackSelection(f.animation.id, [{ trackId: f.x, frame: 4 }])); + f.state.execute(new MoveAnimationKeysCommand(f.animation.id, [{ trackId: f.x, frame: 4 }], 2)); + // The canvas re-selects at the new frame after a drag; the state alone falls back to the row. + expect(f.state.selection).toEqual({ kind: 'track', assetId: f.animation.id, trackId: f.x, keys: [] }); + }); + + it('falls back to the animation when the row itself is deleted, so the tab stays open', () => { + f.state.select({ kind: 'track', assetId: f.animation.id, trackId: f.x, keys: [] }); + f.state.execute(new RemoveAnimationTracksCommand(f.animation.id, [f.x])); + expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.animation.id }); + }); + + it('comes back with undo: the row returns, the selection was already elsewhere', () => { + f.state.select({ kind: 'track', assetId: f.animation.id, trackId: f.x, keys: [] }); + f.state.execute(new RemoveAnimationTracksCommand(f.animation.id, [f.x])); + f.state.undo(); + const rows = (f.state.project.assets.find((a) => a.id === f.animation.id) as AnimationAsset).tracks; + expect(rows.map((t) => t.id)).toEqual([f.x, f.y]); + expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.animation.id }); + }); +});