Files
feuerwerk/tools/e2e/05-drawing.mjs
T
adminandClaude Opus 5.5 50bddf68d4
game-maker / gate (push) Successful in 2m57s
game-maker / suite (push) Failing after 8m37s
Keep scene view names readable on plates and in rows of their own
Symbol names in the scene view were plain grey text. On a background
of a similar colour they could not be read, and names of symbols close
together were drawn over each other.

- Each name stands on a rounded, nearly black plate at 70 % opacity,
  3 px padding left and right, 1 px top and bottom.
- The text is light (viewportLabel) instead of muted grey.
- The plate of a selected object gets an accent border.
- New rule stackLabels in src/viewport/labelRows.ts. A name that
  overlaps another name or an icon moves one row down, then one up,
  then two down and so on, until a row is free.
- Names are placed top to bottom, so the upper one keeps its row.
  A name only moves vertically and stays on the side of its icon.
- Names outside the view still claim their row. Otherwise the visible
  names would jump while panning.
- New hook __gm.sceneLabels() for the e2e check.

The frame names in the texture editor already had a plate, so they
are unchanged.

Not covered by tests: the accent border on selection. Readability on a
light background was only judged from a screenshot on a dark one.

Tests:
- 5 unit tests in tests/viewport/label-rows.test.ts: free names stay,
  the lower of two colliding names moves, the stacking order, names
  avoid icons, 30 crowded names never overlap.
- e2e 5c-ii: 8 symbols on one spot give 8 plates, no overlap with each
  other or with icons, each name on the side of its icon.
- Counter-checks: without the overlap test in the rule 4 of 5 unit
  tests fail. With the scene view ignoring the rows exactly 5c-ii
  fails.
- ./check.sh: 161 files, 2369 tests, CHECK EXIT 0.
- e2e 1-5: ALL PASSED, 140 checks. The e2e sum is now 1382 (computed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:05:39 +02:00

203 lines
9.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');
// 5c-ii. Eight symbols on one spot stand on a ring, their names beside them on dark plates. Names
// that would cover each other go a row up or down instead – none overlaps another or an
// icon – and every name stays on its icon's side of the ring.
const crowd = Array.from({ length: 8 }, (_, k) => `s-crowd-${k}`);
await mutate(`
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
const assets = ${JSON.stringify(Object.values(PLACED))};
${JSON.stringify(crowd)}.forEach((id, k) => {
scene.objects.push(make(id, assets[k % assets.length], '', ${LANES.symbols}, 'crowded name ' + k));
});
__gm.lookAt(0, ${LANES.symbols});`);
const plates = await json(`(() => {
const crowd = ${JSON.stringify(crowd)};
const icons = crowd.map((id) => __gm.sceneIcon(id));
const labels = __gm.sceneLabels().filter((l) => crowd.includes(l.id));
return { icons: icons.map((i) => ({ x: i.x, y: i.y, size: i.size })), labels };
})()`);
const cross = (a, b) =>
a.x < b.x + b.w - 1e-6 && b.x < a.x + a.w - 1e-6 && a.y < b.y + b.h - 1e-6 && b.y < a.y + a.h - 1e-6;
const iconBoxes = plates.icons.map((i) => ({
x: i.x - i.size / 2,
y: i.y - i.size / 2,
w: i.size,
h: i.size,
}));
const clashes = [];
plates.labels.forEach((a, i) => {
plates.labels.slice(i + 1).forEach((b) => cross(a, b) && clashes.push(`${a.id}/${b.id}`));
iconBoxes.forEach((box, k) => cross(a, box) && clashes.push(`${a.id}/icon ${k}`));
});
const sides = plates.labels.filter((l) => {
const icon = plates.icons[crowd.indexOf(l.id)];
return l.left ? l.x + l.w > icon.x : l.x < icon.x;
});
check(
'names crowded on one spot stand on plates in rows of their own, on their icons’ side',
plates.labels.length === 8 && clashes.length === 0 && sides.length === 0,
`${plates.labels.length} plates, clashes ${JSON.stringify(clashes)}, wrong side ${JSON.stringify(sides.map((l) => l.id))}`,
);
await shot('15a-crowded-names');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => ![...${JSON.stringify(ids)}, ...${JSON.stringify(crowd)}].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)`);
}