/** * The throwaway rig steps 17–20 share in the fixture scene: a behavior asset cloned off `mover` with the * step's graph, a crate to hold it, and a logic child that runs it. */ /** * Where each step parks its box. The fixture scene is shared, so two steps at the same y would * overlap and each would fail for reasons belonging to the other. */ export const LANES = { editorRun: 400, editorRunContainer: 800, editorRunEdits: 1200, pause: 300, nested: 700, keyRepeat: -300, stateMachine: -700, console: -1100, transform: -1500, font: -1900, particles: -2300, sound: -2700, tilemap: -3100, physics: -3500, shapes: -3900, queries: -4300, symbols: -4700, vectorArt: -5100, pixelArt: -5400, eyedropper: -5800, containerReference: -6200, nestedReference: -6600, video: -7000, }; /** * Source for a `make(id, assetId, parentId, y, name)` factory, to paste inside a `mutate()` * string. It clones `scene.objects[0]`, so a new object carries whatever shape the fixture has. */ export const MAKE_OBJECT = ` const make = (id, assetId, parentId, y, name = '') => { const o = structuredClone(scene.objects[0]); Object.assign(o, { id, assetId, name, position: { x: 0, y, z: 0 }, parentId, overrides: {}, instanceEdits: {}, }); return o; };`; /** * Returns the created ids plus `remove()`, so a step's cleanup matches what it made. `front` puts the * asset at the head of the list: a row appended at the end scrolls out of `dragAsset`'s reach. */ export async function scaffoldBehavior(t, options) { const { assetId, name, graph, boxId, boxName = 'Box', logicId, y, front = false, props = { enabled: true, runInEditor: false }, } = options; await t.mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const asset = structuredClone(p.assets.find((a) => a.id === 'mover')); Object.assign(asset, { id: ${JSON.stringify(assetId)}, name: ${JSON.stringify(name)}, props: ${JSON.stringify(props)}, variables: [], graph: ${JSON.stringify(graph)}, }); p.assets.${front ? 'unshift' : 'push'}(asset); ${MAKE_OBJECT} scene.objects.push( make(${JSON.stringify(boxId)}, 'crate', '', ${y}, ${JSON.stringify(boxName)}), make(${JSON.stringify(logicId)}, ${JSON.stringify(assetId)}, ${JSON.stringify(boxId)}, 0, ${JSON.stringify(name)}), ); `); return { assetId, boxId, logicId, /** Removes everything this rig added and reports what survived (should be all zeroes). */ async remove() { await t.evaluate('__gm.state.select(null)'); await t.mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const objects = ${JSON.stringify([boxId, logicId])}; const assets = ${JSON.stringify([assetId])}; scene.objects = scene.objects.filter((o) => !objects.includes(o.id)); p.assets = p.assets.filter((a) => !assets.includes(a.id)); `); return t.json(`{ objects: __gm.sceneObjects().filter((o) => ${JSON.stringify([boxId, logicId])}.includes(o.id)).length, assets: __gm.state.project.assets.filter((a) => a.id === ${JSON.stringify(assetId)}).length, }`); }, }; } /** What each tab button reads – the text a check has to click to open it. */ export const TAB_LABELS = { scene: 'Scene', game: 'Game', graph: 'Behavior', statemachine: 'State machine', shader: 'Shader', texture: 'Texture', vectorart: 'Vector art', pixelart: 'Pixel art', animation: 'Animation', curve: 'Curve', tile: 'Tile', container: 'Container', list: 'List', particles: 'Particles', // Added at start-up by the demo plugin (plugins/demo-plugin.js), after every built-in tab. 'plugin:assets': 'Assets', }; /** * Opens a tab by clicking it, through the `»` menu when the row had to hide it. Since the tenth tab * (#73) that is the normal case at the test window's width: a plain `clickText('Container')` found * nothing and took the whole suite down with it. */ export async function openTab(t, tab) { // A kind of document has no tab of its own since #171; the hook shows the one the selection is about. if (DOCUMENT_KINDS.includes(tab)) { await t.evaluate(`__gm.showTab(${JSON.stringify(tab)})`); return; } const label = TAB_LABELS[tab]; const row = await t.json('__gm.tabRow()'); if (row.hidden.includes(tab)) { await t.clickText('»'); await t.clickText(label); return; } // The label alone is not the tab: the toolbar labels its scene selector "Scene" too, and being // drawn first that is what `clickText` had been hitting – so the tab stayed where it was // (measured 2026-09-17: step 39 ended on the Autotile tab – now Tile – although it asks for the Scene tab). // The hit is therefore picked by the box the tab row reports for that tab. const entry = row.shown.find((e) => e.tab === tab); const hits = entry ? await t.json(`__gm.locateAll(${JSON.stringify(label)})`) : []; const hit = hits.find( (p) => p.x >= entry.x && p.x <= entry.right && p.y >= entry.y && p.y <= entry.y + entry.h, ); if (hit) await t.click(hit.x, hit.y); else await t.clickText(label); } const DOCUMENT_KINDS = [ 'graph', 'statemachine', 'shader', 'particles', 'texture', 'vectorart', 'pixelart', 'animation', 'curve', 'tile', 'container', 'variables', 'management', ]; /** * Clicks the tab of an open document (`asset:` or `values:`), through the `»` menu when the row * hides it. The press lands left of the ×, on the name. */ export async function clickDocumentTab(t, doc) { const row = await t.json('__gm.tabRow()'); const entry = row.shown.find((e) => e.tab === doc); if (entry) { await t.click(entry.x + (entry.close.x - entry.x) / 2, entry.y + entry.h / 2); return; } if (!row.hidden.includes(doc)) throw new Error(`no tab for ${doc}`); const label = await t.json(`(() => { const id = ${JSON.stringify(doc)}; if (id.startsWith('values:')) return __gm.tabRow().labels?.[id] ?? null; const asset = __gm.state.project.assets.find((a) => 'asset:' + a.id === id); return asset ? asset.name : null; })()`); await t.clickText('»'); const item = (await t.json('__gm.menuRows()')).find((r) => r === label); const at = await t.locate(item, true); await t.click(at.x, at.y); } /** * Picks an asset type from the already-open `+` menu: first its category, which opens the types of * that category beside the menu, then the type in there. A submenu could still outgrow a short * window, and `locate` refuses a row scrolled out of its list, so the first look is `find`, and a * hit outside the submenu (the centre tab "Texture") counts as a miss. */ export async function pickAssetType(t, label) { const category = await t.json(`__gm.addMenuCategory(${JSON.stringify(label)})`); if (!category) throw new Error(`no category of the + menu holds "${label}"`); await t.clickText(category); const box = await t.json('__gm.overlayRect()'); let p = await t.find(label, true); let scrolled = false; const inMenu = (at) => at && at.x >= box.x && at.x <= box.x + box.w && at.y >= box.y && at.y <= box.y + box.h - 4; if (box && !inMenu(p)) { await t.wheel(box.x + box.w / 2, box.y + box.h / 2, 300); p = await t.locate(label, true); scrolled = true; } await t.click(p.x, p.y); return { box, p, scrolled }; } /** * 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. */ export 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; }