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>
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
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;
|
||
}
|