diff --git a/README.md b/README.md index d77e8907..79568654 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, 1148 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1153 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 04a47319..12937eb9 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -158,13 +158,14 @@ export interface NodeContext { moveAndSlide(target: EntityHandle, dx: number, dy: number): MoveOutcome; /** * The first solid a ray from (x, y) along (dx, dy) meets within `length`, all in world units; - * null for none. `ignore` and whatever is parented below it are not met. + * null for none. `ignore` and whatever is parented below it are not met, and neither is anything + * off the layers of `layers` (a 16-bit mask, #158). */ raycast( from: { x: number; y: number }, direction: { x: number; y: number }, length: number, - options: { ignore: EntityHandle | null; triggers: boolean }, + options: { ignore: EntityHandle | null; triggers: boolean; layers: number }, ): RayOutcome | null; /** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */ ignoreOneWay(target: EntityHandle, seconds: number): void; diff --git a/src/core/graph/nodes/action/Raycast.ts b/src/core/graph/nodes/action/Raycast.ts index e089f327..faec4e44 100644 --- a/src/core/graph/nodes/action/Raycast.ts +++ b/src/core/graph/nodes/action/Raycast.ts @@ -1,7 +1,11 @@ +import { ALL_LAYERS, layerMaskOf } from '../../../model/collisionLayers'; import type { NodeDef } from '../../NodeRegistry'; import { asBoolean, asNumber } from '../../values'; import { bool, exec, num, obj, withDefault } from '../shared'; +/** The mask of layers a ray looks at – shared with `Raycast all` (#157) once that exists. */ +export const RAY_LAYERS_PARAM = 'layers'; + const MISSED = { hit: false, object: null, @@ -20,9 +24,10 @@ export const Raycast: NodeDef = { description: 'Casts a ray from (from x, from y) along (dir x, dir y) for up to length world units and says what ' + 'it meets first: the object, its collider (or the tilemap), where, the normal of the face (pointing ' + - 'out of it) and how far. Solid colliders and tilemap cells are met, triggers only with "Hit ' + - 'triggers", a one-way one only from the side it blocks from. What the ray starts inside is not met, ' + - 'and with "Ignore owner" neither is the owner or anything parented below it.', + 'out of it) and how far. Solid colliders and tilemap cells are met as long as they sit on one of ' + + '"Layers", triggers only with "Hit triggers", a one-way one only from the side it blocks from. What ' + + 'the ray starts inside is not met, and with "Ignore owner" neither is the owner or anything parented ' + + 'below it.', inputs: [ exec(), num('fromX', 'from x'), @@ -45,6 +50,8 @@ export const Raycast: NodeDef = { params: [ { key: 'ignoreOwner', label: 'Ignore owner', kind: 'boolean', default: true }, { key: 'triggers', label: 'Hit triggers', kind: 'boolean', default: false }, + // Every layer, so a graph written before there was a choice answers as it did (#158). + { key: RAY_LAYERS_PARAM, label: 'Layers', kind: 'layers', default: ALL_LAYERS }, ], run: (ctx, inputs, params) => { const hit = ctx.raycast( @@ -54,6 +61,7 @@ export const Raycast: NodeDef = { { ignore: asBoolean(params.ignoreOwner ?? true) ? (ctx.owner ?? ctx.self) : null, triggers: asBoolean(params.triggers ?? false), + layers: layerMaskOf(params[RAY_LAYERS_PARAM] ?? ALL_LAYERS), }, ); if (!hit) return { next: 'exec', outputs: MISSED }; diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 94f9c289..9da4de13 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -15,6 +15,7 @@ import { type PortType, type Value, } from '../../core/graph'; +import { layerIndexOf, layerMaskOf, layerName, maskLabel } from '../../core/model/collisionLayers'; import type { Project } from '../../core/model/Project'; import { isList, type PropertyValue } from '../../core/model/Properties'; import type { PropertyDef } from '../../core/properties/PropertySchema'; @@ -477,6 +478,10 @@ function formatParam(param: PropertyDef, value: PropertyValue | undefined, proje function formatField(def: PropertyDef, value: Value, project: Project | null): string { if (def.kind === 'key') return keyLabel(asString(value)); if (def.kind === 'color') return toHex(asNumber(value)); + // Layers read as names, not as the bits they are stored in (#152, #158). + const layers = project?.collisionLayers ?? []; + if (def.kind === 'layer') return layerName(layers, layerIndexOf(value)); + if (def.kind === 'layers') return maskLabel(layerMaskOf(value), layers); if (def.kind === 'enum') return def.options.find((o) => o.value === value)?.label ?? formatValue(value); return referenceLabel(def, project, asString(value)) ?? formatValue(value); } diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index bb16cfb2..2cc2ecdc 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -610,10 +610,11 @@ export class BehaviorSystem implements System { found.y += dy; return NOT_BLOCKED; }, - raycast: (from, direction, length, { ignore, triggers }) => { + raycast: (from, direction, length, { ignore, triggers, layers }) => { const hit = this.collisions?.raycast(world, from, direction, length, { ignore: resolve(ignore) ?? null, triggers, + layers, }); world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit)); return hit ? { ...hit, object: bodyOf(hit.collider) } : null; diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index d2c47fa0..42cc676f 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -13,7 +13,12 @@ import { solidRects, } from '../../core/model/tilemapCollision'; import { parentChain } from '../../core/model/walk'; -import { type CollisionFilter, collisionFilterOf, layersMeet } from '../../core/model/collisionLayers'; +import { + type CollisionFilter, + collisionFilterOf, + layersMeet, + maskHas, +} from '../../core/model/collisionLayers'; import { type ColliderShape, colliderShape, @@ -116,10 +121,15 @@ export interface RayResult { distance: number; } -/** Which colliders a ray looks at: all but those of `ignore` and below it, triggers only when asked. */ +/** + * Which colliders a ray looks at: all but those of `ignore` and below it, triggers only when asked, + * and only what sits on a layer of `layers` (#158). That last one is one-sided on purpose – a ray is + * not a collider, so the collider's own "Collides with" has no say over who may look at it. + */ export interface RayFilter { ignore: Entity | null; triggers: boolean; + layers: number; } /** Where a sweep stopped: what it met, with which own collider, the normal, and the move left over. */ @@ -222,8 +232,8 @@ export class Solids { /** * The first solid the ray from `from` along `direction` meets within `length` (world units): any - * blocking collider or tilemap cell, triggers when asked, a one-way one only on the side it blocks - * from. What the ray starts inside is not met (see `rayCast`). + * blocking collider or tilemap cell on one of the filter's layers (#158), triggers when asked, a + * one-way one only on the side it blocks from. What the ray starts inside is not met (see `rayCast`). */ raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null { const d = unitOf(direction); @@ -233,6 +243,7 @@ export class Solids { 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; diff --git a/tests/core/one-way-angle.test.ts b/tests/core/one-way-angle.test.ts index 0f084036..a005409b 100644 --- a/tests/core/one-way-angle.test.ts +++ b/tests/core/one-way-angle.test.ts @@ -4,6 +4,7 @@ import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands'; import type { TilemapAsset } from '../../src/core/model/Asset'; +import { ALL_LAYERS } from '../../src/core/model/collisionLayers'; import { IDENTITY } from '../../src/core/model/matrix'; import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; import { findAsset } from '../../src/core/model/Project'; @@ -153,6 +154,7 @@ describe.each(COLLISION_SYSTEMS)('$system: Move and the one-way angle (#159)', ( const hit = r.collisions.raycast(r.world, { x: 0, y: -100 }, { x: 0, y: 1 }, 500, { ignore: null, triggers: false, + layers: ALL_LAYERS, }); if (meets) expect(hit?.point.y).toBeCloseTo(SLOPE_AT_0, 9); else expect(hit).toBeNull(); diff --git a/tests/core/raycast.test.ts b/tests/core/raycast.test.ts index 90e73997..fafc60e0 100644 --- a/tests/core/raycast.test.ts +++ b/tests/core/raycast.test.ts @@ -4,12 +4,19 @@ import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands'; import type { TilemapAsset } from '../../src/core/model/Asset'; +import { + ALL_LAYERS, + COLLIDES_WITH_KEY, + COLLISION_LAYER_KEY, + maskWith, +} from '../../src/core/model/collisionLayers'; import { IDENTITY, type Mat } from '../../src/core/model/matrix'; import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; import { findAsset } from '../../src/core/model/Project'; import { TILE_SOLID_KEY } from '../../src/core/model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap'; import { colliderShape, type ColliderShape } from '../../src/core/properties/collider'; +import { RAY_LAYERS_PARAM } from '../../src/core/graph/nodes/action/Raycast'; import { rayCast } from '../../src/core/properties/ray'; import { loadPhysics } from '../../src/runtime/physics/engine'; import { RAY_SHOWN } from '../../src/runtime/World'; @@ -168,10 +175,18 @@ function scene() { function run(f: SceneFixture, physics: boolean) { const started = startWorld(f, { physics, collisions: true, behaviors: true }); const entity = (id: string) => started.world.findByObjectId(id)!; - const cast = (from: Vec2, dir: Vec2, length = 1000, ignore: string | null = null, triggers = false) => + const cast = ( + from: Vec2, + dir: Vec2, + length = 1000, + ignore: string | null = null, + triggers = false, + layers = ALL_LAYERS, + ) => started.collisions.raycast(started.world, from, dir, length, { ignore: ignore ? entity(ignore) : null, triggers, + layers, }); return { ...started, entity, cast }; } @@ -245,6 +260,47 @@ describe.each(COLLISION_SYSTEMS)('$system: a ray through the world (#153)', ({ p }); }); + it('passes what sits on a layer it does not look at, and meets the next one behind it (#158)', () => { + const s = scene(); + // Near sits on layer 1, far on layer 0; the near one also collides with nothing at all, which + // is a collider's own business and must not decide what a ray may see. + s.add({ x: 100, y: 0 }, { width: 20, height: 100, [COLLISION_LAYER_KEY]: 1, [COLLIDES_WITH_KEY]: 0 }); + const far = s.add({ x: 300, y: 0 }, { width: 20, height: 100 }); + const r = run(s, physics); + expect(r.cast({ x: 0, y: 0 }, RIGHT)?.distance).toBe(90); + expect(r.cast({ x: 0, y: 0 }, RIGHT, 1000, null, false, maskWith(ALL_LAYERS, 1, false))).toMatchObject({ + collider: r.entity(far.collider), + distance: 290, + }); + expect(r.cast({ x: 0, y: 0 }, RIGHT, 1000, null, false, 0)).toBeNull(); + }); + + it('a tilemap is met or passed by its own layer, like any collider (#158)', () => { + 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 }); + s.stack.execute(new SetOverrideCommand(s.sceneId, mapObject, COLLISION_LAYER_KEY, 2)); + const layer = (findAsset(s.project, map.asset.id) as TilemapAsset).layers[0]!.id; + s.stack.execute( + new PaintTilesCommand( + map.asset.id, + layer, + Array.from({ length: 10 }, (_, x) => ({ x, y: 9, tileId: wall.asset.id })), + ), + ); + const r = run(s, physics); + const down = (layers: number) => r.cast({ x: 0, y: 0 }, DOWN, 1000, null, false, layers); + expect(down(ALL_LAYERS)?.distance).toBe(192); + expect(down(1 << 2)?.collider).toBe(r.entity(mapObject)); + expect(down(maskWith(ALL_LAYERS, 2, false))).toBeNull(); + }); + it('the Raycast node casts from the graph, skips its owner and hands on what it met', () => { const s = scene(); const hero = s.add({ x: 0, y: 0 }, { width: 20, height: 20 }); @@ -269,6 +325,36 @@ describe.each(COLLISION_SYSTEMS)('$system: a ray through the world (#153)', ({ p expect(r.world.log).toEqual(['true', '140']); expect(r.world.rays).toEqual([{ from: { x: 0, y: 0 }, to: { x: 0, y: 140 }, hit: true, time: 1 / 60 }]); }); + + it('the node looks only at the layers its setting names, and at every one without it (#158)', () => { + const cast = (layers?: number) => { + 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, [COLLISION_LAYER_KEY]: 1 }); // top face at 140 + s.add({ x: 0, y: 300 }, { width: 200, height: 20 }); // top face at 290, layer 0 + 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 params: Record = { fromX: 0, fromY: 0, dirX: 0, dirY: 1, length: 500 }; + if (layers !== undefined) params[RAY_LAYERS_PARAM] = layers; + const ray = edit.addNode('Raycast', params); + const log = edit.addNode('Log'); + edit.wire([ + [update, 'exec', ray, 'exec'], + [ray, 'exec', log, 'exec'], + [ray, 'distance', log, 'message'], + ]); + const r = run(s, physics); + r.frame(1 / 60); + return r.world.log; + }; + // A graph from before the setting existed carries no `layers` at all and must answer as it did. + expect(cast()).toEqual(['140']); + expect(cast(ALL_LAYERS)).toEqual(['140']); + expect(cast(maskWith(ALL_LAYERS, 1, false))).toEqual(['290']); + expect(cast(0)).toEqual(['0']); + }); }); describe('the rays a run remembers for drawing (#153)', () => { diff --git a/tools/e2e/43-queries.mjs b/tools/e2e/43-queries.mjs index a8101655..a23a4db0 100644 --- a/tools/e2e/43-queries.mjs +++ b/tools/e2e/43-queries.mjs @@ -57,6 +57,21 @@ const eyeGraph = { ], }; +/** Casts downwards like `eyeGraph` and logs how far it reached, so the answer needs no drawing. */ +const layerEyeGraph = { + nodes: [ + ...eyeGraph.nodes, + node('qe-log', 'Log', 750, 0), + node('qe-timer', 'OnTimer', 0, 320, { interval: 0.2 }), + ], + edges: [ + ...eyeGraph.edges.filter((e) => e.from.node !== 'qe-update'), + edge('qe-e5', 'qe-timer', 'exec', 'qe-ray', 'exec'), + edge('qe-e6', 'qe-ray', 'exec', 'qe-log', 'exec'), + edge('qe-e7', 'qe-ray', 'distance', 'qe-log', '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 = { @@ -271,4 +286,77 @@ export async function queries(t) { JSON.stringify({ pairs, interval: STAY }), ); await clear(); + + // 43d. Which layers a ray looks at (#158). A shield on a layer of its own stands between the + // caster and the ground: with every layer picked the ray stops at the shield, and once that + // layer is unticked in the node's "Layers" setting it goes straight through to the ground. + const SHIELD_TOP = Y + 145; + const reached = () => { + const seen = consoleLog.filter((line) => line.startsWith('[behavior Eye logic]')); + return seen.length === 0 ? null : Number(seen.at(-1).split(']').pop()); + }; + await setup(` + if (__gm.state.project.collisionLayers.length < 2) __gm.state.project.collisionLayers = ['Default', 'Shots']; + collider('q-col-ground', { width: 800, height: 40 }); + collider('q-col-shield', { width: 300, height: 10, layer: 1 }); + collider('q-col-box', {}); + behavior('q-eye', 'Query eye', ${JSON.stringify(layerEyeGraph)}); + 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-eye', 'q-eyebox', 0, 0, 'Eye logic', { runInEditor: true });`); + await evaluate(`__gm.lookAt(0, ${Y + 150})`); + await sleep(500); + const blocked = reached(); + check( + 'a ray looking at every layer stops at the shield, whatever layer it is on', + blocked === SHIELD_TOP - Y, + JSON.stringify({ reached: blocked, toShield: SHIELD_TOP - Y, toGround: GROUND_TOP - Y }), + ); + await select({ kind: 'node', assetId: 'q-eye', nodeId: 'qe-ray', nodeIds: ['qe-ray'] }); + const layersRow = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Layers')"); + check( + 'the Raycast node carries a Layers setting, reading as every layer', + Boolean(layersRow) && (await t.has('All layers')), + JSON.stringify({ layersRow, allLayers: await t.has('All layers') }), + ); + await click(layersRow.x, layersRow.y); + const layerTicks = await json('__gm.menuRows()'); + const names = await json('__gm.state.project.collisionLayers'); + check( + 'the setting lists the layers of the project, all of them ticked', + layerTicks.length === names.length && layerTicks.every((row) => row.startsWith('☑')), + JSON.stringify({ layerTicks, names }), + ); + await t.clickText(layerTicks[1]); + await shot('43-ray-layers'); + await t.press('Escape'); + await settle(); + const settings = await json("__gm.graph.nodeSettings('qe-ray')"); + check( + 'the node itself spells the mask out by name rather than as the bits it is stored in', + settings.includes(`Layers: ${names[0]}`), + JSON.stringify(settings), + ); + // A running instance keeps the graph it started with – measured here: the setting stands in the + // project at once, what the node casts does not. So the behavior is switched off and on, which + // ends the instance and builds a new one on the graph as it now stands. + await select({ kind: 'object', holderId: 'scene1', objectId: 'q-eyebox-go', objectIds: ['q-eyebox-go'] }); + for (let i = 0; i < 2; i++) { + const runRow = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Run in editor')"); + await click(runRow.x, runRow.y); + await settle(); + } + await sleep(600); + const through = reached(); + check( + "unticking the shield's layer lets the ray pass it and meet the ground behind it", + through === GROUND_TOP - Y, + JSON.stringify({ reached: through, toGround: GROUND_TOP - Y, toShield: SHIELD_TOP - Y }), + ); + await select(null); + await clear(); }