Files
feuerwerk/src/editor/animation/TimelineCanvas.ts
T

554 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, Text>();
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<string>();
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);
}
}