Draw scene symbols as white panel icons that make room for each other
game-maker / gate (push) Successful in 2m54s
game-maker / suite (push) Successful in 9m5s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 20:07:47 +02:00
1 parent e3d47ca9c1
commit cb32b4bc23
22 files changed
+414 -176

No files matched your search

+3 -2
View File
@@ -130,8 +130,9 @@ export async function inspector(t) {
);
// 2g. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
// on the origin, so compute its screen position from the viewport centre.
await drag(center.x - 150, center.y, center.x - 50, center.y - 70);
// on the origin, so compute its screen position from the viewport centre. Pressed right of
// its middle: the crate wears a collider, whose icon stands on the middle.
await drag(center.x - 120, center.y, center.x - 20, center.y - 70);
const o1Now = (await object('o1')).position;
check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now));
await shot('06-dragged');
+24 -21
View File
@@ -51,21 +51,11 @@ export async function drawing(t) {
await undo();
check('undo restores z', (await object('o1')).position.z === 0, `z=${(await object('o1')).position.z}`);
// 5c. The symbols are SVG files (#175): one of each in a lane of its own, read where each has a
// solid colour of the theme. Offsets are in world units from the symbol's centre.
// 5c. The symbols are the asset panel's icons, white in a dark outline and without a plate
// behind them: one of each in a lane of its own, the pixels read on a grid over each icon
// (±12 world units round its centre). Each must show white and the outline, and none of the
// colours the symbols had before.
const PLACED = { 's-beh': 'mover', 's-box': 's-box-a', 's-rb': 's-rb-a', 's-cam': 'cam', 's-pt': 's-pt-a' };
const SPOTS = [
['s-beh', 8, 0, '#b066ff'],
['s-beh', -6, -3, '#ffffff'],
['s-box', -4, -4, '#3fb8c9'],
['s-box', 4, 4, '#3fb8c9'],
['s-rb', 0, 3, '#4fd18b'],
['s-rb', 0, -4, '#4fd18b'],
['s-cam', 0, 0, '#8a90a0'],
['s-cam', 17, 0, '#8a90a0'],
['s-pt', 12.728, 12.728, '#c26a1f'],
['s-pt', -12.728, -12.728, '#c26a1f'],
];
const ids = Object.keys(PLACED);
await mutate(`
const p = __gm.state.project;
@@ -83,18 +73,31 @@ export async function drawing(t) {
});
__gm.state.select(null);
__gm.lookAt(0, ${LANES.symbols});`);
const wrong = await json(`(() => {
const looks = await json(`(() => {
const zoom = __gm.objectRect('s-beh').w / 28;
return ${JSON.stringify(SPOTS)}.flatMap(([id, dx, dy, want]) => {
const r = __gm.objectRect(id);
const got = __gm.pixelAt(Math.floor(r.x + dx * zoom), Math.floor(r.y + dy * zoom));
return got === want ? [] : [id + ' ' + dx + ',' + dy + ': ' + got + ' != ' + want];
const old = ['#b066ff', '#3fb8c9', '#4fd18b', '#c26a1f'];
const rgb = (c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
const near = (a, b, max) => Math.hypot(...rgb(a).map((v, i) => v - rgb(b)[i])) < max;
return ${JSON.stringify(ids)}.map((id) => {
const icon = __gm.sceneIcon(id);
const seen = { id, white: 0, outline: 0, old: [] };
for (let dx = -12; dx <= 12; dx += 1.5)
for (let dy = -12; dy <= 12; dy += 1.5) {
const got = __gm.pixelAt(Math.floor(icon.screen.x + dx * zoom), Math.floor(icon.screen.y + dy * zoom));
if (got === '#ffffff') seen.white++;
// The outline is thin, so at this zoom it is mostly an edge pixel blended into the
// background (#111216 and the like), rarely the exact #0b0c0f.
else if (near(got, '#0b0c0f', 20)) seen.outline++;
else if (old.includes(got)) seen.old.push(got);
}
return seen;
});
})()`);
const wrong = looks.filter((s) => s.white < 4 || s.outline < 4 || s.old.length > 0);
check(
'every symbol draws its SVG in the theme colour',
'every symbol draws its icon white in a dark outline, in none of the old colours',
wrong.length === 0,
wrong.join('; ') || `${SPOTS.length} spots`,
JSON.stringify(wrong.length ? wrong : looks.map((s) => `${s.id} ${s.white}/${s.outline}`)),
);
await shot('15-symbols');
await mutate(`
+4 -2
View File
@@ -70,9 +70,11 @@ export async function containerTab(t) {
);
// 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.
// 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 at = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
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(
+5 -3
View File
@@ -515,13 +515,15 @@ export async function particles(t) {
// 31n. (#89) The ring is hollow on purpose – the mark may not hide the effect it marks. Scanned
// outward from the source's own point, because a 2-unit stroke at an exact radius would be a
// test of rounding rather than of drawing.
const ICON_COLOUR = '#c26a1f';
// test of rounding rather than of drawing. The ring is white like every symbol's icon, with
// a radius of 3.94 world units: its thin stroke starts at about 3.1, so the scan takes the
// first white from 2.5 units out, and the dark outline leaves the middle two units free.
const ICON_COLOUR = '#ffffff';
const unit = (await json(`__gm.objectRect(${JSON.stringify(SOURCE)})`)).w / 24;
const row = [];
for (let dx = 0; dx <= Math.ceil(19 * unit); dx++)
row.push(await json(`__gm.pixelAt(${Math.round(spotOn.x + dx)}, ${Math.round(spotOn.y)})`));
const ringAt = row.findIndex((c, dx) => dx > 4 * unit && colourDistance(c, ICON_COLOUR) < 12);
const ringAt = row.findIndex((c, dx) => dx >= 2.5 * unit && colourDistance(c, ICON_COLOUR) < 12);
check(
'the icon rings the source and leaves its middle to the particles',
ringAt > 0 && colourDistance(row[0], ICON_COLOUR) > 60 && row[0] !== '#22252c',
+25 -22
View File
@@ -154,55 +154,58 @@ export async function physics(t) {
await evaluate(`__gm.lookAt(0, ${Y + 100})`);
await settle();
await shot('41-rigidbody');
// A collider has an icon like every other symbol. It stands left of the collider's centre, so the
// middle of its sprite stays free, and so does a rigidbody's icon on the same spot.
// 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 collider without a rigidbody shows its icon left of its centre',
icons.ground?.visible === true &&
icons.ground.layer === 'overlay' &&
icons.ground.x === -28 &&
icons.ground.y === Y + 200 &&
icons.ground.color !== null,
'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(
"a collider's icon steps aside from the rigidbody on the same body, in the same colour",
[
[icons.crate, -100],
[icons.ball, 100],
].every(([icon, x]) => icon?.visible === true && icon.x === x - 28 && icon.y === Y) &&
icons.crate.color === icons.ground.color,
'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),
);
// The icon is clicked like the outline: it selects the collider, even over its sprite. On the
// middle of the sprite, beside the icon, the sprite is picked as before.
// 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],
['groundMiddle', 'phys-ground', null],
['crateBodyIcon', crateBody?.id, 0],
['groundBeside', 'phys-ground-col', 40],
]) {
await select(null);
const at = await json(dx === null ? `__gm.objectScreen('${id}')` : `__gm.sceneIcon('${id}')?.screen`);
await click(at.x + (dx ?? 0), at.y);
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 a collider's icon selects the collider, on the sprite's middle the sprite",
'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.groundMiddle.selected === 'phys-ground',
JSON.stringify(picks),
picks.crateBodyIcon.selected === crateBody?.id &&
picks.groundBeside.selected === 'phys-ground',
JSON.stringify({ zoom, picks }),
);
await selectObject('phys-ball-col');