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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0fdde6cb1e
commit
ffdf0a8ddb
20 files changed
+607
-33
No files matched your search
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user