import { edge, node } from '../lib/builders.mjs'; import { inView, LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; /** * What a game asks the collision about, and platforms one jumps through: one-way colliders (#155), * the Raycast node (#153) and On collision stay with an interval and contact data (#149). What only a * running editor shows: the switch in the inspector, the engine and Move landing on a platform and * passing it from below, the ray drawn in the Scene tab by the editor run, and a stay that fires on * its own clock. */ const FALL = 300; /** Falls with Move and "Collide with solids"; 1.5 s after it starts it drops through one-way platforms. */ const dropGraph = { nodes: [ node('qd-update', 'OnUpdate', 0, 0), node('qd-owner', 'Owner', 0, 160), node('qd-move', 'Move', 300, 0, { dy: 1, scale: FALL, collide: true }), node('qd-timer', 'OnTimer', 0, 320, { interval: 1.5 }), node('qd-drop', 'IgnoreOneWay', 300, 320, { seconds: 0.3 }), ], edges: [ edge('qd-e1', 'qd-update', 'exec', 'qd-move', 'exec'), edge('qd-e2', 'qd-update', 'dt', 'qd-move', 'dt'), edge('qd-e3', 'qd-owner', 'object', 'qd-move', 'object'), edge('qd-e4', 'qd-timer', 'exec', 'qd-drop', 'exec'), edge('qd-e5', 'qd-owner', 'object', 'qd-drop', 'object'), ], }; /** Shoots its owner – a dynamic body below the platform – straight up in On start. */ const jumpGraph = { nodes: [ node('qj-start', 'OnStart', 0, 0), node('qj-owner', 'Owner', 0, 160), node('qj-shoot', 'SetVelocity', 300, 0, { x: 0, y: -700 }), ], edges: [ edge('qj-e1', 'qj-start', 'exec', 'qj-shoot', 'exec'), edge('qj-e2', 'qj-owner', 'object', 'qj-shoot', 'object'), ], }; /** Casts a ray straight down from its owner every frame. */ const eyeGraph = { nodes: [ node('qe-update', 'OnUpdate', 0, 0), node('qe-owner', 'Owner', 0, 160), node('qe-where', 'GetPosition', 250, 160), node('qe-ray', 'Raycast', 500, 0, { dirX: 0, dirY: 1, length: 500 }), ], edges: [ edge('qe-e1', 'qe-update', 'exec', 'qe-ray', 'exec'), edge('qe-e2', 'qe-owner', 'object', 'qe-where', 'object'), edge('qe-e3', 'qe-where', 'worldX', 'qe-ray', 'fromX'), edge('qe-e4', 'qe-where', 'worldY', 'qe-ray', 'fromY'), ], }; /** 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'), ], }; /** * 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 = { nodes: [ node('qf-stay', 'OnCollisionStay', 0, 0, { interval: STAY }), node('qf-log-n', 'Log', 300, 0), node('qf-log-i', 'Log', 550, 0), ], edges: [ edge('qf-e1', 'qf-stay', 'exec', 'qf-log-n', 'exec'), edge('qf-e2', 'qf-stay', 'normalY', 'qf-log-n', 'message'), edge('qf-e3', 'qf-log-n', 'exec', 'qf-log-i', 'exec'), edge('qf-e4', 'qf-stay', 'impulse', 'qf-log-i', 'message'), ], }; export async function queries(t) { const { check, json, evaluate, mutate, select, click, object, settle, sleep, play, stop, shot, consoleLog, } = t; const Y = LANES.queries; const GROUND_TOP = Y + 280; const PLATFORM_TOP = Y + 95; const setup = (body) => mutate(` const scene = __gm.state.activeScene; const assets = __gm.state.project.assets; ${MAKE_OBJECT} const collider = (id, props) => assets.push({ id, type: 'collider', name: id, props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false, ...props } }); const behavior = (id, name, graph) => { const asset = structuredClone(assets.find((a) => a.id === 'mover')); Object.assign(asset, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph }); assets.push(asset); }; const put = (id, assetId, parentId, x, y, name, overrides = {}) => { const o = make(id, assetId, parentId, y, name); o.position.x = x; o.overrides = overrides; scene.objects.push(o); }; ${body} __gm.state.select(null)`); const clear = () => mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('q-')); p.assets = p.assets.filter((a) => !a.id.startsWith('q-')); __gm.state.select(null)`); const sample = async (seconds, ids) => { await evaluate(`(() => { const out = (window.__querySamples = []); const start = performance.now(); const sample = () => { const w = __gm.world(); if (!w) return; const y = (id) => w.entities.find((e) => e.objectId === id)?.worldY; out.push({ t: (performance.now() - start) / 1000, ...Object.fromEntries(${JSON.stringify(ids)}.map((id) => [id, y(id)])) }); if (performance.now() - start < ${seconds * 1000}) requestAnimationFrame(sample); }; requestAnimationFrame(sample); })()`); await sleep(seconds * 1000 + 300); return json('window.__querySamples'); }; // 43a. A platform one jumps through (#155), switched to one-way in the inspector. A crate falls // onto it with Move and drops through 1.5 s later; a dynamic crate shot up from below // passes it and lands on it. await setup(` collider('q-col-ground', { width: 800, height: 40 }); collider('q-col-platform', { width: 300, height: 10 }); collider('q-col-box', {}); assets.push({ id: 'q-rb', type: 'rigidbody', name: 'q-rb', props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } }); behavior('q-drop', 'Query drop', ${JSON.stringify(dropGraph)}); behavior('q-jump', 'Query jump', ${JSON.stringify(jumpGraph)}); const box = { width: 40, height: 40 }; 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-platform', 'crate', '', 0, ${Y + 100}, 'Query platform', { width: 300, height: 10 }); put('q-platform-col', 'q-col-platform', 'q-platform', 0, 0, 'Platform collider'); put('q-dropper', 'crate', '', -100, ${Y}, 'Dropper', box); put('q-dropper-col', 'q-col-box', 'q-dropper', 0, 0); put('q-dropper-go', 'q-drop', 'q-dropper', 0, 0); put('q-jumper', 'crate', '', 100, ${Y + 200}, 'Jumper', box); put('q-jumper-col', 'q-col-box', 'q-jumper', 0, 0); put('q-jumper-rb', 'q-rb', 'q-jumper', 0, 0); put('q-jumper-go', 'q-jump', 'q-jumper', 0, 0);`); await select({ kind: 'object', holderId: 'scene1', objectId: 'q-platform-col', objectIds: ['q-platform-col'], }); const toggle = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('One-way')"); // Side and angle mean nothing on a collider blocking from everywhere, so they wait for the switch (#159). const hidden = await json( "[__gm.inspectorField('Blocks from'), __gm.inspectorField('One-way angle')].every((f) => f === null)", ); if (toggle) await click(toggle.x, toggle.y); await settle(); const blocksFrom = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Blocks from')"); const angleRow = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('One-way angle')"); check( 'Blocks from and One-way angle show only once One-way is switched on', hidden === true && Boolean(blocksFrom) && Boolean(angleRow), JSON.stringify({ hidden, blocksFrom, angleRow }), ); check( "a collider's One-way switch in the inspector turns it one-way, blocking from above", Boolean(toggle) && Boolean(blocksFrom) && (await object('q-platform-col'))?.overrides?.oneWay === true && (await t.has('Above')), JSON.stringify({ toggle, blocksFrom, overrides: (await object('q-platform-col'))?.overrides }), ); await select(null); await evaluate(`__gm.lookAt(0, ${Y + 150})`); await settle(); await shot('43-one-way'); await play(); const run = await sample(2.8, ['q-dropper', 'q-jumper']); await stop(); const at = (seconds) => run.reduce((best, s) => (Math.abs(s.t - seconds) < Math.abs(best.t - seconds) ? s : best)); const onPlatform = PLATFORM_TOP - 20; const onGround = GROUND_TOP - 20; check( 'Move lands on the one-way platform, and Ignore one-way drops it through onto the ground', run.length > 60 && Math.abs(at(0.7)['q-dropper'] - onPlatform) < 0.01 && Math.abs(run.at(-1)['q-dropper'] - onGround) < 0.01, JSON.stringify({ frames: run.length, early: at(0.7), last: run.at(-1), onPlatform, onGround }), ); const highest = Math.min(...run.map((s) => s['q-jumper'])); check( 'a dynamic body shot up from below passes through the platform and lands on top of it', highest < PLATFORM_TOP - 60 && Math.abs(run.at(-1)['q-jumper'] - onPlatform) < 1, JSON.stringify({ highest, last: run.at(-1)['q-jumper'], onPlatform }), ); await clear(); // 43b. The Raycast node in the editor run (#153): a crate casting down every frame, the ray drawn // in the Scene tab to where it meets the ground. await setup(` collider('q-col-ground', { width: 800, height: 40 }); collider('q-col-box', {}); behavior('q-eye', 'Query eye', ${JSON.stringify(eyeGraph)}); 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-eyebox', 'crate', '', 200, ${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 rays = await json('__gm.rays()'); const ray = rays.at(-1); check( 'the editor run casts the ray and the Scene tab draws it, down to the ground it met', (await json('__gm.editorRun()')).running === true && ray?.hit === true && ray.from.x === 200 && ray.from.y === Y && Math.abs(ray.to.y - GROUND_TOP) < 1e-6, JSON.stringify({ count: rays.length, ray, groundTop: GROUND_TOP }), ); await shot('43-ray'); await clear(); await sleep(300); // 43c. On collision stay with an interval (#149): a crate resting on the ground logs how it touches // every half second – the normal from the ground towards it, and the impulse holding it up. const logFrom = consoleLog.length; await setup(` collider('q-col-ground', { width: 800, height: 40 }); collider('q-col-box', {}); assets.push({ id: 'q-rb', type: 'rigidbody', name: 'q-rb', props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } }); behavior('q-feel', 'Query feel', ${JSON.stringify(feelGraph)}); 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-rest', 'crate', '', -300, ${Y + 220}, 'Rester', { width: 40, height: 40 }); put('q-rest-col', 'q-col-box', 'q-rest', 0, 0); put('q-rest-rb', 'q-rb', 'q-rest', 0, 0); put('q-rest-go', 'q-feel', 'q-rest', 0, 0, 'Feel');`); await play(); await sleep(2200); await stop(); const logged = consoleLog .slice(logFrom) .filter((line) => line.startsWith('[behavior Feel]')) .map((line) => Number(line.split(']').pop())); const pairs = Array.from({ length: Math.floor(logged.length / 2) }, (_, i) => logged.slice(2 * i, 2 * i + 2), ); check( 'On collision stay with an interval fires every half second, with the normal and the impulse of the contact', pairs.length >= 2 && pairs.length <= 4 && pairs.every(([ny, impulse]) => ny === -1 && impulse > 0), 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 t.menuTicked(); 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.ticked === true), JSON.stringify({ layerTicks, names }), ); const secondLayer = await t.locate(layerTicks[1].label, true); await click(secondLayer.x, secondLayer.y); 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(); // 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(); }