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

+48 -3
View File
@@ -1,14 +1,26 @@
import { describe, expect, it } from 'vitest';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { sameValue, tagged, textOf } from '../../src/core/model/Properties';
import { type PropertyBag, sameValue, tagged, textOf } from '../../src/core/model/Properties';
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
import { deserializeProject } from '../../src/core/store/serialize';
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
import { coerceField } from '../../src/core/properties/kinds';
import { deviatesFromAsset } from '../../src/core/properties/resolve';
import { asBoolean, asEntity, asStruct, writeTyped } from '../../src/core/graph';
import { fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
import {
asBoolean,
asEntity,
asList,
asNumber,
asString,
asStruct,
storedList,
type Value,
writeTyped,
} from '../../src/core/graph';
import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf';
import { NO_LOOKUP } from '../../src/core/graph/lookup';
import { fakeContext, fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
const findObject = (project: Project, id: string) =>
objectHolders(project)
@@ -93,6 +105,39 @@ describe('a list as a value', () => {
});
});
describe('a list carrying objects (#157)', () => {
const hero = fakeEntity('Hero', 10);
const crate = fakeEntity('Crate', 20);
it('hands the objects on as they are, and a lone object reads as a list of one', () => {
expect(asList([hero, crate])).toEqual([hero, crate]);
expect(asEntity(asList(hero)[0])).toBe(hero);
expect(storedList([hero, 3, crate])).toEqual(['Hero', 3, 'Crate']);
});
it('never lets a live object into a stored value', () => {
// The one door into a PropertyBag: whatever arrives is narrowed to what a stored list may hold.
const bag: PropertyBag = { seen: ['x'] };
writeTyped(bag, 'seen', [hero, crate]);
expect(bag.seen).toEqual(['Hero', 'Crate']);
expect(JSON.parse(JSON.stringify(bag))).toEqual({ seen: ['Hero', 'Crate'] });
});
it('reads as text by the names of its objects, not by what they are made of', () => {
expect(asString([hero, 3, crate])).toBe('Hero, 3, Crate');
});
it('is searched by identity, since two objects have no content to compare', () => {
const indexOf = (list: Value, value: Value) =>
asNumber(IndexOf.run!(fakeContext(), { list, value }, {}, IndexOf, 'exec', NO_LOOKUP).outputs?.index);
expect(indexOf([hero, crate], crate)).toBe(1);
expect(indexOf([hero, crate], fakeEntity('Crate', 20))).toBe(-1);
// A name is not the object of that name, and neither stands in for the other.
expect(indexOf([hero, crate], 'Crate')).toBe(-1);
expect(indexOf(['Hero', 'Crate'], crate)).toBe(-1);
});
});
describe('a struct as a value', () => {
it('compares by content, one level deep, and tells a different shape apart', () => {
expect(sameValue({ x: 1, tags: ['a'] }, { x: 1, tags: ['a'] })).toBe(true);
+69
View File
@@ -1001,6 +1001,75 @@ const CASES: Record<string, Case[]> = {
},
},
],
RaycastAll: [
{
name: 'puts the hits out as parallel lists, nearest first, and counts them (#157)',
inputs: { fromX: 10, fromY: 20, dirX: 0, dirY: 1, length: 300, maxHits: 2 },
context: {
raycastAll: (from, direction, length, options) => {
expect([from, direction, length, options.ignore?.id, options.triggers, options.maxHits]).toEqual([
{ x: 10, y: 20 },
{ x: 0, y: 1 },
300,
'owner',
false,
2,
]);
return [
{
object: crate,
collider: painted,
point: { x: 10, y: 120 },
normal: { x: 0, y: -1 },
distance: 100,
},
{
object: dancer,
collider: dancer,
point: { x: 10, y: 220 },
normal: { x: 0, y: 1 },
distance: 200,
},
];
},
},
next: 'exec',
outputs: {
count: 2,
// The objects travel as objects, not as their names: that is what a list may carry now.
objects: [crate, dancer],
colliders: [painted, dancer],
pointsX: [10, 10],
pointsY: [120, 220],
normalsX: [0, 0],
normalsY: [-1, 1],
distances: [100, 200],
},
},
{
name: 'a ray that meets nothing puts out empty lists and a count of 0',
inputs: { fromX: 0, fromY: 0, dirX: 1, dirY: 0, length: 50, maxHits: -3 },
params: { ignoreOwner: false, triggers: true },
context: {
raycastAll: (_from, _direction, _length, options) => {
// A negative "max hits" is no limit at all, the same as 0.
expect([options.ignore, options.triggers, options.maxHits]).toEqual([null, true, 0]);
return [];
},
},
next: 'exec',
outputs: {
count: 0,
objects: [],
colliders: [],
pointsX: [],
pointsY: [],
normalsX: [],
normalsY: [],
distances: [],
},
},
],
IgnoreOneWay: [
{
name: 'asks the context to let the object through for the seconds given',
+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();