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'), ], }; /** 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(); }