Files
adminandClaude Opus 5.5 cb32b4bc23
game-maker / gate (push) Successful in 2m54s
game-maker / suite (push) Successful in 9m5s
Draw scene symbols as white panel icons that make room for each other
The scene view drew its symbols from its own SVGs, each in a type
colour and some on a plate. They looked heavier than the panel icons
and were hard to read on busy ground. Symbols of different objects
also sat on top of each other whenever their positions met.

- Every symbol is now the panel icon of its kind, one file for both
  places. The scene-only SVGs in src/viewport/icons are gone.
- Symbols are white in a thin dark outline with no plate behind them.
  haloSvg widens each shape of the file by its own stroke and draws
  that silhouette dark under the icon, so no second file is needed.
- A butt stroke end would leave its end without outline. The halo caps
  such strokes square, and the rigidbody arrow shaft is now a filled
  rect so its top edge is outlined exactly.
- Symbols are 18 world units across, stay upright and are clickable.
  A click selects the object, a drag moves it.
- symbolLayout places every symbol on its object's position. Symbols
  that overlap stand on a ring round the middle of their positions,
  evenly spaced one icon apart. A ring that reaches into another
  symbol takes it in until nothing overlaps. Ownership plays no part.
- Names sit beside their symbol, on the outside of a ring. Selection
  frames and parent links follow the symbol.
- A collider icon no longer shows trigger or invalid state. The
  outline colour of the shape still does.
- A lone collider sits on its sprite's middle again. A press there on
  an unselected sprite drags the collider, so e2e 02 and 16 now grab
  the sprite beside its middle. Hook __gm.container.objectRect helps
  16 find that spot.

Tests:
- check.sh: 2245 tests, CHECK EXIT 0 (+8 for layout and halo)
- e2e 1-16,31,39-43: 670 PASS, 2 FAIL. Both were sampling too
  strictly for the thinner, smaller icons. 5c looked for the exact
  outline colour, which the thin outline only reaches blended at this
  zoom. 31n started the ring scan one pixel past the smaller ring.
  Both are fixed, and e2e 5,31 then passed with 48 checks.
- 5c checks white with an outline and none of the old colours, 41b
  checks the ring positions and that a click on a symbol selects its
  object, 31n checks the now white particle ring.
- Counter-checks: without the halo only 5c fails, also with the looser
  outline match. Without the layout the ring and click checks in 41b
  fail. Originals restored by cmp.
- The e2e check total stays 1351.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:07:47 +02:00

