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')); // 37g. A shader's Padding on a sliced sprite (#69): a ring around the nine parts, as wide as that // many border pixels. With Edge Clamp the frame's red rim goes on through it, so the ring // shows as red left of the box – and the border inside keeps its 8 pixels. await mutate(` __gm.state.project.assets.push({ id: 'ns-shader', type: 'shader', name: 'Rim', props: {}, graph: { nodes: [ { id: 'ns-texel', type: 'ShaderTextureColor', x: 0, y: 0, params: {} }, { id: 'ns-out', type: 'ShaderOutput', x: 260, y: 0, params: { edge: 'clamp', padding: 0 } }, ], edges: [ { id: 'ns-e1', from: { node: 'ns-texel', port: 'color' }, to: { node: 'ns-out', port: 'color' } }, ] }, }); __gm.state.project.assets.find((a) => a.id === 'ns-sprite').props.shader = 'ns-shader'; __gm.state.select(null)`); const rim = async (padding) => { await mutate(` __gm.state.project.assets.find((a) => a.id === 'ns-shader') .graph.nodes.find((n) => n.id === 'ns-out').params.padding = ${padding};`); await sleep(400); // From twelve units left of the box to twelve units into it. const reach = Math.round(12 * scale); const row = await json(`__gm.pixelRow(${Math.round(box.x - box.w / 2) - reach}, ${rowY}, ${2 * reach})`); const outside = row.slice(0, reach).filter((c) => colourDistance(c, RED) < 60).length; const inside = row.slice(reach).filter((c) => colourDistance(c, RED) < 60).length; return { outside, inside }; }; const tight = await rim(0); const ringed = await rim(4); await shot('41-nine-slice-padding'); check( 'a shader’s padding puts a ring of four border pixels around a sliced sprite and leaves its parts alone', tight.outside === 0 && Math.abs(ringed.outside - 4 * scale) <= 2 && Math.abs(tight.inside - 8 * scale) <= 2 && Math.abs(ringed.inside - 8 * scale) <= 2, JSON.stringify({ tight, ringed, expected: [4 * scale, 8 * scale] }), ); await mutate(` const project = __gm.state.project; delete project.assets.find((a) => a.id === 'ns-sprite').props.shader; project.assets = project.assets.filter((a) => a.id !== 'ns-shader');`); // 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(); }