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
+148
-1
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user