Let animation keys ease with Sine to Bounce in, out or in-out

A key could only ease quadratically, which makes quick moves like a
sword swing feel soft. Animators know the Penner curves from CSS,
GSAP and Unity, so a key now offers all of them.

- Ten curves (Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back,
  Elastic, Bounce), each in, out and in-out, next to Linear and Step.
- The inspector shows two fields instead of one long list: Easing,
  and Direction only when a curve is picked. Switching the curve
  keeps the direction. Coming from Linear or Step starts at in-out.
- Old projects keep working. ease-in/out/in-out now read as Quad and
  are still saved under that name. New curves save as cubic-out,
  back-in-out and so on.
- Back and Elastic overshoot on purpose. A number property with min
  or max is clamped in play and preview, so a Back in from scale 0
  does not flip the sprite. trackWriteAt now takes the property
  definition, and animatedKind* is renamed to animatedDef*.
- The timeline marker still shows only the direction.
- In-out is built for every curve from its in-curve and its mirror.
  easings.net uses own constants for Back and Elastic in-out, so our
  Back in-out winds up about 5 % instead of 10 %.

Tests:
- ./check.sh: 165 files, 2413 tests, CHECK EXIT 0.
- 12 new unit tests in tests/core/easing-curves.test.ts: round trip
  of all 30 values, start/end/midpoint of every curve, reference
  values, only Back and Elastic leave 0..1, clamping in play and
  preview, unknown values are rejected.
- e2e 25: ALL PASSED, 28 checks. New 25b-ii (3 checks) clicks both
  fields and undoes the four choices. Check sum 1393 -> 1396.
- Counter-checks, each fails its test: no clamping (2), Quad saved
  as quad-* (2), mirrored in-out wrong (2), curve lookup with `in`
  instead of Object.hasOwn (1), no Direction field (e2e 25).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-25 22:02:23 +02:00
1 parent 94749b4f3e
commit 147f8ffbea
9 files changed
+500 -74

No files matched your search

+6 -4
View File
@@ -10,6 +10,7 @@ import {
} from '../../core/commands/animationCommands';
import {
animationPlayheadAt,
easingOf,
fpsOf,
frameCount,
hasKey,
@@ -683,8 +684,8 @@ export class TimelineCanvas extends Widget {
/**
* 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.
* half its end, and a round half is a slow one. Linear is the diamond, any curve in-out the circle, a
* step – which holds – the square.
*/
function drawKeyShape(
g: Graphics,
@@ -694,8 +695,9 @@ function drawKeyShape(
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';
const { direction } = easingOf(interpolation);
const slowStart = direction === 'in' || direction === 'in-out';
const slowEnd = direction === 'out' || direction === '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);
+53 -12
View File
@@ -7,15 +7,25 @@ import {
} from '../../core/commands/animationCommands';
import {
blends,
EASING_DIRECTIONS,
EASING_SHAPES,
easingOf,
type EasingShape,
easingValue,
findKey,
findTrack,
interpolationOf,
KEY_INTERPOLATIONS,
type KeyRef,
trackDef,
trackLabel,
} from '../../core/model/animation';
import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
import type {
AnimationAsset,
AnimationKey,
AnimationTrack,
EasingDirection,
KeyInterpolation,
} from '../../core/model/Asset';
import { findAssetOf, type Project } from '../../core/model/Project';
import { Button } from '../../ui';
import { openRowDialog, RowDialog } from '../animation/RowDialog';
@@ -132,30 +142,57 @@ function buildKey(host: InspectorHost, asset: AnimationAsset, ref: KeyRef): void
}
/**
* 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.
* How the picked keys blend into their next key – for all of them at once: a curve and, for one that
* is not linear or step, its direction. 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(
const easing = easingOf(interpolationOf(shown));
// Read at the click, not now: the other field may have changed the key since this row was built.
const current = () => {
const key = easingKey(host.state.project, asset, keys);
return easingOf(key ? interpolationOf(key) : 'linear');
};
const set = (value: KeyInterpolation) =>
host.state.execute(new SetKeyInterpolationCommand(asset.id, [...keys], value));
const shape = createFieldRow(
host,
{
key: 'easing',
label: 'Easing',
kind: 'enum',
default: 'linear',
options: KEY_INTERPOLATIONS.map((i) => ({ ...i })),
options: EASING_SHAPES.map((i) => ({ ...i })),
},
interpolationOf(shown),
easing.shape,
{
onInput: () => {},
onChange: (value) =>
host.state.execute(new SetKeyInterpolationCommand(asset.id, [...keys], value as KeyInterpolation)),
onChange: (value) => set(easingValue(value as EasingShape, current().direction)),
},
);
host.fields.set('easing', row);
host.body.add(row.row);
host.fields.set('easing', shape);
host.body.add(shape.row);
if (easing.direction) {
const direction = createFieldRow(
host,
{
key: 'direction',
label: 'Direction',
kind: 'enum',
default: 'in-out',
options: EASING_DIRECTIONS.map((i) => ({ ...i })),
},
easing.direction,
{
onInput: () => {},
onChange: (value) => set(easingValue(current().shape, value as EasingDirection)),
},
);
host.fields.set('direction', direction);
host.body.add(direction.row);
}
addNote(host, 'Easing: the way from this key to the next one.', 'small');
}
@@ -193,7 +230,11 @@ export function refreshTrackSection(
}
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));
if (!eased) return true;
const easing = easingOf(interpolationOf(eased));
if (Boolean(easing.direction) !== host.fields.has('direction')) return false;
host.fields.get('easing')?.setValue(easing.shape);
if (easing.direction) host.fields.get('direction')?.setValue(easing.direction);
return true;
}