Deleting an object in a container tab only cleared references inside the template. Placements in scenes and other containers kept their instance edit and showed "(missing)" in the inspector. - shownObjectIds records which objects every holder shows before the delete. dropReferencesToGone compares that with the state after it and clears each reference that no longer resolves. - A reference is resolved from its own place with referenceCandidates, the same way the runtime does. One rule covers instance edits, nested placements and full ids like p~crate. - A cleared instance edit is dropped when it now says the same as the template's own value. Without such a value a marked None stays, as if picked by hand, and Reset hands the row back to the template. - All cleared references come back with a single undo. - References that were already dead in saved projects stay untouched. Findings, not fixed here: - A reference from container D into a nested placement q is stored as q~<id>, but the copy is named r~q/<id> in every placement of D. The runtime finds nothing, so that reference is dead from the start. - Delete asset removes placed objects by cascade but does not clear references to them. Tests: - Unit object-references: 17 passed (13 + 4) - Gate: 198 files, 2873 tests, exit 0 - e2e 1-17,57: 564 checks passed, 57 has 13 (+1 for 57h) - Five unit counter-probes and one browser counter-probe fail as expected - e2e total 1607 is computed, not measured in a full run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
7.8 KiB
JavaScript
240 lines
7.8 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,
|
||
};
|
||
|
||
/**
|
||
* 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. Since #43 the menu may grow to the height of
|
||
* the canvas, so on this window every type is usually on screen at once; the scroll stays in as
|
||
* insurance for a shorter window, where entries below the mask edge are still found by `locate`
|
||
* but a click there lands on nothing. That is what the fourteenth type (`Variables`, #91) did to
|
||
* `Font` and to step 30.
|
||
*/
|
||
export async function pickAssetType(t, label) {
|
||
const box = await t.json('__gm.overlayRect()');
|
||
let p = await t.locate(label, true);
|
||
let scrolled = false;
|
||
if (box && (!p || p.y > box.y + box.h - 4)) {
|
||
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;
|
||
}
|