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
@@ -1,3 +1,4 @@
|
||||
import type { VectorPoint } from '../model/Asset';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import type { Graph } from './Graph';
|
||||
|
||||
@@ -35,12 +36,14 @@ export interface StateMachine {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a node may look up in the project around its graph: the behaviors it can run and the
|
||||
* state machines it can read. One handle, so no caller can resolve a node with half of it.
|
||||
* What a node may look up in the project around its graph: the behaviors it can run, the state
|
||||
* machines it can read and the curves a `Lerp` eases along (#247). One handle, so no caller can
|
||||
* resolve a node with half of it.
|
||||
*/
|
||||
export interface GraphLookup {
|
||||
behavior(assetId: string): BehaviorProgram | null;
|
||||
machine(assetId: string): StateMachine | null;
|
||||
curve(assetId: string): readonly VectorPoint[] | null;
|
||||
}
|
||||
|
||||
export const NO_LOOKUP: GraphLookup = { behavior: () => null, machine: () => null };
|
||||
export const NO_LOOKUP: GraphLookup = { behavior: () => null, machine: () => null, curve: () => null };
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from './Graph';
|
||||
import type { GraphLookup } from './lookup';
|
||||
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
||||
import { lerpProblem } from './nodes/data/Lerp';
|
||||
import { agreedType, type SharedPortGroup } from './portTypes';
|
||||
|
||||
export const BEHAVIOR_START = 'BehaviorStart';
|
||||
@@ -273,10 +274,11 @@ export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): strin
|
||||
|
||||
/**
|
||||
* Why this call cannot do anything, or null if it can. A call is the one node that depends on a
|
||||
* *different* graph, so it is the one node that can look perfectly healthy and still be dead.
|
||||
* *different* graph, so it is the one node that can look perfectly healthy and still be dead. A
|
||||
* `Lerp` depends on another asset as well, the curve it eases along (#247), and says so here too.
|
||||
*/
|
||||
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
|
||||
if (node.type !== RUN_BEHAVIOR) return null;
|
||||
if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup);
|
||||
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||||
if (!id) return 'No behavior picked';
|
||||
if (id === ownerId) return 'A behavior cannot run itself';
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { GraphNode } from '../../Graph';
|
||||
import type { GraphLookup } from '../../lookup';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { num, withDefault } from '../shared';
|
||||
|
||||
import { curveValue, LERP, LERP_CURVE } from '../../../model/curve';
|
||||
import { textOf } from '../../../model/Properties';
|
||||
|
||||
/** Quadratic curves over t in 0…1, each running from 0 to 1. */
|
||||
@@ -13,10 +16,12 @@ const EASES: Record<string, (t: number) => number> = {
|
||||
};
|
||||
|
||||
export const Lerp: NodeDef = {
|
||||
type: 'Lerp',
|
||||
type: LERP,
|
||||
label: 'Lerp',
|
||||
category: 'data',
|
||||
description: 'Blends from a to b as t runs from 0 to 1, along the chosen ease. t is held in 0…1.',
|
||||
description:
|
||||
'Blends from a to b as t runs from 0 to 1, along the chosen ease. t is held in 0…1. With Ease ' +
|
||||
'set to Curve it follows the Curve asset picked below, and may overshoot b.',
|
||||
inputs: [withDefault(num('a'), 0), withDefault(num('b'), 1), withDefault(num('t'), 0)],
|
||||
outputs: [num('result')],
|
||||
params: [
|
||||
@@ -30,13 +35,26 @@ export const Lerp: NodeDef = {
|
||||
{ value: 'in', label: 'In' },
|
||||
{ value: 'out', label: 'Out' },
|
||||
{ value: 'inOut', label: 'In-out' },
|
||||
{ value: 'curve', label: 'Curve' },
|
||||
],
|
||||
},
|
||||
{ key: LERP_CURVE, label: 'Curve', kind: 'asset', default: '', assetTypes: ['curve'] },
|
||||
],
|
||||
run: (_ctx, inputs, params) => {
|
||||
hiddenParams: (params) => (textOf(params.ease) === 'curve' ? [] : [LERP_CURVE]),
|
||||
run: (_ctx, inputs, params, _def, _entry, lookup) => {
|
||||
const a = asNumber(inputs.a);
|
||||
const t = Math.min(Math.max(asNumber(inputs.t), 0), 1);
|
||||
const ease = EASES[textOf(params.ease)] ?? EASES.linear!;
|
||||
return { outputs: { result: a + (asNumber(inputs.b) - a) * ease(t) } };
|
||||
const ease = textOf(params.ease);
|
||||
const points = ease === 'curve' ? lookup.curve(textOf(params[LERP_CURVE] ?? '')) : null;
|
||||
const share = points ? curveValue(points, t) : (EASES[ease] ?? EASES.linear!)(t);
|
||||
return { outputs: { result: a + (asNumber(inputs.b) - a) * share } };
|
||||
},
|
||||
};
|
||||
|
||||
/** Why a Lerp set to Curve runs linear instead, or null when it eases as it says. */
|
||||
export function lerpProblem(node: GraphNode, lookup: GraphLookup): string | null {
|
||||
if (node.type !== LERP || textOf(node.params.ease) !== 'curve') return null;
|
||||
const id = textOf(node.params[LERP_CURVE] ?? '');
|
||||
if (!id) return 'No curve picked – runs linear';
|
||||
return lookup.curve(id) ? null : 'That curve is gone – runs linear';
|
||||
}
|
||||
Reference in new issue
Block a user