The + menu was one long list in two groups, placeable types and library ones. That split is no longer wanted. The menu now lists the categories of the asset panel, and each opens its types beside it, so a new asset is filed where the panel shows it. - Menu learns submenus: an entry with submenu instead of onSelect opens a second Menu right of the first, or left of it where the canvas ends. Its first entry lines up with the row that opened it. - A submenu opens on hover or click. Resting on another entry swaps or closes it after 150 ms, so a pointer cutting across the rows on its way into a submenu does not lose it. - The row of an open submenu stays lit, and a triangle pointing right marks every such row. - A submenu has no backdrop and no Escape of its own. Popup takes a backdrop option for that. The first menu's backdrop and Escape close the whole stack, and picking an entry does the same. - assetMenuCategories replaces assetMenuGroups and takes categories and their order from ASSET_GROUPS. The types are sorted by label. - New test hooks __gm.menuSubmenus() and __gm.addMenuCategory(). pickAssetType opens the category first, so the modules that pick a type through it need no change of their own. Modules 04, 10, 47, 48, 53 and 54 open the category themselves where they read the menu. Tests: - Unit asset-menu: 4 green - e2e 04: ALL PASSED, 63 checks. New: categories in panel order, each type once and sorted, submenu placement, hover swaps the submenu, picking closes menu and submenu - Gate: CHECK EXIT 0, 261 files, 4040 tests - The other modules that use the + menu did not run here Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
8.1 KiB
JavaScript
245 lines
8.1 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|
||
|
||
/**
|
||
* 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:<id>` or `values:<key>`), 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;
|
||
}
|