Files
feuerwerk/tools/e2e/05-drawing.mjs
T
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

161 lines
7.5 KiB
JavaScript
Raw 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 { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
/** Draw order: symbols always above sprites, `z` above hierarchy order. */
export async function drawing(t) {
const { check, json, shot, click, drag, locate, typeText, press, undo, mutate, select, object } = t;
const orderOfCrates = () => json(`__gm.drawOrder().filter(id => id === 'o1' || id === 'o2')`);
// 5a. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate
// right on top of its icon → the icon must still be drawn above the sprite.
await mutate(`
const objs = __gm.state.activeScene.objects;
const cam = objs.splice(objs.findIndex(o => o.id === 'o3'), 1)[0];
objs.unshift(cam);
const crate = objs.find(o => o.id === 'o1');
crate.position.x = 0; crate.position.y = 0;
__gm.state.select(null)`);
const layers = await json(`[__gm.layerOf('o3'), __gm.layerOf('o1'), __gm.layerOf('o2')]`);
check(
'camera symbol is drawn in the overlay layer above sprites',
JSON.stringify(layers) === '["overlay","objects","objects"]',
JSON.stringify(layers),
);
await shot('13-symbol-on-top');
// 5b. Z-order: put both crates on the origin. With equal z the hierarchy order wins
// (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it.
await mutate(
`const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2'); o2.position.x = 0; o2.position.y = 0`,
);
check(
'equal z keeps hierarchy order',
JSON.stringify(await orderOfCrates()) === '["o1","o2"]',
JSON.stringify(await orderOfCrates()),
);
await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' });
const zLabel = await locate('Z');
await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected
await typeText('5');
await press('Enter');
check(
'Z field sets position.z',
(await object('o1')).position.z === 5,
`z=${(await object('o1')).position.z}`,
);
check(
'higher z is drawn on top',
JSON.stringify(await orderOfCrates()) === '["o2","o1"]',
JSON.stringify(await orderOfCrates()),
);
await shot('14-z-order');
await undo();
check('undo restores z', (await object('o1')).position.z === 0, `z=${(await object('o1')).position.z}`);
// 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 ids = Object.keys(PLACED);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
p.assets.push(
{ id: 's-box-a', type: 'container', name: 's-box-a', props: { enabled: true }, objects: [] },
{ id: 's-rb-a', type: 'rigidbody', name: 's-rb-a', props: { bodyType: 'static' } },
{ id: 's-pt-a', type: 'particles', name: 's-pt-a', props: {}, graph: { nodes: [], edges: [] } },
);
Object.entries(${JSON.stringify(PLACED)}).forEach(([id, assetId], i) => {
const o = make(id, assetId, '', ${LANES.symbols}, id);
o.position.x = (i - 2) * 120;
scene.objects.push(o);
});
__gm.state.select(null);
__gm.lookAt(0, ${LANES.symbols});`);
const looks = await json(`(() => {
const zoom = __gm.objectRect('s-beh').w / 28;
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 icon white in a dark outline, in none of the old colours',
wrong.length === 0,
JSON.stringify(wrong.length ? wrong : looks.map((s) => `${s.id} ${s.white}/${s.outline}`)),
);
await shot('15-symbols');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !${JSON.stringify(ids)}.includes(o.id));
p.assets = p.assets.filter((a) => !['s-box-a', 's-rb-a', 's-pt-a'].includes(a.id));
__gm.lookAt(0, 0);`);
// 5d. A pan only moves the camera: no collect and no redraw of every shape per move. What it
// leaves on screen – the crate, its outline, the grid – must be what a full redraw draws, and
// an object panned back into view must be drawn again.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' });
const vr = await json('__gm.viewportRect()');
const cx = Math.round(vr.x + vr.w / 2);
const cy = Math.round(vr.y + vr.h / 2);
const info = () => json('__gm.viewportInfo()');
const rows = () =>
json(`(() => {
const r = __gm.objectRect('o1');
const x = Math.round(r.x - r.w / 2 - 20);
return [r.y, r.y - r.h / 2 - 2, r.y + r.h / 2 + 2].map((y) => __gm.pixelRow(x, Math.round(y), Math.round(r.w + 40)).join());
})()`);
const sameAsRedraw = async () => {
const panned = await rows();
await json('(__gm.viewportRedraw(), true)');
const fresh = await rows();
return panned.filter((row, i) => row !== fresh[i]).length;
};
const before = await info();
await drag(cx, cy, cx + 36, cy + 24, { steps: 12, button: 'middle' });
const after = await info();
const moves = after.cameraMoves - before.cameraMoves;
const redraws = after.redraws - before.redraws;
check(
'a pan moves the camera without redrawing the scene',
moves >= 8 && redraws <= 4,
`${moves} camera moves, ${redraws} redraws in 12 steps`,
);
const offAfterPan = await sameAsRedraw();
check('a panned view draws what a full redraw draws', offAfterPan === 0, `${offAfterPan} of 3 rows differ`);
// Far enough that the crate's right edge passes the view's left one.
const crate = await json(`__gm.objectRect('o1')`);
const away = Math.round(crate.x + crate.w / 2 - vr.x + 20);
await drag(cx, cy, cx - away, cy, { steps: 12, button: 'middle' });
const gone = !(await json('__gm.drawOrder()')).includes('o1');
const back0 = await info();
// Pressed inside the view again: a press out over the panel would pan nothing.
await drag(cx, cy, cx + away, cy, { steps: 12, button: 'middle' });
const back = await info();
const shown = (await json('__gm.drawOrder()')).includes('o1');
const offAfterReturn = await sameAsRedraw();
check(
'an object panned out of view and back is drawn again',
gone && shown && back.redraws > back0.redraws && offAfterReturn === 0,
`gone=${gone} shown=${shown} redraws=${back.redraws - back0.redraws} rowsOff=${offAfterReturn}`,
);
await shot('15b-panned');
await mutate(`__gm.state.select(null); __gm.lookAt(0, 0)`);
}