import { colourDistance } from '../lib/harness.mjs'; import { openTab } from './lib/scaffold.mjs'; /** * The Texture tab: cutting frames out of a sheet, and a sprite wearing one. * * Runs after the texture step, whose four-quadrant 64x48 picture gives every quadrant a colour * of its own – that is what makes "which part of the sheet is on screen" a `pixelAt` question. */ /** The four quadrants of the sheet the texture step pastes, plus the sprite's own white. */ const PALETTE = { red: '#e63c3c', green: '#3cc85a', blue: '#466ef0', white: '#f5f5f5', plain: '#ff00ff', }; /** * Nearest known colour rather than an exact match: editor gizmos (a collider fill at 12 % alpha) * tint a sample by a few percent, which still cannot make one quadrant read as another. */ function nearestColour(sample) { let best = ''; let bestDistance = Infinity; for (const [name, hex] of Object.entries(PALETTE)) { const distance = colourDistance(sample, hex); if (distance < bestDistance) { bestDistance = distance; best = name; } } return best; } export async function frames(t) { const { check, sleep, json, evaluate, shot, clickText, drag, press, undo, wheel, showTab } = t; // 13a. The tab exists and shows the texture the selection points at. await press('Escape'); await evaluate('__gm.state.select(null)'); const texture = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''"); await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(texture)} })`); await openTab(t, 'texture'); check('the Texture tab opens', (await json('__gm.tab()')) === 'texture'); check( 'and shows the selected sheet', (await json('__gm.texture.current()')) === texture, `${await json('__gm.texture.current()')} vs ${texture}`, ); await shot('33-texture-tab'); // 13b. Dragging on the picture cuts a frame. Aimed at the top-right quadrant (32,0)-(64,24), // inset by a pixel: a press on a frame edge is read as a resize, not a new rectangle. const from = await json('__gm.texture.pointAt(33, 1)'); const to = await json('__gm.texture.pointAt(63, 23)'); check('the sheet is on screen', Boolean(from) && Boolean(to), JSON.stringify({ from, to })); await drag(from.x, from.y, to.x, to.y); await sleep(500); const cut = await json( `(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []`, ); check('dragging on the sheet cuts a frame', cut.length === 1, JSON.stringify(cut)); const frame = cut[0] ?? {}; check( 'the frame lands on the quadrant it was dragged over', frame.x >= 32 && frame.y >= 0 && frame.x + frame.w <= 64 && frame.y + frame.h <= 48, JSON.stringify(frame), ); check( 'and is stored in whole pixels', Number.isInteger(frame.x) && Number.isInteger(frame.y) && Number.isInteger(frame.w), JSON.stringify(frame), ); check( 'cutting a frame selects it, so the inspector edits what was just made', (await json('__gm.state.selection?.kind')) === 'frame', JSON.stringify(await json('__gm.state.selection')), ); await shot('34-frame-cut'); // 13c. It is one undo step, not one per pointer move. await undo(); check( 'undo removes the frame in one step', (await json( `((__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []).length`, )) === 0, ); await t.redo(); // 13d. A sprite wearing the frame shows that part of the sheet and nothing else. await evaluate(`(() => { const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite'); sprite.props.color = 0xffffff; sprite.props.texture = ${JSON.stringify(frame.id)}; __gm.state.preview(); })()`); await showTab('scene'); await sleep(900); const box = await json("__gm.objectRect('otex')"); check('the textured sprite is on screen again', Boolean(box) && box.w > 8, JSON.stringify(box)); if (box) { const at = async (fx, fy) => json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`); const corners = [await at(-0.3, -0.3), await at(0.3, -0.3), await at(-0.3, 0.3), await at(0.3, 0.3)]; const read = corners.map(nearestColour); // Without the frame these corners read red / green / blue / white, as the texture step // asserts – so four greens is the statement that the fill stayed inside one quadrant. check( 'the whole sprite shows the one quadrant the frame cuts out', read.every((c) => c === 'green'), JSON.stringify({ corners, read }), ); await shot('35-sprite-wearing-a-frame'); } // 13e. Slicing offers a live count before it commits. // Repainted magenta first: magenta is nowhere in the sheet, so the fallback check below // cannot be confused with a part of the picture. await evaluate(`(() => { __gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0xff00ff; __gm.state.preview(); })()`); await showTab('texture'); await clickText('Slice…'); await sleep(500); const dialog = await json('__gm.overlayTexts()'); check( 'the slice dialog states what the numbers would produce', dialog.some((text) => /^\d+ frames of \d+ × \d+ px$/.test(text)), JSON.stringify(dialog), ); check( 'and names the picture size it is cutting', dialog.some((text) => text === 'Picture is 64 × 48 px'), JSON.stringify(dialog), ); await clickText('Slice'); await sleep(700); const sliced = await json( `((__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []).map((f) => f.name + ' ' + f.w + 'x' + f.h)`, ); check( 'slicing 4x4 replaces the frames with sixteen 16x12 cells', sliced.length === 16 && sliced.every((s) => s.endsWith('16x12')), JSON.stringify(sliced.slice(0, 3)), ); check('and numbers them in reading order', sliced[0]?.startsWith('Frame 1 '), JSON.stringify(sliced[0])); await shot('36-sliced-sheet'); // 13f. Zoomed in, the sheet is larger than the tab: nothing of it may be drawn next to the tab. // The bug this guards against drew the checkerboard's base rectangle, the frame outlines // and the name plates over the Assets panel, because only the picture itself was masked. await showTab('texture'); const area = await json('__gm.texture.rect()'); // Points on the panel left of the tab and on the tab row above it – both were painted over. const probes = [ ...[0.2, 0.4, 0.6, 0.8].map((f) => ({ x: Math.round(area.x - 40), y: Math.round(area.y + f * area.h) })), ...[0.25, 0.5, 0.75].map((f) => ({ x: Math.round(area.x + f * area.w), y: Math.round(area.y - 10) })), ]; const readProbes = async () => { const out = []; for (const p of probes) out.push(await json(`__gm.pixelAt(${p.x}, ${p.y})`)); return out; }; const quiet = await readProbes(); const centre = { x: Math.round(area.x + area.w / 2), y: Math.round(area.y + area.h / 2) }; for (let i = 0; i < 6; i++) await wheel(centre.x, centre.y, -240); await sleep(500); const topLeft = await json('__gm.texture.pointAt(0, 0)'); // Without this the probes below would prove nothing: they need a sheet that reaches past the edge. check( 'zooming in pushes the sheet past the tab on every side', topLeft.x < area.x && topLeft.y < area.y, JSON.stringify({ topLeft, area }), ); await shot('36b-zoomed-sheet'); const loud = await readProbes(); const changed = probes .map((p, i) => ({ ...p, was: quiet[i], now: loud[i] })) .filter((p) => p.was !== p.now); check('a zoomed sheet paints nothing outside the tab', changed.length === 0, JSON.stringify(changed)); check( 'and no frame name is drawn past the tab either', (await json('__gm.textOverflows()')).length === 0, JSON.stringify(await json('__gm.textOverflows()')), ); await drag(centre.x, centre.y, centre.x + 50, centre.y + 30, { steps: 6, button: 'middle' }); const pannedTo = await json('__gm.texture.pointAt(0, 0)'); check( 'a middle drag pans the sheet by as much as the pointer moved', Math.abs(pannedTo.x - topLeft.x - 50) < 0.01 && Math.abs(pannedTo.y - topLeft.y - 30) < 0.01, JSON.stringify({ topLeft, pannedTo }), ); await clickText('Fit'); // 13g. A sprite pointed at a frame slicing removed falls back to its colour. await showTab('scene'); await sleep(700); if (box) { const fallback = await json( `__gm.pixelAt(${Math.round(box.x + 0.3 * box.w)}, ${Math.round(box.y + 0.3 * box.h)})`, ); check( 'a sprite whose frame was sliced away falls back to its plain colour', nearestColour(fallback) === 'plain', JSON.stringify({ fallback, read: nearestColour(fallback) }), ); } }