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

+10 -2
View File
@@ -149,8 +149,16 @@ export interface TextureAsset extends AssetBase {
frames: TextureFrame[];
}
/** 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';
export type EasingCurve =
'sine' | 'quad' | 'cubic' | 'quart' | 'quint' | 'expo' | 'circ' | 'back' | 'elastic' | 'bounce';
export type EasingDirection = 'in' | 'out' | 'in-out';
/**
* How a key blends into the next one (#101); absent is `linear`. `step` holds until the next key. A
* curve is stored as `<curve>-<direction>`, except quad, which keeps its first name `ease-<direction>`.
*/
export type KeyInterpolation =
'linear' | 'step' | `ease-${EasingDirection}` | `${Exclude<EasingCurve, 'quad'>}-${EasingDirection}`;
/**
* A value on a whole frame. Its interpolation rules the way to the next key. A value that cannot
+125 -33
View File
@@ -1,7 +1,15 @@
import { isKindWith } from '../properties/kinds';
import { type PropertyDef, schemaFor, shaderParamRows } from '../properties/PropertySchema';
import { shaderOf } from '../properties/shaders';
import type { AnimationAsset, AnimationKey, AnimationTrack, Asset, KeyInterpolation } from './Asset';
import type {
AnimationAsset,
AnimationKey,
AnimationTrack,
Asset,
EasingCurve,
EasingDirection,
KeyInterpolation,
} from './Asset';
import type { AssetType } from './AssetTypes';
import { treeOrder } from './hierarchy';
import { instanceChildId, resolveObjects } from './instance';
@@ -104,17 +112,85 @@ export function blends(kind: string): boolean {
return kind === 'number' || kind === 'color';
}
/** 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 }[] = [
export type EasingShape = 'linear' | 'step' | EasingCurve;
/** The inspector's first easing field, in the order it offers them; `linear` is what an absent field means. */
export const EASING_SHAPES: readonly { value: EasingShape; 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' },
{ value: 'sine', label: 'Sine' },
{ value: 'quad', label: 'Quad' },
{ value: 'cubic', label: 'Cubic' },
{ value: 'quart', label: 'Quart' },
{ value: 'quint', label: 'Quint' },
{ value: 'expo', label: 'Expo' },
{ value: 'circ', label: 'Circ' },
{ value: 'back', label: 'Back' },
{ value: 'elastic', label: 'Elastic' },
{ value: 'bounce', label: 'Bounce' },
];
/** The second field, for a curve only. */
export const EASING_DIRECTIONS: readonly { value: EasingDirection; label: string }[] = [
{ value: 'in', label: 'In' },
{ value: 'out', label: 'Out' },
{ value: 'in-out', label: 'In-out' },
];
const BACK = 1.70158;
const ELASTIC = (2 * Math.PI) / 3;
function bounceOut(t: number): number {
const n = 7.5625;
const d = 2.75;
if (t < 1 / d) return n * t * t;
if (t < 2 / d) return n * (t -= 1.5 / d) * t + 0.75;
if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375;
return n * (t -= 2.625 / d) * t + 0.984375;
}
/** Each curve's `in` form (Penner's); `out` is it mirrored, `in-out` both halves. Back and elastic leave 0…1. */
const EASE_IN: Record<EasingCurve, (t: number) => number> = {
sine: (t) => 1 - Math.cos((t * Math.PI) / 2),
quad: (t) => t * t,
cubic: (t) => t ** 3,
quart: (t) => t ** 4,
quint: (t) => t ** 5,
expo: (t) => (t <= 0 ? 0 : 2 ** (10 * t - 10)),
circ: (t) => 1 - Math.sqrt(1 - t * t),
back: (t) => (BACK + 1) * t ** 3 - BACK * t * t,
elastic: (t) => (t <= 0 ? 0 : t >= 1 ? 1 : -(2 ** (10 * t - 10)) * Math.sin((10 * t - 10.75) * ELASTIC)),
bounce: (t) => 1 - bounceOut(1 - t),
};
function isCurve(value: string): value is EasingCurve {
return Object.hasOwn(EASE_IN, value);
}
/** A stored interpolation split into the inspector's two fields; unknown text reads as `linear`. */
export function easingOf(interpolation: KeyInterpolation): {
shape: EasingShape;
direction?: EasingDirection;
} {
if (interpolation === 'linear' || interpolation === 'step') return { shape: interpolation };
const match = /^([a-z]+)-(in|out|in-out)$/.exec(interpolation);
if (!match) return { shape: 'linear' };
const curve = match[1]!;
const direction = match[2] as EasingDirection;
if (curve === 'ease') return { shape: 'quad', direction };
return isCurve(curve) && curve !== 'quad' ? { shape: curve, direction } : { shape: 'linear' };
}
/** The stored form of the two fields; a curve without a direction eases both ways. */
export function easingValue(shape: EasingShape, direction: EasingDirection = 'in-out'): KeyInterpolation {
if (shape === 'linear' || shape === 'step') return shape;
return shape === 'quad' ? `ease-${direction}` : `${shape}-${direction}`;
}
export function isKeyInterpolation(value: unknown): value is KeyInterpolation {
return KEY_INTERPOLATIONS.some((i) => i.value === value);
if (typeof value !== 'string') return false;
const { shape, direction } = easingOf(value as KeyInterpolation);
return easingValue(shape, direction) === value;
}
export function interpolationOf(key: AnimationKey): KeyInterpolation {
@@ -122,21 +198,21 @@ 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.
* How far along the way to the next key a value is, `t` the share of the time passed. Quad, the eases
* of old, reaches 0.25 at half the time going in, 0.75 going out, and 0.125 at a quarter going both.
*/
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);
export function easedShare(interpolation: KeyInterpolation, t: number): number {
const { shape, direction } = easingOf(interpolation);
if (shape === 'linear') return t;
if (shape === 'step') return 0;
const easeIn = EASE_IN[shape];
switch (direction ?? 'in-out') {
case 'in':
return easeIn(t);
case 'out':
return 1 - easeIn(1 - t);
case 'in-out':
return t < 0.5 ? easeIn(2 * t) / 2 : 1 - easeIn(2 - 2 * t) / 2;
}
}
@@ -182,34 +258,50 @@ export function trackValueAt(
/**
* What a row writes at a frame: its value, or for a relative number row that value added to `base`,
* what the target held when the animation started. Shared by the runtime player and the tab's
* preview, so both show the same picture. `undefined` for a row without keys.
* preview, so both show the same picture. A number is held to the property's `min`/`max`, which a
* back or elastic curve overshoots. `undefined` for a row without keys.
*/
export function trackWriteAt(
track: AnimationTrack,
kind: string,
def: RowDef,
frame: number,
base: PropertyValue | undefined,
reverse = false,
): PropertyValue | undefined {
const value = trackValueAt(track.keys, kind, frame, reverse);
const value = trackValueAt(track.keys, def.kind, frame, reverse);
if (value === undefined) return undefined;
const offset = track.relative && kind === 'number' && typeof base === 'number';
return offset ? base + Number(value) : value;
if (def.kind !== 'number') return value;
const offset = track.relative && typeof base === 'number';
return heldTo(def, offset ? base + Number(value) : value);
}
/** The kind a row's property has on an asset of that type; undefined for one the timeline cannot animate. */
export function animatedKind(type: AssetType, key: string): string | undefined {
return animatableDefs(type).find((d) => d.key === key)?.kind;
/** What `trackWriteAt` needs of a property; a `PropertyDef` has it. */
export interface RowDef {
kind: string;
min?: number;
max?: number;
}
/** The kind `key` has on an asset holding `bag` – see `animatableDefsOf`. */
export function animatedKindOf(
function heldTo(def: RowDef, value: PropertyValue): PropertyValue {
if (typeof value !== 'number') return value;
if (def.min !== undefined && value < def.min) return def.min;
if (def.max !== undefined && value > def.max) return def.max;
return value;
}
/** A row's property on an asset of that type; undefined for one the timeline cannot animate. */
export function animatedDef(type: AssetType, key: string): PropertyDef | undefined {
return animatableDefs(type).find((d) => d.key === key);
}
/** The property `key` is on an asset holding `bag` – see `animatableDefsOf`. */
export function animatedDefOf(
project: Project,
asset: Asset,
bag: PropertyBag,
key: string,
): string | undefined {
return animatableDefsOf(project, asset, bag).find((d) => d.key === key)?.kind;
): PropertyDef | undefined {
return animatableDefsOf(project, asset, bag).find((d) => d.key === key);
}
function hasPosition(type: AssetType): boolean {
+6 -6
View File
@@ -5,7 +5,7 @@
*/
import {
animatableDefs,
animatedKindOf,
animatedDefOf,
frameCount,
isPositionRow,
PLACED_ROOT_ID,
@@ -72,7 +72,7 @@ function placedHolder(root: Asset): ObjectHolder {
function writeScene(holder: ObjectHolder, track: AnimationTrack, frame: number, reverse: boolean): void {
const def = animatableDefs('scene').find((d) => d.key === track.property);
if (!def) return;
const value = trackWriteAt(track, def.kind, frame, holder.props[track.property] ?? def.default, reverse);
const value = trackWriteAt(track, def, frame, holder.props[track.property] ?? def.default, reverse);
if (value !== undefined) holder.props[track.property] = value;
}
@@ -89,14 +89,14 @@ 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 = animatedKindOf(project, asset, props, track.property);
if (!kind) return;
const def = animatedDefOf(project, asset, props, track.property);
if (!def) return;
const key = track.property;
if (isPositionRow(asset.type, key)) {
const value = trackWriteAt(track, kind, frame, ref.object.position[key], reverse);
const value = trackWriteAt(track, def, frame, ref.object.position[key], reverse);
if (value !== undefined) ref.setPosition({ ...ref.object.position, [key]: Number(value) });
return;
}
const value = trackWriteAt(track, kind, frame, props[key], reverse);
const value = trackWriteAt(track, def, frame, props[key], reverse);
if (value !== undefined) ref.setOverride(key, value);
}