import { edge, node } from '../lib/builders.mjs'; import { inView, 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'), ], }; /** * Rolls its owner right at 120 per second with "Collide with solids" on, and logs Move's normal * (x, then y) on every frame that hits something. */ const BALL_R = 20; const CORNER_DROP = 12; const rollGraph = { nodes: [ node('pr-update', 'OnUpdate', 0, 0), node('pr-owner', 'Owner', 0, 160), node('pr-move', 'Move', 300, 0, { dx: 1, scale: WALK, collide: true }), node('pr-hit', 'Branch', 600, 0), node('pr-log-x', 'Log', 850, 0), node('pr-log-y', 'Log', 1100, 0), ], edges: [ edge('pr-e1', 'pr-update', 'exec', 'pr-move', 'exec'), edge('pr-e2', 'pr-update', 'dt', 'pr-move', 'dt'), edge('pr-e3', 'pr-owner', 'object', 'pr-move', 'object'), edge('pr-e4', 'pr-move', 'exec', 'pr-hit', 'exec'), edge('pr-e5', 'pr-move', 'hit', 'pr-hit', 'condition'), edge('pr-e6', 'pr-hit', 'then', 'pr-log-x', 'exec'), edge('pr-e7', 'pr-move', 'normalX', 'pr-log-x', 'message'), edge('pr-e8', 'pr-log-x', 'exec', 'pr-log-y', 'exec'), edge('pr-e9', 'pr-move', 'normalY', 'pr-log-y', 'message'), ], }; /** * 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…'); // An enabled entry opens the second menu (#222); a disabled one leaves the first standing. if (await has('Create new rigidbody')) await clickText('Create new 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'); // Every symbol has an icon and wants its object's position. One with room stands there; icons // that want the same spot share it on a ring round it, whoever owns them – two stand left and // right of it, in hierarchy order: the seeded collider first, the rigidbody added above second. const icons = { ground: await json(`__gm.sceneIcon('phys-ground-col')`), crate: await json(`__gm.sceneIcon('phys-crate-col')`), crateBody: await json(`__gm.sceneIcon('${crateBody?.id}')`), ball: await json(`__gm.sceneIcon('phys-ball-col')`), ballBody: await json(`__gm.sceneIcon('${ballBody?.id}')`), }; const near = (icon, x, y) => icon?.visible === true && Math.abs(icon.x - x) < 0.01 && Math.abs(icon.y - y) < 0.01; check( 'a symbol with room around it stands on its object, in the overlay', near(icons.ground, 0, Y + 200) && icons.ground.layer === 'overlay', JSON.stringify(icons.ground), ); check( 'icons on one spot stand left and right of it, a symbol apart', near(icons.crate, -110.5, Y) && near(icons.crateBody, -89.5, Y) && near(icons.ball, 89.5, Y) && near(icons.ballBody, 110.5, Y), JSON.stringify(icons), ); // An icon is clicked like its object: it selects it, even over a sprite. Beside the icon the // sprite under it is picked as before. const selectedId = async () => { const s = await state(); return s.selection?.kind === 'object' ? s.selection.objectId : null; }; const zoom = (icons.crateBody.screen.x - icons.crate.screen.x) / 21; const picks = {}; for (const [name, id, dx] of [ ['groundIcon', 'phys-ground-col', 0], ['crateIcon', 'phys-crate-col', 0], ['crateBodyIcon', crateBody?.id, 0], ['groundBeside', 'phys-ground-col', 40], ]) { await select(null); const at = await json(`__gm.sceneIcon('${id}')?.screen`); await click(at.x + dx * zoom, at.y); picks[name] = { selected: await selectedId() }; } check( 'a click on an icon selects its object, beside it the sprite below', icons.ground.clickable === true && picks.groundIcon.selected === 'phys-ground-col' && picks.crateIcon.selected === 'phys-crate-col' && picks.crateBodyIcon.selected === crateBody?.id && picks.groundBeside.selected === 'phys-ground', JSON.stringify({ zoom, picks }), ); 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: every turn from rising to falling after the first landing. // Turning points, not a threshold at the ground: the ball lands at ~9 px per frame, and whether a // drawn frame comes within a pixel of the landing depends on where it falls between two steps. const peaks = []; let landed = false; for (let i = 1; i < samples.length - 1; i++) { const [before, y, after] = [samples[i - 1].ball, samples[i].ball, samples[i + 1].ball]; if (y >= before && y > after) landed = true; else if (landed && y <= before && y < after && REST - y > 2) peaks.push(REST - y); } 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(' | ')); const clearPhysics = () => 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)`); await clearPhysics(); // 41d. Move along the contact normal (#150): a ball walked straight right into the upper-left // corner of a block, 12 below its centre, rolls up over it instead of hanging on the corner, // and its graph logs the normal of the first contact from Move's hit / normal outputs. const edgeLog = consoleLog.length; await mutate(` const scene = __gm.state.activeScene; const assets = __gm.state.project.assets; ${MAKE_OBJECT} const asset = structuredClone(assets.find((a) => a.id === 'mover')); Object.assign(asset, { id: 'phys-roll', name: 'Physics roll', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(rollGraph)} }); assets.push(asset); assets.push({ id: 'phys-col-ball', type: 'collider', name: 'phys-col-ball', props: { shape: 'circle', width: 40, height: 40, radius: ${BALL_R}, isTrigger: false } }); assets.push({ id: 'phys-col-block', type: 'collider', name: 'phys-col-block', props: { shape: 'box', width: 100, height: 60, radius: 20, isTrigger: false } }); 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-ball', 'crate', '', 0, ${Y}, 'Rolling ball', { width: 40, height: 40 }); put('phys-ball-col', 'phys-col-ball', 'phys-ball', 0, 0); put('phys-ball-roll', 'phys-roll', 'phys-ball', 0, 0, 'Roll'); put('phys-block', 'crate', '', 150, ${Y + CORNER_DROP + 30}, 'Edge block', { width: 100, height: 60 }); put('phys-block-col', 'phys-col-block', 'phys-block', 0, 0); __gm.state.select(null)`); await play(); await sleep(2000); const ball = await json( `(() => { const e = __gm.world()?.entities.find((x) => x.objectId === 'phys-ball'); return e && { x: e.worldX, y: e.worldY }; })()`, ); await stop(); const top = Y + CORNER_DROP - BALL_R; // the ball's centre when it rolls along the block's top face check( 'Move rolls a ball up over the edge it runs into, onto the top face', ball && ball.x > 150 && ball.y <= top + 0.01 && ball.y > top - 3, JSON.stringify({ ball, top }), ); const lines = consoleLog.slice(edgeLog).filter((line) => line.startsWith('[behavior ')); const logged = lines .filter((line) => line.startsWith('[behavior Roll]')) .map((line) => Number(line.split(']').pop())); const reach = Math.sqrt(BALL_R ** 2 - CORNER_DROP ** 2); check( "Move's hit and normal outputs: the first contact faces from the corner to the ball's centre", logged.length >= 2 && Math.abs(logged[0] + reach / BALL_R) < 1e-6 && Math.abs(logged[1] + CORNER_DROP / BALL_R) < 1e-6, JSON.stringify({ logged: logged.slice(0, 4), expected: [-reach / BALL_R, -CORNER_DROP / BALL_R], lines: lines.slice(0, 4), }), ); await clearPhysics(); }