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