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 <noreply@anthropic.com>
This commit is contained in:
1 parent
f1e3bd8378
commit
0dda6a012a
4 files changed
+118
-2
No files matched your search
@@ -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 } };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { worldPositionOf } from '../../NodeContext';
|
||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { asEntity } from '../../values';
|
import { asEntity } from '../../values';
|
||||||
import { num, obj } from '../shared';
|
import { num, obj } from '../shared';
|
||||||
@@ -6,13 +7,16 @@ export const Distance: NodeDef = {
|
|||||||
type: 'Distance',
|
type: 'Distance',
|
||||||
label: 'Distance',
|
label: 'Distance',
|
||||||
category: 'data',
|
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')],
|
inputs: [obj('a'), obj('b')],
|
||||||
outputs: [num('result')],
|
outputs: [num('result')],
|
||||||
params: [],
|
params: [],
|
||||||
run: (_ctx, inputs) => {
|
run: (_ctx, inputs) => {
|
||||||
const a = asEntity(inputs.a);
|
const a = asEntity(inputs.a);
|
||||||
const b = asEntity(inputs.b);
|
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) } };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -8,6 +8,7 @@ import { LoadScene } from './action/LoadScene';
|
|||||||
import { Log } from './action/Log';
|
import { Log } from './action/Log';
|
||||||
import { IgnoreOneWay } from './action/IgnoreOneWay';
|
import { IgnoreOneWay } from './action/IgnoreOneWay';
|
||||||
import { Move } from './action/Move';
|
import { Move } from './action/Move';
|
||||||
|
import { MoveToward } from './action/MoveToward';
|
||||||
import {
|
import {
|
||||||
ApplyForce,
|
ApplyForce,
|
||||||
ApplyImpulse,
|
ApplyImpulse,
|
||||||
@@ -260,6 +261,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
|||||||
IsTouching,
|
IsTouching,
|
||||||
LayerOf,
|
LayerOf,
|
||||||
Move,
|
Move,
|
||||||
|
MoveToward,
|
||||||
SetPosition,
|
SetPosition,
|
||||||
GetVelocity,
|
GetVelocity,
|
||||||
ApplyForce,
|
ApplyForce,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
resolveDef,
|
resolveDef,
|
||||||
type RunResult,
|
type RunResult,
|
||||||
type Value,
|
type Value,
|
||||||
|
worldPositionOf,
|
||||||
} from '../../src/core/graph';
|
} from '../../src/core/graph';
|
||||||
import type { PropertyBag } from '../../src/core/model/Properties';
|
import type { PropertyBag } from '../../src/core/model/Properties';
|
||||||
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER, runDef } from '../helpers';
|
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 });
|
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. */
|
/** Its own entity, because Emit's whole effect is the count it leaves on the one it is handed. */
|
||||||
const emitTarget = fakeEntity('Sparks');
|
const emitTarget = fakeEntity('Sparks');
|
||||||
/** Their own entity each: the animation nodes leave players on the object they are handed. */
|
/** 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) },
|
inputs: { a: at('a', 0, 0), b: at('b', 3, 4) },
|
||||||
outputs: { result: 5 },
|
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 } },
|
{ name: 'a missing object is distance 0', inputs: { a: at('a', 0, 0), b: null }, outputs: { result: 0 } },
|
||||||
],
|
],
|
||||||
AxisInput: [
|
AxisInput: [
|
||||||
@@ -1389,6 +1400,46 @@ const CASES: Record<string, Case[]> = {
|
|||||||
outputs: { blockedX: false, blockedY: false, hit: false, normalX: 0, normalY: 0 },
|
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: [
|
SetPosition: [
|
||||||
{
|
{
|
||||||
name: 'places the object and continues',
|
name: 'places the object and continues',
|
||||||
|
|||||||
Reference in new issue
Block a user