diff --git a/src/core/commands/animationCommands.ts b/src/core/commands/animationCommands.ts index 1b192ade..6db583b1 100644 --- a/src/core/commands/animationCommands.ts +++ b/src/core/commands/animationCommands.ts @@ -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>(); 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; diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 7e839129..acf3d65e 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -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(); diff --git a/src/core/model/animationPreview.ts b/src/core/model/animationPreview.ts index 34de1b34..5eafabf6 100644 --- a/src/core/model/animationPreview.ts +++ b/src/core/model/animationPreview.ts @@ -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)) { diff --git a/src/core/particles/nodes.ts b/src/core/particles/nodes.ts index 70514fff..dcd2f9ce 100644 --- a/src/core/particles/nodes.ts +++ b/src/core/particles/nodes.ts @@ -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. diff --git a/src/core/properties/declaredRows.ts b/src/core/properties/declaredRows.ts index ae46d01a..8d7deeb1 100644 --- a/src/core/properties/declaredRows.ts +++ b/src/core/properties/declaredRows.ts @@ -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; diff --git a/src/core/properties/particles.ts b/src/core/properties/particles.ts index 534cf65a..18872c6e 100644 --- a/src/core/properties/particles.ts +++ b/src/core/properties/particles.ts @@ -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'; diff --git a/src/core/properties/shaders.ts b/src/core/properties/shaders.ts index 01bf8c94..8c92ab06 100644 --- a/src/core/properties/shaders.ts +++ b/src/core/properties/shaders.ts @@ -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'; diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index dcf898a7..39aed96e 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -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' }, diff --git a/src/editor/FormDialog.ts b/src/editor/FormDialog.ts new file mode 100644 index 00000000..564e27e1 --- /dev/null +++ b/src/editor/FormDialog.ts @@ -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(): { 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; + }, + }; +} diff --git a/src/editor/animation/FramesDialog.ts b/src/editor/animation/FramesDialog.ts index cdeade51..41fa7a2a 100644 --- a/src/editor/animation/FramesDialog.ts +++ b/src/editor/animation/FramesDialog.ts @@ -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(); /** 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); diff --git a/src/editor/animation/PreviewCanvas.ts b/src/editor/animation/PreviewCanvas.ts index 5c139091..3385464d 100644 --- a/src/editor/animation/PreviewCanvas.ts +++ b/src/editor/animation/PreviewCanvas.ts @@ -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; } } diff --git a/src/editor/animation/RowDialog.ts b/src/editor/animation/RowDialog.ts index b7fbb60a..daa12b16 100644 --- a/src/editor/animation/RowDialog.ts +++ b/src/editor/animation/RowDialog.ts @@ -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(); /** 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)); } } diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index c4f2f26a..461bb59b 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -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); diff --git a/src/editor/animation/timelineView.ts b/src/editor/animation/timelineView.ts index 2cc8e3d7..ff38d685 100644 --- a/src/editor/animation/timelineView.ts +++ b/src/editor/animation/timelineView.ts @@ -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. diff --git a/src/editor/inspector/trackSection.ts b/src/editor/inspector/trackSection.ts index fe93f34e..6ef905fb 100644 --- a/src/editor/inspector/trackSection.ts +++ b/src/editor/inspector/trackSection.ts @@ -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; } diff --git a/src/editor/state.ts b/src/editor/state.ts index 01c2b8d8..69e66ce0 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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 ( diff --git a/src/editor/texture/SliceDialog.ts b/src/editor/texture/SliceDialog.ts index d9cfe40e..3b73e76f 100644 --- a/src/editor/texture/SliceDialog.ts +++ b/src/editor/texture/SliceDialog.ts @@ -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 { diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts new file mode 100644 index 00000000..73f47eaf --- /dev/null +++ b/src/editor/viewport/FreeViewport.ts @@ -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(); + } +} diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 9e2dea62..dd34451b 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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> = { 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(); private readonly icons = new Map(); - /** 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(); diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 008dee0a..83902298 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -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); } /** diff --git a/tests/core/animation-preview.test.ts b/tests/core/animation-preview.test.ts index 55c32131..aa9a43b8 100644 --- a/tests/core/animation-preview.test.ts +++ b/tests/core/animation-preview.test.ts @@ -11,10 +11,16 @@ import { } from '../../src/core/commands/animationCommands'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { CommandError } from '../../src/core/commands/Command'; -import { animatableDefs, animationTargets, targetAssetOf, trackLabel } from '../../src/core/model/animation'; +import { + animatableDefs, + animationTargets, + PLACED_ROOT_ID, + targetAssetOf, + trackLabel, +} from '../../src/core/model/animation'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; import { RemoveSceneObjectsCommand } from '../../src/core/commands/sceneObjectCommands'; -import { PREVIEW_ROOT_ID, previewHolder, previewObjectId } from '../../src/core/model/animationPreview'; +import { previewHolder, previewObjectId } from '../../src/core/model/animationPreview'; import type { AnimationAsset, AnimationKey, ContainerAsset } from '../../src/core/model/Asset'; import { instanceChildId, resolveObjects } from '../../src/core/model/instance'; import { findAsset, findAssetOf } from '../../src/core/model/Project'; @@ -59,10 +65,10 @@ describe('the preview of an animation over a sprite', () => { [0, 0], [10, 100], ]); - const root = drawn(f, current(f, walk.id), 5).get(PREVIEW_ROOT_ID)!; + const root = drawn(f, current(f, walk.id), 5).get(PLACED_ROOT_ID)!; expect(root.assetId).toBe(f.spriteId); expect(root.position).toEqual({ x: 50, y: 0, z: 0 }); - expect(previewObjectId(findAsset(f.project, f.spriteId), '')).toBe(PREVIEW_ROOT_ID); + expect(previewObjectId(findAsset(f.project, f.spriteId), '')).toBe(PLACED_ROOT_ID); }); it('holds the first value before the first key and the last one after the last', () => { @@ -73,7 +79,7 @@ describe('the preview of an animation over a sprite', () => { [4, 30], ]); const at = (frame: number) => - drawn(f, current(f, walk.id), frame).get(PREVIEW_ROOT_ID)!.overrides.rotation; + drawn(f, current(f, walk.id), frame).get(PLACED_ROOT_ID)!.overrides.rotation; expect(at(0)).toBe(10); expect(at(3)).toBe(20); expect(at(9)).toBe(30); @@ -85,7 +91,7 @@ describe('the preview of an animation over a sprite', () => { addRow(f, walk, 'y', [[0, 40]]); const holder = previewHolder(f.project, current(f, walk.id), 0); const { items } = collectHolderItems(f.project, holder, 0); - expect(items.map((i) => [i.id, i.type, i.y])).toEqual([[PREVIEW_ROOT_ID, 'sprite', 40]]); + expect(items.map((i) => [i.id, i.type, i.y])).toEqual([[PLACED_ROOT_ID, 'sprite', 40]]); }); it('never writes into the document', () => { @@ -104,14 +110,14 @@ describe('the preview of an animation over a sprite', () => { [0, 0], [9, 90], ]); - expect(drawn(f, current(f, reset.id), 9).get(PREVIEW_ROOT_ID)!.position.x).toBe(90); - expect(drawn(f, current(f, reset.id), 10).get(PREVIEW_ROOT_ID)!.position.x).toBe(0); + expect(drawn(f, current(f, reset.id), 9).get(PLACED_ROOT_ID)!.position.x).toBe(90); + expect(drawn(f, current(f, reset.id), 10).get(PLACED_ROOT_ID)!.position.x).toBe(0); const hold = addAnimation(f, f.spriteId, { playback: 'once' }); addRow(f, hold, 'x', [ [0, 0], [9, 90], ]); - expect(drawn(f, current(f, hold.id), 10).get(PREVIEW_ROOT_ID)!.position.x).toBe(90); + expect(drawn(f, current(f, hold.id), 10).get(PLACED_ROOT_ID)!.position.x).toBe(90); }); }); @@ -133,10 +139,10 @@ describe('the preview of an animation over a container', () => { addRow(f, walk, 'x', [[0, 55]], arm); addRow(f, walk, 'rotation', [[0, 45]]); const objects = drawn(f, current(f, walk.id), 0); - const childId = instanceChildId(PREVIEW_ROOT_ID, arm); + const childId = instanceChildId(PLACED_ROOT_ID, arm); expect(previewObjectId(findAsset(f.project, container.id), arm)).toBe(childId); expect(objects.get(childId)!.position).toEqual({ ...at, x: 55 }); - expect(objects.get(PREVIEW_ROOT_ID)!.overrides.rotation).toBe(45); + expect(objects.get(PLACED_ROOT_ID)!.overrides.rotation).toBe(45); }); it('adds a relative row to what the child holds in the template', () => { @@ -144,7 +150,7 @@ describe('the preview of an animation over a container', () => { const walk = addAnimation(f, container.id); const row = addRow(f, walk, 'x', [[0, 5]], arm); f.stack.execute(new SetTrackRelativeCommand(walk.id, row, true)); - const child = drawn(f, current(f, walk.id), 0).get(instanceChildId(PREVIEW_ROOT_ID, arm))!; + const child = drawn(f, current(f, walk.id), 0).get(instanceChildId(PLACED_ROOT_ID, arm))!; expect(child.position.x).toBe(at.x + 5); }); @@ -162,8 +168,8 @@ describe('the preview of an animation over a container', () => { // The first template object goes: its row stays and aims at nothing. f.stack.execute(new RemoveSceneObjectsCommand(wrap.assetId, [arm!])); const objects = drawn(f, current(f, walk.id), 0); - expect(objects.has(instanceChildId(PREVIEW_ROOT_ID, arm!))).toBe(false); - expect(objects.get(instanceChildId(PREVIEW_ROOT_ID, other!))!.position.x).toBe(66); + expect(objects.has(instanceChildId(PLACED_ROOT_ID, arm!))).toBe(false); + expect(objects.get(instanceChildId(PLACED_ROOT_ID, other!))!.position.x).toBe(66); }); }); @@ -224,7 +230,7 @@ describe('the reach of a row (#109)', () => { const walk = addAnimation(f, outer.assetId); addRow(f, walk, 'x', [[0, 55]], path); const before = JSON.stringify(f.project); - const child = drawn(f, current(f, walk.id), 0).get(instanceChildId(PREVIEW_ROOT_ID, path))!; + const child = drawn(f, current(f, walk.id), 0).get(instanceChildId(PLACED_ROOT_ID, path))!; expect(child.position).toEqual({ ...armTemplate.position, x: 55 }); expect(JSON.stringify(f.project)).toBe(before); }); diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index 5e87f57e..8c77fa55 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -32,6 +32,7 @@ import { animationDuration, frameCount, pauseAnimation, + PLACED_ROOT_ID, playAnimation, playheadAt, stopAnimation, @@ -41,7 +42,7 @@ import { trackValueAt, } from '../../src/core/model/animation'; import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders'; -import { PREVIEW_ROOT_ID, previewHolder } from '../../src/core/model/animationPreview'; +import { previewHolder } from '../../src/core/model/animationPreview'; import type { AnimationAsset, AnimationKey, @@ -375,6 +376,22 @@ describe('editing the timeline', () => { expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [5, 50])); }); + it('refuses a key before frame 0 and a key that is not there, changing nothing', () => { + const f = makeSpriteScene(); + const walk = addAnimation(f, f.spriteId); + const id = addRow(f, walk, 'x', [[0, 0]]); + const missing = [{ trackId: id, frame: 4 }]; + const refused = [ + new SetAnimationKeyCommand(walk.id, id, -1), + new ShiftAnimationFramesCommand(walk.id, -1, 1), + new RemoveAnimationKeysCommand(walk.id, missing), + new MoveAnimationKeysCommand(walk.id, missing, 1), + new SetKeyInterpolationCommand(walk.id, missing, 'step'), + ]; + for (const command of refused) expect(() => f.stack.execute(command)).toThrow(CommandError); + expect(rowOf(walk, id).keys).toEqual(keys([0, 0])); + }); + it('aims a row elsewhere keeping its keys, only at a property of the same kind', () => { const f = makeSpriteScene(); const walk = addAnimation(f, f.spriteId); @@ -599,7 +616,7 @@ describe('the AnimationSystem', () => { frame(0.05); frame(0.45); const previewed = previewHolder(f.project, findAssetOf(f.project, walk.id, 'animation')!, 4.5)!; - const root = previewed.objects.find((o) => o.id === PREVIEW_ROOT_ID)!; + const root = previewed.objects.find((o) => o.id === PLACED_ROOT_ID)!; expect(sprite.x).toBeCloseTo(22.5); expect(root.position.x).toBeCloseTo(sprite.x); });