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
@@ -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,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;
|
||||
|
||||
@@ -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) };
|
||||
},
|
||||
};
|
||||
@@ -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)) } };
|
||||
},
|
||||
};
|
||||
@@ -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)),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user