Files
feuerwerk/tools/e2e/37-nine-slice.mjs
T
adminandClaude Opus 5.5 89048413f1 Let a texture frame set where a sprite stands for Y sorting (#228)
A sprite sorted by Y stood on the bottom of its box. With a shadow or
empty rows under the feet that is too low, so a figure slipped in
front of a tree it should stand behind. The line belongs to the frame,
not the texture, because one texture can hold several sprite sheets.

- New field `sortLine` on a frame: whole image pixels up from the
  frame's bottom edge, 0 to the frame height. 0 is stored as nothing,
  so old projects stay the same. Shrinking a frame keeps the line
  inside it.
- `feetOf` in drawOrder.ts lifts a sprite by the same share of its
  box. Scene view, game view, play and picking all use it. In an
  animation the line of the frame shown right now counts.
- The texture tab draws the selected frame's line dashed in yellow
  with a knob left of the frame. At 0 the line shares the bottom edge
  with the nine-slice border and a resize handle, so the knob is
  tested first. A line inside the frame can be dragged directly,
  tested after the nine-slice lines.
- The inspector has a new "Sorting" section with a "Sort line"
  field. Dragging or typing is one undo step each.

Limit: frames of one walk cycle need the same value, set by hand.
Otherwise the figure changes its place in the order while walking.

Tests:
- ./check.sh: 160 files, 2356 tests, CHECK EXIT 0 (before 2350).
- 6 new unit tests in tests/viewport/sort-by-y.test.ts.
- e2e 37 and 39-43: ALL PASSED, 162 checks. New check 37b-ii drags
  the knob up 6 px and undoes it, plus the inspector row.
- Counter checks: if feetOf ignores the line, exactly the 3 order
  tests fail. If the knob cannot be grabbed, 37b-ii fails.
- The e2e sum in the README is 1380, counted, not yet seen in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:34:37 +02:00

291 lines
13 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 { colourDistance } from '../lib/harness.mjs';
/**
* Nine-slice frames (#41). The borders live on the frame of a texture, and what only a running
* editor can answer is in here: that a corner keeps its pixel size while the box grows, that the
* same picture without borders does not, and that "repeat" really tiles the middle instead of
* stretching it. All three are read off the screen, because all three are about the geometry a
* sprite is drawn with – no unit test can see that.
*/
/**
* A 24×24 button: an 8-pixel red ring around a blue middle, and a 2-pixel white stripe down the
* left of that middle. The stripe is what tells a tiled middle from a stretched one – tiled it
* comes back every 8 units, stretched there is exactly one of it.
*/
const PASTE_BUTTON = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 24;
canvas.height = 24;
const g = canvas.getContext('2d');
g.fillStyle = '#e63c3c'; g.fillRect(0, 0, 24, 24);
g.fillStyle = '#466ef0'; g.fillRect(8, 8, 8, 8);
g.fillStyle = '#ffffff'; g.fillRect(8, 8, 2, 8);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'button.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
const RED = '#e63c3c';
const BLUE = '#466ef0';
const WHITE = '#ffffff';
const SIZE = 200;
export async function nineSlice(t) {
const { check, sleep, json, evaluate, mutate, clickText, drag, press, shot, showTab, settle } = t;
const { undo, redo, select, has } = t;
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)');
// 37a. A picture of its own, and one frame over the whole of it: the borders are a property of
// the frame, so a texture nobody cut has nothing to slice.
await evaluate(PASTE_BUTTON);
await sleep(2500);
const texture = await json(
"(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" +
' return a ? { id: a.id, w: a.width, h: a.height } : null; })()',
);
check(
'the button picture is imported at its own size',
texture?.w === 24 && texture?.h === 24,
JSON.stringify(texture),
);
await mutate(`
const texture = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)});
texture.props.filter = 'nearest';
texture.frames.push({ id: 'ns-frame', name: 'Button', x: 0, y: 0, w: 24, h: 24 });`);
await evaluate(
`__gm.state.select({ kind: 'frame', assetId: ${JSON.stringify(texture.id)}, frameId: 'ns-frame' })`,
);
await showTab('texture');
await settle();
const frame = () =>
json(
`(() => { const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})` +
`.frames.find((f) => f.id === 'ns-frame');` +
' return { x: f.x, y: f.y, w: f.w, h: f.h, border: f.border ?? null, fill: f.fill ?? null }; })()',
);
check('a frame starts unsliced, with no borders at all', (await frame()).border === null);
// 37b. Dragging the border lines in the Texture tab. Grabbed away from the frame's own handles:
// at zero the line sits on the frame's edge, and the handle there resizes the frame itself.
const before = await frame();
const from = await json('__gm.texture.pointAt(0, 6)');
const to = await json('__gm.texture.pointAt(8, 6)');
await drag(from.x, from.y, to.x, to.y);
await settle();
const topFrom = await json('__gm.texture.pointAt(6, 0)');
const topTo = await json('__gm.texture.pointAt(6, 8)');
await drag(topFrom.x, topFrom.y, topTo.x, topTo.y);
await settle();
const dragged = await frame();
check(
'dragging the two lines sets the borders and leaves the frame where it was',
dragged.border?.left === 8 &&
dragged.border?.top === 8 &&
dragged.x === before.x &&
dragged.y === before.y &&
dragged.w === before.w &&
dragged.h === before.h,
JSON.stringify(dragged),
);
await shot('37-frame-borders');
// One drag, one entry – and the last one takes the keys away again rather than writing zeros:
// a frame nobody sliced must read exactly as it did before, or every document would grow four
// numbers it never asked for.
await undo();
const halfUndone = await frame();
await undo();
const undone = await frame();
check(
'each border drag is one undo step, and the last one leaves no slice at all',
halfUndone.border?.left === 8 && halfUndone.border?.top === 0 && undone.border === null,
JSON.stringify({ halfUndone, undone }),
);
await redo();
await redo();
// 37b-ii. The sort line (#228) sits on the bottom edge with the bottom border and the resize
// handle, so it is dragged by its knob left of the frame: 6 pixels up, one undo step.
const sortLine = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})` +
`.frames.find((f) => f.id === 'ns-frame').sortLine ?? null`,
);
const knob = (await json('__gm.texture.drawInfo()')).sortKnob;
const knobTo = await json('__gm.texture.pointAt(0, 18)');
await drag(knob.x, knob.y, knob.x, knobTo.y);
await settle();
const lifted = await sortLine();
const afterLift = await frame();
await undo();
const dropped = await sortLine();
check(
'dragging the knob sets the sort line and leaves the frame and its borders, undo takes it away',
lifted === 6 && dropped === null && afterLift.h === 24 && afterLift.border?.bottom === 0,
JSON.stringify({ knob, lifted, dropped, afterLift }),
);
check('the sort line is a row of the frame inspector', await has('Sort line'));
await mutate(`
const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})
.frames.find((f) => f.id === 'ns-frame');
f.border = { left: 8, top: 8, right: 8, bottom: 8 };`);
// 37c. A sprite wearing the frame, far bigger than the picture. Cloned from one that exists,
// because a literal misses the fields the viewport needs (see step 12).
await mutate(`
const project = __gm.state.project;
const sprite = structuredClone(project.assets.find((a) => a.type === 'sprite'));
sprite.id = 'ns-sprite';
sprite.name = 'Button';
sprite.props = { ...sprite.props, width: ${SIZE}, height: ${SIZE}, color: 0xffffff, texture: 'ns-frame' };
project.assets.push(sprite);
const scene = __gm.state.activeScene;
const object = structuredClone(scene.objects.find((o) => o.id === 'otex') ?? scene.objects[0]);
object.id = 'ons';
object.name = 'Button';
object.assetId = 'ns-sprite';
object.overrides = {};
object.position = { ...object.position, z: 950 };
scene.objects.push(object);
// The stage to itself: the fixture's other objects draw gizmos over everything – a collider
// outline, the behavior icons – and a pixel reading cannot tell those from the picture.
window.__nsOthers = scene.objects.filter((o) => o.id !== 'ons');
scene.objects = scene.objects.filter((o) => o.id === 'ons');
__gm.state.select(null)`);
await showTab('scene');
await sleep(600);
const box = await json("__gm.objectRect('ons')");
check('the nine-sliced sprite is on screen', Boolean(box) && box.w > 100, JSON.stringify(box));
if (!box) return;
const scale = box.w / SIZE;
// A run of pixels from just inside the left edge: one read rather than a hundred round trips.
// Across the middle of the sprite, which is where the parts between the corners are.
const rowY = Math.round(box.y);
const rowAcross = (count) => json(`__gm.pixelRow(${Math.round(box.x - box.w / 2 + 1)}, ${rowY}, ${count})`);
const runOf = (row, colour) => {
let n = 0;
while (n < row.length && colourDistance(row[n], colour) < 60) n++;
return n;
};
const sliced = await rowAcross(120);
const slicedEdge = runOf(sliced, RED);
check(
'the border keeps its own pixel size while the sprite is eight times the picture',
Math.abs(slicedEdge - 8 * scale) <= 2 && colourDistance(sliced[Math.round(100 * scale)], BLUE) < 60,
JSON.stringify({ run: slicedEdge, expected: 8 * scale, row: sliced.slice(0, 16) }),
);
await shot('38-nine-sliced-sprite');
// The counter-proof: the very same picture without borders. Without it, "the edge is 8 wide"
// could just as well mean the measurement is wrong.
await mutate(`
const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})
.frames.find((f) => f.id === 'ns-frame');
delete f.border;`);
await sleep(400);
const stretched = await rowAcross(120);
const stretchedEdge = runOf(stretched, RED);
check(
'and without the borders the very same picture scales its edge with the box',
stretchedEdge > 8 * scale * 4 && Math.abs(stretchedEdge - (8 / 24) * SIZE * scale) <= 4,
JSON.stringify({ sliced: slicedEdge, stretched: stretchedEdge }),
);
// 37d. Repeat against stretch, picked in the inspector. The white stripe down the left of the
// middle is the witness: tiled there is one per 8 units, stretched there is one of it.
// Repeat is the default, so a frame nobody picked a fill for already tiles.
await mutate(`
const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})
.frames.find((f) => f.id === 'ns-frame');
f.border = { left: 8, top: 8, right: 8, bottom: 8 };`);
const stripes = async () => {
const row = await rowAcross(Math.round(SIZE * scale) - 2);
let runs = 0;
for (let i = 0; i < row.length; i++) {
const white = colourDistance(row[i], WHITE) < 60;
const wasWhite = i > 0 && colourDistance(row[i - 1], WHITE) < 60;
if (white && !wasWhite) runs++;
}
return runs;
};
const repeatedStripes = await stripes();
await evaluate(
`__gm.state.select({ kind: 'frame', assetId: ${JSON.stringify(texture.id)}, frameId: 'ns-frame' })`,
);
await showTab('texture');
await clickText('Repeat');
// Repeat first: the default leads the list.
const offered = (await json('__gm.overlayTexts()')).filter((text) => /^(Repeat|Stretch)$/.test(text));
await clickText('Stretch');
await settle();
const picked = await frame();
check(
'the inspector offers repeat before stretch, and picking stretch writes the frame',
picked.fill === 'stretch' && offered.join() === 'Repeat,Stretch',
JSON.stringify({ picked, offered }),
);
await showTab('scene');
await sleep(400);
const stretchedStripes = await stripes();
check(
'a frame repeats unless told otherwise, and stretch draws the middle once',
repeatedStripes > 15 && stretchedStripes === 1,
JSON.stringify({ repeated: repeatedStripes, stretched: stretchedStripes }),
);
await shot('39-nine-slice-repeat');
// 37e. Narrower than its own two borders: they are scaled down together until the corners meet,
// so the row across it is all corner and no middle. Kept tall, so the reading stays in the
// band the gizmos leave alone.
await mutate(`
Object.assign(__gm.state.project.assets.find((a) => a.id === 'ns-sprite').props, { width: 8 });`);
await sleep(400);
const small = await json("__gm.objectRect('ons')");
const seam = await json(
`__gm.pixelRow(${Math.round(small.x - small.w / 2 + 1)}, ${rowY}, ${Math.max(2, Math.round(small.w) - 2)})`,
);
check(
'a sprite narrower than its two borders is all corner, with no middle between them',
seam.length > 2 && seam.every((c) => colourDistance(c, RED) < 60),
JSON.stringify({ w: small.w, seam }),
);
// 37f. The texture's nine-slice scale (#96): the same 8-pixel border, drawn 3 units per pixel.
// Set on the texture and not on the frame, so the sprite must follow a value it never names.
await mutate(`
Object.assign(__gm.state.project.assets.find((a) => a.id === 'ns-sprite').props, { width: ${SIZE} });
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.sliceScale = 3;`);
await sleep(400);
const tripled = runOf(await rowAcross(120), RED);
await mutate(`
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.sliceScale = 1;`);
await sleep(400);
const single = runOf(await rowAcross(120), RED);
check(
"the texture's nine-slice scale draws every border that many times bigger, and 1 undoes it",
Math.abs(tripled - 24 * scale) <= 2 && Math.abs(single - 8 * scale) <= 2,
JSON.stringify({ tripled, single, expected: [24 * scale, 8 * scale] }),
);
await shot('40-nine-slice-scale');
await select({ kind: 'asset', assetId: texture.id });
check("the scale is a row of the texture's inspector", await has('Nine-slice scale'));
// The scene back as it was, without the sprite this step brought: later readers count what is in it.
await mutate(`
__gm.state.activeScene.objects = window.__nsOthers ?? [];
delete window.__nsOthers;
__gm.state.select(null)`);
await settle();
}