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 <noreply@anthropic.com>
This commit is contained in:
1 parent
dd60a795e1
commit
8679dfbe65
9 files changed
+449
-69
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<string, { at: number; depth: number }>([['', { 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,
|
||||
|
||||
@@ -36,6 +36,8 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
|
||||
reverse: timeline.playheadReverse,
|
||||
}));
|
||||
timeline.onPlayhead = () => 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<AnimationAsset, TimelineCanv
|
||||
/** Only called while the tab is visible. */
|
||||
tick(dt: number): void {
|
||||
const drawn = this.preview.redrawCount;
|
||||
if (this.canvas.isPlaying) {
|
||||
this.canvas.tick(dt);
|
||||
// A finished `once` pass pauses the canvas; the button has to say so.
|
||||
this.syncPlayButton();
|
||||
}
|
||||
if (this.canvas.isPlaying) this.canvas.tick(dt);
|
||||
// A video moves by itself, and a driven one (#404) has to be told every frame that the
|
||||
// playhead stands; the playhead redraws only when it moves.
|
||||
this.preview.animate(drawn);
|
||||
@@ -117,7 +115,6 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
|
||||
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. */
|
||||
|
||||
@@ -24,14 +24,18 @@ import {
|
||||
hasKey,
|
||||
interpolationOf,
|
||||
type KeyRef,
|
||||
lineValueAt,
|
||||
rowVideoAt,
|
||||
sameKey,
|
||||
type TimelineLine,
|
||||
timelineLines,
|
||||
trackDef,
|
||||
trackLabel,
|
||||
trackValueAt,
|
||||
} from '../../core/model/animation';
|
||||
import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { curveLookup } from '../../core/model/curve';
|
||||
import type { PropertyValue, Vec2 } from '../../core/model/Properties';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { type FrameValue, realTimeEnd, videoLengthOf } from '../../core/model/video';
|
||||
import { VIDEO_TIME_KEY } from '../../core/properties/videos';
|
||||
@@ -43,11 +47,13 @@ import {
|
||||
type Size,
|
||||
Theme,
|
||||
textStyle,
|
||||
truncate,
|
||||
ui,
|
||||
Widget,
|
||||
zoomFactor,
|
||||
crispText,
|
||||
} from '../../ui';
|
||||
import { createField, type Field } from '../inspector/fieldFactory';
|
||||
import { type EditorState, trackSelection } from '../state';
|
||||
import { ViewTween } from '../viewTween';
|
||||
import { FramesDialog, openFramesDialog } from './FramesDialog';
|
||||
@@ -62,6 +68,7 @@ import {
|
||||
KEY_SIZE,
|
||||
keyAt,
|
||||
keysWithin,
|
||||
type Lane,
|
||||
ROW_HEIGHT,
|
||||
rowAt,
|
||||
rowCentreY,
|
||||
@@ -76,6 +83,16 @@ import {
|
||||
const DRAG_THRESHOLD = 3;
|
||||
/** Inset of a row's label from the header's left edge. */
|
||||
const LABEL_INSET = 8;
|
||||
/** Width of the value field at the right of a row's header (#408). */
|
||||
const FIELD_WIDTH = 104;
|
||||
const FIELD_INSET = 4;
|
||||
|
||||
/** A row's value field, and what it was built for: a row aimed elsewhere needs another widget. */
|
||||
interface ValueField {
|
||||
field: Field;
|
||||
builtFor: string;
|
||||
shown: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys being dragged: what was picked, and how far the pointer has taken them, in whole frames.
|
||||
@@ -108,8 +125,12 @@ export class TimelineCanvas extends Widget {
|
||||
private readonly cells = new Graphics();
|
||||
private readonly overlay = new Graphics();
|
||||
private readonly labelLayer = new Container();
|
||||
private readonly fieldLayer = new Container();
|
||||
private readonly rulerLayer = new Container();
|
||||
/** By row id, and by `group:<target>` for an object's head. */
|
||||
private readonly labels = new Map<string, Text>();
|
||||
private readonly fields = new Map<string, ValueField>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user