Keys carry an easing for the span to the next key: linear, step, ease in, ease out, ease in-out (#101)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c92a9478f
commit
b278605c53
7 files changed
+331
-27
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>[] };
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user