From 0dda6a012a7a7291f55226c0b1310322f8165927 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 3 Oct 2026 10:41:21 +0200 Subject: [PATCH] Walk an object to a point, and measure distance in the world (#296) The slime of #296 walks to three kinds of point: a spot in its home area, home again, and the player. Each of those took about eight nodes, and Distance measured between local positions, so it was wrong for the player, whose sprite sits inside a container. - New node Move toward: steps an object toward a world point by at most speed times dt and stops exactly on it. Below a moved or turned parent the step is turned into the axes Move counts in. - With Collide with solids it moves the way Move does. Outputs are arrived, the distance still left and hit. - Distance now measures in world units, however deep each object is nested. The slime itself, its state machine, the player's lives and the change to the Break animation live in the project, not in the repo. Tests: - Gate on melektaus-atx: 220 files, 3442 tests (3436 + 6), CHECK EXIT 0. - Six new catalog cases. Both counter-checks bite: Distance back to local positions fails one, Move toward without the parent's axes fails one. - No e2e module touches these nodes. The e2e sum stays at 1701. Co-Authored-By: Claude Opus 5.5 --- src/core/graph/nodes/action/MoveToward.ts | 59 +++++++++++++++++++++++ src/core/graph/nodes/data/Distance.ts | 8 ++- src/core/graph/nodes/index.ts | 2 + tests/core/node-catalog.test.ts | 51 ++++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 src/core/graph/nodes/action/MoveToward.ts diff --git a/src/core/graph/nodes/action/MoveToward.ts b/src/core/graph/nodes/action/MoveToward.ts new file mode 100644 index 00000000..f22931c1 --- /dev/null +++ b/src/core/graph/nodes/action/MoveToward.ts @@ -0,0 +1,59 @@ +import { applyVector, determinant, IDENTITY, invert, linearOf, type Mat } from '../../../model/matrix'; +import { type EntityHandle, worldMatrixOf, worldPositionOf } from '../../NodeContext'; +import type { NodeDef } from '../../NodeRegistry'; +import { asBoolean, asEntity, asNumber } from '../../values'; +import { bool, exec, num, obj, withDefault } from '../shared'; + +/** Closer than this to the point counts as standing on it. */ +const ARRIVED = 1e-6; + +/** The parent's linear map, the axes `Move` counts in; the world's under a parent scaled to nothing. */ +function parentAxes(parent: EntityHandle | null): Mat { + const m = parent ? linearOf(worldMatrixOf(parent)) : IDENTITY; + return Math.abs(determinant(m)) < 1e-12 ? IDENTITY : m; +} + +export const MoveToward: NodeDef = { + type: 'MoveToward', + label: 'Move toward', + category: 'action', + description: + 'Moves an object toward the world point (x, y) by at most speed × dt, and stops exactly on it. ' + + 'Connect dt to an event\'s dt for units per second. With "Collide with solids" on it moves the way ' + + '`Move` does and hit tells whether a solid was in the way. arrived is whether it stands on the point ' + + 'now, distance how far it still is.', + inputs: [ + exec(), + obj('object'), + num('x'), + num('y'), + withDefault(num('speed'), 1), + withDefault(num('dt'), 1), + ], + outputs: [exec(), bool('arrived'), num('distance'), bool('hit')], + params: [{ key: 'collide', label: 'Collide with solids', kind: 'boolean', default: false }], + run: (ctx, inputs, params) => { + const target = asEntity(inputs.object); + if (!target) return { next: 'exec', outputs: { arrived: false, distance: 0, hit: false } }; + const goal = { x: asNumber(inputs.x), y: asNumber(inputs.y) }; + const from = worldPositionOf(target); + const gap = Math.hypot(goal.x - from.x, goal.y - from.y); + const reach = Math.max(0, asNumber(inputs.speed) * asNumber(inputs.dt)); + let hit = false; + if (gap > ARRIVED && reach > 0) { + const share = Math.min(1, reach / gap); + const step = applyVector(invert(parentAxes(target.parent)), { + x: (goal.x - from.x) * share, + y: (goal.y - from.y) * share, + }); + if (asBoolean(params.collide)) hit = ctx.moveAndSlide(target, step.x, step.y).hit; + else { + target.x += step.x; + target.y += step.y; + } + } + const now = worldPositionOf(target); + const distance = Math.hypot(goal.x - now.x, goal.y - now.y); + return { next: 'exec', outputs: { arrived: distance <= ARRIVED, distance, hit } }; + }, +}; diff --git a/src/core/graph/nodes/data/Distance.ts b/src/core/graph/nodes/data/Distance.ts index 03400e10..f988ec07 100644 --- a/src/core/graph/nodes/data/Distance.ts +++ b/src/core/graph/nodes/data/Distance.ts @@ -1,3 +1,4 @@ +import { worldPositionOf } from '../../NodeContext'; import type { NodeDef } from '../../NodeRegistry'; import { asEntity } from '../../values'; import { num, obj } from '../shared'; @@ -6,13 +7,16 @@ export const Distance: NodeDef = { type: 'Distance', label: 'Distance', category: 'data', - description: 'Distance between two objects.', + description: 'Distance between two objects, in world units – however deep each one is nested.', inputs: [obj('a'), obj('b')], outputs: [num('result')], params: [], run: (_ctx, inputs) => { const a = asEntity(inputs.a); const b = asEntity(inputs.b); - return { outputs: { result: a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0 } }; + if (!a || !b) return { outputs: { result: 0 } }; + const pa = worldPositionOf(a); + const pb = worldPositionOf(b); + return { outputs: { result: Math.hypot(pa.x - pb.x, pa.y - pb.y) } }; }, }; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index c0cac67b..5003f443 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -8,6 +8,7 @@ import { LoadScene } from './action/LoadScene'; import { Log } from './action/Log'; import { IgnoreOneWay } from './action/IgnoreOneWay'; import { Move } from './action/Move'; +import { MoveToward } from './action/MoveToward'; import { ApplyForce, ApplyImpulse, @@ -260,6 +261,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ IsTouching, LayerOf, Move, + MoveToward, SetPosition, GetVelocity, ApplyForce, diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 1b778cba..b6e3e729 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -15,6 +15,7 @@ import { resolveDef, type RunResult, type Value, + worldPositionOf, } from '../../src/core/graph'; import type { PropertyBag } from '../../src/core/model/Properties'; import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER, runDef } from '../helpers'; @@ -96,6 +97,11 @@ const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => }; const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y }); +/** At (100, 0) in the world, below a parent that is moved, turned a quarter and doubled. */ +const nestedMover = { + ...at('mover', 0, 0), + parent: { ...at('frame', 100, 0), props: { rotation: 90, scale: 2 } }, +}; /** Its own entity, because Emit's whole effect is the count it leaves on the one it is handed. */ const emitTarget = fakeEntity('Sparks'); /** Their own entity each: the animation nodes leave players on the object they are handed. */ @@ -1232,6 +1238,11 @@ const CASES: Record = { inputs: { a: at('a', 0, 0), b: at('b', 3, 4) }, outputs: { result: 5 }, }, + { + name: 'measures in world units, also below a parent', + inputs: { a: { ...at('a', 3, 4), parent: at('p', 100, 0) }, b: at('b', 100, 0) }, + outputs: { result: 5 }, + }, { name: 'a missing object is distance 0', inputs: { a: at('a', 0, 0), b: null }, outputs: { result: 0 } }, ], AxisInput: [ @@ -1389,6 +1400,46 @@ const CASES: Record = { outputs: { blockedX: false, blockedY: false, hit: false, normalX: 0, normalY: 0 }, }, ], + MoveToward: [ + { + name: 'steps speed × dt toward the point', + inputs: { object: at('m', 0, 0), x: 10, y: 0, speed: 4, dt: 0.5 }, + next: 'exec', + outputs: { arrived: false, distance: 8, hit: false }, + }, + { + name: 'stops on the point instead of running past it', + inputs: { object: at('m', 0, 0), x: 3, y: 4, speed: 100, dt: 1 }, + next: 'exec', + outputs: { arrived: true, distance: 0, hit: false }, + }, + { + name: 'counts the point in world units under a moved, turned parent', + inputs: { object: nestedMover, x: 100, y: 10, speed: 100, dt: 1 }, + next: 'exec', + after: () => { + const world = worldPositionOf(nestedMover); + expect(world.x).toBeCloseTo(100, 9); + expect(world.y).toBeCloseTo(10, 9); + }, + }, + { + name: 'with collision a solid in the way holds it back and says so', + inputs: { object: at('m', 0, 0), x: 10, y: 0, speed: 100, dt: 1 }, + params: { collide: true }, + context: { + moveAndSlide: () => ({ blockedX: true, blockedY: false, hit: true, normal: { x: -1, y: 0 } }), + }, + next: 'exec', + outputs: { arrived: false, distance: 10, hit: true }, + }, + { + name: 'no object is a no-op that still continues the flow', + inputs: { object: null, x: 1, y: 1 }, + next: 'exec', + outputs: { arrived: false, distance: 0, hit: false }, + }, + ], SetPosition: [ { name: 'places the object and continues',