Files
feuerwerk/tools/e2e/37-nine-slice.mjs
T
adminandClaude Opus 5 bf0be66c9d
game-maker / gate (push) Successful in 1m32s
game-maker / suite (push) Successful in 5m47s
A frame with borders tiles unless told to stretch
The fill of a sliced frame defaulted to `Stretch`. It is `Repeat` now, and the Fill dropdown lists
`Repeat` first.

The saved document keeps writing only the fill that differs from the default, so the key it
carries is now `stretch`, and a frame without it repeats. A frame saved before with the key
absent and meaning stretch would therefore tile; none of the stored projects has one - both sliced
frames on the server carry `fill: "repeat"` explicitly, so they draw as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:13:11 +02:00

268 lines
12 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();
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();
}