diff --git a/README.md b/README.md index 8a9e4433..49da083a 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,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, 1617 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1619 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 53f68fa0..79a78b1b 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -187,6 +187,11 @@ export interface NodeContext { length: number, options: { ignore: EntityHandle | null; triggers: boolean; layers: number; maxHits: number }, ): RayOutcome[]; + /** + * The number of the collision layer `target` is on (#211): a collider's or tilemap's own, any + * other object's that of its first collider child; null for an object without a collider. + */ + collisionLayerOf(target: EntityHandle): number | null; /** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */ ignoreOneWay(target: EntityHandle, seconds: number): void; /** Whether `target` is a placed tilemap – what the tile nodes act on (#135). */ diff --git a/src/core/graph/lookup.ts b/src/core/graph/lookup.ts index 5eee5e60..5b2ed3aa 100644 --- a/src/core/graph/lookup.ts +++ b/src/core/graph/lookup.ts @@ -39,9 +39,9 @@ export interface StateMachine { /** * What a node may look up in the project around its graph: the behaviors it can run, the state * machines it can read, the curves a `Lerp` eases along (#247) and the struct types `Add item` - * checks (#254), what a variable written by a graph is held to (#271) and how long an animation is - * (#233). One handle, so no caller - * can resolve a node with half of it. + * checks (#254), what a variable written by a graph is held to (#271), how long an animation is + * (#233) and what the collision layers are called (#211). One handle, so no caller can resolve a + * node with half of it. */ export interface GraphLookup { behavior(assetId: string): BehaviorProgram | null; @@ -53,6 +53,8 @@ export interface GraphLookup { textureSize(pictureId: string): { w: number; h: number } | null; /** An animation's length and playback, for a node that asks where a player stands in it (#233). */ animation(assetId: string): AnimationClip | null; + /** The project's collision layers by number, as `Layer of` names them (#211). */ + collisionLayers(): readonly string[]; /** The asset whose graph this is, `''` for none. */ ownerOf(graph: Graph): string; /** What a variable of that asset declares about the structs it holds (#271); none for nothing typed. */ @@ -83,6 +85,7 @@ export const NO_LOOKUP: GraphLookup = { structType: () => null, textureSize: () => null, animation: () => null, + collisionLayers: () => [], ownerOf: () => '', variableTyping: () => undefined, }; diff --git a/src/core/graph/nodes/data/LayerOf.ts b/src/core/graph/nodes/data/LayerOf.ts new file mode 100644 index 00000000..7a5f30c6 --- /dev/null +++ b/src/core/graph/nodes/data/LayerOf.ts @@ -0,0 +1,22 @@ +import { layerName } from '../../../model/collisionLayers'; +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { num, obj, str } from '../shared'; + +export const LayerOf: NodeDef = { + type: 'LayerOf', + label: 'Layer of', + category: 'data', + description: + 'The collision layer an object is on, by name and by number (an object stands for its first ' + + 'collider child; a collider or tilemap for itself). Without a collider the name is empty and the number -1.', + inputs: [obj('object')], + outputs: [str('name'), num('number')], + params: [], + read: (ctx, _params, input, _def, lookup) => { + const target = asEntity(input('object')); + const layer = target ? ctx.collisionLayerOf(target) : null; + if (layer === null) return { name: '', number: -1 }; + return { name: layerName(lookup.collisionLayers(), layer), number: layer }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 3b4584c8..3e046a06 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -50,6 +50,7 @@ import { IsInState } from './data/IsInState'; import { IsTouching } from './data/IsTouching'; import { Join } from './data/Join'; import { KeyIsDown } from './data/KeyIsDown'; +import { LayerOf } from './data/LayerOf'; import { Length } from './data/Length'; import { Lerp } from './data/Lerp'; import { @@ -249,6 +250,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ MouseButton, IsHovered, IsTouching, + LayerOf, Move, SetPosition, GetVelocity, diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index beb65fa9..ab21cc6c 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -50,6 +50,7 @@ export function graphLookup(project: Project): GraphLookup { const asset = findAssetOf(project, assetId, 'animation'); return asset ? clipOf(asset) : null; }, + collisionLayers: () => project.collisionLayers, ownerOf: (graph) => project.assets.find((a) => isGraphAsset(a) && a.graph === graph)?.id ?? '', variableTyping: (assetId, key) => { const owner = findAsset(project, assetId); diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 3b0cd38d..13c82757 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -4,6 +4,7 @@ * Handles arriving from a graph are resolved to the world's entities here, so no node holds one. */ import type { EntityHandle, NodeContext, PhysicsControl, SharedState } from '../../core/graph'; +import { collisionFilterOf } from '../../core/model/collisionLayers'; import type { Vec2 } from '../../core/model/Properties'; import { findAssetOf, findScene, type Project } from '../../core/model/Project'; import { soundFileOf } from '../../core/model/sound'; @@ -15,7 +16,7 @@ import type { World } from '../World'; import type { AudioSink } from './Audio'; import type { Instance } from './behaviorPulses'; import type { Input } from './Input'; -import { bodyOf, type CollisionSource, NOT_BLOCKED } from './solids'; +import { bodyOf, type CollisionSource, layerHolderOf, NOT_BLOCKED } from './solids'; export interface TimeScaled { scale: number; @@ -112,6 +113,11 @@ export function behaviorContext( world.noteRay(from, end, hits.length > 0); return hits.map((hit) => ({ ...hit, object: bodyOf(hit.collider) })); }, + collisionLayerOf: (target) => { + const found = resolve(target); + const holder = found && layerHolderOf(world, found); + return holder ? collisionFilterOf(holder.props).layer : null; + }, ignoreOneWay: (target, seconds) => { const found = resolve(target); if (found) world.ignoreOneWay(found, seconds); diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index 89cfac3f..e380ef5b 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -646,6 +646,14 @@ export function isCollisionBody(entity: Entity): boolean { return entity.type === 'collider' || entity.type === 'tilemap'; } +/** + * Whose layer an object is on (#211): a collider or tilemap its own, anything else that of its first + * collider child, switched on or not – the layer is set either way. + */ +export function layerHolderOf(world: World, entity: Entity): Entity | undefined { + return isCollisionBody(entity) ? entity : world.childrenOf(entity).find((e) => e.type === 'collider'); +} + /** * The object a collider belongs to: its parent, or the collider itself when top-level. A tilemap * is its own body wherever it is placed – its cells are the thing, not a shape stuck on one. diff --git a/tests/core/collision-layers.test.ts b/tests/core/collision-layers.test.ts index 28e6285d..d5f22c83 100644 --- a/tests/core/collision-layers.test.ts +++ b/tests/core/collision-layers.test.ts @@ -1,7 +1,9 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands'; import { SetCollisionLayersCommand } from '../../src/core/commands/projectCommands'; -import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import type { DeclaredPort, GraphNode } from '../../src/core/graph'; +import { OBJECT_TYPE_PARAM } from '../../src/core/graph/propertyPorts'; import { ALL_LAYERS, COLLIDES_WITH_KEY, @@ -17,9 +19,18 @@ import { maskWith, MAX_COLLISION_LAYERS, } from '../../src/core/model/collisionLayers'; +import { findAsset } from '../../src/core/model/Project'; import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; import { loadPhysics } from '../../src/runtime/physics/engine'; -import { COLLISION_SYSTEMS, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers'; +import { + COLLISION_SYSTEMS, + graphEditor, + makeBehavior, + makeSpriteScene, + place, + type SceneFixture, + startWorld, +} from '../helpers'; beforeAll(async () => { await loadPhysics(); @@ -205,7 +216,150 @@ describe.each(COLLISION_SYSTEMS)('$system: layers filter both systems alike (#15 }); }); +/** + * A wall and a walker left of it, both on layer 0 against every layer. The walker's behavior moves + * its collider to layer 3, colliding only with layer 3, on `g`, and back on `b` – with a Properties + * node of type Collider, the way a graph changes a layer (#211). + */ +function ghostScene() { + const s = scene(); + s.add({ x: 0, y: 0 }, WALL); + const walker = s.add({ x: -100, y: 0 }, { width: 20, height: 20 }); + const collider = s.viewOf().objects.find((o) => o.parentId === walker)!; + const behavior = makeBehavior(s, 'Ghost'); + const edit = graphEditor(s, behavior); + const child = edit.addNode('ChildByName', { name: findAsset(s.project, collider.assetId)!.name }); + edit.connect(edit.addNode('Owner'), 'object', child, 'object'); + const ports = [COLLISION_LAYER_KEY, COLLIDES_WITH_KEY].map((key): DeclaredPort => ({ + key, + kind: 'number', + write: true, + })); + const setter = (filter: Record) => { + const set = edit.properties(ports, { [OBJECT_TYPE_PARAM]: 'collider', ...filter }); + edit.connect(child, 'child', set, 'object'); + return set; + }; + const press = (key: string, filter: Record) => + edit.connect(edit.addNode('OnKeyDown', { key }), 'exec', setter(filter), 'exec'); + press('g', on(3, [3])); + press('b', on(0, [0])); + // `Layer of` (#211): asked of the walker on `l`, on `p` before and after moving it in one pulse, + // and on `c` of the collider itself and of the behavior, which has no collider. + const layerOf = (source: GraphNode, port: string) => { + const node = edit.addNode('LayerOf'); + edit.connect(source, port, node, 'object'); + return node; + }; + const log = (from: GraphNode, source: GraphNode, port: string) => { + const node = edit.addNode('Log'); + edit.connect(source, port, node, 'message'); + edit.connect(from, 'exec', node, 'exec'); + return node; + }; + const ofWalker = layerOf(edit.addNode('Owner'), 'object'); + const ofCollider = layerOf(child, 'child'); + const ofBehavior = layerOf(edit.addNode('Self'), 'object'); + log(log(edit.addNode('OnKeyDown', { key: 'l' }), ofWalker, 'name'), ofWalker, 'number'); + const moved = setter(on(3, [3])); + edit.connect(log(edit.addNode('OnKeyDown', { key: 'p' }), ofWalker, 'name'), 'exec', moved, 'exec'); + log(moved, ofWalker, 'name'); + const fromC = log(edit.addNode('OnKeyDown', { key: 'c' }), ofCollider, 'number'); + log(log(fromC, ofBehavior, 'name'), ofBehavior, 'number'); + s.stack.execute(new PlaceAssetCommand(s.sceneId, behavior.id, { x: 0, y: 0 }, walker)); + return { ...s, walker, colliderId: collider.id }; +} + +describe.each(COLLISION_SYSTEMS)( + '$system: a graph changes the layer during the run (#211)', + ({ physics }) => { + const start = () => { + const s = ghostScene(); + const started = startWorld(s, { physics, collisions: true, behaviors: true }); + const walker = started.world.findByObjectId(s.walker)!; + const walk = () => { + walker.x = -100; + for (let i = 0; i < 20; i++) started.collisions.moveAndSlide(started.world, walker, 10, 0); + return walker.x; + }; + const press = (key: string) => { + started.input.press(key); + started.frame(); + started.input.release(key); + }; + return { ...s, ...started, walk, press }; + }; + + it('a blocked mover walks through once its collider left the layer, and is blocked again once back', () => { + const r = start(); + r.frame(); + expect(r.walk()).toBeCloseTo(-14, 9); + r.press('g'); + expect(collisionFilterOf(r.world.findByObjectId(r.colliderId)!.props)).toEqual({ layer: 3, mask: 8 }); + expect(r.walk()).toBeCloseTo(100, 9); + r.press('b'); + expect(r.walk()).toBeCloseTo(-14, 9); + }); + + it('a ray filtered by layers finds the collider on its new layer only', () => { + const r = start(); + r.frame(); + const seen = (layer: number) => + r.collisions.raycast(r.world, { x: -100, y: -50 }, { x: 0, y: 1 }, 200, { + ignore: null, + triggers: false, + layers: maskWith(0, layer, true), + }) !== null; + expect([seen(0), seen(3)]).toEqual([true, false]); + r.press('g'); + expect([seen(0), seen(3)]).toEqual([false, true]); + }); + + it('Layer of names the layer of the object’s collider, already in the pulse that changed it', () => { + const r = start(); + r.stack.execute(new SetCollisionLayersCommand(['Ground', 'Layer 1', 'Layer 2', 'Ghost'])); + r.frame(); + r.press('l'); + expect(r.world.log).toEqual(['Ground', '0']); + r.press('p'); + expect(r.world.log.slice(2)).toEqual(['Ground', 'Ghost']); + r.press('l'); + expect(r.world.log.slice(4)).toEqual(['Ghost', '3']); + }); + + it('Layer of answers for a collider itself, and with no name for an object that has none', () => { + const r = start(); + r.frame(); + r.press('g'); + r.press('c'); + expect(r.world.log).toEqual(['3', '', '-1']); + }); + + it('Layer of still answers while the collider is switched off', () => { + const r = start(); + r.frame(); + r.press('g'); + r.world.findByObjectId(r.colliderId)!.props.enabled = false; + r.press('l'); + expect(r.world.log).toEqual(['Layer 3', '3']); + }); + }, +); + describe('a dynamic body falls through a layer it does not meet (#152)', () => { + it('drops through the ground from the step after a graph took it off the layer (#211)', () => { + const s = scene(); + s.add({ x: 0, y: 104 }, { width: 400, height: 8 }); + const box = s.add({ x: 0, y: 60 }, { width: 20, height: 20 }, { bodyType: 'dynamic' }); + const r = run(s, true); + for (let i = 0; i < 90; i++) r.frame(1 / 60); + expect(r.entity(box).y).toBeCloseTo(90, 0); + const collider = r.world.entities.find((e) => e.type === 'collider' && e.parent === r.entity(box))!; + Object.assign(collider.props, on(3, [3])); + for (let i = 0; i < 60; i++) r.frame(1 / 60); + expect(r.entity(box).y).toBeGreaterThan(300); + }); + it('lands on ground that meets it and keeps falling through ground that does not', () => { const fall = (groundLayers: number[]) => { const s = scene(); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 93d149e3..5088a63e 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -573,6 +573,28 @@ const CASES: Record = { }, { name: 'no object is null', inputs: { object: null, name: 'Hitbox' }, outputs: { child: null } }, ], + LayerOf: [ + { + name: 'names the layer the context says the object is on (#211)', + inputs: { object: fakeEntity('Player') }, + context: { collisionLayerOf: (target) => (target.name === 'Player' ? 1 : null) }, + lookup: { ...NO_LOOKUP, collisionLayers: () => ['Ground', 'Ghost'] }, + outputs: { name: 'Ghost', number: 1 }, + }, + { + name: 'a layer the project has not named goes by its number', + inputs: { object: fakeEntity('Player') }, + context: { collisionLayerOf: () => 4 }, + lookup: { ...NO_LOOKUP, collisionLayers: () => ['Ground'] }, + outputs: { name: 'Layer 4', number: 4 }, + }, + { + name: 'an object without a collider is on no layer', + inputs: { object: fakeEntity('Player') }, + outputs: { name: '', number: -1 }, + }, + { name: 'no object is on no layer', inputs: { object: null }, outputs: { name: '', number: -1 } }, + ], ObjectsByName: [ { name: 'every object so named, in scene order', diff --git a/tests/helpers.ts b/tests/helpers.ts index 3efd806e..6080db5a 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -240,6 +240,7 @@ export function fakeContext(overrides: Partial = {}): FakeContext { isTouching: () => false, raycast: () => null, raycastAll: () => [], + collisionLayerOf: () => null, ignoreOneWay: () => {}, isTilemap: () => false, tileAt: () => null, diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 9b6d7802..5810dadb 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -1107,6 +1107,7 @@ export async function graph(t) { await pluralLookups(); await eventOrder(); await animationPlaying(); + await layerOf(); await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`); @@ -1831,4 +1832,101 @@ export async function graph(t) { scene.objects = scene.objects.filter((o) => !o.id.startsWith('ap-')); `); } + + // 6z-x. Layer of (#211): typed into the palette it arrives with its ports. In a run it names the + // layer of the owner's collider as the project calls it, and its number. + async function layerOf() { + await mutate(` + const p = __gm.state.project; + window.__layersBefore = p.collisionLayers; + p.collisionLayers = ['Default', 'Ghost']; + p.assets.push( + { + id: 'lo-col', + type: 'collider', + name: 'Ghost collider', + props: { shape: 'box', width: 20, height: 20, radius: 10, isTrigger: false, layer: 1 }, + }, + { + id: 'loy', + type: 'behavior', + name: 'Layer asker', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: { nodes: [], edges: [] }, + }, + ); + __gm.state.select({ kind: 'asset', assetId: 'loy' }); + `); + await showTab('graph'); + const spot = await json('__gm.graph.emptySpot()'); + await click(spot.x, spot.y, 'right'); + await typeText('layer of'); + await press('Enter'); + const asker = (await asset('loy')).graph.nodes[0]; + const types = asker ? await json(`__gm.graph.portTypes(${JSON.stringify(asker.id)})`) : null; + check( + 'typing "layer of" into the palette adds the node: object in, name and number out', + asker?.type === 'LayerOf' && + JSON.stringify(Object.keys(types?.inputs ?? {})) === JSON.stringify(['object']) && + JSON.stringify(types?.outputs) === JSON.stringify({ name: 'string', number: 'number' }), + JSON.stringify({ asker, types }), + ); + if (!asker) return; + const id = JSON.stringify(asker.id); + await mutate(` + const p = __gm.state.project; + const behavior = p.assets.find((a) => a.id === 'loy'); + const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params }); + const edge = (id, from, fromPort, to, toPort) => + ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } }); + behavior.graph.nodes.push( + node('lo-start', 'OnStart', -300, -200), + node('lo-owner', 'Owner', -560, 0), + node('lo-name', 'Log', 420, -200), + node('lo-number', 'Log', 700, -200), + ); + behavior.graph.edges.push( + edge('lo-e1', 'lo-start', 'exec', 'lo-name', 'exec'), + edge('lo-e2', 'lo-name', 'exec', 'lo-number', 'exec'), + edge('lo-e3', 'lo-owner', 'object', ${id}, 'object'), + edge('lo-e4', ${id}, 'name', 'lo-name', 'message'), + edge('lo-e5', ${id}, 'number', 'lo-number', 'message'), + ); + const scene = __gm.state.activeScene; + const sprite = p.assets.find((a) => a.type === 'sprite'); + for (const [id, assetId, parentId, name, x] of [ + ['lo-target', sprite.id, '', 'Ghost 6', -6000], + ['lo-part', 'lo-col', 'lo-target', 'Ghost part', 0], + ['lo-holder', 'loy', 'lo-target', 'Layer holder', 0], + ]) { + const o = structuredClone(scene.objects[0]); + Object.assign(o, { id, assetId, parentId, name, position: { x, y: x, z: 0 }, overrides: {}, instanceEdits: {} }); + delete o.groupId; + scene.objects.push(o); + } + `); + await clickText('Fit'); + await shot('29-layer-of'); + const logFrom = t.consoleLog.length; + await t.play(); + await sleep(600); + await stop(); + const logged = t.consoleLog + .slice(logFrom) + .filter((line) => line.startsWith('[behavior Layer holder]')) + .map((line) => line.split(']').pop().trim()); + check( + 'a run names the layer of the owner’s collider the way the project calls it, and its number', + JSON.stringify(logged) === JSON.stringify(['Ghost', '1']), + JSON.stringify(logged), + ); + await mutate(` + const p = __gm.state.project; + p.collisionLayers = window.__layersBefore; + p.assets = p.assets.filter((a) => a.id !== 'loy' && a.id !== 'lo-col'); + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => !o.id.startsWith('lo-')); + `); + } }