Files
feuerwerk/tools/e2e/05-drawing.mjs
T
adminandClaude Opus 5.5 8b44f97c35
game-maker / gate (push) Successful in 3m7s
game-maker / suite (push) Failing after 8m51s
Let a child sort with its parent so its Z stays local (#232)
An animated or set Z of -1 on a weapon put it behind everything on Z 0,
not just behind the player holding it. A child can now opt in to being
drawn at its parent's place, so its own Z only orders it against the
parent and its siblings.

- New property "Sort with parent" on sprites, texts and particle
  emitters, shown in the inspector right under Z. It is a normal
  property, set on the asset and overridable per object.
- With it on, the rest of the scene sees only the parent's Z and, where
  the layer sorts by Y, the parent's feet. Below 0 the child is drawn
  behind the parent, from 0 on in front.
- It works across levels: a grandchild with the switch hangs on the top
  object of the chain.
- It has no effect under a parent that is not drawn itself (container,
  behavior, camera, collider) or under a tilemap.
- New assets start with it on, saved ones read as off, so old projects
  draw unchanged. For that a schema row now has "initial" next to
  "default". The gold "changed" mark and reset follow "initial", so a
  fresh sprite is not marked as changed. No load migration is needed.
- parentGroups() in drawOrder.ts gives a slot only to the top of a
  chain and inserts the children there. Editor, play mode and picking
  all use it, so they agree.

Tests:
- 10 new unit tests in tests/viewport/sort-with-parent.test.ts.
- 8 existing tests list the sprite rows or defaults and got the row.
- New e2e check 5b-ii clicks the switch and checks order and undo.
- Counter-checks, all originals restored by cmp: switch ignored fails
  7, children not sorted by Z fails 6, no chain fails 1, "initial"
  ignored fails 9, row missing in the inspector fails e2e 5.
- ./check.sh: 166 files, 2426 tests, CHECK EXIT 0.
- e2e 5: ALL PASSED, 13 checks. Check total 1398, computed only.

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

237 lines
11 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}`);
// 5b-ii. Sort with parent (#232): o2 hangs under o1 at z -1, and a third crate stands before o1
// in the hierarchy. The fixture's crate is stored without the switch, so o2 first drops
// behind everything on 0; switched on in the inspector it is drawn right behind o1. Undo,
// and the scene is put back for the blocks below.
await mutate(`
const objs = __gm.state.activeScene.objects;
const o2 = objs.find(o => o.id === 'o2');
o2.parentId = 'o1'; o2.position.z = -1;
objs.splice(objs.findIndex(o => o.id === 'o1'), 0, { id: 'o-sort', assetId: 'crate', name: '', position: { x: 0, y: 0, z: 0 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} });`);
const sortOrder = () => json(`__gm.drawOrder().filter(id => ['o-sort', 'o1', 'o2'].includes(id))`);
const orderOff = await sortOrder();
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2' });
const sortRow = await json("__gm.inspectorField('Sort with parent')");
if (sortRow) await click(sortRow.x, sortRow.y);
const sortOverride = (await object('o2')).overrides.sortWithParent;
const orderOn = await sortOrder();
await undo();
const orderUndone = await sortOrder();
check(
'Sort with parent draws a child below 0 right behind its parent, and undo takes it back',
JSON.stringify(orderOff) === '["o2","o-sort","o1"]' &&
sortOverride === true &&
JSON.stringify(orderOn) === '["o-sort","o2","o1"]' &&
JSON.stringify(orderUndone) === JSON.stringify(orderOff),
JSON.stringify({ sortRow, orderOff, sortOverride, orderOn, orderUndone }),
);
await shot('14b-sort-with-parent');
await mutate(`
const objs = __gm.state.activeScene.objects;
const o2 = objs.find(o => o.id === 'o2');
o2.parentId = ''; o2.position.z = 0;
objs.splice(objs.findIndex(o => o.id === 'o-sort'), 1);
__gm.state.select(null)`);
// 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)`);
}