diff --git a/README.md b/README.md index 79568654..99c8b8d0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 12937eb9..0f490afe 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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. */ diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 1a7fca53..d4b612f5 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -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; diff --git a/src/core/graph/nodes/action/RaycastAll.ts b/src/core/graph/nodes/action/RaycastAll.ts new file mode 100644 index 00000000..30274793 --- /dev/null +++ b/src/core/graph/nodes/action/RaycastAll.ts @@ -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 => ({ + 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) }; + }, +}; diff --git a/src/core/graph/nodes/data/Fill.ts b/src/core/graph/nodes/data/Fill.ts index 352c24b3..d4c9d95f 100644 --- a/src/core/graph/nodes/data/Fill.ts +++ b/src/core/graph/nodes/data/Fill.ts @@ -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)) } }; }, }; diff --git a/src/core/graph/nodes/data/IndexOf.ts b/src/core/graph/nodes/data/IndexOf.ts index 4e2d16d4..92044782 100644 --- a/src/core/graph/nodes/data/IndexOf.ts +++ b/src/core/graph/nodes/data/IndexOf.ts @@ -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)), + }, }; }, }; diff --git a/src/core/graph/nodes/data/ItemAt.ts b/src/core/graph/nodes/data/ItemAt.ts index 02811b95..305a74fc 100644 --- a/src/core/graph/nodes/data/ItemAt.ts +++ b/src/core/graph/nodes/data/ItemAt.ts @@ -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: [], }; diff --git a/src/core/graph/nodes/data/SetItem.ts b/src/core/graph/nodes/data/SetItem.ts index f837c184..115faad4 100644 --- a/src/core/graph/nodes/data/SetItem.ts +++ b/src/core/graph/nodes/data/SetItem.ts @@ -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: [], diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index eb961bdb..592bdc1a 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index a3675b74..16d18e97 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -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. */ diff --git a/src/core/graph/values.ts b/src/core/graph/values.ts index 6fa76ce0..62041ee8 100644 --- a/src/core/graph/values.ts +++ b/src/core/graph/values.ts @@ -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); diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index 2a2fa612..adf949c0 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -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}`); diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 2cc2ecdc..c378f5aa 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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); diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index 242500a3..ed768637 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -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; diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index 42cc676f..4e0169c2 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -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 { 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, + }; } /** diff --git a/tests/core/list-value.test.ts b/tests/core/list-value.test.ts index 1a0e06d8..3b6a6a6a 100644 --- a/tests/core/list-value.test.ts +++ b/tests/core/list-value.test.ts @@ -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); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 740e9cff..4572287e 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -1001,6 +1001,75 @@ const CASES: Record = { }, }, ], + 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', diff --git a/tests/core/raycast.test.ts b/tests/core/raycast.test.ts index fafc60e0..c5dc1dc3 100644 --- a/tests/core/raycast.test.ts +++ b/tests/core/raycast.test.ts @@ -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(); diff --git a/tests/helpers.ts b/tests/helpers.ts index 16201e67..448e44eb 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -225,6 +225,7 @@ export function fakeContext(overrides: Partial = {}): FakeContext { hitTest: () => false, isTouching: () => false, raycast: () => null, + raycastAll: () => [], ignoreOneWay: () => {}, moveAndSlide: (target, dx, dy) => { target.x += dx; diff --git a/tools/e2e/43-queries.mjs b/tools/e2e/43-queries.mjs index a23a4db0..13574922 100644 --- a/tools/e2e/43-queries.mjs +++ b/tools/e2e/43-queries.mjs @@ -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(); }