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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -225,6 +225,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
hitTest: () => false,
|
||||
isTouching: () => false,
|
||||
raycast: () => null,
|
||||
raycastAll: () => [],
|
||||
ignoreOneWay: () => {},
|
||||
moveAndSlide: (target, dx, dy) => {
|
||||
target.x += dx;
|
||||
|
||||
Reference in new issue
Block a user