diff --git a/src/core/commands/animationCommands.ts b/src/core/commands/animationCommands.ts index 0f2cec26..2840a39a 100644 --- a/src/core/commands/animationCommands.ts +++ b/src/core/commands/animationCommands.ts @@ -3,11 +3,13 @@ import { ANIMATION_ROOT_TYPES, animatableDefs, animatedKind, + blends, + isKeyInterpolation, normalizeKeys, targetAssetOf, trackValueAt, } from '../model/animation'; -import type { AnimationAsset, AnimationKey, AnimationTrack } from '../model/Asset'; +import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../model/Asset'; import { findAsset, type Project } from '../model/Project'; import type { PropertyValue } from '../model/Properties'; import { coerceValue } from '../properties/PropertySchema'; @@ -195,9 +197,9 @@ export class SetTrackRelativeCommand extends TimelineCommand { } /** - * Sets the key at `frame`, replacing one already there. Without a value it takes the value the row - * has at that frame – what a double click in the timeline means – and on an empty row the property's - * default, which for a relative row is no offset at all. + * Sets the key at `frame`, replacing the value of one already there but keeping its interpolation. + * Without a value it takes the value the row has at that frame – what a double click in the timeline + * means – and on an empty row the property's default, which for a relative row is no offset at all. */ export class SetAnimationKeyCommand extends TimelineCommand { readonly label = 'Set key'; @@ -221,10 +223,46 @@ export class SetAnimationKeyCommand extends TimelineCommand { const current = trackValueAt(track.keys, def.kind, this.frame); const raw = this.value ?? current ?? (track.relative ? 0 : def.default); const key: AnimationKey = { frame: this.frame, value: coerceValue(def, raw) }; + const interpolation = track.keys.find((k) => k.frame === this.frame)?.interpolation; + if (interpolation) key.interpolation = interpolation; track.keys = normalizeKeys([...track.keys, key]); } } +/** + * Sets how each key blends into the next one. `linear` is stored as no field at all. A key on a row + * that cannot blend is left as it is – it jumps whatever it says – and only such keys is refused. + */ +export class SetKeyInterpolationCommand extends TimelineCommand { + readonly label: string; + + constructor( + assetId: string, + private readonly keys: readonly KeyRef[], + private readonly interpolation: KeyInterpolation, + ) { + super(assetId); + this.label = keys.length === 1 ? 'Set easing' : `Set easing of ${keys.length} keys`; + } + + protected apply(project: Project, asset: AnimationAsset): void { + 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 aimed = targetAssetOf(project, asset, track.target); + const kind = aimed ? animatedKind(aimed.type, track.property) : undefined; + if (kind !== undefined && !blends(kind)) continue; + if (this.interpolation === 'linear') delete key.interpolation; + else key.interpolation = this.interpolation; + changed += 1; + } + if (changed === 0) throw new CommandError('Only a number or colour key can ease'); + } +} + export class RemoveAnimationKeysCommand extends TimelineCommand { readonly label: string; diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 7ca1ea65..a9af342d 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -101,10 +101,13 @@ export interface TextureAsset extends AssetBase { frames: TextureFrame[]; } -/** How a key blends into the next one; absent is `linear`, the only one there is so far (#97). */ -export type KeyInterpolation = 'linear'; +/** How a key blends into the next one (#101); absent is `linear`. `step` holds until the next key. */ +export type KeyInterpolation = 'linear' | 'step' | 'ease-in' | 'ease-out' | 'ease-in-out'; -/** A value on a whole frame. A value that cannot blend (a texture, a switch) holds until the next key. */ +/** + * A value on a whole frame. Its interpolation rules the way to the next key. A value that cannot + * blend (a texture, a switch) holds until the next key whatever it says. + */ export interface AnimationKey { frame: number; value: PropertyValue; diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 14f7ce14..480c4589 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -1,6 +1,6 @@ import { isKindWith } from '../properties/kinds'; import { type PropertyDef, schemaFor } from '../properties/PropertySchema'; -import type { AnimationAsset, AnimationKey, AnimationTrack, Asset } from './Asset'; +import type { AnimationAsset, AnimationKey, AnimationTrack, Asset, KeyInterpolation } from './Asset'; import type { AssetType } from './AssetTypes'; import { treeOrder } from './hierarchy'; import { instanceChildId, resolveObjects } from './instance'; @@ -111,6 +111,42 @@ function lerpColor(a: number, b: number, t: number): number { return channel(16) | channel(8) | channel(0); } +/** In the order the inspector offers them; `linear` first, since it is what an absent field means. */ +export const KEY_INTERPOLATIONS: readonly { value: KeyInterpolation; label: string }[] = [ + { value: 'linear', label: 'Linear' }, + { value: 'step', label: 'Step' }, + { value: 'ease-in', label: 'Ease in' }, + { value: 'ease-out', label: 'Ease out' }, + { value: 'ease-in-out', label: 'Ease in-out' }, +]; + +export function isKeyInterpolation(value: unknown): value is KeyInterpolation { + return KEY_INTERPOLATIONS.some((i) => i.value === value); +} + +export function interpolationOf(key: AnimationKey): KeyInterpolation { + return key.interpolation ?? 'linear'; +} + +/** + * 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 { + switch (interpolation) { + case 'linear': + return t; + case 'step': + return 0; + case 'ease-in': + return t * t; + case 'ease-out': + return 1 - (1 - t) * (1 - t); + case 'ease-in-out': + return t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t); + } +} + /** The value between two keys, `t` from 0 (at `a`) to 1 (at `b`). */ export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t: number): PropertyValue { if (kind === 'number') { @@ -124,8 +160,9 @@ export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t: /** * A row's value at a (fractional) frame: the first value before the first key, the last after the - * last one. A value that jumps holds until the next key in the direction of play: played in - * `reverse`, that is the key above. `undefined` only for a row without keys. + * last one. Between two keys the earlier one's interpolation decides the way. A value that jumps – + * one that cannot blend, or a `step` key – holds until the next key in the direction of play: played + * in `reverse`, that is the key above. `undefined` only for a row without keys. */ export function trackValueAt( keys: readonly AnimationKey[], @@ -140,8 +177,11 @@ export function trackValueAt( const next = keys[i]!; if (frame >= next.frame) continue; const prev = keys[i - 1]!; - if (!blends(kind)) return reverse && frame > prev.frame ? next.value : prev.value; - return blendValue(kind, prev.value, next.value, (frame - prev.frame) / (next.frame - prev.frame)); + const interpolation = interpolationOf(prev); + if (!blends(kind) || interpolation === 'step') + return reverse && frame > prev.frame ? next.value : prev.value; + const t = (frame - prev.frame) / (next.frame - prev.frame); + return blendValue(kind, prev.value, next.value, easedShare(interpolation, t)); } return keys[keys.length - 1]!.value; } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index e3bbc924..5c27922a 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -18,7 +18,7 @@ import { } from '../graph'; import { isParticleNodeDef } from '../particles/nodes'; import { isShaderNodeDef } from '../shader/nodes'; -import { normalizeKeys } from '../model/animation'; +import { isKeyInterpolation, normalizeKeys } from '../model/animation'; import type { AnimationKey, AnimationTrack, Asset, StateMachineAsset, TextureFrame } from '../model/Asset'; import { isAssetType } from '../model/AssetTypes'; import { isFontFile } from '../model/font'; @@ -473,7 +473,11 @@ function normalizeTracks(raw: unknown): AnimationTrack[] { const key = asRecord(k); const frame = Number(key.frame); if (!Number.isFinite(frame) || !isPrimitive(key.value)) continue; - keys.push({ frame, value: key.value }); + const kept: AnimationKey = { frame, value: key.value }; + // `linear` is what no field means, so it is not kept; an unknown one reads as linear, too. + if (isKeyInterpolation(key.interpolation) && key.interpolation !== 'linear') + kept.interpolation = key.interpolation; + keys.push(kept); } const track: AnimationTrack = { id: t.id, target, property: t.property, keys: normalizeKeys(keys) }; if (t.relative === true) track.relative = true; diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index ddbfcddd..3b485c1a 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -8,10 +8,11 @@ import { animatedKind, animationPlayheadAt, frameCount, + interpolationOf, targetAssetOf, trackLabel, } from '../../core/model/animation'; -import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; +import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset'; import type { Vec2 } from '../../core/model/Properties'; import { findAssetOf } from '../../core/model/Project'; import { drawBand, pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui'; @@ -214,16 +215,17 @@ export class TimelineCanvas extends Widget { return { x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame), y: origin.y + RULER_HEIGHT / 2 }; } - /** The keys as drawn, with whether each is picked. */ - keysDrawn(): { trackId: string; frame: number; selected: boolean }[] { + /** The keys as drawn, with whether each is picked and the easing its shape shows. */ + keysDrawn(): { trackId: string; frame: number; selected: boolean; interpolation: KeyInterpolation }[] { const picked = this.state.selectedKeys; - const out: { trackId: string; frame: number; selected: boolean }[] = []; + const out: { trackId: string; frame: number; selected: boolean; interpolation: KeyInterpolation }[] = []; for (const track of this.asset()?.tracks ?? []) for (const key of track.keys) out.push({ trackId: track.id, frame: key.frame, selected: hasKey(picked, { trackId: track.id, frame: key.frame }), + interpolation: interpolationOf(key), }); return out; } @@ -491,12 +493,7 @@ export class TimelineCanvas extends Widget { const shifted = drag && drag.keys.some((k) => sameKey(k, ref)) ? drag.delta : 0; const x = HEADER_WIDTH + frameCentreX(this.view, key.frame + shifted); if (x < HEADER_WIDTH - half || x > this.w + half) continue; - this.overlay - .moveTo(x, y - half) - .lineTo(x + half, y) - .lineTo(x, y + half) - .lineTo(x - half, y) - .closePath() + drawKeyShape(this.overlay, interpolationOf(key), x, y, half) .fill(selected ? c.accent : c.text) .stroke({ width: 1, color: selected ? c.accentText : c.viewport }); } @@ -556,3 +553,26 @@ export class TimelineCanvas extends Widget { this.hint.position.set(HEADER_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2); } } + +/** + * A key's outline tells its easing: the right half is the start of the way to the next key, the left + * half its end, and a round half is a slow one. Linear is the diamond, ease-in-out the circle, a step + * – which holds – the square. + */ +function drawKeyShape( + g: Graphics, + interpolation: KeyInterpolation, + x: number, + y: number, + half: number, +): Graphics { + if (interpolation === 'step') return g.rect(x - half * 0.75, y - half * 0.75, half * 1.5, half * 1.5); + const slowStart = interpolation === 'ease-in' || interpolation === 'ease-in-out'; + const slowEnd = interpolation === 'ease-out' || interpolation === 'ease-in-out'; + g.moveTo(x, y - half); + if (slowStart) g.arc(x, y, half, -Math.PI / 2, Math.PI / 2); + else g.lineTo(x + half, y).lineTo(x, y + half); + if (slowEnd) g.arc(x, y, half, Math.PI / 2, (3 * Math.PI) / 2); + else g.lineTo(x - half, y).lineTo(x, y - half); + return g.closePath(); +} diff --git a/src/editor/inspector/trackSection.ts b/src/editor/inspector/trackSection.ts index 88eadd3a..bec517cc 100644 --- a/src/editor/inspector/trackSection.ts +++ b/src/editor/inspector/trackSection.ts @@ -1,11 +1,20 @@ import { + type KeyRef, RemoveAnimationKeysCommand, RemoveAnimationTracksCommand, SetAnimationKeyCommand, + SetKeyInterpolationCommand, SetTrackRelativeCommand, } from '../../core/commands/animationCommands'; -import { animatableDefs, targetAssetOf, trackLabel } from '../../core/model/animation'; -import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; +import { + animatableDefs, + blends, + interpolationOf, + KEY_INTERPOLATIONS, + targetAssetOf, + trackLabel, +} from '../../core/model/animation'; +import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../../core/model/Asset'; import { findAssetOf, type Project } from '../../core/model/Project'; import { Button } from '../../ui'; import { openRowDialog, RowDialog } from '../animation/RowDialog'; @@ -87,6 +96,7 @@ export function buildTrackSection( else if (keys.length > 1) { addGap(host); addTitle(host, `${keys.length} KEYS`); + buildEasing(host, asset, keys); host.body.add( new Button('Delete keys', { variant: 'danger', @@ -120,6 +130,51 @@ function buildKey(host: InspectorHost, asset: AnimationAsset, ref: { trackId: st host.fields.set('value', row); host.body.add(row.row); if (track.relative) addNote(host, 'An offset, since the row is relative.', 'small'); + buildEasing(host, asset, [ref]); +} + +/** + * How the picked keys blend into their next key – for all of them at once. It shows the easing of the + * key picked last among those that can blend; a row that cannot blend offers none, it always jumps. + */ +function buildEasing(host: InspectorHost, asset: AnimationAsset, keys: readonly KeyRef[]): void { + const shown = easingKey(host.state.project, asset, keys); + if (!shown) return; + const row = createFieldRow( + host, + { + key: 'easing', + label: 'Easing', + kind: 'enum', + default: 'linear', + options: KEY_INTERPOLATIONS.map((i) => ({ ...i })), + }, + interpolationOf(shown), + { + onInput: () => {}, + onChange: (value) => + host.state.execute(new SetKeyInterpolationCommand(asset.id, [...keys], value as KeyInterpolation)), + }, + ); + host.fields.set('easing', row); + host.body.add(row.row); + addNote(host, 'Easing: the way from this key to the next one.', 'small'); +} + +/** The last picked key on a row that blends; undefined when none of them can ease. */ +function easingKey( + project: Project, + asset: AnimationAsset, + keys: readonly KeyRef[], +): AnimationKey | undefined { + for (const ref of [...keys].reverse()) { + const track = asset.tracks.find((t) => t.id === ref.trackId); + const kind = track && kindOf(project, asset, track); + if (!kind || !blends(kind)) continue; + const key = track.keys.find((k) => k.frame === ref.frame); + if (key) return key; + } + return undefined; } /** Pushes the current values into the rows. False when the row is gone (rebuild). */ @@ -139,6 +194,9 @@ export function refreshTrackSection( if (!key) return false; host.fields.get('value')?.setValue(key.value); } + const eased = easingKey(host.state.project, asset, keys); + if (Boolean(eased) !== host.fields.has('easing')) return false; + if (eased) host.fields.get('easing')?.setValue(interpolationOf(eased)); return true; } diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index c97f5715..badaa5e8 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -11,6 +11,7 @@ import { RetargetAnimationTrackCommand, SetAnimationKeyCommand, SetAnimationRootCommand, + SetKeyInterpolationCommand, SetTrackRelativeCommand, } from '../../src/core/commands/animationCommands'; import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands'; @@ -26,7 +27,13 @@ import { stopAnimation, trackValueAt, } from '../../src/core/model/animation'; -import type { AnimationAsset, AnimationKey, ContainerAsset } from '../../src/core/model/Asset'; +import { PREVIEW_ROOT_ID, previewHolder } from '../../src/core/model/animationPreview'; +import type { + AnimationAsset, + AnimationKey, + ContainerAsset, + KeyInterpolation, +} from '../../src/core/model/Asset'; import { instanceChildId } from '../../src/core/model/instance'; import { findAsset, findAssetOf } from '../../src/core/model/Project'; import { animationsUsing } from '../../src/core/model/usages'; @@ -152,6 +159,128 @@ describe('the value of a row', () => { }); }); +describe('easing per key (#101)', () => { + const eased = (interpolation: KeyInterpolation | undefined, frame: number, reverse = false) => + trackValueAt( + [ + { frame: 0, value: 0, interpolation }, + { frame: 10, value: 100 }, + ], + 'number', + frame, + reverse, + ); + + it('bends the way to the next key: ease-in lags at half the time, ease-out leads, ease-in-out meets it', () => { + expect(eased(undefined, 5)).toBe(50); + expect(eased('linear', 5)).toBe(50); + expect(eased('ease-in', 5)).toBeCloseTo(25); + expect(eased('ease-out', 5)).toBeCloseTo(75); + expect(eased('ease-in-out', 5)).toBeCloseTo(50); + expect(eased('ease-in-out', 2.5)).toBeCloseTo(12.5); + expect(eased('ease-in-out', 7.5)).toBeCloseTo(87.5); + for (const interpolation of ['ease-in', 'ease-out', 'ease-in-out'] as const) { + expect(eased(interpolation, 0)).toBe(0); + expect(eased(interpolation, 10)).toBe(100); + } + }); + + it('holds a step key until the next one, and the one above on the way back', () => { + expect(eased('step', 9.9)).toBe(0); + expect(eased('step', 10)).toBe(100); + expect(eased('step', 5, true)).toBe(100); + }); + + it("takes the earlier key's easing for each stretch, and eases a colour over its channels", () => { + const row: AnimationKey[] = [ + { frame: 0, value: 0 }, + { frame: 10, value: 100, interpolation: 'ease-in' }, + { frame: 20, value: 200 }, + ]; + expect(trackValueAt(row, 'number', 5)).toBe(50); + expect(trackValueAt(row, 'number', 15)).toBeCloseTo(125); + const colours: AnimationKey[] = [ + { frame: 0, value: 0x000000, interpolation: 'ease-out' }, + { frame: 10, value: 0xff0000 }, + ]; + expect(trackValueAt(colours, 'color', 5)).toBe(0xbf0000); + }); + + it('sets the easing of picked keys on several rows, skips a row that cannot blend, and undoes', () => { + const f = makeSpriteScene(); + const walk = addAnimation(f, f.spriteId); + const x = addRow(f, walk, 'x', [ + [0, 0], + [10, 100], + ]); + const tint = addRow(f, walk, 'color', [[0, 0]]); + const shown = addRow(f, walk, 'enabled', [[0, true]]); + f.stack.execute( + new SetKeyInterpolationCommand( + walk.id, + [ + { trackId: x, frame: 0 }, + { trackId: tint, frame: 0 }, + { trackId: shown, frame: 0 }, + ], + 'ease-out', + ), + ); + expect(rowOf(walk, x).keys[0]).toEqual({ frame: 0, value: 0, interpolation: 'ease-out' }); + expect(rowOf(walk, tint).keys[0]!.interpolation).toBe('ease-out'); + expect(rowOf(walk, shown).keys[0]).toEqual({ frame: 0, value: true }); + expect(() => + f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: shown, frame: 0 }], 'step')), + ).toThrow(CommandError); + + // A new value and a move keep the easing; linear is stored as no field. + f.stack.execute(new SetAnimationKeyCommand(walk.id, x, 0, 30)); + f.stack.execute(new MoveAnimationKeysCommand(walk.id, [{ trackId: x, frame: 0 }], 2)); + expect(rowOf(walk, x).keys[0]).toEqual({ frame: 2, value: 30, interpolation: 'ease-out' }); + f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: x, frame: 2 }], 'linear')); + expect(rowOf(walk, x).keys[0]).not.toHaveProperty('interpolation'); + + f.stack.undo(); + f.stack.undo(); + f.stack.undo(); + f.stack.undo(); + expect(rowOf(walk, x).keys).toEqual(keys([0, 0], [10, 100])); + expect(rowOf(walk, tint).keys).toEqual(keys([0, 0])); + }); + + it('keeps the easing through a round trip, and drops linear and what is no easing', () => { + const f = makeSpriteScene(); + const walk = addAnimation(f, f.spriteId); + const x = addRow(f, walk, 'x', [ + [0, 0], + [10, 100], + ]); + f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: x, frame: 0 }], 'ease-in-out')); + expect(findAssetOf(roundTrip(f.project), walk.id, 'animation')).toEqual(walk); + + const raw = structuredClone(f.project) as unknown as { assets: Record[] }; + const stored = raw.assets.find((a) => a.id === walk.id)!; + stored.tracks = [ + { + id: 'r1', + target: '', + property: 'x', + keys: [ + { frame: 0, value: 0, interpolation: 'linear' }, + { frame: 1, value: 1, interpolation: 'bouncy' }, + { frame: 2, value: 2, interpolation: 'step' }, + ], + }, + ]; + const coerced = findAssetOf(deserializeProject(raw), walk.id, 'animation')!; + expect(coerced.tracks[0]!.keys).toEqual([ + { frame: 0, value: 0 }, + { frame: 1, value: 1 }, + { frame: 2, value: 2, interpolation: 'step' }, + ]); + }); +}); + describe('editing the timeline', () => { it('builds only over a sprite, text, container or scene', () => { const f = makeSpriteScene(); @@ -317,6 +446,18 @@ describe('the AnimationSystem', () => { expect(sprite.x).toBeCloseTo(55); }); + it('eases like the preview in the tab shows it (#101)', () => { + const { f, walk, row, sprite, frame } = slide(); + f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: row, frame: 0 }], 'ease-in')); + playAnimation(sprite, walk.id); + 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)!; + expect(sprite.x).toBeCloseTo(22.5); + expect(root.position.x).toBeCloseTo(sprite.x); + }); + it('holds the last value when a once animation ends, and reports it exactly once', () => { const { walk, box, f } = slide({ playback: 'once' }); const world = World.instantiate(f.project, f.sceneId);