Files
feuerwerk/tools/e2e/05-drawing.mjs
T
adminandClaude Opus 5 28b1afa7af
game-maker / gate (push) Successful in 2m22s
game-maker / suite (push) Successful in 8m38s
Panning the scene view no longer redraws the whole scene
A middle-mouse pan used to run the full redraw on every frame. It
collected every object from the project, cleared and redrew every
shape, re-sorted all children and made Pixi re-upload every buffer,
although only the camera had moved. The user saw the frame rate drop
while panning.

- WorldViewport gets moveCamera(). When zoom and view size are
  unchanged it only moves the world and redraws the overlays drawn in
  screen pixels. collectFrame, the shape redraw and the re-sort drop
  out of a pan frame.
- Every full redraw remembers the box each object is culled by. A pan
  that moves an object across the edge of the view still takes the
  full redraw, so objects appear and vanish as before.
- Grid, selection outlines, rays and the container origin mark move
  into a shared drawOnScreen hook. It runs after every full redraw and
  after every pan. The parent links stay in afterItems because they
  are drawn in world units.
- Wheel zoom keeps the full redraw, because line widths follow the
  zoom. A cache for collectFrame was left out: it would save about
  1 ms and risk stale data.
- debugInfo() reports redraws and cameraMoves, and __gm gets a hook
  that forces a full redraw, so a check can compare against it.

Findings:
- Chrome already coalesces mouse moves to one per frame (500 moves
  gave 183 redraws in 189 frames). A throttle was tried and removed
  again, because its counter-check did not fail.
- A check in module 03 compared the whole gameInfo() object and broke
  on the new counters. It now compares camera, objects and rendering.
- The stall without ?vsync most likely comes from the desynchronized
  context in src/main.ts. It cannot be reproduced headless.

Tests:
- Measured on Jump and Run, main-thread time per frame, 3 runs of 240
  moves: pan 5.7-7.4 ms before, 3.8-4.8 ms after. Plain mouse moves
  stay at 2.1-2.4 ms. 208 of 240 moves took the fast path.
- ./check.sh: 141 files, 2080 tests, exit 0.
- GM_STEPS=1-16,29,42,43: 535 checks, 1 failed (the module 03 test
  above). After the fix GM_STEPS=1-5: all passed, 130 checks.
- 3 new e2e checks in 5d: 12 pan steps give 12 camera moves and 0
  full redraws, pixel rows after the pan match a fresh redraw, a box
  panned out of view and back is drawn again.
- Counter-checks: overlays not redrawn on pan gives "3 of 3 rows
  differ". Edge test disabled keeps the box visible with 0 redraws.
- Check total 1285 (1282 confirmed by CI task 229, plus 3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:38:05 +02:00

158 lines
6.9 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 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.
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;
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 wrong = 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];
});
})()`);
check(
'every symbol draws its SVG in the theme colour',
wrong.length === 0,
wrong.join('; ') || `${SPOTS.length} spots`,
);
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)`);
}