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',