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
+606 -32

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1153 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1157 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+10
View File
@@ -167,6 +167,16 @@ export interface NodeContext {
length: number,
options: { ignore: EntityHandle | null; triggers: boolean; layers: number },
): RayOutcome | null;
/**
* Everything that ray meets, nearest first, rather than only the first of them (#157). One entry
* per collider and one per tilemap cell; `maxHits` above 0 keeps that many of the nearest.
*/
raycastAll(
from: { x: number; y: number },
direction: { x: number; y: number },
length: number,
options: { ignore: EntityHandle | null; triggers: boolean; layers: number; maxHits: number },
): RayOutcome[];
/** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */
ignoreOneWay(target: EntityHandle, seconds: number): void;
/** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */
+4 -2
View File
@@ -4,7 +4,7 @@ import { flatDef, type PropertyDef } from '../properties/PropertySchema';
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
import type { GraphLookup } from './lookup';
import type { NodeContext } from './NodeContext';
import { asList, type Value } from './values';
import { asList, storedList, type Value } from './values';
export interface PortDef {
key: string;
@@ -179,7 +179,9 @@ export function withInlineDefault(port: PortDef): PortDef {
export function inlineInputDef(port: PortDef): PropertyDef {
if (port.field) return { ...port.field, key: port.key, label: port.label };
const kind = port.type === 'any' ? 'text' : plainKindOf(port.type);
if (kind === 'list') return { key: port.key, label: port.label, kind, default: asList(port.default) };
// A declared default is a stored value, never a live handle (see `storedList`).
if (kind === 'list')
return { key: port.key, label: port.label, kind, default: storedList(asList(port.default)) };
const flat = kind !== undefined && isKindWith('field', kind) ? kind : 'number';
const def = flatDef(port.key, flat, { label: port.label, value: port.default });
return def.kind === 'number' ? { ...def, step: 1 } : def;
+73
View File
@@ -0,0 +1,73 @@
import { ALL_LAYERS, layerMaskOf } from '../../../model/collisionLayers';
import type { RayOutcome } from '../../NodeContext';
import type { NodeDef } from '../../NodeRegistry';
import { asBoolean, asNumber, type ValueList } from '../../values';
import { exec, int, list, num, withDefault } from '../shared';
import { RAY_LAYERS_PARAM } from './Raycast';
/**
* The hits as parallel lists: item 0 of every list is the nearest hit, item 1 the next, and so on
* (#157). Lists rather than one flow run per hit, because that is what the list nodes already work
* with – `Count`, `Item at`, `Sub list` – and it needs no record type the graph does not have.
*/
const listsOf = (hits: readonly RayOutcome[]): Record<string, ValueList | number> => ({
objects: hits.map((hit) => hit.object),
colliders: hits.map((hit) => hit.collider),
pointsX: hits.map((hit) => hit.point.x),
pointsY: hits.map((hit) => hit.point.y),
normalsX: hits.map((hit) => hit.normal.x),
normalsY: hits.map((hit) => hit.normal.y),
distances: hits.map((hit) => hit.distance),
count: hits.length,
});
export const RaycastAll: NodeDef = {
type: 'RaycastAll',
label: 'Raycast all',
category: 'action',
description:
'Casts a ray like Raycast, but says everything it meets instead of only the first: the lists ' +
'come out sorted by distance, one item per hit, and "item at" the same index in each of them ' +
'belongs to the same hit. A collider counts once, however many pieces it is made of; a tilemap ' +
'once per cell the ray goes through. "max hits" above 0 keeps that many of the nearest.',
inputs: [
exec(),
num('fromX', 'from x'),
num('fromY', 'from y'),
withDefault(num('dirX', 'dir x'), 1),
num('dirY', 'dir y'),
withDefault(num('length'), 1000),
int('maxHits', 0, 'max hits'),
],
outputs: [
exec(),
num('count'),
list('objects'),
list('colliders'),
list('pointsX', 'points x'),
list('pointsY', 'points y'),
list('normalsX', 'normals x'),
list('normalsY', 'normals y'),
list('distances'),
],
params: [
{ key: 'ignoreOwner', label: 'Ignore owner', kind: 'boolean', default: true },
{ key: 'triggers', label: 'Hit triggers', kind: 'boolean', default: false },
// The same param as `Raycast` carries, so both nodes are filtered the same way (#158).
{ key: RAY_LAYERS_PARAM, label: 'Layers', kind: 'layers', default: ALL_LAYERS },
],
run: (ctx, inputs, params) => {
const hits = ctx.raycastAll(
{ x: asNumber(inputs.fromX), y: asNumber(inputs.fromY) },
{ x: asNumber(inputs.dirX), y: asNumber(inputs.dirY) },
asNumber(inputs.length),
{
ignore: asBoolean(params.ignoreOwner ?? true) ? (ctx.owner ?? ctx.self) : null,
triggers: asBoolean(params.triggers ?? false),
layers: layerMaskOf(params[RAY_LAYERS_PARAM] ?? ALL_LAYERS),
maxHits: Math.max(0, Math.floor(asNumber(inputs.maxHits))),
},
);
return { next: 'exec', outputs: listsOf(hits) };
},
};
+2 -2
View File
@@ -1,5 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { asListItem, asNumber } from '../../values';
import { asNumber, asValueItem } from '../../values';
import { any, int, list } from '../shared';
/** Same order of magnitude as the interpreter's step limit – a typo in `count` must not eat memory. */
@@ -15,6 +15,6 @@ export const Fill: NodeDef = {
params: [],
run: (_ctx, inputs) => {
const count = Math.min(MAX_ITEMS, Math.max(0, Math.floor(asNumber(inputs.count))));
return { outputs: { result: new Array(count).fill(asListItem(inputs.value)) } };
return { outputs: { result: new Array(count).fill(asValueItem(inputs.value)) } };
},
};
+9 -3
View File
@@ -1,6 +1,6 @@
import { sameValue, untag } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asListItem } from '../../values';
import { asList, asValueItem, isEntity } from '../../values';
import { any, list, num } from '../shared';
export const IndexOf: NodeDef = {
@@ -14,9 +14,15 @@ export const IndexOf: NodeDef = {
// Compared by content, not by identity: two equal structs are never the same object. A color item
// is compared as its number, or searching a list of colors with one would never find it.
run: (_ctx, inputs) => {
const wanted = untag(asListItem(inputs.value));
const wanted = asValueItem(inputs.value);
// Two objects are the same one or they are not – there is no content to compare (#157).
if (isEntity(wanted))
return { outputs: { index: asList(inputs.list).findIndex((item) => item === wanted) } };
const value = untag(wanted);
return {
outputs: { index: asList(inputs.list).findIndex((item) => sameValue(untag(item), wanted)) },
outputs: {
index: asList(inputs.list).findIndex((item) => !isEntity(item) && sameValue(untag(item), value)),
},
};
},
};
+6 -2
View File
@@ -1,6 +1,6 @@
import { untag } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber } from '../../values';
import { asList, asNumber, isEntity } from '../../values';
import { any, int, list } from '../shared';
export const ItemAt: NodeDef = {
@@ -14,7 +14,11 @@ export const ItemAt: NodeDef = {
run: (_ctx, inputs) => {
const items = asList(inputs.list);
const i = Math.floor(asNumber(inputs.index));
return { outputs: { item: untag(items[i]) ?? 0 } };
const item = items[i];
// An object comes out as the object it is, not as its name: that is the point of a list of
// objects, as `Raycast all` hands one out (#157).
if (isEntity(item)) return { outputs: { item } };
return { outputs: { item: untag(item) ?? 0 } };
},
params: [],
};
+5 -2
View File
@@ -1,6 +1,6 @@
import { itemLike } from '../../../properties/listItems';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asListItem, asNumber } from '../../values';
import { asList, asNumber, asValueItem, isEntity } from '../../values';
import { any, int, list } from '../shared';
export const SetItem: NodeDef = {
@@ -17,7 +17,10 @@ export const SetItem: NodeDef = {
const next = [...items];
// The slot keeps its kind: writing a number into a color item leaves a color behind, the same
// way writing into a number variable leaves a number (see `asTypeOf`).
next[i] = itemLike(items[i], asListItem(inputs.value));
const value = asValueItem(inputs.value);
const slot = items[i];
// An object has no kind to keep and none to take on, so it is written as it is (#157).
next[i] = isEntity(value) || isEntity(slot) ? value : itemLike(slot, value);
return { outputs: { result: next } };
},
params: [],
+2
View File
@@ -16,6 +16,7 @@ import {
} from './action/physicsNodes';
import { PlaySound } from './action/PlaySound';
import { Raycast } from './action/Raycast';
import { RaycastAll } from './action/RaycastAll';
import { RestartGame } from './action/RestartGame';
import { SetPosition } from './action/SetPosition';
import { Spawn } from './action/Spawn';
@@ -221,6 +222,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
SetAngularVelocity,
SetGravity,
Raycast,
RaycastAll,
IgnoreOneWay,
Spawn,
Destroy,
+3 -3
View File
@@ -15,12 +15,12 @@ export const any = (key: string, label = key): PortDef => ({ key, label, type: '
export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value });
/** A whole-number input (an index, a count): its field shows no decimals, the node floors it anyway. */
export const int = (key: string, value = 0): PortDef => ({
export const int = (key: string, value = 0, label = key): PortDef => ({
key,
label: key,
label,
type: 'number',
default: value,
field: { key, label: key, kind: 'number', default: value, step: 1, decimals: 0 },
field: { key, label, kind: 'number', default: value, step: 1, decimals: 0 },
});
/** Outputs of the three collision events: the other body, its collider and the own collider. */
+30 -6
View File
@@ -11,13 +11,25 @@ import {
} from '../model/Properties';
import { ENTITY_HANDLE, type EntityHandle } from './NodeContext';
/**
* A list travelling along a data edge. Unlike a stored one it may hold live object handles, which is
* what lets `Raycast all` hand out the objects it met (#157). Nothing of the sort reaches a
* PropertyBag: everything written into one goes through `storedList`.
*/
export type ValueList = readonly (number | string | boolean | StructValue | EntityHandle)[];
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
export type Value = PropertyValue | EntityHandle | null;
export type Value = PropertyValue | EntityHandle | ValueList | null;
export function isEntity(value: Value | undefined): value is EntityHandle {
return typeof value === 'object' && value !== null && ENTITY_HANDLE in value;
}
/** A list of anything a data edge carries – a handle among the items is what `isList` cannot say. */
export function isValueList(value: Value | undefined): value is ValueList {
return Array.isArray(value);
}
export function asNumber(value: Value | undefined): number {
if (typeof value === 'number') return value;
if (typeof value === 'boolean') return value ? 1 : 0;
@@ -32,14 +44,24 @@ export function asNumber(value: Value | undefined): number {
}
/** Anything that is not a list reads as a one-item list, so a lone value can feed a list input. */
export function asList(value: Value | undefined): ListValue {
if (isList(value)) return value;
export function asList(value: Value | undefined): ValueList {
if (isValueList(value)) return value;
if (value === null || value === undefined) return [];
if (isEntity(value)) return [value.name];
if (isEntity(value)) return [value];
if (isStructValue(value)) return Object.values(value).map(asListItem);
return [value];
}
/** A list as a PropertyBag may hold it: an object handle becomes its name, since nothing stored is live. */
export function storedList(items: ValueList): ListValue {
return items.map(asListItem);
}
/** A value narrowed to what a *travelling* list may hold: unlike `asListItem`, a handle stays one. */
export function asValueItem(value: Value | undefined): ValueList[number] {
return isEntity(value) ? value : asListItem(value);
}
/** A value narrowed to what a list may hold: a struct stays one, handles and lists flatten. */
export function asListItem(value: Value | undefined): number | string | boolean | StructValue {
if (isStructValue(value)) return value;
@@ -64,7 +86,9 @@ export function asBoolean(value: Value | undefined): boolean {
/** Entities read as their name, so a handle can be logged or joined into text directly. */
export function asString(value: Value | undefined): string {
if (value === null) return '';
return isEntity(value) ? value.name : textOf(value);
if (isEntity(value)) return value.name;
// A handle among the items would otherwise be printed as the object it is made of.
return textOf(isValueList(value) ? storedList(value) : value);
}
export function asEntity(value: Value | undefined): EntityHandle | null {
@@ -94,7 +118,7 @@ export function mergeStruct(current: StructValue, value: Value | undefined): Str
/** `value` converted to the type the slot already holds, so a number stays a number. */
export function asTypeOf(current: PropertyValue | undefined, value: Value | undefined): PropertyValue {
if (isList(current)) return asList(value);
if (isList(current)) return storedList(asList(value));
if (isStruct(current)) return mergeStruct(current, value);
if (typeof current === 'number') return asNumber(value);
if (typeof current === 'boolean') return asBoolean(value);
+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;
+66 -7
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,
};
}
/**
+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();
+1
View File
@@ -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;
+91
View File
@@ -72,6 +72,46 @@ const layerEyeGraph = {
],
};
/**
* Casts downwards like `layerEyeGraph`, but with `Raycast all` (#157): logs how many it met, the
* distances as one line, the name of the object behind the second hit – which only a live object in
* the list can answer – and what a second cast with "max hits" 1 comes back with.
*/
const allEyeGraph = {
nodes: [
node('qa-timer', 'OnTimer', 0, 320, { interval: 0.2 }),
node('qa-owner', 'Owner', 0, 160),
node('qa-where', 'GetPosition', 250, 160),
node('qa-ray', 'RaycastAll', 500, 0, { dirX: 0, dirY: 1, length: 500 }),
node('qa-count', 'Log', 750, 0),
node('qa-distances', 'Log', 1000, 0),
node('qa-second', 'ItemAt', 750, 300, { index: 1 }),
node('qa-name', 'NameOf', 1000, 300),
node('qa-log-name', 'Log', 1250, 0),
node('qa-one', 'RaycastAll', 500, 500, { dirX: 0, dirY: 1, length: 500, maxHits: 1 }),
node('qa-log-one', 'Log', 1500, 0),
],
edges: [
edge('qa-e1', 'qa-timer', 'exec', 'qa-ray', 'exec'),
edge('qa-e2', 'qa-owner', 'object', 'qa-where', 'object'),
edge('qa-e3', 'qa-where', 'worldX', 'qa-ray', 'fromX'),
edge('qa-e4', 'qa-where', 'worldY', 'qa-ray', 'fromY'),
edge('qa-e5', 'qa-ray', 'exec', 'qa-count', 'exec'),
edge('qa-e6', 'qa-ray', 'count', 'qa-count', 'message'),
edge('qa-e7', 'qa-count', 'exec', 'qa-distances', 'exec'),
edge('qa-e8', 'qa-ray', 'distances', 'qa-distances', 'message'),
edge('qa-e9', 'qa-ray', 'objects', 'qa-second', 'list'),
edge('qa-e10', 'qa-second', 'item', 'qa-name', 'object'),
edge('qa-e11', 'qa-distances', 'exec', 'qa-log-name', 'exec'),
edge('qa-e12', 'qa-name', 'name', 'qa-log-name', 'message'),
edge('qa-e13', 'qa-log-name', 'exec', 'qa-one', 'exec'),
edge('qa-e14', 'qa-where', 'worldX', 'qa-one', 'fromX'),
edge('qa-e15', 'qa-where', 'worldY', 'qa-one', 'fromY'),
edge('qa-e16', 'qa-one', 'exec', 'qa-log-one', 'exec'),
edge('qa-e17', 'qa-one', 'distances', 'qa-log-one', 'message'),
],
};
/** Logs how its owner touches the ground (normal y, then impulse) each half second it stays on it. */
const STAY = 0.5;
const feelGraph = {
@@ -359,4 +399,55 @@ export async function queries(t) {
);
await select(null);
await clear();
// 43e. Every hit on the line (#157): the same shield and ground, cast through by `Raycast all`.
// What it met comes out as parallel lists – the count, the distances, and the object behind
// the second hit, which only a live object in the list can be asked for.
const lastLines = (n) =>
consoleLog
.filter((line) => line.startsWith('[behavior All eyes]'))
.slice(-n)
.map((line) => line.split(']').pop().trim());
await setup(`
collider('q-col-ground', { width: 800, height: 40 });
collider('q-col-shield', { width: 300, height: 10 });
collider('q-col-box', {});
behavior('q-all', 'Query all', ${JSON.stringify(allEyeGraph)});
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
put('q-shield', 'crate', '', 0, ${Y + 150}, 'Shield', { width: 300, height: 10 });
put('q-shield-col', 'q-col-shield', 'q-shield', 0, 0);
put('q-eyebox', 'crate', '', 0, ${Y}, 'Eye', { width: 40, height: 40 });
put('q-eyebox-col', 'q-col-box', 'q-eyebox', 0, 0);
put('q-eyebox-go', 'q-all', 'q-eyebox', 0, 0, 'All eyes', { runInEditor: true });`);
await evaluate(`__gm.lookAt(0, ${Y + 150})`);
await sleep(700);
const [count, distances, behind, limited] = lastLines(4);
check(
'Raycast all meets the shield and the ground behind it, nearest first',
count === '2' && distances === `${SHIELD_TOP - Y}, ${GROUND_TOP - Y}`,
JSON.stringify({ count, distances, toShield: SHIELD_TOP - Y, toGround: GROUND_TOP - Y }),
);
check(
'the objects it met are objects, not their names: the second one answers where it is',
behind === 'Query ground',
JSON.stringify({ behind, lines: lastLines(4) }),
);
check(
'"max hits" 1 keeps the nearest of them and stops there',
limited === String(SHIELD_TOP - Y),
JSON.stringify({ limited, toShield: SHIELD_TOP - Y }),
);
await select({ kind: 'node', assetId: 'q-all', nodeId: 'qa-ray', nodeIds: ['qa-ray'] });
await settle();
const allInputs = await json("__gm.graph.nodeInputs('qa-ray')");
const allSettings = await json("__gm.graph.nodeSettings('qa-ray')");
check(
'the node takes a "max hits" of its own and carries the same Layers setting as Raycast (#158)',
allInputs.some((row) => row.startsWith('max hits')) && allSettings.includes('Layers: All layers'),
JSON.stringify({ allInputs, allSettings }),
);
await shot('43-ray-all');
await select(null);
await clear();
}