import { edge, node } from '../lib/builders.mjs'; import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; /** Kicks its owner in On start, with the scene's gravity switched off first: it glides at 100 per second. */ const KICK = 200; const KICKED_MASS = 2; const kickGraph = { nodes: [ node('pk-start', 'OnStart', 0, 0), node('pk-gravity', 'SetGravity', 300, 0, { x: 0, y: 0 }), node('pk-owner', 'Owner', 300, 160), node('pk-kick', 'ApplyImpulse', 600, 0, { x: KICK, y: 0 }), ], edges: [ edge('pk-e1', 'pk-start', 'exec', 'pk-gravity', 'exec'), edge('pk-e2', 'pk-gravity', 'exec', 'pk-kick', 'exec'), edge('pk-e3', 'pk-owner', 'object', 'pk-kick', 'object'), ], }; /** Walks its owner right at 120 per second with "Collide with solids" on. */ const WALK = 120; const walkGraph = { nodes: [ node('pw-update', 'OnUpdate', 0, 0), node('pw-owner', 'Owner', 0, 160), node('pw-move', 'Move', 300, 0, { dx: 1, scale: WALK, collide: true }), ], edges: [ edge('pw-e1', 'pw-update', 'exec', 'pw-move', 'exec'), edge('pw-e2', 'pw-update', 'dt', 'pw-move', 'dt'), edge('pw-e3', 'pw-owner', 'object', 'pw-move', 'object'), ], }; /** * A point of a panel (`panel` and `point` are hook expressions), scrolled into view first: a * collider's material rows sit below the fold of the inspector, and after the earlier steps the * hierarchy is long enough to hide this step's objects. A click beside the panel would land on * whatever is drawn there instead. */ async function inView(t, panel, point) { for (let i = 0; i < 15; i++) { const rect = await t.json(panel); const at = await t.json(point); if (!rect) return null; if (at && at.y > rect.y + 8 && at.y < rect.y + rect.h - 8) return at; await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, at && at.y < rect.y ? -200 : 200); await t.settle(); } return null; } /** * The physics engine (Rapier) loads in the editor, in the background after the first project is * open, from a chunk of its own – the editor bundle does not carry it. A played scene simulates its * rigidbodies on it (#146). */ export async function physics(t) { const { check, json, waitUntil, consoleLog, evaluate, mutate, select, click, clickText, press, typeText } = t; const { has, object, asset, objects, state, settle, sleep, play, stop, shot } = t; await waitUntil(`['ready', 'failed'].includes(__gm.physics().state)`, { timeout: 15000 }); const status = await json('__gm.physics()'); check( 'Rapier loads in the editor without Play', status.state === 'ready' && status.version === '0.20.0', JSON.stringify(status), ); const scripts = await json( `performance.getEntriesByType('resource').map((e) => e.name).filter((n) => n.endsWith('.js')).map((n) => n.split('/').pop())`, ); check( 'it comes from a chunk of its own', scripts.some((name) => /^rapier-.+\.js$/.test(name)), JSON.stringify(scripts), ); check( 'loading it writes nothing to the console', !consoleLog.some((line) => /\[physics\]|rapier/i.test(line)), consoleLog.filter((line) => /\[physics\]|rapier/i.test(line)).join(' | '), ); // 41b. A crate and a ball above a ground on a lane of their own, each with a collider and no // rigidbody yet. The rigidbodies come from the hierarchy menu, the ball's bounciness is typed // into its collider's field, and the played scene is sampled frame by frame. const Y = LANES.physics; const REST = Y + 200 - 20 - 20; // the ground's top face minus half of a 40-high body await 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 } }); collider('phys-col-ground', { width: 600, height: 40 }); collider('phys-col-crate', {}); collider('phys-col-ball', { shape: 'circle' }); 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); }; put('phys-ground', 'crate', '', 0, ${Y + 200}, 'Physics ground', { width: 600, height: 40 }); put('phys-crate', 'crate', '', -100, ${Y}, 'Physics crate', { width: 40, height: 40 }); put('phys-ball', 'crate', '', 100, ${Y}, 'Physics ball', { width: 40, height: 40 }); put('phys-ground-col', 'phys-col-ground', 'phys-ground', 0, 0); put('phys-crate-col', 'phys-col-crate', 'phys-crate', 0, 0); put('phys-ball-col', 'phys-col-ball', 'phys-ball', 0, 0); __gm.state.select(null)`); const selectObject = (id) => select({ kind: 'object', holderId: 'scene1', objectId: id, objectIds: [id] }); const rigidbodyFromMenu = async (id) => { await selectObject(id); const row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow(${JSON.stringify(id)})`); const before = (await objects()).length; await click(row.x, row.y, 'right'); await clickText('Add rigidbody'); await press('Escape'); const s = await state(); const selected = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; return { added: (await objects()).length - before, selected }; }; const crate = await rigidbodyFromMenu('phys-crate'); const crateBody = crate.selected; check( 'Add rigidbody puts a dynamic rigidbody below the object and shows its settings', crate.added === 1 && crateBody?.parentId === 'phys-crate' && (await asset(crateBody.assetId))?.type === 'rigidbody' && (await has('Body type')) && (await has('Dynamic')), JSON.stringify(crate), ); check( 'the rigidbody is drawn as an icon in the overlay', crateBody && (await evaluate(`__gm.layerOf('${crateBody.id}')`)) === 'overlay', ); const again = await rigidbodyFromMenu('phys-crate'); check('the menu offers no second rigidbody', again.added === 0, JSON.stringify(again)); const ballBody = (await rigidbodyFromMenu('phys-ball')).selected; await evaluate(`__gm.lookAt(0, ${Y + 100})`); await settle(); await shot('41-rigidbody'); await selectObject('phys-ball-col'); const field = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Bounciness')"); if (field) { await click(field.x, field.y); await press('a', { ctrl: true }); await typeText('0.8'); await press('Enter'); } await settle(); check( "the collider's Bounciness field takes 0.8", (await object('phys-ball-col'))?.overrides?.bounciness === 0.8, JSON.stringify({ field, overrides: (await object('phys-ball-col'))?.overrides }), ); await select(null); await play(); await evaluate(`(() => { const out = (window.__physicsSamples = []); 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, crate: y('phys-crate'), ball: y('phys-ball') }); if (performance.now() - start < 3000) requestAnimationFrame(sample); }; requestAnimationFrame(sample); })()`); await sleep(3300); const samples = await json('window.__physicsSamples'); await stop(); const last = samples.slice(-20).map((s) => s.crate); check( 'the crate falls onto the ground and stays there', samples.length > 100 && samples[0].crate < REST - 50 && last.every((y) => Math.abs(y - REST) < 1), JSON.stringify({ frames: samples.length, first: samples[0], last: last.slice(-3), rest: REST }), ); // The heights the ball climbs back to: the highest point between two landings. const peaks = []; let landings = 0; let down = false; let top = Infinity; for (const { ball } of samples) { if (ball > REST - 1) { if (!down && landings++ > 0 && Number.isFinite(top)) peaks.push(REST - top); down = true; top = Infinity; } else { down = false; if (landings > 0) top = Math.min(top, ball); } } const drop = REST - Y; check( 'the ball bounces back up and every bounce is lower than the one before', peaks.length >= 2 && peaks[0] / drop > 0.4 && peaks[0] / drop < 0.8 && peaks.every((h, i) => i === 0 || h < peaks[i - 1]), JSON.stringify({ drop, peaks: peaks.map((h) => Math.round(h * 10) / 10), frames: samples.length }), ); check( 'playing left the scene as it was', (await object('phys-crate'))?.position.y === Y && (await object('phys-ball'))?.position.y === Y, ); const bodies = [crateBody?.id, ballBody?.id].filter(Boolean); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const gone = new Set(${JSON.stringify(bodies)}); const assets = new Set(scene.objects.filter((o) => gone.has(o.id)).map((o) => o.assetId)); scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-') && !gone.has(o.id)); p.assets = p.assets.filter((a) => !a.id.startsWith('phys-col-') && !assets.has(a.id)); __gm.state.select(null)`); // 41c. The graph drives the physics (#147): a crate kicked in On start with gravity switched off // by Set gravity, and a walker whose Move with "Collide with solids" runs into a free crate. const hintsBefore = consoleLog.filter((line) => line.includes('[physics]')).length; await mutate(` const scene = __gm.state.activeScene; const p = __gm.state.project; const assets = p.assets; ${MAKE_OBJECT} 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); }; behavior('phys-kick', 'Physics kick', ${JSON.stringify(kickGraph)}); behavior('phys-walk', 'Physics walk', ${JSON.stringify(walkGraph)}); assets.push({ id: 'phys-col-box', type: 'collider', name: 'phys-col-box', props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false } }); assets.push({ id: 'phys-rb-kicked', type: 'rigidbody', name: 'phys-rb-kicked', props: { bodyType: 'dynamic', mass: ${KICKED_MASS} } }); assets.push({ id: 'phys-rb-free', type: 'rigidbody', name: 'phys-rb-free', props: { bodyType: 'dynamic', mass: 0 } }); 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); }; const box = { width: 40, height: 40 }; put('phys-kicked', 'crate', '', -200, ${Y}, 'Kicked crate', box); put('phys-kicked-col', 'phys-col-box', 'phys-kicked', 0, 0); put('phys-kicked-rb', 'phys-rb-kicked', 'phys-kicked', 0, 0); put('phys-kicked-kick', 'phys-kick', 'phys-kicked', 0, 0); put('phys-walker', 'crate', '', 0, ${Y + 100}, 'Walker', box); put('phys-walker-col', 'phys-col-box', 'phys-walker', 0, 0); put('phys-walker-walk', 'phys-walk', 'phys-walker', 0, 0); put('phys-pushed', 'crate', '', 60, ${Y + 100}, 'Pushed crate', box); put('phys-pushed-col', 'phys-col-box', 'phys-pushed', 0, 0); put('phys-pushed-rb', 'phys-rb-free', 'phys-pushed', 0, 0); __gm.state.select(null)`); await play(); // A model event while playing re-reads the scene (CI had one in the first frames): what the run // set with Set gravity has to outlive it. await evaluate(`__gm.state.preview()`); await evaluate(`(() => { const out = (window.__physicsGraph = []); const start = performance.now(); const sample = () => { const w = __gm.world(); if (!w) return; const at = (id) => w.entities.find((e) => e.objectId === id); const kicked = at('phys-kicked'); out.push({ t: (performance.now() - start) / 1000, kickedX: kicked?.worldX, kickedY: kicked?.worldY, walker: at('phys-walker')?.worldX, pushed: at('phys-pushed')?.worldX, }); if (performance.now() - start < 2000) requestAnimationFrame(sample); }; requestAnimationFrame(sample); })()`); await sleep(2300); const run = await json('window.__physicsGraph'); await stop(); const at = (seconds) => run.reduce((best, s) => (Math.abs(s.t - seconds) < Math.abs(best.t - seconds) ? s : best)); const [early, late] = [at(0.5), at(1.5)]; const glide = (late.kickedX - early.kickedX) / (late.t - early.t); const speed = KICK / KICKED_MASS; check( `Set gravity and Apply impulse in On start: the crate glides straight at impulse ÷ mass (${speed} per second)`, run.length > 60 && Math.abs(glide - speed) < speed * 0.05 && run.every((s) => Math.abs(s.kickedY - Y) < 0.5), JSON.stringify({ frames: run.length, glide: Math.round(glide * 10) / 10, early, late }), ); const gap = Math.min(...run.map((s) => s.pushed - 20 - (s.walker + 20))); const pushed = run.at(-1).pushed - 60; check( 'Move with "Collide with solids" pushes the crate in its way and never gets into it', pushed > 100 && gap > -0.01, JSON.stringify({ pushed: Math.round(pushed * 10) / 10, walked: Math.round(run.at(-1).walker * 10) / 10, gap, }), ); const hints = consoleLog.filter((line) => line.includes('[physics]')).slice(hintsBefore); check('nothing in the step made the physics hint at a no-op', hints.length === 0, hints.join(' | ')); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-')); p.assets = p.assets.filter((a) => !a.id.startsWith('phys-')); __gm.state.select(null)`); }