Files
feuerwerk/src/core/model/curve.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

152 lines
6.1 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.
/**
* Curve assets (#247): an easing drawn by hand, read wherever a key or a `Lerp` node picks one.
* One pure function evaluates it for the Curve tab, the animation player and the graph alike.
*
* A segment is a cubic Bezier from one point to the next, a line where neither end has a handle –
* the same rule vector art draws by. It is solved for `t` like CSS `cubic-bezier`: with both inner
* control points between the ends in `t`, `t` rises monotonically along the segment, so bisection
* finds the one place a `t` is reached.
*/
import { isGraphAsset, type VectorPoint } from './Asset';
import { findAssetOf, type Project } from './Project';
import type { Vec2 } from './Properties';
import { cloneVectorPoint } from './vectorart';
/** The node type that eases along a curve, and its setting naming the curve. */
export const LERP = 'Lerp';
export const LERP_CURVE = 'curve';
/** How many animation keys and `Lerp` nodes ease along a curve – what deleting it has to warn about. */
export function curveUsages(project: Project, curveId: string): { keys: number; nodes: number } {
let keys = 0;
let nodes = 0;
for (const asset of project.assets) {
if (asset.type === 'animation')
for (const track of asset.tracks)
keys += track.keys.filter((key) => key.interpolation === `curve:${curveId}`).length;
if (isGraphAsset(asset))
nodes += asset.graph.nodes.filter((n) => n.type === LERP && n.params[LERP_CURVE] === curveId).length;
}
return { keys, nodes };
}
/** The points of the curve an id names; undefined once it is gone. */
export type CurveLookup = (curveId: string) => readonly VectorPoint[] | undefined;
export const NO_CURVES: CurveLookup = () => undefined;
export function curveLookup(project: Project): CurveLookup {
return (curveId) => (curveId ? findAssetOf(project, curveId, 'curve')?.points : undefined);
}
/** What a new curve is: a straight line from (0, 0) to (1, 1), the same as `linear`. */
export function linearCurve(): VectorPoint[] {
return [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
];
}
/** Halvings of the segment; 2^-40 of a segment is far below anything a value could show. */
const BISECTIONS = 40;
/**
* The value at `t`, held to 0…1. Between two points the segment decides; a segment without width –
* two points on one `t` – is a jump, and the later point's value wins from there on.
*/
export function curveValue(points: readonly VectorPoint[], t: number): number {
const first = points[0];
if (!first) return t;
const at = Math.min(Math.max(Number.isFinite(t) ? t : 0, 0), 1);
if (at <= first.x) return first.y;
for (let i = 1; i < points.length; i++) {
const to = points[i]!;
if (at > to.x) continue;
return segmentValue(points[i - 1]!, to, at);
}
return points[points.length - 1]!.y;
}
function segmentValue(from: VectorPoint, to: VectorPoint, t: number): number {
const span = to.x - from.x;
if (span <= 0) return to.y;
if (!from.out && !to.in) return from.y + ((to.y - from.y) * (t - from.x)) / span;
// Held between the ends in `t`, whatever is stored: that is what keeps the solve unique.
const c1x = clamp(from.x + (from.out?.x ?? 0), from.x, to.x);
const c2x = clamp(to.x + (to.in?.x ?? 0), from.x, to.x);
const c1y = from.y + (from.out?.y ?? 0);
const c2y = to.y + (to.in?.y ?? 0);
let lo = 0;
let hi = 1;
for (let step = 0; step < BISECTIONS; step++) {
const mid = (lo + hi) / 2;
if (cubic(from.x, c1x, c2x, to.x, mid) < t) lo = mid;
else hi = mid;
}
return cubic(from.y, c1y, c2y, to.y, (lo + hi) / 2);
}
function cubic(a: number, b: number, c: number, d: number, s: number): number {
const u = 1 - s;
return u * u * u * a + 3 * u * u * s * b + 3 * u * s * s * c + s * s * s * d;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* The curve as the rules want it, whatever was handed in. At least two points; sorted by `t`, `t`
* held to 0…1, the first at 0 and the last at 1. The first point has no handle in and the last none
* out. A handle pointing backwards in `t` is turned upright, and one reaching past its neighbour is
* shortened along its own direction, so a smooth point stays smooth. What does not come out a number
* is dropped; with fewer than two points left the curve is linear.
*/
export function sanitizedCurve(points: readonly VectorPoint[]): VectorPoint[] {
const kept = points
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y))
.map((p) => ({ ...cloneVectorPoint(p), x: clamp(p.x, 0, 1) }))
.sort((a, b) => a.x - b.x);
if (kept.length < 2) return linearCurve();
kept[0]!.x = 0;
kept[kept.length - 1]!.x = 1;
for (const [i, point] of kept.entries()) {
const before = kept[i - 1];
const after = kept[i + 1];
const handleIn = before && point.in ? heldHandle(point.in, before.x - point.x) : undefined;
const handleOut = after && point.out ? heldHandle(point.out, after.x - point.x) : undefined;
if (handleIn) point.in = handleIn;
else delete point.in;
if (handleOut) point.out = handleOut;
else delete point.out;
if (!point.in && !point.out) delete point.mode;
}
return kept;
}
/** A handle kept on its own side of the point, reaching `reach` in `t` at most (negative for `in`). */
function heldHandle(handle: Vec2, reach: number): Vec2 | undefined {
const finite = Number.isFinite(handle.x) && Number.isFinite(handle.y);
if (!finite) return undefined;
let { x, y } = handle;
if (x * reach < 0) x = 0;
if (Math.abs(x) > Math.abs(reach)) {
const scale = Math.abs(reach) / Math.abs(x);
x *= scale;
y *= scale;
}
return Math.hypot(x, y) < 1e-9 ? undefined : { x, y };
}
/** The lowest and highest value the curve can reach – its points and handles – and never less than 0…1. */
export function curveExtent(points: readonly VectorPoint[]): { min: number; max: number } {
let min = 0;
let max = 1;
for (const point of points)
for (const y of [point.y, point.y + (point.in?.y ?? 0), point.y + (point.out?.y ?? 0)]) {
min = Math.min(min, y);
max = Math.max(max, y);
}
return { min, max };
}