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

+11
View File
@@ -225,6 +225,17 @@ export class PhysicsSystem implements System, CollisionSource {
return this.solids.raycast(world, from, direction, length, filter);
}
raycastAll(
world: World,
from: Vec2,
direction: Vec2,
length: number,
filter: RayFilter,
maxHits?: number,
): RayResult[] {
return this.solids.raycastAll(world, from, direction, length, filter, maxHits);
}
/** How a current pair touches as of the last step: read from the engine's contact. */
contactOf(pair: CollisionPair): Contact | null {
const handles = this.pairColliders.get(`${pair.a.id}|${pair.b.id}`);
+16
View File
@@ -619,6 +619,22 @@ export class BehaviorSystem implements System {
world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit));
return hit ? { ...hit, object: bodyOf(hit.collider) } : null;
},
raycastAll: (from, direction, length, { ignore, triggers, layers, maxHits }) => {
const hits =
this.collisions?.raycastAll(
world,
from,
direction,
length,
{ ignore: resolve(ignore) ?? null, triggers, layers },
maxHits,
) ?? [];
// Drawn to the last thing it met, not to the first: that is how far this ray looked.
const last = hits[hits.length - 1];
const end = last ? last.point : pointAlong(from, direction, length);
world.noteRay(from, end, hits.length > 0);
return hits.map((hit) => ({ ...hit, object: bodyOf(hit.collider) }));
},
ignoreOneWay: (target, seconds) => {
const found = resolve(target);
if (found) world.ignoreOneWay(found, seconds);
+11
View File
@@ -89,6 +89,17 @@ export class CollisionSystem implements System, CollisionSource {
return this.solids.raycast(world, from, direction, length, filter);
}
raycastAll(
world: World,
from: Vec2,
direction: Vec2,
length: number,
filter: RayFilter,
maxHits?: number,
): RayResult[] {
return this.solids.raycastAll(world, from, direction, length, filter, maxHits);
}
/** Nothing is simulated here, so a contact has no normal or impulse to tell. */
contactOf(): Contact | null {
return null;
+67 -8
View File
@@ -148,6 +148,15 @@ export interface CollisionSource {
touching(world: World, a: Entity, b: Entity): boolean;
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult;
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null;
/** Every solid on the line, nearest first; `maxHits` above 0 keeps that many of them (#157). */
raycastAll(
world: World,
from: Vec2,
direction: Vec2,
length: number,
filter: RayFilter,
maxHits?: number,
): RayResult[];
/**
* How a pair that stays in contact touches right now (#149): the normal from `b` towards `a`, the
* impulse of the last physics step and the middle of the contact points; null where nothing
@@ -239,20 +248,70 @@ export class Solids {
const d = unitOf(direction);
if ((d.x === 0 && d.y === 0) || !(length > 0)) return null;
let best: RayResult | null = null;
for (const body of this.rayBodies(world, filter)) {
for (const { shape, facing } of this.solidShapesOf(world, body)) {
const hit = rayCast(shape, from, d, best?.distance ?? length);
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
if (best && hit.distance >= best.distance) continue;
best = this.rayResult(body, from, d, hit);
}
}
return best;
}
/**
* Everything the ray meets within `length`, nearest first (#157). One entry per collider – the
* first face it enters, however many convex pieces it is made of – and one per tilemap cell the
* ray goes through, since a map is many solids under one entity. `maxHits` above 0 keeps that many
* of the nearest and stops there; the filter is the one `raycast` uses.
*/
raycastAll(
world: World,
from: Vec2,
direction: Vec2,
length: number,
filter: RayFilter,
maxHits = 0,
): RayResult[] {
const d = unitOf(direction);
if ((d.x === 0 && d.y === 0) || !(length > 0)) return [];
const hits: RayResult[] = [];
for (const body of this.rayBodies(world, filter)) {
// One solid shape is one hit, except for a collider: its pieces are one shape to the world,
// so only the nearest of them counts (a polygon in three parts is not three enemies).
const perShape = body.type === 'tilemap';
let nearest: RayResult | null = null;
for (const { shape, facing } of this.solidShapesOf(world, body)) {
const hit = rayCast(shape, from, d, length);
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
const result = this.rayResult(body, from, d, hit);
if (perShape) hits.push(result);
else if (!nearest || result.distance < nearest.distance) nearest = result;
}
if (nearest) hits.push(nearest);
}
hits.sort((a, b) => a.distance - b.distance);
return maxHits > 0 ? hits.slice(0, maxHits) : hits;
}
/** The bodies a ray is allowed to look at, in world order (see `RayFilter`). */
private *rayBodies(world: World, filter: RayFilter): Generator<Entity> {
for (const body of world.entities) {
if (!isCollisionBody(body) || !isHandleEnabled(body)) continue;
if (body.type === 'collider' && isTrigger(body.props) && !filter.triggers) continue;
if (filter.ignore && travelsWith(body, filter.ignore)) continue;
if (!maskHas(filter.layers, collisionFilterOf(body.props).layer)) continue;
for (const { shape, facing } of this.solidShapesOf(world, body)) {
const hit = rayCast(shape, from, d, best?.distance ?? length);
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
if (best && hit.distance >= best.distance) continue;
const point = { x: from.x + d.x * hit.distance, y: from.y + d.y * hit.distance };
best = { collider: body, point, normal: hit.normal, distance: hit.distance };
}
yield body;
}
return best;
}
private rayResult(body: Entity, from: Vec2, d: Vec2, hit: { normal: Vec2; distance: number }): RayResult {
return {
collider: body,
point: { x: from.x + d.x * hit.distance, y: from.y + d.y * hit.distance },
normal: hit.normal,
distance: hit.distance,
};
}
/**