Walk an object to a point, and measure distance in the world (#296)
game-maker / gate (push) Successful in 2m55s
game-maker / suite (push) Successful in 14m10s

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 10:41:21 +02:00
1 parent f1e3bd8378
commit 0dda6a012a
4 files changed
+118 -2

No files matched your search

+59
View File
@@ -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 } };
},
};
+6 -2
View File
@@ -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) } };
},
};
+2
View File
@@ -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,
+51
View File
@@ -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<string, Case[]> = {
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<string, Case[]> = {
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',