From 8679dfbe65fc56281f53952c7c07da246d03ae0a Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 11 Oct 2026 17:42:10 +0200 Subject: [PATCH] Group timeline rows by object and edit values in the row (#408) The rows of the animation timeline stood in the order they were keyed. With several objects and properties the list was hard to read, and a value could only be changed by picking a key and using the inspector. The Play button also kept saying Pause after scrubbing had paused. - Each object gets a header row with its name, indented by depth. The groups follow the scene hierarchy, the root first. Rows whose object is missing go to the end. - Under an object the rows follow the order in which it lists its properties: X, Y, Z, then the order of the inspector. Only the view is sorted, the animation itself is unchanged and there is no undo step. After a move in the hierarchy the order is right by itself. - The row head shows the same field as the inspector with the value at the playhead. Without keys it shows the value of the object, for a relative row the offset. - A change in that field sets or changes the key at the playhead, selects it and is one undo step. A running playback pauses first. - The Play/Pause button follows every change of the playing state, so scrubbing that pauses shows Play again. Tests: - Unit animation + timeline-view: 100 passed, 3 new. - e2e 25 on melektaus-atx: ALL PASSED, 34 checks, 3 new (groups, order and values at frame 4, a typed 55 becomes a selected key on frame 4, Play then a click on the ruler shows Play and pauses). - Gate on melektaus-atx: exit 0, 297 files, 4561 tests. - tsc, eslint, Prettier clean. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/model/animation.ts | 71 +++++++ src/editor/animation/AnimationEditor.ts | 9 +- src/editor/animation/TimelineCanvas.ts | 268 +++++++++++++++++++----- src/editor/animation/timelineView.ts | 20 +- src/editor/debug/documentHooks.ts | 3 + tests/core/animation.test.ts | 76 +++++++ tests/editor/timeline-view.test.ts | 13 ++ tools/e2e/25-animation.mjs | 56 +++++ 9 files changed, 449 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 77156062..42c3c73c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2004 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2007 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index a9d440ce..b2cbc56b 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -577,6 +577,77 @@ export function trackLabel(project: Project, animation: AnimationAsset, track: A return `${who} · ${def ? def.label : aimed ? `${track.property} (missing)` : track.property}`; } +/** + * A line of the timeline's header column (#408): the head of an object's group, or a row under it, + * named by its property alone. `depth` is the object's in the hierarchy. A row carries its property + * (none once that is gone) and `held`, what it shows before it has keys: it writes nothing then, so + * that is what the object holds – or no offset at all for a relative row. + */ +export type TimelineLine = + | { kind: 'group'; target: string; name: string; depth: number } + | { + kind: 'row'; + track: AnimationTrack; + label: string; + def: PropertyDef | undefined; + held: PropertyValue; + depth: number; + }; + +/** + * The rows grouped by the object they aim at, in the order the objects stand in the hierarchy – the + * root first – and under each object in the order it lists its properties. Rows aiming at something + * gone come last, as they were added. Only the view is sorted: the asset keeps its own order. + */ +export function timelineLines(project: Project, animation: AnimationAsset): TimelineLine[] { + const placed = new Map([['', { at: 0, depth: 0 }]]); + animationTargets(project, findAsset(project, animation.root)).forEach((t, i) => + placed.set(t.id, { at: i + 1, depth: t.depth }), + ); + const targets = [...new Set(animation.tracks.map((t) => t.target))]; + const rank = (target: string) => placed.get(target)?.at ?? placed.size + targets.indexOf(target); + const lines: TimelineLine[] = []; + for (const target of targets.sort((a, b) => rank(a) - rank(b))) { + const aimed = aimedAt(project, animation, target); + const defs = aimed ? animatableDefsOf(project, aimed.asset, aimed.bag) : []; + const depth = placed.get(target)?.depth ?? 0; + const name = !aimed + ? '(missing)' + : aimed.object + ? objectName(aimed.object, aimed.asset) + : aimed.asset.name; + lines.push({ kind: 'group', target, name, depth }); + const order = (track: AnimationTrack) => { + const i = defs.findIndex((d) => d.key === track.property); + return i < 0 ? defs.length : i; + }; + const rows = animation.tracks.filter((t) => t.target === target).sort((a, b) => order(a) - order(b)); + for (const track of rows) { + const def = defs.find((d) => d.key === track.property); + const label = def ? def.label : `${track.property} (missing)`; + const held = !aimed || !def ? 0 : track.relative ? 0 : heldBy(aimed, def); + lines.push({ kind: 'row', track, label, def, held, depth }); + } + } + return lines; +} + +function heldBy(aimed: Aimed, def: PropertyDef): PropertyValue { + if (isPositionRow(aimed.asset.type, def.key)) return aimed.object?.position[def.key] ?? 0; + return aimed.bag[def.key] ?? def.default; +} + +/** What a row's header shows at `frame` (#408): the row's value there, else what it holds without keys. */ +export function lineValueAt( + line: TimelineLine & { kind: 'row' }, + frame: number, + reverse = false, + curves: CurveLookup = NO_CURVES, +): PropertyValue | undefined { + if (!line.def) return undefined; + return trackValueAt(line.track.keys, line.def.kind, frame, reverse, curves) ?? line.held; +} + /** 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 8ee2d425..7a3033e1 100644 --- a/src/editor/animation/AnimationEditor.ts +++ b/src/editor/animation/AnimationEditor.ts @@ -36,6 +36,8 @@ export class AnimationEditor extends AssetTabEditor this.preview.redraw(); + // Scrubbing on the ruler and the end of a `once` pass stop play, too. + timeline.onPlaying = () => this.syncPlayButton(); this.mount(timeline, [this.rootPicker, this.addRowButton, this.playButton]); } @@ -67,11 +69,7 @@ export class AnimationEditor extends AssetTabEditor` for an object's head. */ private readonly labels = new Map(); + private readonly fields = new Map(); + private linesFor: { asset: AnimationAsset | null; project: object; lines: TimelineLine[] } | null = null; private readonly rulerTexts: Text[] = []; private readonly hint = crispText({ text: '', style: textStyle('muted') }); private readonly clip = new Graphics(); @@ -136,10 +157,21 @@ export class TimelineCanvas extends Widget { private scrubbing = false; /** Told whenever the playhead moves – the preview above draws the frame it stands on. */ onPlayhead: () => void = () => undefined; + /** Told whenever play starts or stops, whoever stopped it – the header's button says which. */ + onPlaying: () => void = () => undefined; constructor(private readonly state: EditorState) { super(); - this.addChild(this.bg, this.cells, this.overlay, this.labelLayer, this.rulerLayer, this.hint, this.clip); + this.addChild( + this.bg, + this.cells, + this.overlay, + this.labelLayer, + this.fieldLayer, + this.rulerLayer, + this.hint, + this.clip, + ); this.cells.mask = this.clip; this.overlay.mask = this.clip; this.rulerLayer.mask = this.clip; @@ -152,9 +184,13 @@ export class TimelineCanvas extends Widget { this.on('pointerupoutside', () => this.onPointerUp()); this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); - state.on('project', () => this.redraw()); + state.on('project', () => { + this.linesFor = null; + this.redraw(); + }); state.on('selection', () => this.redraw()); state.on('load', () => { + this.linesFor = null; this.view = { ...DEFAULT_VIEW }; this.redraw(); }); @@ -198,19 +234,25 @@ export class TimelineCanvas extends Widget { const count = frameCount(asset); if (count > 0 && this.playhead >= count) this.playhead = 0; this.time = this.timeOf(asset, this.playhead); - this.playingNow = true; + this.setPlaying(true); this.redraw(); this.onPlayhead(); } pause(): void { - this.playingNow = false; + this.setPlaying(false); this.redraw(); } + private setPlaying(playing: boolean): void { + if (this.playingNow === playing) return; + this.playingNow = playing; + this.onPlaying(); + } + /** Pauses and puts the playhead back to frame 0. */ stop(): void { - this.playingNow = false; + this.setPlaying(false); this.playhead = 0; this.playheadReverse = false; this.time = 0; @@ -225,7 +267,7 @@ export class TimelineCanvas extends Widget { const at = animationPlayheadAt(asset, this.time); this.playhead = at.frame; this.playheadReverse = at.reverse; - if (at.finished) this.playingNow = false; + if (at.finished) this.setPlaying(false); this.redraw(); this.onPlayhead(); } @@ -355,11 +397,10 @@ export class TimelineCanvas extends Widget { private openMenu(asset: AnimationAsset, local: Vec2, global: Vec2): void { const hit = local.x >= HEADER_WIDTH && local.y >= RULER_HEIGHT - ? keyAt(this.view, asset.tracks, { x: local.x - HEADER_WIDTH, y: local.y }) + ? keyAt(this.view, this.lanes(), { x: local.x - HEADER_WIDTH, y: local.y }) : null; if (hit && !hasKey(this.state.selectedKeys, hit)) this.state.select(trackSelection(asset.id, [hit])); - const row = local.y >= RULER_HEIGHT ? rowAt(local.y, asset.tracks.length) : -1; - const track = row >= 0 ? asset.tracks[row] : undefined; + const track = this.laneAt(local.y); if (track && local.x < HEADER_WIDTH && this.state.selectedTrackId !== track.id) this.state.select(trackSelection(asset.id, [], track.id)); const picked = this.state.selectedKeys.length; @@ -442,10 +483,34 @@ export class TimelineCanvas extends Widget { return out; } - /** The row labels as drawn, top to bottom. */ + /** The rows as their full labels (`object · property`), top to bottom. */ rowsDrawn(): string[] { const asset = this.asset(); - return asset ? asset.tracks.map((t) => trackLabel(this.state.project, asset, t)) : []; + return asset ? this.lanes().flatMap((t) => (t ? [trackLabel(this.state.project, asset, t)] : [])) : []; + } + + /** The header column as drawn (#408): objects' heads and rows, with the value a row's field shows. */ + linesDrawn(): { kind: string; text: string; indent: number; value?: PropertyValue; trackId?: string }[] { + return this.lines().map((line) => { + const label = this.labels.get(this.labelKey(line)); + const base = { kind: line.kind, text: label?.text ?? '', indent: label?.x ?? 0 }; + if (line.kind === 'group') return base; + const shown = this.fields.get(line.track.id)?.shown; + return { + ...base, + trackId: line.track.id, + value: shown === undefined ? undefined : (JSON.parse(shown) as PropertyValue), + }; + }); + } + + /** Where a row's value field sits on screen, or null for a row without one. */ + valueGlobal(trackId: string): (Vec2 & { kind: string }) | null { + const entry = this.fields.get(trackId); + if (!entry?.field.widget.visible) return null; + const widget = entry.field.widget; + const g = widget.getGlobalPosition(); + return { x: g.x + widget.w / 2, y: g.y + widget.h / 2, kind: entry.builtFor.split('|')[2] ?? '' }; } viewInfo(): TimelineView & { playhead: number; playing: boolean } { @@ -458,13 +523,34 @@ export class TimelineCanvas extends Widget { return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'animation') ?? null) : null; } + /** The header's lines, built once per change of the project rather than per frame of play. */ + private lines(): TimelineLine[] { + const asset = this.asset(); + const project = this.state.project; + if (this.linesFor?.asset !== asset || this.linesFor.project !== project) + this.linesFor = { asset, project, lines: asset ? timelineLines(project, asset) : [] }; + return this.linesFor.lines; + } + + private lanes(): Lane[] { + return this.lines().map((line) => (line.kind === 'row' ? line.track : null)); + } + + /** The row under `y`; undefined over an object's head, the ruler and below the last line. */ + private laneAt(y: number): AnimationTrack | undefined { + const lanes = this.lanes(); + const row = rowAt(y, lanes.length); + return (row >= 0 ? lanes[row] : null) ?? undefined; + } + private rowOf(trackId: string): number { - return this.asset()?.tracks.findIndex((t) => t.id === trackId) ?? -1; + return this.lines().findIndex((line) => line.kind === 'row' && line.track.id === trackId); } private onPointerDown(e: FederatedPointerEvent): void { const asset = this.asset(); - if (!asset) return; + // A value field (#408) takes its own presses, and keeps the focus they give it. + if (!asset || isWithin(e.target, this.fieldLayer)) 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)) { @@ -483,8 +569,7 @@ export class TimelineCanvas extends Widget { e.stopPropagation(); if (local.x < HEADER_WIDTH) { - const row = rowAt(local.y, asset.tracks.length); - const track = row >= 0 ? asset.tracks[row] : undefined; + const track = this.laneAt(local.y); if (track) this.state.select(trackSelection(asset.id, [], track.id)); return; } @@ -496,7 +581,7 @@ export class TimelineCanvas extends Widget { return; } const picked = this.state.selectedKeys; - const hit = keyAt(this.view, asset.tracks, cell); + const hit = keyAt(this.view, this.lanes(), 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]; @@ -510,8 +595,7 @@ export class TimelineCanvas extends Widget { }; return; } - const row = rowAt(cell.y, asset.tracks.length); - const track = row >= 0 ? asset.tracks[row] : undefined; + const track = this.laneAt(cell.y); if (track && pressCount(e, this) === 2) { this.setKey(asset, track, frameAt(this.view, cell.x)); return; @@ -587,7 +671,7 @@ export class TimelineCanvas extends Widget { } 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); + const caught = keysWithin(this.view, this.lanes(), a, b); this.state.select(trackSelection(asset.id, caught) ?? { kind: 'asset', assetId: asset.id }); this.redraw(); } @@ -619,7 +703,8 @@ export class TimelineCanvas extends Widget { this.cells.clear(); this.overlay.clear(); const asset = this.asset(); - const tracks = asset?.tracks ?? []; + const lines = this.lines(); + const lanes = this.lanes(); const selectedRow = this.state.selectedTrackId ?? ''; // Header column and ruler chrome. @@ -629,25 +714,32 @@ export class TimelineCanvas extends Widget { this.bg.rect(0, RULER_HEIGHT - 1, this.w, 1).fill(c.border); if (asset) { - this.drawRows(asset, tracks, selectedRow); + this.drawRows(asset, lanes, selectedRow); this.drawRuler(asset); - this.drawKeys(tracks); + this.drawKeys(lanes); this.drawPlayhead(); } if (this.band?.active) drawBand(this.overlay, this.band.start, this.band.current); - this.syncLabels(asset, tracks, selectedRow); + this.syncLabels(lines, selectedRow); + this.syncFields(lines); this.syncHint(asset); } - private drawRows(asset: AnimationAsset, tracks: readonly AnimationTrack[], selectedRow: string): void { + private drawRows(asset: AnimationAsset, lanes: readonly Lane[], 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) => { + lanes.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 }); + // An object's head: a band across both halves, no cells – nothing is keyed on it. + if (!track) { + this.bg.rect(0, y, this.w, ROW_HEIGHT).fill(c.panelHeader); + this.bg.rect(0, y + ROW_HEIGHT - 1, this.w, 1).fill({ color: c.border, alpha: 0.6 }); + return; + } + if (track.id === selectedRow) + 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, @@ -660,17 +752,14 @@ export class TimelineCanvas extends Widget { 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++) { + // Cell borders only when they have room to be seen. + if (this.view.cellWidth < 8) return; + for (let frame = frameAt(this.view, 0); ; 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 }); + this.cells.rect(x, y, 1, ROW_HEIGHT).fill({ color: c.border, alpha: 0.35 }); } - } + }); } private drawRuler(asset: AnimationAsset): void { @@ -703,12 +792,13 @@ export class TimelineCanvas extends Widget { for (let i = used; i < this.rulerTexts.length; i++) this.rulerTexts[i]!.visible = false; } - private drawKeys(tracks: readonly AnimationTrack[]): void { + private drawKeys(lanes: readonly Lane[]): 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) => { + lanes.forEach((track, row) => { + if (!track) return; const y = rowCentreY(row); for (const key of track.keys) { const ref = { trackId: track.id, frame: key.frame }; @@ -744,25 +834,40 @@ export class TimelineCanvas extends Widget { .fill(Theme.colors.hot); } - private syncLabels( - asset: AnimationAsset | null, - tracks: readonly AnimationTrack[], - selectedRow: string, - ): void { + private labelKey(line: TimelineLine): string { + return line.kind === 'group' ? `group:${line.target}` : line.track.id; + } + + /** An object's name at its depth in the hierarchy; under it its rows, a step further in. */ + private syncLabels(lines: readonly TimelineLine[], selectedRow: string): void { const alive = new Set(); - tracks.forEach((track, row) => { - alive.add(track.id); - let label = this.labels.get(track.id); + lines.forEach((line, row) => { + const id = this.labelKey(line); + alive.add(id); + let label = this.labels.get(id); if (!label) { label = crispText({ style: textStyle('body') }); - this.labels.set(track.id, label); + this.labels.set(id, label); this.labelLayer.addChild(label); } - const missing = !asset || !trackDef(this.state.project, asset, track); - const text = asset ? trackLabel(this.state.project, asset, track) : ''; + const indent = LABEL_INSET + Math.min(line.depth, 6) * Theme.treeIndent; + const x = line.kind === 'group' ? indent : indent + Theme.treeIndent; + const style = + line.kind === 'group' + ? line.name === '(missing)' + ? 'warning' + : 'bodyBold' + : !line.def + ? 'warning' + : line.track.id === selectedRow + ? 'accent' + : 'body'; + label.style = textStyle(style); + const room = + (line.kind === 'row' && line.def ? HEADER_WIDTH - FIELD_WIDTH - FIELD_INSET * 2 : HEADER_WIDTH) - x; + const text = truncate(line.kind === 'group' ? line.name : line.label, label.style, room - FIELD_INSET); 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.position.set(x, rowTop(row) + (ROW_HEIGHT - label.height) / 2); label.visible = rowTop(row) + ROW_HEIGHT <= this.h; }); for (const [id, label] of this.labels) { @@ -772,6 +877,62 @@ export class TimelineCanvas extends Widget { } } + /** + * A row's value at the playhead in the widget the inspector edits its kind with (#408). A change + * keys it there. A field being typed into is left alone, and play leaves it be until it stops. + */ + private syncFields(lines: readonly TimelineLine[]): void { + const alive = new Set(); + const curves = curveLookup(this.state.project); + lines.forEach((line, row) => { + if (line.kind !== 'row' || !line.def) return; + const id = line.track.id; + alive.add(id); + const value = lineValueAt(line, this.playhead, this.playheadReverse, curves) ?? line.def.default; + const builtFor = `${line.track.target}|${line.track.property}|${line.def.kind}`; + let entry = this.fields.get(id); + if (entry && entry.builtFor !== builtFor) { + entry.field.widget.destroy({ children: true }); + entry = undefined; + } + if (!entry) { + const field = createField({ project: () => this.state.project }, { ...line.def }, value, { + onInput: () => undefined, + onChange: (v) => this.keyAtPlayhead(id, v), + }); + entry = { field, builtFor, shown: '' }; + this.fields.set(id, entry); + this.fieldLayer.addChild(field.widget); + } + const shown = JSON.stringify(value); + if (entry.shown !== shown && !entry.field.widget.focused) { + entry.field.setValue(value); + entry.shown = shown; + } + const widget = entry.field.widget; + const w = entry.field.grow ? FIELD_WIDTH : Math.min(FIELD_WIDTH, widget.measure().w); + const h = Theme.controlHeight; + widget.position.set(HEADER_WIDTH - FIELD_INSET - w, rowTop(row) + (ROW_HEIGHT - h) / 2); + if (widget.w !== w || widget.h !== h) widget.resize(w, h); + widget.visible = rowTop(row) + ROW_HEIGHT <= this.h; + }); + for (const [id, entry] of this.fields) { + if (alive.has(id)) continue; + entry.field.widget.destroy({ children: true }); + this.fields.delete(id); + } + } + + /** A value typed into a row's header: the key at the playhead, picked so the inspector shows it. */ + private keyAtPlayhead(trackId: string, value: PropertyValue): void { + const asset = this.asset(); + if (!asset) return; + this.pause(); + const frame = this.playheadFrame; + if (this.state.execute(new SetAnimationKeyCommand(asset.id, trackId, frame, value))) + this.state.select(trackSelection(asset.id, [{ trackId, frame }])); + } + private syncHint(asset: AnimationAsset | null): void { const text = !asset ? 'No animation selected – pick one in the Assets panel or create one.' @@ -809,3 +970,8 @@ function drawKeyShape( else g.lineTo(x - half, y).lineTo(x, y - half); return g.closePath(); } + +function isWithin(target: unknown, root: Container): boolean { + for (let node = target as Container | null; node; node = node.parent) if (node === root) return true; + return false; +} diff --git a/src/editor/animation/timelineView.ts b/src/editor/animation/timelineView.ts index 95a643eb..70c06b21 100644 --- a/src/editor/animation/timelineView.ts +++ b/src/editor/animation/timelineView.ts @@ -15,8 +15,11 @@ export interface TimelineView { cellWidth: number; } +/** A line of the timeline as the geometry sees it: a row, or `null` for an object's head (#408). */ +export type Lane = AnimationTrack | null; + /** Width of the row header column, in pixels. */ -export const HEADER_WIDTH = 220; +export const HEADER_WIDTH = 240; /** Height of the frame ruler above the rows. */ export const RULER_HEIGHT = 22; export const ROW_HEIGHT = 26; @@ -93,10 +96,10 @@ export function rulerStep(view: TimelineView): number { * 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 { +export function keyAt(view: TimelineView, tracks: readonly Lane[], point: Vec2): KeyRef | null { const row = rowAt(point.y, tracks.length); - if (row < 0) return null; - const track = tracks[row]!; + const track = row < 0 ? null : tracks[row]; + if (!track) return null; 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) @@ -106,12 +109,7 @@ export function keyAt(view: TimelineView, tracks: readonly AnimationTrack[], poi } /** 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[] { +export function keysWithin(view: TimelineView, tracks: readonly Lane[], 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); @@ -119,7 +117,7 @@ export function keysWithin( const out: KeyRef[] = []; tracks.forEach((track, row) => { const y = rowCentreY(row); - if (y < top || y > bottom) return; + if (!track || 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 }); diff --git a/src/editor/debug/documentHooks.ts b/src/editor/debug/documentHooks.ts index 226f0661..7d526295 100644 --- a/src/editor/debug/documentHooks.ts +++ b/src/editor/debug/documentHooks.ts @@ -43,6 +43,9 @@ export function documentHooks({ state, editor }: HookContext) { rulerAt: (frame: number) => editor.animationEditor.canvas.rulerGlobal(frame), keys: () => editor.animationEditor.canvas.keysDrawn(), rows: () => editor.animationEditor.canvas.rowsDrawn(), + /** The header column (#408): objects' heads and rows, and where a row's value field sits. */ + lines: () => editor.animationEditor.canvas.linesDrawn(), + valueAt: (trackId: string) => editor.animationEditor.canvas.valueGlobal(trackId), 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. */ diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index c2eff0be..48b563a7 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -36,6 +36,7 @@ import { frameCount, isPositionRow, leadingPlayer, + lineValueAt, NOT_PLAYING, offeredDefs, pauseAnimation, @@ -49,6 +50,8 @@ import { stopAnimation, targetDefs, targetNamePath, + type TimelineLine, + timelineLines, trackDef, trackLabel, trackValueAt, @@ -422,6 +425,79 @@ describe('editing the timeline', () => { }); }); +describe("the timeline's header (#408)", () => { + /** A scene with `A` and `C` at the top and `B` under `A`, placed in the order A, C, B. */ + function scene() { + const f = makeSpriteScene(); + const a = place(f, f.spriteId, { x: 5, y: 0 }); + const c = place(f, f.spriteId, { x: 0, y: 0 }); + const b = place(f, f.spriteId, { x: 0, y: 0 }, a); + const named = (id: string, name: string) => (f.sceneOf().objects.find((o) => o.id === id)!.name = name); + named(a, 'A'); + named(b, 'B'); + named(c, 'C'); + return { f, a, b, c }; + } + + const shape = (lines: TimelineLine[]) => + lines.map((l) => (l.kind === 'group' ? `${l.name}@${l.depth}` : ` ${l.track.property}`)); + + it('groups the rows by object in the order of the hierarchy, each in the order of its properties', () => { + const { f, a, b, c } = scene(); + const walk = addAnimation(f, f.sceneId); + addRow(f, walk, 'rotation', [], c); + addRow(f, walk, 'scale', [], a); + addRow(f, walk, 'rotation', [], b); + addRow(f, walk, 'backgroundColor', []); + addRow(f, walk, 'x', [], a); + addRow(f, walk, 'enabled', [], a); + current(f, walk).tracks.push({ id: 'lost', target: 'gone', property: 'x', keys: [] }); + expect(shape(timelineLines(f.project, current(f, walk)))).toEqual([ + `${f.sceneOf().name}@0`, + ' backgroundColor', + 'A@0', + ' x', + ' enabled', + ' scale', + 'B@1', + ' rotation', + 'C@0', + ' rotation', + '(missing)@0', + ' x', + ]); + // Only the view is sorted. + expect(current(f, walk).tracks.map((t) => t.property)[0]).toBe('rotation'); + }); + + it('shows the value at the playhead, what the object holds before the first key, and an offset when relative', () => { + const { f, a } = scene(); + const walk = addAnimation(f, f.sceneId); + addRow(f, walk, 'x', [], a); + addRow( + f, + walk, + 'scale', + [ + [0, 1], + [10, 3], + ], + a, + ); + const y = addRow(f, walk, 'y', [], a); + f.stack.execute(new SetTrackRelativeCommand(walk.id, y, true)); + const row = (property: string) => + timelineLines(f.project, current(f, walk)).find( + (l): l is TimelineLine & { kind: 'row' } => l.kind === 'row' && l.track.property === property, + )!; + expect(lineValueAt(row('x'), 4)).toBe(5); + expect(lineValueAt(row('y'), 4)).toBe(0); + expect(lineValueAt(row('scale'), 5)).toBeCloseTo(2); + current(f, walk).tracks.push({ id: 'lost', target: 'gone', property: 'x', keys: [] }); + expect(lineValueAt(row('x'), 4)).toBe(5); + }); +}); + describe('copy, paste, duplicate and frames (#102)', () => { it('pastes relative to the frame, into the same rows, replacing a key there, and undoes in one step', () => { const f = makeSpriteScene(); diff --git a/tests/editor/timeline-view.test.ts b/tests/editor/timeline-view.test.ts index 5c0cf9e1..1bcfb11b 100644 --- a/tests/editor/timeline-view.test.ts +++ b/tests/editor/timeline-view.test.ts @@ -14,6 +14,7 @@ import { ROW_HEIGHT, rowAt, rowCentreY, + rowTop, RULER_HEIGHT, rulerStep, type TimelineView, @@ -125,6 +126,18 @@ describe('picking keys', () => { expect(keysWithin(view, TRACKS, a, b).map((k) => k.trackId)).toEqual(['a', 'a']); }); + it("skips an object's head between the rows (#408)", () => { + const lanes = [null, TRACKS[0]!, null, TRACKS[1]!]; + expect(keyAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(0) })).toBeNull(); + expect(keyAt(view, lanes, { x: frameCentreX(view, 4), y: rowCentreY(1) })).toEqual({ + trackId: 'a', + frame: 4, + }); + expect(keyAt(view, lanes, { x: frameCentreX(view, 2), y: rowCentreY(3) })?.trackId).toBe('b'); + const all = keysWithin(view, lanes, { x: 0, y: RULER_HEIGHT }, { x: frameToX(view, 10), y: rowTop(4) }); + expect(all.map((k) => k.trackId)).toEqual(['a', 'a', 'b']); + }); + it('a move never takes a key before frame 0', () => { const keys = [ { trackId: 'a', frame: 2 }, diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index ba2c0f53..992f8060 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -422,6 +422,62 @@ export async function animation(t) { JSON.stringify({ emptied: emptied.tracks, undone: undone.tracks }), ); + // 25i. The header (#408): rows grouped under their object in the order of the hierarchy, each + // object's rows in the order of its properties, the value at the playhead in the row, and + // a value typed there keys it at the playhead. + await mutate(` + __gm.state.project.assets.find((a) => a.id === 'an-walk').tracks = [ + { id: 'an-r1', target: 'an-arm', property: 'color', keys: [] }, + { id: 'an-r2', target: '', property: 'enabled', keys: [] }, + { id: 'an-r3', target: 'an-arm', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 10, value: 100 }] }, + ]; + `); + await evaluate('__gm.animation.scrub(4)'); + await sleep(150); + const lines = await json('__gm.animation.lines()'); + const shape = lines.map((l) => (l.kind === 'group' ? l.text : ` ${l.text}=${JSON.stringify(l.value)}`)); + check( + 'the header groups the rows under Dancer and Arm, X before Color, each with its value at frame 4', + JSON.stringify(shape) === + JSON.stringify(['Dancer', ' Enabled=true', 'Arm', ' X=40', ` Color=${0xff0000}`]) && + lines[1].indent > lines[0].indent, + JSON.stringify(lines), + ); + await shot('20-grouped-header'); + const xField = await json("__gm.animation.valueAt('an-r3')"); + await click(xField.x, xField.y); + await typeText('55'); + await press('Enter'); + await sleep(150); + const typed = (await walk()).tracks.find((r) => r.id === 'an-r3').keys; + const typedPick = await json('__gm.state.selectedKeys'); + check( + 'a value typed into the row keys it at the playhead and picks that key', + JSON.stringify(typed) === + JSON.stringify([ + { frame: 0, value: 0 }, + { frame: 4, value: 55 }, + { frame: 10, value: 100 }, + ]) && JSON.stringify(typedPick) === JSON.stringify([{ trackId: 'an-r3', frame: 4 }]), + JSON.stringify({ typed, typedPick }), + ); + + // Pressing the ruler while playing pauses – and the button says Play again. + const playButton = await json('__gm.animation.playAt()'); + await click(playButton.x, playButton.y); + const whilePlaying = (await json('__gm.animation.playAt()')).text; + const ruler = await json('__gm.animation.rulerAt(2)'); + await click(ruler.x, ruler.y); + const scrubbed = { + text: (await json('__gm.animation.playAt()')).text, + ...(await json('__gm.animation.view()')), + }; + check( + 'scrubbing on the ruler while playing pauses, and the button turns back to Play', + whilePlaying === 'Pause' && scrubbed.text === 'Play' && scrubbed.playing === false, + JSON.stringify({ whilePlaying, scrubbed }), + ); + await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => !['an-walk', 'an-box', 'an-sprite'].includes(a.id));