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

+18 -16
View File
@@ -1,8 +1,8 @@
import { isHandleEnabled } from '../../core/graph';
import {
type AnimationPlayer,
animatedKind,
animatedKindOf,
animatedDef,
animatedDefOf,
animationPlayheadAt,
blendShare,
blends,
@@ -15,6 +15,7 @@ import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
import { instanceChildId, splitInstanceId } from '../../core/model/instance';
import { findAsset, findAssetOf } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import type { Entity } from '../Entity';
import type { World } from '../World';
import type { System } from './System';
@@ -28,7 +29,7 @@ export interface AnimationFinish {
/** A row that reaches something this tick: how to read and write it, and the kind it blends as. */
interface Binding {
track: AnimationTrack;
kind: string;
def: PropertyDef;
/** Where the player keeps the starting value: `<entity id> <property>`, or `scene <property>`. */
base: string;
read(): PropertyValue;
@@ -80,16 +81,17 @@ export class AnimationSystem implements System {
const fade = player.fade;
const fadeShare = fade ? blendShare(fade) : 0;
for (const b of bindings) {
let value = trackWriteAt(b.track, b.kind, head.frame, player.bases[b.base], head.reverse);
let value = trackWriteAt(b.track, b.def, head.frame, player.bases[b.base], head.reverse);
if (value === undefined) continue;
const below = written.has(b.base) ? b.read() : undefined;
if (share < 1 && blends(b.kind)) {
if (share < 1 && blends(b.def.kind)) {
const from = below ?? blend?.from?.[b.base];
if (from !== undefined) value = blendValue(b.kind, from, value, share);
if (from !== undefined) value = blendValue(b.def.kind, from, value, share);
}
if (fade) {
const back = below ?? player.bases[b.base];
if (back !== undefined) value = blends(b.kind) ? blendValue(b.kind, value, back, fadeShare) : back;
if (back !== undefined)
value = blends(b.def.kind) ? blendValue(b.def.kind, value, back, fadeShare) : back;
}
b.write(value);
written.add(b.base);
@@ -139,11 +141,11 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
for (const track of asset.tracks) {
const key = track.property;
if (root.type === 'scene' && track.target === '') {
const kind = world.sceneId === root.id ? animatedKind('scene', key) : undefined;
if (kind)
const def = world.sceneId === root.id ? animatedDef('scene', key) : undefined;
if (def)
bindings.push({
track,
kind,
def,
base: `scene ${key}`,
read: () => world.sceneProps[key] ?? 0,
write: (value) => {
@@ -157,12 +159,12 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
entity = world.sceneId === root.id ? world.findByObjectId(track.target) : undefined;
else if (track.target === '') entity = owner;
else entity = world.findByObjectId(childId(owner.objectId, track.target));
const kind = entity && kindOn(world, entity, key);
if (entity && kind) {
const def = entity && defOn(world, entity, key);
if (entity && def) {
const aimed = entity;
bindings.push({
track,
kind,
def,
base: `${aimed.id} ${key}`,
read: () => read(aimed, key),
write: (value) => write(aimed, key, value),
@@ -173,12 +175,12 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
}
/**
* The kind `key` has on the entity as it is now: its bag names the shader it wears, and swapping that
* The property `key` is on the entity as it is now: its bag names the shader it wears, and swapping that
* shader at runtime takes the parameter rows with it (#104).
*/
function kindOn(world: World, entity: Entity, key: string): string | undefined {
function defOn(world: World, entity: Entity, key: string): PropertyDef | undefined {
const asset = findAsset(world.project, entity.assetId);
return asset && animatedKindOf(world.project, asset, entity.props, key);
return asset && animatedDefOf(world.project, asset, entity.props, key);
}
/**