Offer the easings of an animation key on Lerp and sound FX ramps
game-maker / gate (push) Successful in 3m15s
game-maker / suite (push) Successful in 15m36s

A timed Set sound FX parameters ran only linearly, and a Lerp had four
quadratic eases of its own. An animation key already had the full set.
Now all three offer the same choice: Linear, Step, the ten curves from
Sine to Bounce with In, Out or In-out, and a drawn Curve asset.

- easingParams in src/core/graph/nodes/easing.ts gives a node the
  fields Easing, Direction and Curve from the key's own lists, and
  easingOfParams reads them back as a key interpolation.
- There is no migration. A Lerp saved with the old in, out or inOut
  runs linear until its easing is picked again.
- Set sound FX parameters hands the easing to the player. Linear and
  Step stay ramps of the audio clock, every other easing goes to
  setValueCurveAtTime as 200 samples a second, in semitones and dB.
- Step now reaches its end at t 1. A key never asked for that, it
  reads the next key itself, so animations play as before.
- The curve-in-use count and the warning for a gone curve cover the
  sound node too.

Tests:
- node-catalog: Lerp with cubic in, quad out and in-out, a curve
  without a direction, step, an unknown easing as linear. The sound
  node hands on a curve, step and a gone curve as linear.
- sound-library: an eased change becomes a curve in semitones and
  decibels, a step a jump at the end.
- node-catalog and curve after dropping the old values: 399, green.
- e2e 6, 34, 53 on melektaus-atx: ALL PASSED, 198 checks. In Chrome a
  2 s Quad in from depth 7 to -5 read speed 0.864 early and 0.5 at the
  end, a 1 s Step held 1 and then jumped to 0.5.
- Counter-check: with curves sent as linear ramps, the curve test of
  the sound library fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 22:05:00 +02:00
1 parent 501a1524ed
commit e7a360db73
17 files changed
+347 -104

No files matched your search

