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>
This commit is contained in:
1 parent
320d3e27e1
commit
393b8ec7f9
41 files changed
+1981
-67
No files matched your search
@@ -13,6 +13,7 @@ import {
|
||||
trackValueAt,
|
||||
} from '../model/animation';
|
||||
import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../model/Asset';
|
||||
import { curveLookup } from '../model/curve';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import { coerceValue } from '../properties/PropertySchema';
|
||||
@@ -233,7 +234,7 @@ export class SetAnimationKeyCommand extends TimelineCommand {
|
||||
requireFrame(this.frame, 'A key sits on a whole frame from 0');
|
||||
const def = trackDef(project, asset, track);
|
||||
if (!def) throw new CommandError('The row aims at something that is gone');
|
||||
const current = trackValueAt(track.keys, def.kind, this.frame);
|
||||
const current = trackValueAt(track.keys, def.kind, this.frame, false, curveLookup(project));
|
||||
const raw = this.value ?? current ?? (track.relative ? 0 : def.default);
|
||||
const key: AnimationKey = { frame: this.frame, value: coerceValue(def, raw) };
|
||||
const interpolation = track.keys.find((k) => k.frame === this.frame)?.interpolation;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
VariablesAsset,
|
||||
} from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import { linearCurve } from '../model/curve';
|
||||
import { isFontFile } from '../model/font';
|
||||
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
|
||||
import { isSoundFile } from '../model/sound';
|
||||
@@ -71,6 +72,9 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'palette', colors: [...DEFAULT_PALETTE] };
|
||||
case 'animation':
|
||||
return { ...base, type: 'animation', root: '', tracks: [] };
|
||||
// Linear until someone bends it: a key that picks a new curve still moves as it did.
|
||||
case 'curve':
|
||||
return { ...base, type: 'curve', points: linearCurve() };
|
||||
case 'tile':
|
||||
return { ...base, type: 'tile', pictures: [] };
|
||||
// A tilemap without a layer could not be painted on, so it starts with one.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Editing the points of a Curve asset (#247). One gesture is one command, as with a vector art ring:
|
||||
* the whole point list is written once when the pointer lets go, so an undo takes the drag back.
|
||||
* Whatever is written is held to the curve's rules first (`sanitizedCurve`), so no command – and no
|
||||
* hand-made list from a preset – can leave a curve that runs backwards in `t`.
|
||||
*/
|
||||
import type { CurveAsset, VectorPoint } from '../model/Asset';
|
||||
import { sanitizedCurve } from '../model/curve';
|
||||
import type { Project } from '../model/Project';
|
||||
import type { Command } from './Command';
|
||||
import { requireCurve } from './require';
|
||||
|
||||
export class SetCurvePointsCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: VectorPoint[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly points: readonly VectorPoint[],
|
||||
label = 'Edit curve',
|
||||
) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/** Nothing to do when the curve already reads exactly like this – no empty undo step. */
|
||||
static changes(asset: CurveAsset, points: readonly VectorPoint[]): boolean {
|
||||
return JSON.stringify(asset.points) !== JSON.stringify(sanitizedCurve(points));
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireCurve(project, this.assetId);
|
||||
this.previous = asset.points;
|
||||
asset.points = sanitizedCurve(this.points);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireCurve(project, this.assetId).points = this.previous;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/G
|
||||
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
||||
import type {
|
||||
Asset,
|
||||
CurveAsset,
|
||||
TileAsset,
|
||||
BehaviorAsset,
|
||||
StateMachineAsset,
|
||||
@@ -121,6 +122,12 @@ export function requireVectorShape(project: Project, assetId: string, shapeId: s
|
||||
return shape;
|
||||
}
|
||||
|
||||
export function requireCurve(project: Project, assetId: string): CurveAsset {
|
||||
const asset = findAssetOf(project, assetId, 'curve');
|
||||
if (!asset) throw new CommandError(`Curve ${assetId} not found`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
export function requireStateMachine(project: Project, assetId: string): StateMachineAsset {
|
||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||
if (!asset) throw new CommandError(`State machine ${assetId} not found`);
|
||||
|
||||
Reference in new issue
Block a user