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:
1 parent
94749b4f3e
commit
147f8ffbea
9 files changed
+500
-74
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user