+4 -3
View File
@@ -1,4 +1,4 @@
import type { AnimationPlayer } from '../model/animation';
import type { AnimationPlayer, EaseShare } from '../model/animation';
import type { Mat } from '../model/matrix';
import type { Position, PropertyBag } from '../model/Properties';
import type { FxParams } from '../soundfx/nodes';
@@ -255,9 +255,10 @@ export interface NodeContext {
/**
* Hands new values to the sound FX parameters of one voice, by the number `playSound` gave it
* (#315), so a running sound follows the game. A voice that has ended is not reached. `time` is
* how many seconds the values take to get there, on the audio clock; 0 is a quick glide.
* how many seconds the values take to get there, on the audio clock and along `ease`; 0 is a
* quick glide.
*/
tuneSound(voice: number, fxParams: FxParams, time: number): void;
tuneSound(voice: number, fxParams: FxParams, time: number, ease: EaseShare): void;
/**
* Asks for another scene, by asset id. The swap happens once the frame is over – a graph must be
* able to finish the flow it is in, and half a frame in the old world and half in the new one is
+4 -3
View File
@@ -13,7 +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 { easingProblem } from './nodes/easing';
import { withSplits } from './portSplits';
import { agreedType, type SharedPortGroup } from './portTypes';
@@ -375,13 +375,14 @@ 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. A
* `Lerp` depends on another asset as well, the curve it eases along (#247), and says so here too.
* `Lerp` or a `Set sound FX parameters` 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 === ON_BEHAVIOR) return listenProblem(node, lookup, ownerId);
if (node.type === SHADER_CALL) return functionCallProblem(node, lookup, ownerId);
if (node.type === SOUND_FX_PARAMETERS) return soundFxParametersProblem(node, lookup);
if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup);
if (node.type !== RUN_BEHAVIOR) return easingProblem(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,8 +1,11 @@
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { curvesOf, easingOfParams, easingParams, hiddenEasing } from '../easing';
import { exec, num } from '../shared';
import { fxParamsOf } from './PlaySound';
import { easeShareOf } from '../../../model/animation';
/**
* Changes the sound FX parameters of a sound that is already playing (#315). It reaches one voice,
* by the number its `Play sound` handed out: every play is a sound of its own, and two objects with
@@ -17,8 +20,8 @@ export const SetSoundFxParameters: NodeDef = {
'glides to the values from a Sound FX parameters node. Keep the voice in a variable to reach it ' +
'from another event. A parameter not given keeps its value, a voice that has ended is not ' +
'reached. A pitch changes by speed here, so the sound gets shorter or longer while it is moved. ' +
'time: seconds the values take to get there, evenly and on the clock of the sound itself, so ' +
'a sweep sounds the same every time; 0 is a quick glide.',
'time: seconds the values take to get there, along the chosen easing and on the clock of the ' +
'sound itself, so a sweep sounds the same every time; 0 is a quick glide.',
inputs: [
exec(),
{ ...num('voice'), inline: false },
@@ -26,12 +29,18 @@ export const SetSoundFxParameters: NodeDef = {
num('time'),
],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
params: easingParams(),
hiddenParams: hiddenEasing,
run: (ctx, inputs, params, _def, _entry, lookup) => {
const voice = asNumber(inputs.voice);
const time = asNumber(inputs.time);
if (voice > 0)
ctx.tuneSound(voice, fxParamsOf(inputs.fxParams), Number.isFinite(time) && time > 0 ? time : 0);
ctx.tuneSound(
voice,
fxParamsOf(inputs.fxParams),
Number.isFinite(time) && time > 0 ? time : 0,
easeShareOf(easingOfParams(params), curvesOf(lookup)),
);
return { next: 'exec' };
},
};
+9 -42
View File
@@ -1,60 +1,27 @@
import type { GraphNode } from '../../Graph';
import type { GraphLookup } from '../../lookup';
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { curvesOf, easingOfParams, easingParams, hiddenEasing } from '../easing';
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. */
const EASES: Record<string, (t: number) => number> = {
linear: (t) => t,
in: (t) => t * t,
out: (t) => 1 - (1 - t) * (1 - t),
inOut: (t) => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t)),
};
import { easedShare } from '../../../model/animation';
import { LERP } from '../../../model/curve';
export const Lerp: NodeDef = {
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. With Ease ' +
'set to Curve it follows the Curve asset picked below, and may overshoot b.',
'Blends from a to b as t runs from 0 to 1, along the chosen easing – the same ones an animation ' +
'key offers. t is held in 0…1. Step stays at a until t reaches 1. Back, Elastic and a Curve ' +
'asset may overshoot b.',
inputs: [withDefault(num('a'), 0), withDefault(num('b'), 1), withDefault(num('t'), 0)],
outputs: [num('result')],
params: [
{
key: 'ease',
label: 'Ease',
kind: 'enum',
default: 'linear',
options: [
{ value: 'linear', label: 'Linear' },
{ 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'] },
],
hiddenParams: (params) => (textOf(params.ease) === 'curve' ? [] : [LERP_CURVE]),
params: easingParams(),
hiddenParams: hiddenEasing,
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 = 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);
const share = easedShare(easingOfParams(params), t, curvesOf(lookup));
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';
}
+59
View File
@@ -0,0 +1,59 @@
import type { GraphNode } from '../Graph';
import type { GraphLookup } from '../lookup';
import type { KeyInterpolation } from '../../model/Asset';
import { EASING_DIRECTIONS, EASING_SHAPES, easingOf, easingValue } from '../../model/animation';
import { type CurveLookup, EASED_NODES, LERP_CURVE } from '../../model/curve';
import { type PropertyBag, textOf } from '../../model/Properties';
import type { PropertyDef } from '../../properties/PropertySchema';
const EASE = 'ease';
const DIRECTION = 'direction';
/** The easings an animation key offers, as node settings: the same choice wherever a value runs somewhere. */
export function easingParams(): PropertyDef[] {
return [
{
key: EASE,
label: 'Easing',
kind: 'enum',
default: 'linear',
options: EASING_SHAPES.map((o) => ({ ...o })),
},
{
key: DIRECTION,
label: 'Direction',
kind: 'enum',
default: 'in-out',
options: EASING_DIRECTIONS.map((o) => ({ ...o })),
},
{ key: LERP_CURVE, label: 'Curve', kind: 'asset', default: '', assetTypes: ['curve'] },
];
}
/** The settings read as an animation key's interpolation; unknown text is linear. */
export function easingOfParams(params: PropertyBag): KeyInterpolation {
const ease = textOf(params[EASE] ?? 'linear');
const shape = EASING_SHAPES.find((o) => o.value === ease)?.value ?? 'linear';
const direction = EASING_DIRECTIONS.find((o) => o.value === textOf(params[DIRECTION] ?? ''))?.value;
return easingValue(shape, direction, textOf(params[LERP_CURVE] ?? ''));
}
/** The settings that say nothing for this easing, for `hiddenParams`. */
export function hiddenEasing(params: PropertyBag): string[] {
const { shape, direction } = easingOf(easingOfParams(params));
return [...(direction ? [] : [DIRECTION]), ...(shape === 'curve' ? [] : [LERP_CURVE])];
}
export function curvesOf(lookup: GraphLookup): CurveLookup {
return (id) => lookup.curve(id) ?? undefined;
}
/** Why a node set to Curve runs linear instead, or null when it eases as it says. */
export function easingProblem(node: GraphNode, lookup: GraphLookup): string | null {
if (!EASED_NODES.includes(node.type)) return null;
const { shape, curve } = easingOf(easingOfParams(node.params));
if (shape !== 'curve') return null;
if (!curve) return 'No curve picked – runs linear';
return lookup.curve(curve) ? null : 'That curve is gone – runs linear';
}
+15 -1
View File
@@ -233,7 +233,8 @@ export function easedShare(
): number {
const { shape, direction, curve } = easingOf(interpolation);
if (shape === 'linear') return t;
if (shape === 'step') return 0;
// A key reads the next key's own value once it is there, so for it a step only ever holds.
if (shape === 'step') return t >= 1 ? 1 : 0;
if (shape === 'curve') {
const points = curves(curve ?? '');
return points ? curveValue(points, t) : t;
@@ -249,6 +250,19 @@ export function easedShare(
}
}
/**
* An easing as something that runs on its own clock, the audio one: the two shapes it has built in
* stay names, everything else is the share at each `t`, a gone curve already read as linear.
*/
export type EaseShare = 'linear' | 'step' | ((t: number) => number);
export function easeShareOf(interpolation: KeyInterpolation, curves: CurveLookup = NO_CURVES): EaseShare {
const { shape, curve } = easingOf(interpolation);
if (shape === 'linear' || shape === 'step') return shape;
if (shape === 'curve' && !curves(curve ?? '')) return 'linear';
return (t) => easedShare(interpolation, t, curves);
}
/** The value between two keys, `t` from 0 (at `a`) to 1 (at `b`). */
export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t: number): PropertyValue {
if (kind === 'number') {
+6 -2
View File
@@ -15,8 +15,10 @@ 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';
/** Every node type with the easing settings of a `Lerp`, the curve among them. */
export const EASED_NODES: readonly string[] = [LERP, 'SetSoundFxParameters'];
/** How many animation keys and `Lerp` nodes ease along a curve – what deleting it has to warn about. */
/** How many animation keys and eased nodes run 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;
@@ -25,7 +27,9 @@ export function curveUsages(project: Project, curveId: string): { keys: number;
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;
nodes += asset.graph.nodes.filter(
(n) => EASED_NODES.includes(n.type) && n.params[LERP_CURVE] === curveId,
).length;
}
return { keys, nodes };
}