A container is not drawn, so it had no place in the draw order. The chain from its content to the parent of the container broke there, and a container under a character could not be drawn together with it. - A container gets the switch Sort with parent under Z. It defaults to off, also for new containers, so projects draw as they did. - With the switch on, the whole content is drawn in the slot of the container's parent. The content's own switch does not count there. - Containers in a row pass the content on as long as each has the switch on. On the top level or under a tilemap it has no effect. - drawSlots follows the new sortsWith field of a draw item. Scene view, game and picking all use drawSlots, so all three agree. Found on the way: a scene object hung directly under a container spells the container out, and its template content is dropped. That is the existing spelledOut rule, the tests now keep both cases apart. Tests: - ./check.sh: 178 files, 2573 tests, CHECK EXIT 0 - 7 new unit tests in sort-with-parent.test.ts: the switch, template content, override per container, a chain, top level and tilemap, game and picking - e2e 5b-iii: a real click on the switch in the inspector, with undo - 4 counter-probes over #242 and #243 bite (2 unit, 2 browser) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
13 KiB
JavaScript
273 lines
13 KiB
JavaScript
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)`);
|
||
|
||
// 5b-iii. A container sorted with its parent (#243): a container under o1 holds a crate on -1 in
|
||
// its template, a third crate stands before o1. Off, as a new container starts, the crate
|
||
// inside drops behind everything on 0; the container's own switch draws it right behind o1.
|
||
const INSIDE = 'o-cont~sort-in';
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const objs = __gm.state.activeScene.objects;
|
||
const make = (id, assetId, parentId, z) => ({ id, assetId, name: '', position: { x: 0, y: 0, z }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId, instanceEdits: {} });
|
||
p.assets.push({ id: 'sort-box', type: 'container', name: 'sort-box', props: { enabled: true }, objects: [make('sort-in', 'crate', '', -1)] });
|
||
objs.splice(objs.findIndex(o => o.id === 'o1'), 0, make('o-sort', 'crate', '', 0));
|
||
objs.push(make('o-cont', 'sort-box', 'o1', 0));`);
|
||
const boxOrder = () =>
|
||
json(`__gm.drawOrder().filter(id => ['o-sort', 'o1', ${JSON.stringify(INSIDE)}].includes(id))`);
|
||
const boxOff = await boxOrder();
|
||
await select({ kind: 'object', holderId: 'scene1', objectId: 'o-cont' });
|
||
const boxRow = await json("__gm.inspectorField('Sort with parent')");
|
||
if (boxRow) await click(boxRow.x, boxRow.y);
|
||
const boxOverride = (await object('o-cont')).overrides.sortWithParent;
|
||
const boxOn = await boxOrder();
|
||
await undo();
|
||
const boxUndone = await boxOrder();
|
||
check(
|
||
'Sort with parent on a container draws its content right behind the parent of the container, and undo takes it back',
|
||
JSON.stringify(boxOff) === `["${INSIDE}","o-sort","o1"]` &&
|
||
boxOverride === true &&
|
||
JSON.stringify(boxOn) === `["o-sort","${INSIDE}","o1"]` &&
|
||
JSON.stringify(boxUndone) === JSON.stringify(boxOff),
|
||
JSON.stringify({ boxRow, boxOff, boxOverride, boxOn, boxUndone }),
|
||
);
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter(o => o.id !== 'o-sort' && o.id !== 'o-cont');
|
||
p.assets = p.assets.filter(a => a.id !== 'sort-box');
|
||
__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)`);
|
||
}
|