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, mouse, showTab } = t; const { drag, undo } = 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, ); // A body dragged in the scene view while the game runs sticks to the pointer as if it had no // rigidbody: lifted off the ground it hangs where it is held, and is let go at rest. const LIFT = 150; await play(); await sleep(1500); await showTab('scene'); await selectObject('phys-crate'); await settle(); const grab = await json(`__gm.objectRect('phys-crate')`); const scale = grab.w / 40; // Below the middle, where the collider's and the rigidbody's icons are not. const from = { x: grab.x, y: grab.y + grab.h * 0.35 }; const to = { x: from.x, y: from.y - LIFT * scale }; await mouse('mouseMoved', from.x, from.y); await mouse('mousePressed', from.x, from.y, 'left'); for (let i = 1; i <= 10; i++) { await mouse('mouseMoved', from.x, from.y + ((to.y - from.y) * i) / 10, 'left', { buttons: 1 }); await sleep(16); } const target = REST - LIFT; const crateY = "__gm.world()?.entities.find((e) => e.objectId === 'phys-crate')?.worldY"; // The last move reaches the body a few frames later: preview, physics step, drawn one step // behind. Reading right away raced it and caught the ninth move's height (#282). const arrived = await waitUntil(`Math.abs(${crateY} - ${target}) < 1.5`, { timeout: 2000, label: 'the crate reached the pointer', }).then( () => true, () => false, ); // Every frame recorded in the page, so the count follows the frames and not the round trips. // The page also stamps the release itself: a stamp taken after the round trip came frames late, // and the first sample after it was already 7 units into the fall on the WSL runner. await evaluate(`(() => { const out = (window.__dragSamples = []); const sample = () => { out.push({ t: performance.now(), y: ${crateY} }); if (!window.__dragSamplesDone) requestAnimationFrame(sample); }; window.__dragSamplesDone = false; window.__dragLetGo = undefined; window.addEventListener('pointerup', () => (window.__dragLetGo = performance.now()), { capture: true, once: true, }); requestAnimationFrame(sample); })()`); await sleep(1000); await shot('41-drag-while-playing'); const dragInfo = { grab, selected: (await state()).selection?.objectId }; await mouse('mouseReleased', to.x, to.y, 'left'); await sleep(300); await evaluate('window.__dragSamplesDone = true'); const dragSamples = await json('window.__dragSamples'); const letGo = await evaluate('window.__dragLetGo'); const log = (await json('__gm.world()'))?.log ?? []; await stop(); const heldSamples = dragSamples.filter((s) => s.t < letGo); const held = heldSamples.map((s) => s.y); // How long the hold was watched, not how many frames that took: a busy runner drew 21 frames // in the second a quiet one draws 50 in, and a count of 30 called that a failure. const heldFor = held.length > 1 ? heldSamples.at(-1).t - heldSamples[0].t : 0; const released = dragSamples.filter((s) => s.t >= letGo); // 200 ms of free fall from rest is about 25 below the hold, a body let go at speed is not. const falling = released.filter((s) => s.t - letGo <= 200).map((s) => s.y); const fell = falling.at(-1); check( 'a body dragged while playing hangs where the pointer holds it and is let go at rest', arrived && held.length >= 5 && heldFor >= 800 && held.every((y) => Math.abs(y - target) < 1.5) && // Let go it only falls: no jump above the hold, no kick up. How far the first frame after // the release has already fallen follows the frame time, so that is not a bound here. falling.every((y, i) => y > target - 1.5 && (i === 0 || y >= falling[i - 1] - 0.01)) && fell > target + 1 && fell < target + 40 && !log.some((line) => line.includes('moved directly')), JSON.stringify({ target, arrived, held: { frames: held.length, ms: Math.round(heldFor), first: held[0], last: held.at(-1) }, stamped: typeof letGo === 'number', released: released.slice(0, 3).map((s) => [Math.round(s.t - letGo), s.y]), fell, log, dragInfo, }), ); 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(); // 41e. Joints (#154): a bob below a hook gets a joint from the hierarchy menu, is made a rope to // the hook in the inspector, its far end is dragged by its handle in the scene view, and in // the played scene the bob hangs where it would otherwise fall. await mutate(` const scene = __gm.state.activeScene; const assets = __gm.state.project.assets; ${MAKE_OBJECT} 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-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); }; put('phys-hook', 'crate', '', 0, ${Y}, 'Physics hook', { width: 20, height: 20 }); put('phys-bob', 'crate', '', 0, ${Y + 100}, 'Physics bob', { width: 40, height: 40 }); put('phys-bob-col', 'phys-col-box', 'phys-bob', 0, 0); put('phys-bob-rb', 'phys-rb-free', 'phys-bob', 0, 0); put('phys-loose', 'crate', '', 200, ${Y + 100}, 'Physics loose', { width: 40, height: 40 }); put('phys-loose-col', 'phys-col-box', 'phys-loose', 0, 0); put('phys-loose-rb', 'phys-rb-free', 'phys-loose', 0, 0); __gm.state.select(null)`); await showTab('scene'); await evaluate(`__gm.lookAt(0, ${Y + 50})`); await selectObject('phys-bob'); const bobRow = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow('phys-bob')`); const countBefore = (await objects()).length; await click(bobRow.x, bobRow.y, 'right'); await clickText('Add joint…'); if (await has('Create new joint')) await clickText('Create new joint'); await press('Escape'); await settle(); const jointSelection = (await state()).selection; const joint = jointSelection?.kind === 'object' ? await object(jointSelection.objectId) : null; const jointAsset = joint && (await asset(joint.assetId)); check( 'Add joint puts a hinge below the object and shows its rows', (await objects()).length === countBefore + 1 && joint?.parentId === 'phys-bob' && jointAsset?.type === 'joint' && jointAsset?.props?.jointType === 'hinge' && (await has('Connected object')) && (await has('Limit angle')) && !(await has('Stiffness')) && (await evaluate(`__gm.layerOf('${joint?.id}')`)) === 'overlay', JSON.stringify({ joint, jointAsset }), ); const pressAt = async (at, what) => { if (!at) throw new Error(`41e: ${what} is not on screen`); await click(at.x, at.y); await settle(); }; await pressAt(await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Type')"), 'the Type field'); await pressAt(await json("__gm.locate('Rope', true)"), 'Rope in the menu'); await pressAt( await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Connected object')"), 'the Connected object field', ); // The menu lists every object of the scene: found by typing, as in a scene that has many. await typeText('Physics hook'); await settle(); await pressAt(await json("__gm.locate('Physics hook', true)"), 'Physics hook in the menu'); const jointObject = async () => object(joint.id); const links = async () => json('__gm.joints()'); const roped = await links(); const ropeLink = roped.links.find((l) => l.id === joint?.id); check( 'a rope connected to the hook is drawn from the bob to it, with a handle on its far end', (await jointObject())?.overrides?.jointType === 'rope' && (await jointObject())?.overrides?.connected?.$object === 'phys-hook' && ropeLink?.connectedId === 'phys-hook' && ropeLink.anchored === true && Math.abs(ropeLink.from.x) < 0.01 && Math.abs(ropeLink.from.y - (Y + 100)) < 0.01 && Math.abs(ropeLink.to.x) < 0.01 && Math.abs(ropeLink.to.y - Y) < 0.01 && roped.handle?.objectId === joint?.id && (await has('Connected anchor X')) && (await has('Length (0 = as placed)')) && !(await has('Limit angle')), JSON.stringify({ roped, overrides: (await jointObject())?.overrides }), ); await shot('41-joint'); // The scale of the view, read off the bob's 40 units. const bobRect = await json(`__gm.objectRect('phys-bob')`); const unit = bobRect.w / 40; await drag(roped.handle.x, roped.handle.y, roped.handle.x + 30 * unit, roped.handle.y); await settle(); const moved = (await jointObject())?.overrides; const movedLink = (await links()).links.find((l) => l.id === joint?.id); await undo(); await settle(); const undone = (await jointObject())?.overrides; check( "dragging the handle moves the rope's connected anchor, and one undo puts it back", Math.abs(moved?.connectedAnchorX - 30) <= 1 && Math.abs(moved?.connectedAnchorY ?? 0) <= 1 && Math.abs(movedLink?.to.x - moved?.connectedAnchorX) < 0.01 && undone?.connectedAnchorX === undefined && undone?.jointType === 'rope', JSON.stringify({ moved, movedLink, undone, unit }), ); const jointHints = consoleLog.filter((line) => line.includes('[physics]')).length; await select(null); await play(); await evaluate(`(() => { const out = (window.__jointSamples = []); 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, bob: y('phys-bob'), loose: y('phys-loose') }); if (performance.now() - start < 1500) requestAnimationFrame(sample); }; requestAnimationFrame(sample); })()`); await sleep(1800); const hung = await json('window.__jointSamples'); await stop(); const bobYs = hung.map((s) => s.bob); check( 'in the played scene the bob hangs on its rope while the one without a joint falls', hung.length > 30 && bobYs.every((y) => Math.abs(y - (Y + 100)) < 2) && hung.at(-1).loose > Y + 400 && consoleLog.filter((line) => line.includes('[physics]')).length === jointHints, JSON.stringify({ frames: hung.length, bob: [Math.min(...bobYs), Math.max(...bobYs)], loose: hung.at(-1).loose, hints: consoleLog.filter((line) => line.includes('[physics]')).slice(jointHints), }), ); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const joints = new Set(scene.objects.filter((o) => o.parentId === 'phys-bob').map((o) => o.assetId)); scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-') && o.parentId !== 'phys-bob'); p.assets = p.assets.filter((a) => !a.id.startsWith('phys-') && !(a.type === 'joint' && joints.has(a.id))); __gm.state.select(null)`); }