323 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { instanceChild } from '../lib/builders.mjs';
import { openTab } from './lib/scaffold.mjs';
/**
* The Container tab: editing a template with the scene view's tools. Relies on the container step
* having left two instances of a two-crate container in the scene.
*/
const NAME = 'Crate pair';
export async function containerTab(t) {
const {
check,
sleep,
json,
evaluate,
mutate,
select,
shot,
click,
clickText,
drag,
dragAsset,
mouse,
press,
undo,
state,
object,
objects,
storedObjects,
showTab,
} = t;
await press('Escape');
const containerId = await json("(__gm.state.project.assets.find(a => a.type === 'container') ?? {}).id");
// The overflow step renames assets to absurd lengths; a readable name makes the header checkable.
await evaluate(
`(() => { __gm.state.project.assets.find(a => a.id === ${JSON.stringify(containerId)}).name = ${JSON.stringify(NAME)}; __gm.state.preview(); })()`,
);
await sleep(200);
// 16a. The tab opens on the container the project has, and its header says which one.
await openTab(t, 'container');
const template = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
const count = template.length;
check(
'the Container tab opens showing a container',
(await json('__gm.tab()')) === 'container' && (await json('__gm.container.current()')) === containerId,
JSON.stringify({ tab: await json('__gm.tab()'), shown: await json('__gm.container.current()') }),
);
check(
'the header names the container and counts its template objects',
Boolean(await evaluate(`__gm.locate(${JSON.stringify(NAME)})`)) &&
Boolean(await evaluate(`__gm.locate(${JSON.stringify(`${count} objects`)})`)),
JSON.stringify({ name: NAME, count }),
);
check(
'the hierarchy panel retitles itself for the container it lists',
// A panel draws its title in capitals, so that is what there is to find.
Boolean(await evaluate(`__gm.locate(${JSON.stringify(`Container: ${NAME}`.toUpperCase())})`)),
);
await shot('30-container-tab');
// 16b. The hierarchy follows the visible tab: it lists the template, not the scene.
const rows = await json('__gm.hierarchyOrder()');
check(
'the hierarchy lists the template objects while the tab is open',
rows.length > 0 && rows.every((id) => template.some((o) => o.id === id)),
JSON.stringify(rows),
);
// 16c. Clicking a template object selects it against the container as its holder – the same
// id set as the scene would give, but a different holder. Pressed right of its middle: the
// crate wears a collider, whose icon stands on the middle.
const root = template.find((o) => o.parentId === '');
const rect = await json(`__gm.container.objectRect(${JSON.stringify(root.id)})`);
const at = { x: rect.x + rect.w * 0.3, y: rect.y };
await click(at.x, at.y);
let s = await state();
check(
'clicking a template object selects it against the container, not the scene',
s.selection?.kind === 'object' &&
s.selection.holderId === containerId &&
s.selection.objectId === root.id,
JSON.stringify(s.selection),
);
// 16d. The point of the tab: a drag writes the template, and every instance follows it – except
// one that was moved there on its own, whose deviation the instance keeps.
const templateX = () =>
json(
`__gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}').position.x`,
);
// The instances resolve this template object under `<instance>~${root.id}`; nothing of it is
// stored, so the resolved list is the only place to read it.
const instanceXs = () =>
json(
`Object.fromEntries(__gm.sceneObjects().filter(o => o.id.endsWith('~${root.id}')).map(o => [o.id, o.position.x]))`,
);
const shown = await instanceXs();
const [dirtyId, ...cleanIds] = Object.keys(shown);
const dirtyInstance = dirtyId.slice(0, dirtyId.indexOf('~'));
const setDeviation = (body) =>
mutate(
`const edits = __gm.state.activeScene.objects.find(o => o.id === '${dirtyInstance}').instanceEdits;
${body}`,
);
await setDeviation(`edits['${root.id}'] = { position: { x: ${shown[dirtyId] + 7} }, overrides: {} }`);
const before = await templateX();
await drag(at.x, at.y, at.x + 90, at.y);
const after = await templateX();
const afterDrag = await instanceXs();
check(
'dragging in the Container tab moves the template',
after === before + 90,
JSON.stringify({ before, after }),
);
check(
'the instances that still matched the template follow it',
cleanIds.length > 0 && cleanIds.every((id) => afterDrag[id] === shown[id] + 90),
JSON.stringify({ shown, afterDrag, cleanIds }),
);
check(
'the instance that was moved on its own keeps its own position',
afterDrag[dirtyId] === shown[dirtyId] + 7,
JSON.stringify({ was: shown[dirtyId], now: afterDrag[dirtyId] }),
);
await undo();
const undone = await instanceXs();
check('undo takes the template move back', (await templateX()) === before);
check(
'and takes the instances back with it',
cleanIds.every((id) => undone[id] === shown[id]),
JSON.stringify({ shown, undone }),
);
await setDeviation(`delete edits['${root.id}']`);
// 16e. An asset dragged from the panel lands in the view it is dropped on: dropping on this
// tab adds one object to the template, not to the scene.
// What the scene *stores* – that must not move. What it *lists* has to grow by one per placed
// instance instead, since every one of them resolves the new template object too.
const sceneStored = (await storedObjects()).length;
const sceneListed = (await objects()).length;
const instanceCount = Object.keys(await instanceXs()).length;
const canvas = await json('__gm.container.center()');
await dragAsset('crate', canvas.x + 40, canvas.y + 40);
const grown = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
s = await state();
check(
'Place in container puts one object into the template and selects it there',
grown.length === count + 1 &&
s.selection?.holderId === containerId &&
grown.some((o) => o.id === s.selection.objectId),
JSON.stringify({ before: count, after: grown.length, selection: s.selection }),
);
check('the scene stores nothing more for it', (await storedObjects()).length === sceneStored);
check(
'but every placed instance shows the new template object at once',
(await objects()).length === sceneListed + instanceCount,
JSON.stringify({ listed: (await objects()).length, was: sceneListed, instances: instanceCount }),
);
await shot('31-container-object-added');
// 16f. Delete works on a template object like on any other, as one undo step.
await press('Delete');
check(
'Delete removes a template object',
(await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`)).length === count,
);
await undo();
check(
'undo puts it back',
(await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`)).length ===
count + 1,
);
await press('Delete');
// 16g. Fit frames the content. Proven against an object parked far outside the view: without
// fitting it is nowhere near the canvas.
await mutate(
`const obj = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); obj.position.x = 3000`,
);
const area = await json('__gm.container.rect()');
const inside = (p) => p.x >= area.x && p.x <= area.x + area.w && p.y >= area.y && p.y <= area.y + area.h;
const far = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
await clickText('Fit');
const fitted = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
check(
'Fit brings an object parked far outside back into view',
!inside(far) && inside(fitted),
JSON.stringify({ area, far, fitted }),
);
await evaluate(
`(() => { const t = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); t.position.x = ${before}; __gm.state.preview(); })()`,
);
await clickText('Fit');
await shot('32-container-fitted');
// 16h. A template object exists only in this tab, so selecting one from elsewhere switches to
// it – the rule a graph node and a texture frame already follow. A scene object does not.
await showTab('scene');
await select({ kind: 'object', holderId: containerId, objectId: root.id });
check(
'selecting a template object switches to the Container tab',
(await json('__gm.tab()')) === 'container',
);
await showTab('scene');
await evaluate("__gm.state.select({ kind: 'object', holderId: 'scene1', objectId: 'otex' })");
check('selecting a scene object leaves the Scene tab alone', (await json('__gm.tab()')) === 'scene');
check(
'the hierarchy is back on the scene with it',
(await json('__gm.hierarchyOrder()')).includes('otex'),
JSON.stringify(await json('__gm.hierarchyOrder()')),
);
// 16i. Opening another container shows it in a tab of its own (#171); the hierarchy has to
// follow it, title and rows both.
await showTab('container');
await mutate(`const src = __gm.state.project.assets.find(a => a.id === '${containerId}');
const copy = JSON.parse(JSON.stringify(src));
copy.id = 'c2'; copy.name = 'Spare box';
copy.objects = copy.objects.slice(0, 1).map((o) => ({ ...o, id: 'c2o1', parentId: '' }));
__gm.state.project.assets.push(copy)`);
await evaluate("__gm.state.openAsset('c2')");
check(
'opening another container moves the tab and the hierarchy to it',
(await json('__gm.container.current()')) === 'c2' &&
JSON.stringify(await json('__gm.hierarchyOrder()')) === JSON.stringify(['c2o1']) &&
Boolean(await evaluate("__gm.locate('CONTAINER: SPARE BOX')")),
JSON.stringify({
shown: await json('__gm.container.current()'),
rows: await json('__gm.hierarchyOrder()'),
}),
);
await shot('33-container-switched');
// 22i2. Dragging the open container onto its own template would nest it in itself. The drag
// has to say so while it hovers – an outline that only turns out to be a lie on release
// is worse than none – and the drop has to change nothing.
const canvas2 = await json('__gm.container.center()');
const own = await json("__gm.assetRow('c2')");
await mouse('mouseMoved', own.x, own.y);
await mouse('mousePressed', own.x, own.y);
for (let i = 1; i <= 8; i++) {
await mouse(
'mouseMoved',
own.x + ((canvas2.x - own.x) * i) / 8,
own.y + ((canvas2.y - own.y) * i) / 8,
'left',
{
buttons: 1,
},
);
await sleep(16);
}
const refused = await json('__gm.container.dropPreview()');
check(
'a container dragged over its own template is refused while it hovers',
Boolean(refused) && /nests forever/.test(refused.rejection ?? ''),
JSON.stringify(refused),
);
// Picking it up selects it; the tab must not follow that, or the target would swap mid-drag.
check(
'and the tab keeps showing the container being dropped into',
(await json('__gm.container.current()')) === 'c2',
JSON.stringify({ shown: await json('__gm.container.current()') }),
);
await shot('33b-container-cycle-refused');
const ownBefore = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
await mouse('mouseReleased', canvas2.x, canvas2.y);
await sleep(200);
const ownAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
check(
'releasing it changes nothing',
ownAfter.length === ownBefore.length,
JSON.stringify({ before: ownBefore.length, after: ownAfter.length }),
);
// 16j. Nesting: another container, though, may go in – and the tab it lands in stays put.
const nested = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
await dragAsset(containerId, canvas2.x - 40, canvas2.y + 40);
const nestedAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
check(
'and placing it nests it inside that one',
nestedAfter.length === nested.length + 1 &&
nestedAfter.some((o) => o.assetId === containerId) &&
(await json('__gm.container.current()')) === 'c2',
JSON.stringify({ before: nested.length, after: nestedAfter.length }),
);
// 16k. Nesting resolves all the way down: placing the outer container in the scene stores one
// object, and the inner container's content appears under `<instance>~<outer>/<inner>`.
const innerObject = nestedAfter.find((o) => o.assetId === containerId);
const innerTemplate = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
await showTab('scene');
const storedBefore = (await storedObjects()).length;
const listedBefore = (await objects()).length;
const spot = await json('__gm.viewportCenter()');
await dragAsset('c2', spot.x + 80, spot.y + 70);
const outer = (await storedObjects()).find((o) => o.assetId === 'c2');
const deep = innerTemplate.map((o) => instanceChild(outer.id, innerObject.id, o.id));
const listedIds = (await objects()).map((o) => o.id);
check(
'a container nested in a container template resolves through both levels',
(await storedObjects()).length === storedBefore + 1 &&
(await objects()).length === listedBefore + 1 + nestedAfter.length + innerTemplate.length &&
deep.every((id) => listedIds.includes(id)),
JSON.stringify({
stored: (await storedObjects()).length - storedBefore,
listed: (await objects()).length - listedBefore,
deep: deep.filter((id) => !listedIds.includes(id)),
}),
);
check(
'and the nested content hangs below the nested container object',
(await object(deep[0])).parentId === instanceChild(outer.id, innerObject.id),
JSON.stringify(await object(deep[0])),
);
await shot('34-container-nested');
await undo(); // the placement
await undo(); // the nesting
}