The new node "Raycast all" answers with every collider along the ray instead of only the nearest one: it carries the same inputs and settings as Raycast (Ignore owner, Hit triggers, Layers from #158) plus "max hits" where 0 means all, and it hands back parallel lists sorted by distance - objects, colliders, points x/y, normals x/y, distances - next to count, so index i of every list speaks about the same hit; a collider counts once no matter how many convex parts its shape is built from, while a tilemap counts per cell it is hit in, and the filter sits in Solids.raycastAll like the one of #158, the one path the editor run and Play both take, so all 12 new unit tests run through describe.each against both systems; the node needed the value system to learn something it could not do - a graph list carried numbers, text, booleans and bundles but no object handles, asList turned an object into its name, so objects/colliders would have been lists of names, which loses the object when two carry the same name, and collider children usually do; Value now knows a travelling list that may hold handles and "Item at" gives such an item out as an object, ready for Destroy, Apply force or Name of, while the single door into a stored file - writeTyped/asTypeOf - runs the list through the new storedList() so every object becomes its name again and nothing living reaches a project file, which is its own unit test over JSON.stringify; three list nodes follow the handles: "Index of" compares objects by identity because a name is not the object of that name, "Set item" and "Fill" write an object as an object, and "Join text" prints the names instead of the innards; this decision was mine to make because the question ran into the MCP timeout - the alternative was a list of names plus "Object by name", which is the smaller change but lossy; 4 new e2e checks in the new block 43e (shield and floor in one go reading 2 and "145, 280", the second object answering to its name, which only a real object in a list can do, "max hits" 1 stopping at the nearest, and the node showing the row) and 18 new unit tests, 12 in raycast.test.ts, 4 on the value system and 2 in the node catalog; two counter-checks hold: not counting tilemap cells one by one drops 2 unit tests, and answering with the nearest hit only drops module 43 with 2 FAILs; one finding on the way - the node read "maxHits" while every other input carries a spoken label, because int() took the key as its label, so it has a third parameter now, and the e2e check caught that, no unit test did (#157)
game-maker / gate (push) Successful in 2m4s
game-maker / suite (push) Successful in 7m26s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 18:54:12 +02:00
1 parent 0fdde6cb1e
commit ffdf0a8ddb
20 files changed
+607 -33

No files matched your search

+148 -1
View File
@@ -188,7 +188,21 @@ function run(f: SceneFixture, physics: boolean) {
triggers,
layers,
});
return { ...started, entity, cast };
const castAll = (
from: Vec2,
dir: Vec2,
length = 1000,
{ ignore = null as string | null, triggers = false, layers = ALL_LAYERS, maxHits = 0 } = {},
) =>
started.collisions.raycastAll(
started.world,
from,
dir,
length,
{ ignore: ignore ? entity(ignore) : null, triggers, layers },
maxHits,
);
return { ...started, entity, cast, castAll };
}
describe.each(COLLISION_SYSTEMS)('$system: a ray through the world (#153)', ({ physics }) => {
@@ -357,6 +371,139 @@ describe.each(COLLISION_SYSTEMS)('$system: a ray through the world (#153)', ({ p
});
});
describe.each(COLLISION_SYSTEMS)('$system: every hit on the line (#157)', ({ physics }) => {
it('gives them nearest first, and the nearest of them is what a plain raycast says', () => {
const s = scene();
const far = s.add({ x: 300, y: 0 }, { width: 20, height: 100 }); // near face at 290
const near = s.add({ x: 100, y: 0 }, { shape: 'circle', radius: 10 }); // near face at 90
const middle = s.add({ x: 200, y: 0 }, { width: 20, height: 100 }); // near face at 190
const r = run(s, physics);
const hits = r.castAll({ x: 0, y: 0 }, RIGHT);
expect(hits.map((hit) => hit.distance)).toEqual([90, 190, 290]);
expect(hits.map((hit) => hit.collider)).toEqual([
r.entity(near.collider),
r.entity(middle.collider),
r.entity(far.collider),
]);
expect(hits[0]).toEqual(r.cast({ x: 0, y: 0 }, RIGHT));
// Every hit is where it says it is, with the face's normal pointing back at the ray.
expect(hits[1]).toMatchObject({ point: { x: 190, y: 0 }, normal: { x: -1, y: 0 } });
expect(r.castAll({ x: 0, y: 0 }, RIGHT, 200).map((hit) => hit.distance)).toEqual([90, 190]);
expect(r.castAll({ x: 0, y: -300 }, RIGHT)).toEqual([]);
});
it('counts a collider once however many convex pieces it is made of', () => {
const s = scene();
// A U on its side: the ray down the middle enters the left arm, the notch and the right arm.
const u = s.add(
{ x: 0, y: 0 },
{ shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) },
);
const r = run(s, physics);
const hits = r.castAll({ x: 0, y: -100 }, DOWN);
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({ collider: r.entity(u.collider), distance: 130 });
});
it('keeps the filters of a plain cast: the owner, triggers and the layers (#158)', () => {
const s = scene();
const owner = s.add({ x: 100, y: 0 }, { width: 10, height: 100 });
const trigger = s.add({ x: 150, y: 0 }, { width: 10, height: 100, isTrigger: true });
const hidden = s.add({ x: 200, y: 0 }, { width: 10, height: 100, [COLLISION_LAYER_KEY]: 1 });
const wall = s.add({ x: 300, y: 0 }, { width: 10, height: 100 });
const r = run(s, physics);
const ids = (hits: { collider: unknown }[]) => hits.map((hit) => hit.collider);
expect(ids(r.castAll({ x: 0, y: 0 }, RIGHT))).toEqual([
r.entity(owner.collider),
r.entity(hidden.collider),
r.entity(wall.collider),
]);
expect(ids(r.castAll({ x: 0, y: 0 }, RIGHT, 1000, { triggers: true }))).toContain(
r.entity(trigger.collider),
);
expect(ids(r.castAll({ x: 0, y: 0 }, RIGHT, 1000, { ignore: owner.id }))).toEqual([
r.entity(hidden.collider),
r.entity(wall.collider),
]);
expect(ids(r.castAll({ x: 0, y: 0 }, RIGHT, 1000, { layers: maskWith(ALL_LAYERS, 1, false) }))).toEqual([
r.entity(owner.collider),
r.entity(wall.collider),
]);
});
it('meets a tilemap once per cell the ray goes through, the map being the collider each time', () => {
const s = scene();
const wall = new CreateAssetCommand('tile', 'Wall');
const map = new CreateAssetCommand('tilemap', 'Ground');
s.stack.execute(wall);
s.stack.execute(map);
s.stack.execute(new SetAssetPropertyCommand(wall.asset.id, TILE_SOLID_KEY, true));
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
const mapObject = place(s, map.asset.id, { x: 0, y: 0 });
const layer = (findAsset(s.project, map.asset.id) as TilemapAsset).layers[0]!.id;
// Cells of 48, centred: a lone cell in row 5 (top face at 0) and one in row 9 (top face at 192).
s.stack.execute(
new PaintTilesCommand(map.asset.id, layer, [
{ x: 5, y: 5, tileId: wall.asset.id },
{ x: 5, y: 9, tileId: wall.asset.id },
]),
);
const r = run(s, physics);
const hits = r.castAll({ x: 0, y: -100 }, DOWN);
expect(hits.map((hit) => hit.distance)).toEqual([100, 292]);
expect(hits.map((hit) => hit.collider)).toEqual([r.entity(mapObject), r.entity(mapObject)]);
});
it('"max hits" keeps that many of the nearest, and 0 keeps them all', () => {
const s = scene();
s.add({ x: 100, y: 0 }, { width: 20, height: 100 });
s.add({ x: 200, y: 0 }, { width: 20, height: 100 });
s.add({ x: 300, y: 0 }, { width: 20, height: 100 });
const r = run(s, physics);
const far = (maxHits: number) =>
r.castAll({ x: 0, y: 0 }, RIGHT, 1000, { maxHits }).map((h) => h.distance);
expect(far(0)).toEqual([90, 190, 290]);
expect(far(2)).toEqual([90, 190]);
expect(far(9)).toEqual([90, 190, 290]);
});
it('the node hands the hits to the graph as lists, and the objects in them are objects', () => {
const s = scene();
const hero = s.add({ x: 0, y: 0 }, { width: 20, height: 20 });
s.add({ x: 0, y: 150 }, { width: 200, height: 20 }); // top face at 140
s.add({ x: 0, y: 300 }, { width: 200, height: 20 }); // top face at 290
const behavior = makeBehavior(s, 'Eyes');
place(s, behavior.id, { x: 0, y: 0 }, hero.id);
const edit = graphEditor(s, behavior);
const update = edit.addNode('OnUpdate');
const ray = edit.addNode('RaycastAll', { fromX: 0, fromY: 0, dirX: 0, dirY: 1, length: 500 });
const logCount = edit.addNode('Log');
const logDistances = edit.addNode('Log');
const second = edit.addNode('ItemAt', { index: 1 });
const where = edit.addNode('GetPosition');
const logSecond = edit.addNode('Log');
edit.wire([
[update, 'exec', ray, 'exec'],
[ray, 'exec', logCount, 'exec'],
[ray, 'count', logCount, 'message'],
[logCount, 'exec', logDistances, 'exec'],
[ray, 'distances', logDistances, 'message'],
[ray, 'objects', second, 'list'],
[second, 'item', where, 'object'],
[logDistances, 'exec', logSecond, 'exec'],
[where, 'worldY', logSecond, 'message'],
]);
const r = run(s, physics);
r.frame(1 / 60);
// It starts inside its own hero, which "Ignore owner" leaves out anyway. The last line is the
// proof that the list holds live objects and not their names: a name has no position (#157).
expect(r.world.log).toEqual(['2', '140, 290', '300']);
// The ray is drawn to the last thing it met, which is how far it looked.
expect(r.world.rays).toEqual([{ from: { x: 0, y: 0 }, to: { x: 0, y: 290 }, hit: true, time: 1 / 60 }]);
});
});
describe('the rays a run remembers for drawing (#153)', () => {
it('keeps those of the last moment only, and a ray that met nothing ends at its length', () => {
const s = scene();