diff --git a/README.md b/README.md index f654b0c7..0e668cfe 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1457 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1459 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/nodes/data/Lerp.ts b/src/core/graph/nodes/data/Lerp.ts new file mode 100644 index 00000000..7423a5b9 --- /dev/null +++ b/src/core/graph/nodes/data/Lerp.ts @@ -0,0 +1,42 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; +import { num, withDefault } from '../shared'; + +import { textOf } from '../../../model/Properties'; + +/** Quadratic curves over t in 0…1, each running from 0 to 1. */ +const EASES: Record 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)), +}; + +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.', + 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' }, + ], + }, + ], + run: (_ctx, inputs, params) => { + 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) } }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 36725920..d32a135d 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -47,6 +47,7 @@ import { IsTouching } from './data/IsTouching'; import { Join } from './data/Join'; import { KeyIsDown } from './data/KeyIsDown'; import { Length } from './data/Length'; +import { Lerp } from './data/Lerp'; import { AnimationLiteral, AssetLiteral, @@ -186,6 +187,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ Abs, Sqrt, Clamp, + Lerp, Floor, Ceil, Round, diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index e3704403..f32e9e24 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -556,6 +556,30 @@ const CASES: Record = { { name: 'above the range', inputs: { value: 5, min: 0, max: 1 }, outputs: { result: 1 } }, { name: 'below the range', inputs: { value: -5, min: 0, max: 1 }, outputs: { result: 0 } }, ], + Lerp: [ + { name: 'linear by default', inputs: { a: 10, b: 20, t: 0.25 }, outputs: { result: 12.5 } }, + { name: 'ease in', inputs: { a: 0, b: 8, t: 0.5 }, params: { ease: 'in' }, outputs: { result: 2 } }, + { name: 'ease out', inputs: { a: 0, b: 8, t: 0.5 }, params: { ease: 'out' }, outputs: { result: 6 } }, + { + name: 'ease in-out, slow at both ends', + inputs: { a: 0, b: 8, t: 0.25 }, + params: { ease: 'inOut' }, + outputs: { result: 1 }, + }, + { + name: 'ease in-out, half way at the middle', + inputs: { a: 0, b: 8, t: 0.5 }, + params: { ease: 'inOut' }, + outputs: { result: 4 }, + }, + { name: 't past 1 stops at b', inputs: { a: 3, b: -5, t: 1.2 }, outputs: { result: -5 } }, + { + name: 't below 0 stops at a', + inputs: { a: 3, b: -5, t: -0.5 }, + params: { ease: 'out' }, + outputs: { result: 3 }, + }, + ], Floor: [ { name: 'rounds down', inputs: { value: 2.9 }, outputs: { result: 2 } }, { name: 'rounds down for negatives too', inputs: { value: -2.1 }, outputs: { result: -3 } }, diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 057d7c8a..17bbbd53 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -1098,8 +1098,60 @@ export async function graph(t) { await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'spawny'); - __gm.state.select({ kind: 'asset', assetId: 'mover' }); `); + await lerpNode(); + + await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`); + await showTab('scene'); + + // 6z-iii. Lerp (#246): typed into the palette it arrives linear with a 0…1 range, and the ease + // picked in the inspector lands on the node and on its setting row. + async function lerpNode() { + await mutate(` + const p = __gm.state.project; + p.assets.push({ + id: 'lerpy', + type: 'behavior', + name: 'Lerpy', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: { nodes: [], edges: [] }, + }); + __gm.state.select({ kind: 'asset', assetId: 'lerpy' }); + `); + await showTab('graph'); + const spot = await json('__gm.graph.emptySpot()'); + await click(spot.x, spot.y, 'right'); + await typeText('lerp'); + await press('Enter'); + const lerp = (await asset('lerpy')).graph.nodes[0]; + const inputs = lerp ? await json(`__gm.graph.nodeInputs(${JSON.stringify(lerp.id)})`) : null; + const settings = lerp ? await json(`__gm.graph.nodeSettings(${JSON.stringify(lerp.id)})`) : null; + check( + 'typing "lerp" into the palette adds a linear Lerp from 0 to 1', + lerp?.type === 'Lerp' && + JSON.stringify(inputs) === JSON.stringify(['a 0', 'b 1', 't 0']) && + JSON.stringify(settings) === JSON.stringify(['Ease: Linear']), + JSON.stringify({ lerp, inputs, settings }), + ); + const easeField = await json(`__gm.inspectorField('Ease')`); + if (easeField) { + await click(easeField.x, easeField.y); + await clickText('Out'); + } + const picked = lerp ? (await asset('lerpy')).graph.nodes.find((n) => n.id === lerp.id) : null; + const pickedRows = lerp ? await json(`__gm.graph.nodeSettings(${JSON.stringify(lerp.id)})`) : null; + check( + 'picking "Out" in the inspector sets the node and its setting row', + picked?.params.ease === 'out' && JSON.stringify(pickedRows) === JSON.stringify(['Ease: Out']), + JSON.stringify({ easeField, params: picked?.params, pickedRows }), + ); + await shot('22-lerp'); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'lerpy'); + `); + } }