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

+36 -30
View File
@@ -2,7 +2,10 @@ import { newId } from '../ids';
import {
ANIMATION_ROOT_TYPES,
blends,
findTrack,
hasKey,
isKeyInterpolation,
type KeyRef,
normalizeKeys,
targetAssetOf,
targetDefs,
@@ -16,12 +19,6 @@ import { coerceValue } from '../properties/PropertySchema';
import { type Command, CommandError } from './Command';
import { requireAsset } from './require';
/** A key on one row, as the timeline selects it. */
export interface KeyRef {
trackId: string;
frame: number;
}
function requireAnimation(project: Project, assetId: string): AnimationAsset {
const asset = requireAsset(project, assetId);
if (asset.type !== 'animation') throw new CommandError(`${asset.name} is not an animation`);
@@ -29,11 +26,27 @@ function requireAnimation(project: Project, assetId: string): AnimationAsset {
}
function requireTrack(asset: AnimationAsset, trackId: string): AnimationTrack {
const track = asset.tracks.find((t) => t.id === trackId);
const track = findTrack(asset, trackId);
if (!track) throw new CommandError(`${asset.name} has no such row`);
return track;
}
function requireKey(asset: AnimationAsset, ref: KeyRef): { track: AnimationTrack; key: AnimationKey } {
const track = requireTrack(asset, ref.trackId);
const key = track.keys.find((k) => k.frame === ref.frame);
if (!key) throw new CommandError(`No key on frame ${ref.frame}`);
return { track, key };
}
function requireFrame(frame: number, message: string): void {
if (!Number.isInteger(frame) || frame < 0) throw new CommandError(message);
}
/** `Remove key`, `Remove 3 keys` – how a key command names itself. */
function keysLabel(verb: string, count: number): string {
return count === 1 ? `${verb} key` : `${verb} ${count} keys`;
}
/** The kind of `property` on the object `target` names, refusing what cannot be animated there. */
function requireAnimatable(
project: Project,
@@ -217,8 +230,7 @@ export class SetAnimationKeyCommand extends TimelineCommand {
protected apply(project: Project, asset: AnimationAsset): void {
const track = requireTrack(asset, this.trackId);
if (!Number.isInteger(this.frame) || this.frame < 0)
throw new CommandError('A key sits on a whole frame from 0');
requireFrame(this.frame, 'A key sits on a whole frame from 0');
const def = trackDef(project, asset, track);
if (!def) throw new CommandError('The row aims at something that is gone');
const current = trackValueAt(track.keys, def.kind, this.frame);
@@ -250,9 +262,7 @@ export class SetKeyInterpolationCommand extends TimelineCommand {
if (!isKeyInterpolation(this.interpolation)) throw new CommandError('No such easing');
let changed = 0;
for (const ref of this.keys) {
const track = requireTrack(asset, ref.trackId);
const key = track.keys.find((k) => k.frame === ref.frame);
if (!key) throw new CommandError(`No key on frame ${ref.frame}`);
const { track, key } = requireKey(asset, ref);
const kind = trackDef(project, asset, track)?.kind;
if (kind !== undefined && !blends(kind)) continue;
if (this.interpolation === 'linear') delete key.interpolation;
@@ -271,16 +281,14 @@ export class RemoveAnimationKeysCommand extends TimelineCommand {
private readonly keys: readonly KeyRef[],
) {
super(assetId);
this.label = keys.length === 1 ? 'Remove key' : `Remove ${keys.length} keys`;
this.label = keysLabel('Remove', keys.length);
}
protected apply(_project: Project, asset: AnimationAsset): void {
if (this.keys.length === 0) throw new CommandError('No keys to remove');
for (const ref of this.keys) {
const track = requireTrack(asset, ref.trackId);
const at = track.keys.findIndex((k) => k.frame === ref.frame);
if (at < 0) throw new CommandError(`No key on frame ${ref.frame}`);
track.keys.splice(at, 1);
const { track, key } = requireKey(asset, ref);
track.keys.splice(track.keys.indexOf(key), 1);
}
}
}
@@ -299,8 +307,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
private readonly copy = false,
) {
super(assetId);
const noun = keys.length === 1 ? 'key' : `${keys.length} keys`;
this.label = `${copy ? 'Duplicate' : 'Move'} ${noun}`;
this.label = keysLabel(copy ? 'Duplicate' : 'Move', keys.length);
}
protected apply(_project: Project, asset: AnimationAsset): void {
@@ -308,9 +315,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
if (this.copy && this.delta === 0) throw new CommandError('A duplicate has to land somewhere else');
const byTrack = new Map<string, Set<number>>();
for (const ref of this.keys) {
const track = requireTrack(asset, ref.trackId);
if (!track.keys.some((k) => k.frame === ref.frame))
throw new CommandError(`No key on frame ${ref.frame}`);
requireKey(asset, ref);
if (ref.frame + this.delta < 0) throw new CommandError('A key cannot move before frame 0');
byTrack.set(ref.trackId, (byTrack.get(ref.trackId) ?? new Set()).add(ref.frame));
}
@@ -342,6 +347,10 @@ export interface KeyClipboard {
rows: ClipboardRow[];
}
export function clipboardKeyCount(clipboard: KeyClipboard): number {
return clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
}
/** The picked keys as a clipboard, or null when none of them exists. */
export function copyAnimationKeys(
project: Project,
@@ -351,7 +360,7 @@ export function copyAnimationKeys(
const picked = asset.tracks
.map((track) => ({
track,
keys: track.keys.filter((k) => refs.some((r) => r.trackId === track.id && r.frame === k.frame)),
keys: track.keys.filter((k) => hasKey(refs, { trackId: track.id, frame: k.frame })),
}))
.filter((p) => p.keys.length > 0);
if (picked.length === 0) return null;
@@ -391,13 +400,11 @@ export class PasteAnimationKeysCommand extends TimelineCommand {
private readonly intoTrackId = '',
) {
super(assetId);
const count = clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
this.label = count === 1 ? 'Paste key' : `Paste ${count} keys`;
this.label = keysLabel('Paste', clipboardKeyCount(clipboard));
}
protected apply(project: Project, asset: AnimationAsset): void {
if (!Number.isInteger(this.frame) || this.frame < 0)
throw new CommandError('Keys paste onto a whole frame from 0');
requireFrame(this.frame, 'Keys paste onto a whole frame from 0');
this.pasted = [];
this.skipped = 0;
const single = this.clipboard.rows.length === 1 && this.intoTrackId !== '';
@@ -434,7 +441,7 @@ export class PasteAnimationKeysCommand extends TimelineCommand {
private matchingTrack(asset: AnimationAsset, row: ClipboardRow): AnimationTrack | undefined {
return asset.id === this.clipboard.assetId
? asset.tracks.find((t) => t.id === row.trackId)
? findTrack(asset, row.trackId)
: asset.tracks.find((t) => t.target === row.target && t.property === row.property);
}
}
@@ -467,8 +474,7 @@ export class ShiftAnimationFramesCommand extends TimelineCommand {
}
protected apply(_project: Project, asset: AnimationAsset): void {
if (!Number.isInteger(this.at) || this.at < 0)
throw new CommandError('Frames are inserted at a whole frame from 0');
requireFrame(this.at, 'Frames are inserted at a whole frame from 0');
if (!Number.isInteger(this.count) || this.count === 0)
throw new CommandError('Give a whole number of frames');
let touched = 0;
+36 -4
View File
@@ -17,9 +17,9 @@ export type Playback = (typeof PLAYBACKS)[number];
export const ANIMATION_ROOT_TYPES: readonly AssetType[] = ['sprite', 'text', 'container', 'scene'];
/** Position lives on the object, not in its property bag, so the timeline offers it by name. */
export const POSITION_KEYS = ['x', 'y'] as const;
const POSITION_KEYS = ['x', 'y'] as const;
export function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] {
function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] {
return (POSITION_KEYS as readonly string[]).includes(key);
}
@@ -46,7 +46,7 @@ export function fpsOf(asset: AnimationAsset): number {
}
/** The frame of the last key over all rows; -1 for an animation without keys. */
export function lastKeyFrame(asset: AnimationAsset): number {
function lastKeyFrame(asset: AnimationAsset): number {
let last = -1;
for (const track of asset.tracks) {
const key = track.keys[track.keys.length - 1];
@@ -133,7 +133,7 @@ export function interpolationOf(key: AnimationKey): KeyInterpolation {
* How far along the way to the next key a value is, `t` the share of the time passed. The eases are
* quadratic: ease-in reaches 0.25 at half the time, ease-out 0.75, ease-in-out 0.5 but 0.125 at a quarter.
*/
export function easedShare(interpolation: KeyInterpolation, t: number): number {
function easedShare(interpolation: KeyInterpolation, t: number): number {
switch (interpolation) {
case 'linear':
return t;
@@ -210,6 +210,16 @@ export function animatedKind(type: AssetType, key: string): string | undefined {
return animatableDefs(type).find((d) => d.key === key)?.kind;
}
/** The kind `key` has on an asset holding `bag` – see `animatableDefsOf`. */
export function animatedKindOf(
project: Project,
asset: Asset,
bag: PropertyBag,
key: string,
): string | undefined {
return animatableDefsOf(project, asset, bag).find((d) => d.key === key)?.kind;
}
function hasPosition(type: AssetType): boolean {
return type !== 'scene' && type !== 'behavior';
}
@@ -501,6 +511,28 @@ export function pauseAnimation(holder: PlayerHolder, animationId: string): void
if (animationId === '' || player.animationId === animationId) player.playing = false;
}
/** A key on one row, as the timeline selects it. */
export interface KeyRef {
trackId: string;
frame: number;
}
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));
}
export function findTrack(asset: AnimationAsset, trackId: string): AnimationTrack | undefined {
return asset.tracks.find((t) => t.id === trackId);
}
export function findKey(asset: AnimationAsset, ref: KeyRef): AnimationKey | undefined {
return findTrack(asset, ref.trackId)?.keys.find((k) => k.frame === ref.frame);
}
/** Sorted by frame, at most one per frame (the later wins), frames whole and not negative. */
export function normalizeKeys(keys: readonly AnimationKey[]): AnimationKey[] {
const byFrame = new Map<number, AnimationKey>();
+4 -7
View File
@@ -5,7 +5,7 @@
*/
import {
animatableDefs,
animatableDefsOf,
animatedKindOf,
frameCount,
isPositionRow,
PLACED_ROOT_ID,
@@ -18,9 +18,6 @@ import { instanceChildId, objectRef } from './instance';
import { findAsset, type ObjectHolder, type Project } from './Project';
import { resolveSceneObjectProps } from '../properties/resolve';
/** The one object the preview places the root on – what a row aimed at the root itself writes to. */
export const PREVIEW_ROOT_ID = PLACED_ROOT_ID;
/**
* The holder the preview draws at `frame`: a copy of the scene for a scene root, otherwise a holder
* of one object placing the root at the origin, its content resolved from the template like any
@@ -50,7 +47,7 @@ export function previewHolder(
/** The id the preview draws a row's target under – for a check that wants to look at it. */
export function previewObjectId(root: Asset | undefined, target: string): string {
if (root?.type === 'scene') return target;
return target === '' ? PREVIEW_ROOT_ID : instanceChildId(PREVIEW_ROOT_ID, target);
return target === '' ? PLACED_ROOT_ID : instanceChildId(PLACED_ROOT_ID, target);
}
/** Deep enough that nothing a row writes – a position, an override, an instance edit – is the document's. */
@@ -68,7 +65,7 @@ function sceneCopy(scene: ObjectHolder): ObjectHolder {
}
function placedHolder(root: Asset): ObjectHolder {
return { id: PREVIEW_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] };
return { id: PLACED_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] };
}
/** A row on the scene itself: its value goes into the copy's own properties. */
@@ -92,7 +89,7 @@ function writeObject(
if (!ref || !asset) return;
// Resolved, so a shader worn through an override or an instance edit brings its parameters (#104).
const props = resolveSceneObjectProps(project, asset, ref.object);
const kind = animatableDefsOf(project, asset, props).find((d) => d.key === track.property)?.kind;
const kind = animatedKindOf(project, asset, props, track.property);
if (!kind) return;
const key = track.property;
if (isPositionRow(asset.type, key)) {
+3 -14
View File
@@ -1,14 +1,8 @@
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
import { type PropertyBag, textOf } from '../model/Properties';
import { propertyNodeParams } from '../properties/declaredRows';
import { flatDef } from '../properties/PropertySchema';
import {
PARTICLE_PROPERTY,
PROPERTY_DEFAULT_PARAM,
PROPERTY_MAX_PARAM,
PROPERTY_MIN_PARAM,
PROPERTY_NAME_PARAM,
propertyNameOf,
} from '../properties/particles';
import { PARTICLE_PROPERTY, PROPERTY_NAME_PARAM, propertyNameOf } from '../properties/particles';
/** What one particle is, at one moment: everything a graph may read about it. */
export interface ParticleEnv {
@@ -249,12 +243,7 @@ export const ParticleProperty: ParticleNodeDef = {
description: 'A number the source sets: a property row on the placed source, and a value here.',
inputs: [],
outputs: [numberOut('value')],
params: [
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step: NUMBER_STEP },
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step: NUMBER_STEP },
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step: NUMBER_STEP },
],
params: propertyNodeParams(NUMBER_STEP),
derivePorts: ({ node }) => ({ label: propertyNameOf(node) || ParticleProperty.label }),
emit: (_inputs, params, ctx) => {
// The slot is bound once, here: reading it per particle would cost a lookup by name each time.
+11 -1
View File
@@ -1,6 +1,6 @@
import type { Graph, GraphNode } from '../graph/Graph';
import type { PropertyBag } from '../model/Properties';
import type { FlatDef } from './PropertySchema';
import { flatDef, type FlatDef, type PropertyDef } from './PropertySchema';
/** The params of a `Property` node in a shader or particle graph: its name and the row it declares. */
export const PROPERTY_NAME_PARAM = 'name';
@@ -8,6 +8,16 @@ export const PROPERTY_DEFAULT_PARAM = 'default';
export const PROPERTY_MIN_PARAM = 'min';
export const PROPERTY_MAX_PARAM = 'max';
/** The params of a `Property` node, the same in both graphs but for the step of their number fields. */
export function propertyNodeParams(step: number): PropertyDef[] {
return [
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step },
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step },
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step },
];
}
export interface Declaration {
isDeclaration(node: GraphNode): boolean;
nameOf(node: GraphNode): string;
+1 -6
View File
@@ -3,12 +3,7 @@ import { type PropertyBag, textOf } from '../model/Properties';
import { declaredNumberRows, PROPERTY_NAME_PARAM } from './declaredRows';
import { type FlatDef, isValidFieldName } from './PropertySchema';
export {
PROPERTY_DEFAULT_PARAM,
PROPERTY_MAX_PARAM,
PROPERTY_MIN_PARAM,
PROPERTY_NAME_PARAM,
} from './declaredRows';
export { PROPERTY_NAME_PARAM } from './declaredRows';
/** The node of a particle graph that declares a parameter: a row on every source placed from the asset. */
export const PARTICLE_PROPERTY = 'ParticleProperty';
+1 -6
View File
@@ -3,12 +3,7 @@ import { type PropertyBag, textOf } from '../model/Properties';
import { declaredNumberRows, PROPERTY_NAME_PARAM } from './declaredRows';
import { type FlatDef, isSpriteProp, isValidFieldName } from './PropertySchema';
export {
PROPERTY_DEFAULT_PARAM,
PROPERTY_MAX_PARAM,
PROPERTY_MIN_PARAM,
PROPERTY_NAME_PARAM,
} from './declaredRows';
export { PROPERTY_NAME_PARAM } from './declaredRows';
/** The sprite property naming the shader asset it wears; `''` is no shader and no render pass. */
export const SHADER_KEY = 'shader';
+3 -14
View File
@@ -1,14 +1,8 @@
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
import { type PropertyBag, textOf } from '../model/Properties';
import { propertyNodeParams } from '../properties/declaredRows';
import { flatDef } from '../properties/PropertySchema';
import {
PROPERTY_DEFAULT_PARAM,
PROPERTY_MAX_PARAM,
PROPERTY_MIN_PARAM,
PROPERTY_NAME_PARAM,
propertyName,
SHADER_PROPERTY,
} from '../properties/shaders';
import { PROPERTY_NAME_PARAM, propertyName, SHADER_PROPERTY } from '../properties/shaders';
export type GlslType = 'float' | 'vec3';
@@ -232,12 +226,7 @@ export const ShaderProperty: ShaderNodeDef = {
description: 'A number the sprite sets: a property row below the Shader dropdown, and a value here.',
inputs: [],
outputs: [{ key: 'value', label: 'value', type: 'number' }],
params: [
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step: NUMBER_STEP },
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step: NUMBER_STEP },
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step: NUMBER_STEP },
],
params: propertyNodeParams(NUMBER_STEP),
derivePorts: ({ node }) => ({ label: propertyName(node) || ShaderProperty.label }),
emit: (_inputs, params) => ({
value: { code: uniformName(textOf(params[PROPERTY_NAME_PARAM] ?? '')), type: 'float' },
+62
View File
@@ -0,0 +1,62 @@
import { Button, type ButtonOptions, HStack, Label, Popup, Spacer, Theme, type Widget } from '../ui';
/** Matches the inspector's label column. */
const LABEL_WIDTH = 76;
/**
* A small modal form – the texture's Slice dialog, the timeline's row and frames dialogs: a bold
* title, rows led by a label in the inspector's label column, Cancel and a confirming button last.
*/
export abstract class FormDialog extends Popup {
protected constructor(minWidth: number, title: string) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.minWidth = minWidth;
this.content.add(new Label(title, { style: 'bodyBold' }));
}
protected fieldLabel(text: string): Label {
const label = new Label(text, { style: 'muted' });
label.minWidth = LABEL_WIDTH;
return label;
}
/** A row of `widgets`, left-aligned at the height of an inspector row. */
protected formRow(...widgets: Widget[]): HStack {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
for (const widget of widgets) row.add(widget);
row.add(new Spacer(), 1);
return row;
}
/** Cancel and the confirming button, right-aligned; the dialog closes before `onConfirm` runs. */
protected addButtons(text: string, variant: ButtonOptions['variant'], onConfirm: () => void): Button {
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);
const confirm = new Button(text, { variant });
confirm.onClick = this.closeThen(onConfirm);
buttons.add(confirm);
this.content.add(buttons);
return confirm;
}
}
/** One open dialog of a kind at a time: opening the next closes the last. `current` is for the check scripts. */
export function dialogSlot<T extends Popup>(): { current(): T | null; open(dialog: T): T } {
let current: T | null = null;
return {
current: () => current,
open(dialog) {
current?.close();
current = dialog;
dialog.onClose = () => {
if (current === dialog) current = null;
};
dialog.openCentered();
return dialog;
},
};
}
+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.
+26 -23
View File
@@ -1,5 +1,4 @@
import {
type KeyRef,
RemoveAnimationKeysCommand,
RemoveAnimationTracksCommand,
SetAnimationKeyCommand,
@@ -8,8 +7,11 @@ import {
} from '../../core/commands/animationCommands';
import {
blends,
findKey,
findTrack,
interpolationOf,
KEY_INTERPOLATIONS,
type KeyRef,
trackDef,
trackLabel,
} from '../../core/model/animation';
@@ -28,10 +30,9 @@ export function trackSignature(
project: Project,
assetId: string,
trackId: string,
keys: readonly { trackId: string; frame: number }[],
keys: readonly KeyRef[],
): string {
const asset = findAssetOf(project, assetId, 'animation');
const track = asset?.tracks.find((t) => t.id === trackId);
const { asset, track } = rowOf(project, assetId, trackId);
if (!asset || !track) return 'missing';
const kind = kindOf(project, asset, track) ?? 'missing';
const key = keys.length === 1 ? `${keys[0]!.trackId}@${keys[0]!.frame}` : String(keys.length);
@@ -46,10 +47,9 @@ export function buildTrackSection(
host: InspectorHost,
assetId: string,
trackId: string,
keys: readonly { trackId: string; frame: number }[],
keys: readonly KeyRef[],
): void {
const asset = findAssetOf(host.state.project, assetId, 'animation');
const track = asset?.tracks.find((t) => t.id === trackId);
const { asset, track } = rowOf(host.state.project, assetId, trackId);
if (!asset || !track) return;
const kind = kindOf(host.state.project, asset, track);
@@ -68,9 +68,8 @@ export function buildTrackSection(
host.body.add(
new Button('Change target…', {
onClick: () => {
const current = findAssetOf(host.state.project, assetId, 'animation');
const row = current?.tracks.find((t) => t.id === trackId);
if (current && row) openRowDialog(new RowDialog(host.state, current, row));
const now = rowOf(host.state.project, assetId, trackId);
if (now.asset && now.track) openRowDialog(new RowDialog(host.state, now.asset, now.track));
},
}),
);
@@ -114,10 +113,10 @@ export function buildTrackSection(
}
/** The value of the one picked key, editable in the widget its kind calls for. */
function buildKey(host: InspectorHost, asset: AnimationAsset, ref: { trackId: string; frame: number }): void {
const track = asset.tracks.find((t) => t.id === ref.trackId);
const key = track?.keys.find((k) => k.frame === ref.frame);
const def = track && defOf(host.state.project, asset, track);
function buildKey(host: InspectorHost, asset: AnimationAsset, ref: KeyRef): void {
const track = findTrack(asset, ref.trackId);
const key = findKey(asset, ref);
const def = track && trackDef(host.state.project, asset, track);
if (!track || !key || !def) return;
addGap(host);
addTitle(host, `KEY AT FRAME ${ref.frame}`);
@@ -167,10 +166,10 @@ function easingKey(
keys: readonly KeyRef[],
): AnimationKey | undefined {
for (const ref of [...keys].reverse()) {
const track = asset.tracks.find((t) => t.id === ref.trackId);
const track = findTrack(asset, ref.trackId);
const kind = track && kindOf(project, asset, track);
if (!kind || !blends(kind)) continue;
const key = track.keys.find((k) => k.frame === ref.frame);
const key = findKey(asset, ref);
if (key) return key;
}
return undefined;
@@ -181,15 +180,14 @@ export function refreshTrackSection(
host: InspectorHost,
assetId: string,
trackId: string,
keys: readonly { trackId: string; frame: number }[],
keys: readonly KeyRef[],
): boolean {
const asset = findAssetOf(host.state.project, assetId, 'animation');
const track = asset?.tracks.find((t) => t.id === trackId);
const { asset, track } = rowOf(host.state.project, assetId, trackId);
if (!asset || !track) return false;
host.fields.get('relative')?.setValue(Boolean(track.relative));
const ref = keys.length === 1 ? keys[0] : undefined;
if (ref) {
const key = asset.tracks.find((t) => t.id === ref.trackId)?.keys.find((k) => k.frame === ref.frame);
const key = findKey(asset, ref);
if (!key) return false;
host.fields.get('value')?.setValue(key.value);
}
@@ -199,10 +197,15 @@ export function refreshTrackSection(
return true;
}
function defOf(project: Project, asset: AnimationAsset, track: AnimationTrack) {
return trackDef(project, asset, track);
function rowOf(
project: Project,
assetId: string,
trackId: string,
): { asset?: AnimationAsset; track?: AnimationTrack } {
const asset = findAssetOf(project, assetId, 'animation');
return { asset, track: asset && findTrack(asset, trackId) };
}
function kindOf(project: Project, asset: AnimationAsset, track: AnimationTrack): string | undefined {
return defOf(project, asset, track)?.kind;
return trackDef(project, asset, track)?.kind;
}
+11 -8
View File
@@ -1,4 +1,5 @@
import type { KeyClipboard, KeyRef } from '../core/commands/animationCommands';
import type { KeyClipboard } from '../core/commands/animationCommands';
import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation';
import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
import type { Asset, SceneAsset } from '../core/model/Asset';
@@ -126,8 +127,7 @@ export function graphSelection(
*/
export function trackSelection(assetId: string, keys: readonly KeyRef[], trackId?: string): Selection {
const unique: KeyRef[] = [];
for (const key of keys)
if (!unique.some((k) => k.trackId === key.trackId && k.frame === key.frame)) unique.push(key);
for (const key of keys) if (!hasKey(unique, key)) unique.push(key);
const last = unique[unique.length - 1];
const row = trackId ?? last?.trackId;
return row ? { kind: 'track', assetId, trackId: row, keys: unique } : null;
@@ -495,10 +495,8 @@ export class EditorState {
if (!asset) this.select(null);
else {
// A key that moved or died leaves the selection; a row that died takes its keys with it.
const alive = s.keys.filter((key) =>
asset.tracks.some((t) => t.id === key.trackId && t.keys.some((k) => k.frame === key.frame)),
);
const rowAlive = asset.tracks.some((t) => t.id === s.trackId);
const alive = s.keys.filter((key) => findKey(asset, key));
const rowAlive = findTrack(asset, s.trackId) !== undefined;
if (alive.length !== s.keys.length || !rowAlive)
this.select(
trackSelection(s.assetId, alive, rowAlive ? s.trackId : undefined) ?? {
@@ -525,6 +523,11 @@ export class EditorState {
return findAssetOf(this.project, s.assetId, 'animation')?.id ?? null;
}
/** The timeline row the selection is about; undefined when it is about something else. */
get selectedTrackId(): string | undefined {
return this.selection?.kind === 'track' ? this.selection.trackId : undefined;
}
/** The keys picked in the Animation tab, none when the selection is about something else. */
get selectedKeys(): KeyRef[] {
return this.selection?.kind === 'track' ? this.selection.keys : [];
@@ -627,7 +630,7 @@ export function sameSelection(a: Selection, b: Selection): boolean {
a.assetId === b.assetId &&
a.trackId === b.trackId &&
a.keys.length === b.keys.length &&
a.keys.every((key, i) => key.trackId === b.keys[i]!.trackId && key.frame === b.keys[i]!.frame)
a.keys.every((key, i) => sameKey(key, b.keys[i]!))
);
if (a.kind === 'annotation' && b.kind === 'annotation')
return (
+15 -36
View File
@@ -1,16 +1,15 @@
import type { TextureAsset } from '../../core/model/Asset';
import { DEFAULT_SLICE, type SliceOptions, sliceFrames } from '../../core/model/texture';
import { Button, Checkbox, HStack, Label, NumberField, Popup, Spacer, Theme, type Widget } from '../../ui';
import { type Button, Checkbox, Label, NumberField, Spacer, Theme, type Widget } from '../../ui';
import { FormDialog } from '../FormDialog';
/** Matches the inspector's label column. */
const LABEL_WIDTH = 76;
const FIELD_WIDTH = 76;
/**
* Cuts a sheet into a grid, asked for as columns and rows rather than a cell size: the counts are
* what the user knows, and a cell size derived from the picture cannot disagree with them.
*/
export class SliceDialog extends Popup {
export class SliceDialog extends FormDialog {
private readonly options: SliceOptions = { ...DEFAULT_SLICE };
private replace = true;
private readonly summary: Label;
@@ -20,37 +19,25 @@ export class SliceDialog extends Popup {
private readonly asset: TextureAsset,
onSlice: (options: SliceOptions, replace: boolean) => void,
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.minWidth = 340;
this.content.add(new Label(`Slice "${asset.name}" into a grid`, { style: 'bodyBold' }));
super(340, `Slice "${asset.name}" into a grid`);
this.content.add(new Label(`Picture is ${asset.width} × ${asset.height} px`, { style: 'muted' }));
this.content.add(this.pair('Columns', 'columns', 1, 'Rows', 'rows', 1));
this.content.add(this.pair('Offset X', 'offsetX', 0, 'Offset Y', 'offsetY', 0));
this.content.add(this.pair('Spacing X', 'spacingX', 0, 'Spacing Y', 'spacingY', 0));
const replaceRow = new HStack({ gap: Theme.gap, align: 'center' });
replaceRow.minHeight = Theme.rowHeight;
const box = new Checkbox(this.replace);
box.onChange = (value) => {
this.replace = Boolean(value);
};
replaceRow.add(box);
replaceRow.add(new Label('Replace the frames this texture already has', { style: 'muted' }));
this.content.add(replaceRow);
this.content.add(
this.formRow(box, new Label('Replace the frames this texture already has', { style: 'muted' })),
);
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('Slice', { variant: 'primary' });
this.confirm.onClick = this.closeThen(() => onSlice({ ...this.options }, this.replace));
buttons.add(this.confirm);
this.content.add(buttons);
this.confirm = this.addButtons('Slice', 'primary', () => onSlice({ ...this.options }, this.replace));
this.refreshSummary();
}
@@ -63,21 +50,13 @@ export class SliceDialog extends Popup {
keyB: keyof SliceOptions,
minB: number,
): Widget {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(this.labelFor(labelA));
row.add(this.fieldFor(keyA, minA));
row.add(new Spacer(Theme.padding));
row.add(this.labelFor(labelB));
row.add(this.fieldFor(keyB, minB));
row.add(new Spacer(), 1);
return row;
}
private labelFor(text: string): Label {
const label = new Label(text, { style: 'muted' });
label.minWidth = LABEL_WIDTH;
return label;
return this.formRow(
this.fieldLabel(labelA),
this.fieldFor(keyA, minA),
new Spacer(Theme.padding),
this.fieldLabel(labelB),
this.fieldFor(keyB, minB),
);
}
private fieldFor(key: keyof SliceOptions, min: number): NumberField {
+98
View File
@@ -0,0 +1,98 @@
import type { FederatedPointerEvent, FederatedWheelEvent } from 'pixi.js';
import { footprintExtent } from '../../core/properties/bounds';
import { boundsOf } from '../framing';
import type { EditorState } from '../state';
import { clamp, zoomFactor } from '../../ui';
import { freeView, screenToWorld, type View } from '../../viewport/view';
import { WorldViewport } from './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;
/**
* A world view with a camera of its own – the Scene and Container tabs and the Animation tab's
* preview: the wheel zooms about the pointer, a pan drags the world along, `fit()` frames the content.
*/
export abstract class FreeViewport extends WorldViewport {
/** World point at the viewport centre, and zoom. */
protected view = { x: 0, y: 0, zoom: 1 };
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
protected constructor(state: EditorState) {
super(state);
this.on('wheel', (e: FederatedWheelEvent) => this.zoomAt(e));
}
/**
* Frames everything drawn. The origin is always included: it is where a container is grabbed when
* placed and where the preview places its root, so content far off to one side would hide it.
*/
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,
),
};
this.redraw();
}
/** Centres the view on a world point, keeping the zoom (test hook). */
lookAt(x: number, y: number): void {
this.view.x = x;
this.view.y = y;
this.redraw();
}
protected resetView(): void {
this.view = { x: 0, y: 0, zoom: 1 };
}
protected override currentView(): View {
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h);
}
protected startPan(e: FederatedPointerEvent): void {
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
e.stopPropagation();
}
/** Follows the pointer while a pan is on; false when there is none, so the move is someone else's. */
protected panTo(e: FederatedPointerEvent): boolean {
if (!this.panning) return false;
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();
return true;
}
protected endPan(): void {
this.panning = null;
}
/** Zooms about the pointer, so the world point under it stays under it. */
private zoomAt(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();
}
}
+12 -81
View File
@@ -3,7 +3,7 @@
* copy, so live positions are visible and stay editable. A container's template is the same
* work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
*/
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import { oneStep } from '../../core/commands/CompoundCommand';
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { hasSceneIcon } from '../../core/model/AssetTypes';
@@ -13,25 +13,15 @@ import { applyVector, type Extent, invert, linearOf, type Mat } from '../../core
import { placementRejection } from '../../core/model/placement';
import type { Position, Vec2 } from '../../core/model/Properties';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { footprintCorners, footprintExtent } from '../../core/properties/bounds';
import { footprintCorners } from '../../core/properties/bounds';
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
import { boundsOf } from '../framing';
import { type EditorState, objectSelection } from '../state';
import {
activeDrag,
clamp,
drawBand,
drawGrid,
endDrag,
onDragChange,
Theme,
textStyle,
zoomFactor,
} from '../../ui';
import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui';
import { collectHolderItems, type DrawItem } from '../../viewport/items';
import { dashedLine, drawSourceIcon } from '../../viewport/symbols';
import { freeView, screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { type Frame, WorldViewport } from './WorldViewport';
import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { FreeViewport } from './FreeViewport';
import type { Frame } from './WorldViewport';
/** Pixels of travel before a press turns into a drag. */
const MARQUEE_THRESHOLD = 3;
@@ -39,10 +29,6 @@ const DRAG_THRESHOLD = 2;
const GRID_STEP = 100;
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
const MIN_GRID_STEP = 12;
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 8;
/** Breathing room left around the content by `fit()`, in screen pixels. */
const FIT_MARGIN = 40;
interface ObjectDrag {
holderId: string;
@@ -81,15 +67,12 @@ const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
particles: Theme.colors.particles,
};
export class SceneViewport extends WorldViewport {
export class SceneViewport extends FreeViewport {
private readonly grid = new Graphics();
private readonly links = new Graphics();
private readonly gizmos = new Graphics();
private readonly labels = new Map<string, Text>();
private readonly icons = new Map<string, Graphics>();
/** 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;
private dragging: ObjectDrag | null = null;
private marquee: Marquee | null = null;
private dropPreview: DropPreview | null = null;
@@ -109,10 +92,9 @@ export class SceneViewport extends WorldViewport {
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('load', () => {
this.view = { x: 0, y: 0, zoom: 1 };
this.resetView();
this.redraw();
});
// A drag can end anywhere – over another panel, off the canvas – and this view never hears
@@ -139,42 +121,6 @@ export class SceneViewport extends WorldViewport {
return p ? { assetId: p.assetId, x: p.at.x, y: p.at.y, rejection: p.rejection } : null;
}
/**
* Frames everything the holder contains. The origin is always included: it is where a container
* is grabbed when placed, so a template drawn far off to one side would otherwise hide it.
*/
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,
),
};
this.redraw();
}
/** Centres the view on a world point, keeping the zoom (test hook). */
lookAt(x: number, y: number): void {
this.view.x = x;
this.view.y = y;
this.redraw();
}
protected override currentView(): View {
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h);
}
protected override collectFrame(): Frame {
return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport);
}
@@ -194,8 +140,7 @@ export class SceneViewport extends WorldViewport {
private onPointerDown(e: FederatedPointerEvent): void {
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
e.stopPropagation();
this.startPan(e);
return;
}
if (e.button === 0 && e.target === this) {
@@ -216,11 +161,8 @@ export class SceneViewport extends WorldViewport {
this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e);
return;
}
if (this.panning) {
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom;
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom;
this.redraw();
} else if (this.dragging) {
if (this.panTo(e)) return;
if (this.dragging) {
const local = this.toLocal(e.global);
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
@@ -296,7 +238,7 @@ export class SceneViewport extends WorldViewport {
this.dropAsset(preview);
return;
}
this.panning = null;
this.endPan();
const marquee = this.marquee;
this.marquee = null;
if (marquee) {
@@ -321,17 +263,6 @@ export class SceneViewport extends WorldViewport {
if (moves.length > 0) this.state.execute(oneStep('Move objects', moves));
}
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();
}
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
if (e.button !== 0) return;
const holder = this.resolved();
+3 -3
View File
@@ -1,8 +1,8 @@
import { isHandleEnabled } from '../../core/graph';
import {
type AnimationPlayer,
animatableDefsOf,
animatedKind,
animatedKindOf,
animationPlayheadAt,
blendShare,
blends,
@@ -133,7 +133,7 @@ function drop(entity: Entity, player: AnimationPlayer): void {
* property, is skipped.
*/
function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
const root = world.project.assets.find((a) => a.id === asset.root);
const root = findAsset(world.project, asset.root);
if (!root) return [];
const bindings: Binding[] = [];
for (const track of asset.tracks) {
@@ -178,7 +178,7 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
*/
function kindOn(world: World, entity: Entity, key: string): string | undefined {
const asset = findAsset(world.project, entity.assetId);
return asset && animatableDefsOf(world.project, asset, entity.props).find((d) => d.key === key)?.kind;
return asset && animatedKindOf(world.project, asset, entity.props, key);
}
/**