The scene view and the animation preview share one camera, the three form dialogs one base, and keys are found through one set of helpers (#85)
game-maker / gate (push) Successful in 1m36s
game-maker / suite (push) Successful in 5m41s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 14:19:53 +02:00
1 parent bf02c9e8b3
commit 2a390e52ff
22 files changed
+418 -452

No files matched your search

+13 -46
View File
@@ -1,31 +1,23 @@
import { shiftedFrame } from '../../core/commands/animationCommands';
import type { AnimationAsset } from '../../core/model/Asset';
import { Button, HStack, Label, NumberField, Popup, Spacer, Theme } from '../../ui';
import { type Button, Label, NumberField } from '../../ui';
import { dialogSlot, FormDialog } from '../FormDialog';
/** Matches the inspector's label column. */
const LABEL_WIDTH = 76;
const FIELD_WIDTH = 76;
let current: FramesDialog | null = null;
const slot = dialogSlot<FramesDialog>();
/** The open frames dialog, for the check scripts. */
export function currentFramesDialog(): FramesDialog | null {
return current;
}
export const currentFramesDialog = slot.current;
/** Opens the dialog centred, closing whichever one was open before it. */
export function openFramesDialog(dialog: FramesDialog): FramesDialog {
current?.close();
current = dialog;
dialog.openCentered();
return dialog;
}
export const openFramesDialog = slot.open;
/**
* Asks how many frames to insert at the playhead, or to remove from it (#102). The summary says what
* that does to the keys before anything happens – removing frames takes keys with it.
*/
export class FramesDialog extends Popup {
export class FramesDialog extends FormDialog {
private count = 1;
private readonly summary: Label;
private readonly field: NumberField;
@@ -38,19 +30,7 @@ export class FramesDialog extends Popup {
private readonly mode: 'insert' | 'remove',
onConfirm: (count: number) => void,
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.minWidth = 320;
this.content.add(
new Label(mode === 'insert' ? `Insert frames at frame ${at}` : `Remove frames from frame ${at}`, {
style: 'bodyBold',
}),
);
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label('Frames', { style: 'muted' });
label.minWidth = LABEL_WIDTH;
row.add(label);
super(320, mode === 'insert' ? `Insert frames at frame ${at}` : `Remove frames from frame ${at}`);
this.field = new NumberField(this.count, {
min: 1,
step: 1,
@@ -59,32 +39,19 @@ export class FramesDialog extends Popup {
onChange: (value) => this.setCount(value),
});
this.field.minWidth = FIELD_WIDTH;
row.add(this.field);
row.add(new Spacer(), 1);
this.content.add(row);
this.content.add(this.formRow(this.fieldLabel('Frames'), this.field));
this.summary = new Label('', { style: 'muted' });
this.content.add(this.summary);
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
buttons.add(new Spacer(), 1);
const cancel = new Button('Cancel');
cancel.onClick = this.closeThen();
buttons.add(cancel);
this.confirm = new Button(mode === 'insert' ? 'Insert' : 'Remove', {
variant: mode === 'insert' ? 'primary' : 'danger',
});
this.confirm.onClick = this.closeThen(() => onConfirm(mode === 'insert' ? this.count : -this.count));
buttons.add(this.confirm);
this.content.add(buttons);
this.confirm = this.addButtons(
mode === 'insert' ? 'Insert' : 'Remove',
mode === 'insert' ? 'primary' : 'danger',
() => onConfirm(mode === 'insert' ? this.count : -this.count),
);
this.refreshSummary();
}
override close(): void {
if (current === this) current = null;
super.close();
}
setCount(value: number): void {
this.count = Math.max(1, Math.round(value));
this.field.setNumber(this.count);
+15 -72
View File
@@ -1,19 +1,12 @@
import type { FederatedPointerEvent, FederatedWheelEvent } from 'pixi.js';
import type { FederatedPointerEvent } from 'pixi.js';
import { previewHolder } from '../../core/model/animationPreview';
import type { AnimationAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { footprintExtent } from '../../core/properties/bounds';
import { boundsOf } from '../framing';
import type { EditorState } from '../state';
import { clamp, Theme, zoomFactor } from '../../ui';
import { Theme } from '../../ui';
import { collectHolderItems } from '../../viewport/items';
import { freeView, screenToWorld, type View } from '../../viewport/view';
import { type Frame, WorldViewport } from '../viewport/WorldViewport';
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 8;
/** Breathing room left around the content by `fit()`, in screen pixels. */
const FIT_MARGIN = 40;
import { FreeViewport } from '../viewport/FreeViewport';
import type { Frame } from '../viewport/WorldViewport';
/**
* The picture above the timeline (#99): the animation's root with every row's value at the
@@ -21,10 +14,7 @@ const FIT_MARGIN = 40;
* the wheel zooms, nothing is picked. What it draws comes from `previewHolder`, so the document
* stays untouched and undo never hears of it.
*/
export class PreviewCanvas extends WorldViewport {
/** World point at the viewport centre, and zoom. */
private view = { x: 0, y: 0, zoom: 1 };
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
export class PreviewCanvas extends FreeViewport {
private assetId = '';
/** Frames the content on the first draw with a size – a fresh subject gets a whole view. */
private fitPending = false;
@@ -35,11 +25,12 @@ export class PreviewCanvas extends WorldViewport {
private readonly playhead: () => { frame: number; reverse: boolean },
) {
super(state);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', () => (this.panning = null));
this.on('pointerupoutside', () => (this.panning = null));
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button === 0 || e.button === 1) this.startPan(e);
});
this.on('globalpointermove', (e: FederatedPointerEvent) => this.panTo(e));
this.on('pointerup', () => this.endPan());
this.on('pointerupoutside', () => this.endPan());
state.on('load', () => this.show(''));
}
@@ -50,27 +41,9 @@ export class PreviewCanvas extends WorldViewport {
this.redraw();
}
/** Frames the content; the origin is always included, since the root is placed there. */
fit(): void {
const { items } = this.collectFrame();
const boxes = items.map((item) => {
const e = footprintExtent(item.type, item.props, item.m);
return { id: item.id, x: e.left, y: e.top, w: e.right - e.left, h: e.bottom - e.top };
});
const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!;
const width = Math.max(maxX - minX, 1);
const height = Math.max(maxY - minY, 1);
this.view = {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
zoom: clamp(
Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height),
MIN_ZOOM,
MAX_ZOOM,
),
};
override fit(): void {
this.fitPending = false;
this.redraw();
super.fit();
}
/** The values as drawn, for a check: an object's position and property bag at the playhead. */
@@ -89,12 +62,8 @@ export class PreviewCanvas extends WorldViewport {
super.redraw();
}
protected override currentView(): View {
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h);
}
protected override collectFrame(): Frame {
const asset = this.assetId ? findAssetOf(this.state.project, this.assetId, 'animation') : undefined;
const asset = this.asset();
const head = this.playhead();
const holder = asset && previewHolder(this.state.project, asset, head.frame, head.reverse);
return collectHolderItems(this.state.project, holder, Theme.colors.viewport);
@@ -110,32 +79,6 @@ export class PreviewCanvas extends WorldViewport {
/** A scene root paints its own background; anything placed on the preview's holder has none. */
protected override paintsBackground(): boolean {
const asset = this.asset();
return findAssetOf(this.state.project, asset?.root ?? '', 'scene') !== undefined;
}
private onPointerDown(e: FederatedPointerEvent): void {
if (e.button !== 0 && e.button !== 1) return;
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
e.stopPropagation();
}
private onPointerMove(e: FederatedPointerEvent): void {
if (!this.panning) return;
const { zoom } = this.view;
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / zoom;
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / zoom;
this.redraw();
}
private onWheel(e: FederatedWheelEvent): void {
const local = this.toLocal(e.global);
const before = screenToWorld(this.currentView(), local.x, local.y);
this.view.zoom = clamp(this.view.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
const after = screenToWorld(this.currentView(), local.x, local.y);
this.view.x += before.x - after.x;
this.view.y += before.y - after.y;
this.redraw();
e.preventDefault();
return findAssetOf(this.state.project, this.asset()?.root ?? '', 'scene') !== undefined;
}
}
+12 -51
View File
@@ -6,27 +6,19 @@ import { animationTargets, targetDefs, trackDef } from '../../core/model/animati
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Popup, Spacer, Theme } from '../../ui';
import type { EditorState } from '../state';
import { type Button, Dropdown, Label, Menu, type MenuItem } from '../../ui';
import { dialogSlot, FormDialog } from '../FormDialog';
import { type EditorState, trackSelection } from '../state';
/** Matches the inspector's label column. */
const LABEL_WIDTH = 76;
const PICKER_WIDTH = 240;
let current: RowDialog | null = null;
const slot = dialogSlot<RowDialog>();
/** The open row dialog, for the check scripts. */
export function currentRowDialog(): RowDialog | null {
return current;
}
export const currentRowDialog = slot.current;
/** Opens the dialog centred, closing whichever one was open before it. */
export function openRowDialog(dialog: RowDialog): RowDialog {
current?.close();
current = dialog;
dialog.openCentered();
return dialog;
}
export const openRowDialog = slot.open;
/** One choice of the object picker: the root itself (`''`) or an object inside it. */
interface TargetChoice {
@@ -39,7 +31,7 @@ interface TargetChoice {
* asset itself, or one placed inside it when that is a container or scene – then a property of
* it. Retargeting offers only properties of the row's kind, since its keys stay (#97).
*/
export class RowDialog extends Popup {
export class RowDialog extends FormDialog {
private target: string;
private property: string;
private readonly targetButton: Dropdown;
@@ -52,46 +44,27 @@ export class RowDialog extends Popup {
private readonly asset: AnimationAsset,
private readonly track: AnimationTrack | null = null,
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.minWidth = 360;
super(360, track ? 'Change what the row animates' : `Add a row to "${asset.name}"`);
this.target = track?.target ?? '';
this.property = track?.property ?? '';
const root = findAsset(state.project, asset.root);
this.content.add(
new Label(track ? 'Change what the row animates' : `Add a row to "${asset.name}"`, {
style: 'bodyBold',
}),
);
this.content.add(new Label(`Animates ${root ? `"${root.name}"` : 'nothing yet'}`, { style: 'muted' }));
this.targetButton = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openTargets() });
this.content.add(this.pair('Object', this.targetButton));
this.content.add(this.formRow(this.fieldLabel('Object'), this.targetButton));
this.propertyButton = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openProperties() });
this.content.add(this.pair('Property', this.propertyButton));
this.content.add(this.formRow(this.fieldLabel('Property'), this.propertyButton));
this.summary = new Label('', { style: 'muted' });
this.content.add(this.summary);
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
buttons.add(new Spacer(), 1);
const cancel = new Button('Cancel');
cancel.onClick = this.closeThen();
buttons.add(cancel);
this.confirm = new Button(track ? 'Change' : 'Add row', { variant: 'primary' });
this.confirm.onClick = this.closeThen(() => this.apply());
buttons.add(this.confirm);
this.content.add(buttons);
this.confirm = this.addButtons(track ? 'Change' : 'Add row', 'primary', () => this.apply());
// Without a property yet, the first one the object offers is as good a start as any.
if (!this.property) this.property = this.properties()[0]?.key ?? '';
this.refresh();
}
override close(): void {
super.close();
if (current === this) current = null;
}
/** What the dialog offers and holds, for the check scripts. */
debugInfo(): { target: string; property: string; targets: string[]; properties: string[]; ok: boolean } {
return {
@@ -110,17 +83,6 @@ export class RowDialog extends Popup {
this.refresh();
}
private pair(text: string, control: Dropdown): HStack {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label(text, { style: 'muted' });
label.minWidth = LABEL_WIDTH;
row.add(label);
row.add(control);
row.add(new Spacer(), 1);
return row;
}
private targets(): TargetChoice[] {
const root = findAsset(this.state.project, this.asset.root);
if (!root) return [];
@@ -202,7 +164,6 @@ export class RowDialog extends Popup {
return;
}
const command = new AddAnimationTrackCommand(this.asset.id, this.target, this.property);
if (this.state.execute(command))
this.state.select({ kind: 'track', assetId: this.asset.id, trackId: command.trackId, keys: [] });
if (this.state.execute(command)) this.state.select(trackSelection(this.asset.id, [], command.trackId));
}
}
+15 -19
View File
@@ -1,7 +1,7 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import {
clipboardKeyCount,
copyAnimationKeys,
type KeyRef,
MoveAnimationKeysCommand,
PasteAnimationKeysCommand,
SetAnimationKeyCommand,
@@ -10,8 +10,12 @@ import {
} from '../../core/commands/animationCommands';
import {
animationPlayheadAt,
fpsOf,
frameCount,
hasKey,
interpolationOf,
type KeyRef,
sameKey,
trackDef,
trackLabel,
} from '../../core/model/animation';
@@ -28,7 +32,6 @@ import {
frameAt,
frameCentreX,
frameToX,
hasKey,
HEADER_WIDTH,
KEY_SIZE,
keyAt,
@@ -39,7 +42,6 @@ import {
rowTop,
RULER_HEIGHT,
rulerStep,
sameKey,
type TimelineView,
zoomCells,
} from './timelineView';
@@ -196,7 +198,7 @@ export class TimelineCanvas extends Widget {
/** 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);
const fps = fpsOf(asset);
return fps > 0 ? frame / fps : 0;
}
@@ -214,7 +216,7 @@ export class TimelineCanvas extends Widget {
const clipboard = copyAnimationKeys(this.state.project, asset, this.state.selectedKeys);
if (!clipboard) return false;
this.state.keyClipboard = clipboard;
const count = clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
const count = clipboardKeyCount(clipboard);
this.state.setNotice(`Copied ${count} ${count === 1 ? 'key' : 'keys'}`, 'quiet');
return true;
}
@@ -227,8 +229,7 @@ export class TimelineCanvas extends Widget {
const asset = this.asset();
const clipboard = this.state.keyClipboard;
if (!asset || !clipboard) return false;
const s = this.state.selection;
const into = s?.kind === 'track' && s.assetId === asset.id ? s.trackId : '';
const into = this.state.selectedAnimationId === asset.id ? (this.state.selectedTrackId ?? '') : '';
const command = new PasteAnimationKeysCommand(asset.id, clipboard, this.playheadFrame, into);
if (this.state.execute(command)) {
this.state.select(trackSelection(asset.id, command.pasted));
@@ -247,7 +248,7 @@ export class TimelineCanvas extends Widget {
if (!asset) return false;
const at = this.playheadFrame;
const picked = this.state.selectedKeys;
const primary = this.state.selection?.kind === 'track' ? this.state.selection.trackId : undefined;
const primary = this.state.selectedTrackId;
if (!this.state.execute(new ShiftAnimationFramesCommand(asset.id, at, count))) return false;
if (picked.length > 0) {
const kept = picked.flatMap((k) => {
@@ -318,9 +319,9 @@ export class TimelineCanvas extends Widget {
}
/** The keys as drawn, with whether each is picked and the easing its shape shows. */
keysDrawn(): { trackId: string; frame: number; selected: boolean; interpolation: KeyInterpolation }[] {
keysDrawn(): (KeyRef & { selected: boolean; interpolation: KeyInterpolation })[] {
const picked = this.state.selectedKeys;
const out: { trackId: string; frame: number; selected: boolean; interpolation: KeyInterpolation }[] = [];
const out: (KeyRef & { selected: boolean; interpolation: KeyInterpolation })[] = [];
for (const track of this.asset()?.tracks ?? [])
for (const key of track.keys)
out.push({
@@ -374,7 +375,7 @@ export class TimelineCanvas extends Widget {
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: [] });
if (track) this.state.select(trackSelection(asset.id, [], track.id));
return;
}
const cell = { x: local.x - HEADER_WIDTH, y: local.y };
@@ -415,12 +416,7 @@ export class TimelineCanvas extends Widget {
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 }],
});
this.state.select(trackSelection(asset.id, [{ trackId: track.id, frame }]));
}
private onPointerMove(e: FederatedPointerEvent): void {
@@ -465,7 +461,7 @@ export class TimelineCanvas extends Widget {
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;
const primary = this.state.selectedTrackId;
if (this.state.execute(new MoveAnimationKeysCommand(asset.id, drag.keys, drag.delta, drag.copy)))
this.state.select(trackSelection(asset.id, moved, primary));
}
@@ -510,7 +506,7 @@ export class TimelineCanvas extends Widget {
this.overlay.clear();
const asset = this.asset();
const tracks = asset?.tracks ?? [];
const selectedRow = this.state.selection?.kind === 'track' ? this.state.selection.trackId : '';
const selectedRow = this.state.selectedTrackId ?? '';
// Header column and ruler chrome.
this.bg.rect(0, 0, HEADER_WIDTH, this.h).fill(c.panel);
+1 -14
View File
@@ -1,4 +1,4 @@
import type { KeyRef } from '../../core/commands/animationCommands';
import type { KeyRef } from '../../core/model/animation';
import type { AnimationTrack } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { clamp } from '../../ui';
@@ -80,11 +80,6 @@ export function clampPan(view: TimelineView): TimelineView {
return view.panX > 0 ? { ...view, panX: 0 } : view;
}
/** How many frames the cell area shows at its width. */
export function visibleFrames(view: TimelineView, width: number): number {
return Math.ceil(width / view.cellWidth);
}
/** Every `n`-th frame gets a number on the ruler; a round step so the numbers read like a scale. */
export function rulerStep(view: TimelineView): number {
const steps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000];
@@ -130,14 +125,6 @@ export function keysWithin(
return out;
}
export function sameKey(a: KeyRef, b: KeyRef): boolean {
return a.trackId === b.trackId && a.frame === b.frame;
}
export function hasKey(keys: readonly KeyRef[], key: KeyRef): boolean {
return keys.some((k) => sameKey(k, key));
}
/**
* The frames a drag may move the picked keys by: never before frame 0. `delta` is what the pointer
* asked for, in whole frames.