577 lines
21 KiB
TypeScript
577 lines
21 KiB
TypeScript
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||
import {
|
||
type KeyRef,
|
||
MoveAnimationKeysCommand,
|
||
SetAnimationKeyCommand,
|
||
} from '../../core/commands/animationCommands';
|
||
import {
|
||
animationPlayheadAt,
|
||
frameCount,
|
||
interpolationOf,
|
||
trackDef,
|
||
trackLabel,
|
||
} from '../../core/model/animation';
|
||
import type { AnimationAsset, AnimationTrack, KeyInterpolation } 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;
|
||
/** Whether the playhead runs ping-pong's way back – a jumping value then holds the key above. */
|
||
playheadReverse = false;
|
||
/** 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;
|
||
/** Told whenever the playhead moves – the preview above draws the frame it stands on. */
|
||
onPlayhead: () => 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.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;
|
||
}
|
||
|
||
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();
|
||
this.onPlayhead();
|
||
}
|
||
|
||
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.playheadReverse = false;
|
||
this.time = 0;
|
||
this.onPlayhead();
|
||
}
|
||
|
||
/** 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;
|
||
this.playheadReverse = at.reverse;
|
||
if (at.finished) this.playingNow = false;
|
||
this.redraw();
|
||
this.onPlayhead();
|
||
}
|
||
|
||
/** 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);
|
||
this.playheadReverse = false;
|
||
const asset = this.asset();
|
||
if (asset) this.time = this.timeOf(asset, this.playhead);
|
||
this.redraw();
|
||
this.onPlayhead();
|
||
}
|
||
|
||
/** 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 and the easing its shape shows. */
|
||
keysDrawn(): { trackId: string; frame: number; selected: boolean; interpolation: KeyInterpolation }[] {
|
||
const picked = this.state.selectedKeys;
|
||
const out: { trackId: string; frame: number; selected: boolean; interpolation: KeyInterpolation }[] = [];
|
||
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 }),
|
||
interpolation: interpolationOf(key),
|
||
});
|
||
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 {
|
||
if (!trackDef(this.state.project, asset, track)) {
|
||
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;
|
||
drawKeyShape(this.overlay, interpolationOf(key), x, y, half)
|
||
.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 || !trackDef(this.state.project, asset, track);
|
||
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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A key's outline tells its easing: the right half is the start of the way to the next key, the left
|
||
* half its end, and a round half is a slow one. Linear is the diamond, ease-in-out the circle, a step
|
||
* – which holds – the square.
|
||
*/
|
||
function drawKeyShape(
|
||
g: Graphics,
|
||
interpolation: KeyInterpolation,
|
||
x: number,
|
||
y: number,
|
||
half: number,
|
||
): Graphics {
|
||
if (interpolation === 'step') return g.rect(x - half * 0.75, y - half * 0.75, half * 1.5, half * 1.5);
|
||
const slowStart = interpolation === 'ease-in' || interpolation === 'ease-in-out';
|
||
const slowEnd = interpolation === 'ease-out' || interpolation === 'ease-in-out';
|
||
g.moveTo(x, y - half);
|
||
if (slowStart) g.arc(x, y, half, -Math.PI / 2, Math.PI / 2);
|
||
else g.lineTo(x + half, y).lineTo(x, y + half);
|
||
if (slowEnd) g.arc(x, y, half, Math.PI / 2, (3 * Math.PI) / 2);
|
||
else g.lineTo(x - half, y).lineTo(x, y - half);
|
||
return g.closePath();
|
||
}
|