Files
feuerwerk/src/editor/inspector/trackSection.ts
T
adminandClaude Opus 5.5 393b8ec7f9
game-maker / gate (push) Successful in 3m44s
game-maker / suite (push) Successful in 10m25s
Build your own easing curve and use it on keys and Lerp (#247)
The built-in eases cover the usual shapes, but a game often wants its
own: a coin that bounces twice, a door that overshoots and settles.
A new asset type holds such a curve, and keys and the Lerp node can
pick it instead of a built-in ease.

- New asset type "curve": points like in Vector art, x is t and y is
  the value. A new curve is the straight line from (0, 0) to (1, 1).
- One pure evaluation in src/core/model/curve.ts serves the timeline
  preview, the running game and the Lerp node.
- The curve always has one value per t. The end points stay at t 0
  and 1, points cannot pass each other, and a handle that reaches past
  its neighbour is shortened along its direction. Loading a hand-edited
  file applies the same rules.
- Curve tab: click on the line to insert a point that keeps the shape,
  click on free space to put one there, drag points and handles,
  double-click or Delete removes, Alt-click toggles round and sharp.
  Every step is one undo step.
- Presets: all 30 built-in eases plus Linear, fitted with adaptive
  subdivision. Each stays within 0.4 % of the built-in ease.
- Preview: a dot runs along the curve once a second, a second dot
  shows only the value. The Preview button stops it.
- Animation keys get "Curve" under Easing with a curve picker. Lerp
  gets "Curve" under Ease, and its result may overshoot b.
- A missing or unpicked curve runs linear. The inspector says so, the
  node shows it as a problem, and deleting a used curve warns with the
  number of keys and Lerp nodes.

Tests:
- ./check.sh: 180 files, 2612 tests, CHECK EXIT 0
- GM_STEPS=1-6,25,53: ALL PASSED, 309 checks
- New e2e module 53 with 13 checks, all with the real mouse. Expected
  values come from its own Bezier evaluation.
- Counter-checks bite: without Bezier evaluation 4 unit tests fail,
  without handle clamping 2. In the browser, a key that cannot find its
  curve, a click that sets no point and a handle drag that moves the
  point all turn checks red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:58:05 +02:00

294 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
RemoveAnimationKeysCommand,
SetAnimationKeyCommand,
SetKeyInterpolationCommand,
SetTrackRelativeCommand,
} from '../../core/commands/animationCommands';
import {
blends,
EASING_DIRECTIONS,
EASING_SHAPES,
easingOf,
type EasingShape,
easingValue,
findKey,
findTrack,
interpolationOf,
type KeyRef,
trackDef,
trackLabel,
} from '../../core/model/animation';
import type {
AnimationAsset,
AnimationKey,
AnimationTrack,
EasingDirection,
KeyInterpolation,
} from '../../core/model/Asset';
import { findAssetOf, type Project } from '../../core/model/Project';
import { textOf } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button } from '../../ui';
import { openRowDialog, RowDialog } from '../animation/RowDialog';
import { createFieldRow } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
/**
* Structure-defining facts of a track selection: which row, how many keys and of what kind – a
* key's value field is built for its kind, and a colour key needs another widget than a number.
*/
export function trackSignature(
project: Project,
assetId: string,
trackId: string,
keys: readonly KeyRef[],
): string {
const { asset, track } = rowOf(project, assetId, trackId);
if (!asset || !track) return 'missing';
const kind = kindOf(project, asset, track) ?? 'missing';
const key = keys.length === 1 ? `${keys[0]!.trackId}@${keys[0]!.frame}` : String(keys.length);
return `${track.id}|${track.target}|${track.property}|${kind}|${track.relative ? 'rel' : 'abs'}|${key}|${curveState(
project,
asset,
keys,
)}`;
}
/**
* Which curve the shown easing picks and whether it is there (#247): the Curve row and its warning
* come and go with it, so a change here has to rebuild the section.
*/
function curveState(project: Project, asset: AnimationAsset, keys: readonly KeyRef[]): string {
const key = easingKey(project, asset, keys);
const easing = key && easingOf(interpolationOf(key));
if (easing?.shape !== 'curve') return '';
const found = findAssetOf(project, easing.curve ?? '', 'curve');
return `curve:${easing.curve ?? ''}:${found ? 'here' : 'gone'}`;
}
const CURVE_ROW: PropertyDef = {
key: 'curve',
label: 'Curve',
kind: 'asset',
default: '',
assetTypes: ['curve'],
};
/**
* A row of the timeline: what it aims at, whether it is relative, and – with one key picked – the
* key's value. Several keys show their count and the way to delete them at once.
*/
export function buildTrackSection(
host: InspectorHost,
assetId: string,
trackId: string,
keys: readonly KeyRef[],
): void {
const { asset, track } = rowOf(host.state.project, assetId, trackId);
if (!asset || !track) return;
const kind = kindOf(host.state.project, asset, track);
addTitle(host, 'TIMELINE ROW');
const label = createFieldRow(
host,
{ key: 'row', label: 'Row', kind: 'text', default: '' },
trackLabel(host.state.project, asset, track),
{ onInput: () => {}, onChange: () => {} },
);
host.body.add(label.row);
if (!kind)
addNote(host, 'The object or property this row aims at is gone; it is skipped when played.', 'warning');
else addNote(host, `A ${kind} row of "${asset.name}".`);
host.body.add(
new Button('Change target…', {
onClick: () => {
const now = rowOf(host.state.project, assetId, trackId);
if (now.asset && now.track) openRowDialog(new RowDialog(host.state, now.asset, now.track));
},
}),
);
if (kind === 'number') {
const relative = createFieldRow(
host,
{ key: 'relative', label: 'Relative', kind: 'boolean', default: false },
Boolean(track.relative),
{
onInput: () => {},
onChange: (value) =>
host.state.execute(new SetTrackRelativeCommand(assetId, trackId, Boolean(value))),
},
);
host.fields.set('relative', relative);
host.body.add(relative.row);
addNote(host, 'Relative: the keys add to where the target stood when the animation started.', 'small');
}
if (keys.length === 1) buildKey(host, asset, keys[0]!);
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',
onClick: () => host.state.execute(new RemoveAnimationKeysCommand(assetId, [...keys])),
}),
);
}
}
/** The value of the one picked key, editable in the widget its kind calls for. */
function buildKey(host: InspectorHost, asset: AnimationAsset, ref: KeyRef): void {
const track = findTrack(asset, ref.trackId);
const key = findKey(asset, ref);
const def = track && trackDef(host.state.project, asset, track);
if (!track || !key || !def) return;
addGap(host);
addTitle(host, `KEY AT FRAME ${ref.frame}`);
const row = createFieldRow(host, { ...def, key: 'value', label: 'Value' }, key.value, {
onInput: () => {},
onChange: (value) =>
host.state.execute(new SetAnimationKeyCommand(asset.id, ref.trackId, ref.frame, value)),
});
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: 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 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: EASING_SHAPES.map((i) => ({ ...i })),
},
easing.shape,
{
onInput: () => {},
onChange: (value) => {
const now = current();
// Switched to Curve, a key takes the curve it had, else the first one there is.
const curve = now.curve || firstCurveId(host.state.project);
set(easingValue(value as EasingShape, now.direction, curve));
},
},
);
host.fields.set('easing', shape);
host.body.add(shape.row);
if (easing.shape === 'curve') buildCurveRow(host, easing.curve ?? '', set);
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');
}
/** The Curve asset the keys blend along, and why they blend linearly when there is none. */
function buildCurveRow(host: InspectorHost, curveId: string, set: (value: KeyInterpolation) => void): void {
const row = createFieldRow(host, CURVE_ROW, curveId, {
onInput: () => {},
onChange: (value) => set(easingValue('curve', undefined, textOf(value))),
});
host.fields.set('curve', row);
host.body.add(row.row);
if (!curveId) addNote(host, 'No curve picked – the key blends linearly.', 'warning');
else if (!findAssetOf(host.state.project, curveId, 'curve'))
addNote(host, 'That curve is gone – the key blends linearly.', 'warning');
}
function firstCurveId(project: Project): string {
return project.assets.find((a) => a.type === 'curve')?.id ?? '';
}
/** 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 = findTrack(asset, ref.trackId);
const kind = track && kindOf(project, asset, track);
if (!kind || !blends(kind)) continue;
const key = findKey(asset, ref);
if (key) return key;
}
return undefined;
}
/** Pushes the current values into the rows. False when the row is gone (rebuild). */
export function refreshTrackSection(
host: InspectorHost,
assetId: string,
trackId: string,
keys: readonly KeyRef[],
): boolean {
const { asset, track } = rowOf(host.state.project, assetId, trackId);
if (!asset || !track) return false;
host.fields.get('relative')?.setValue(Boolean(track.relative));
const ref = keys.length === 1 ? keys[0] : undefined;
if (ref) {
const key = findKey(asset, ref);
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) 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);
if (easing.shape === 'curve') host.fields.get('curve')?.setValue(easing.curve ?? '');
return true;
}
function rowOf(
project: Project,
assetId: string,
trackId: string,
): { asset?: AnimationAsset; track?: AnimationTrack } {
const asset = findAssetOf(project, assetId, 'animation');
return { asset, track: asset && findTrack(asset, trackId) };
}
function kindOf(project: Project, asset: AnimationAsset, track: AnimationTrack): string | undefined {
return trackDef(project, asset, track)?.kind;
}