Files
feuerwerk/tools/e2e/37-nine-slice.mjs
T
adminandClaude Opus 5 be8f2331de
game-maker / suite (push) Successful in 6m21s
A frame that keeps its corners can be stretched to any size (#41)
TextureFrame carries optional `border` (left/right/top/bottom) and
`fill` (stretch | repeat); absent keys mean "not sliced", so no format
bump and undo restores the plain frame instead of four zeroes.

The sliced sprite builds its own geometry instead of the shared
UNIT_QUAD: a 4x4 grid, and for `repeat` one quad per tile carrying the
frame cell's real UVs -- tiling in the geometry, not in the sampler, so
a frame out of a sheet cannot pull its neighbour in. Above 64 tiles per
axis it stretches, to bound the buffer.

A sprite narrower than left+right scales both borders down together
until the middle is exactly 0, so the corners meet at a seam and never
overlap.

The texture editor gets four draggable border lines plus the same four
numbers in the inspector. Hit order is resize handles, then the lines,
then the frame body -- handleAt reports 'move' anywhere inside a frame,
so lines placed after it were unreachable.

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

245 lines
10 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 } = 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. Stretch against repeat, picked in the inspector. The white stripe down the left of the
// middle is the witness: stretched there is one of it, tiled there is one per 8 units.
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 stretchedStripes = await stripes();
await evaluate(
`__gm.state.select({ kind: 'frame', assetId: ${JSON.stringify(texture.id)}, frameId: 'ns-frame' })`,
);
await showTab('texture');
await clickText('Stretch');
await clickText('Repeat');
await settle();
const picked = await frame();
check(
'the inspector offers the fill beside the borders, and picking it writes the frame',
picked.fill === 'repeat',
JSON.stringify(picked),
);
await showTab('scene');
await sleep(400);
const repeatedStripes = await stripes();
check(
'a repeated middle tiles the picture instead of stretching it once',
stretchedStripes === 1 && repeatedStripes > 15,
JSON.stringify({ stretched: stretchedStripes, repeated: repeatedStripes }),
);
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 }),
);
// 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();
}