/** * Uploading a picture and wearing it. Runs last because it adds an asset and repaints the * scene; the checks that count assets live in earlier steps. * * The image is built in the page and handed over as a paste, which is a real user gesture and * exercises the whole chain: decode → upload → the server storing it under its content hash → * the asset → the renderer. Nothing here is stubbed. */ /** Paints a four-quadrant PNG in the page and pastes it. The quadrants make the mapping readable. */ const PASTE_IMAGE = `(async () => { const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 48; const g = canvas.getContext('2d'); g.fillStyle = '#e63c3c'; g.fillRect(0, 0, 32, 24); g.fillStyle = '#3cc85a'; g.fillRect(32, 0, 32, 24); g.fillStyle = '#466ef0'; g.fillRect(0, 24, 32, 24); g.fillStyle = '#f5f5f5'; g.fillRect(32, 24, 32, 24); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); const transfer = new DataTransfer(); transfer.items.add(new File([blob], 'pasted.png', { type: 'image/png' })); window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); })()`; /** How far apart two `#rrggbb` colours are, summed over the channels. */ function colourDistance(a, b) { const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); const [ar, ag, ab] = channels(a); const [br, bg, bb] = channels(b); return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb); } export async function textures(t) { const { check, sleep, json, evaluate, shot, mutate, press } = t; // The previous step leaves a picker open and a text field focused. Both matter here: paste is // deliberately ignored while an input has focus, so that Ctrl+V inside a field still edits // text. Closing and blurring is what clicking the canvas would do. await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await sleep(200); // 19. Pasting an image imports it: uploaded, stored under its hash, and made an asset. await evaluate(PASTE_IMAGE); await sleep(2500); const imported = await json( "__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => ({ id: a.id, name: a.name, file: a.file, w: a.width, h: a.height }))", ); check('pasting an image creates a texture asset', imported.length === 1, JSON.stringify(imported)); const texture = imported[0] ?? {}; check( 'the file is named after its content hash', /^[a-f0-9]{64}\.png$/.test(texture.file ?? ''), String(texture.file), ); check( 'the pixel size comes back from the server', texture.w === 64 && texture.h === 48, JSON.stringify({ w: texture.w, h: texture.h }), ); // 19a. Importing the same picture again is a no-op with an explanation: the server hands back // the file it already has (the name is the hash), so the existing asset is reused rather // than a second one made for the same bytes. await evaluate(PASTE_IMAGE); await sleep(2500); const again = await json( "__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)", ); check( 'the same image imported twice stays one asset on one file', again.length === 1, JSON.stringify(again), ); check( 'and the notice says it was already there', (await json('__gm.state.notice')) === 'That image is already in the project.', JSON.stringify(await json('__gm.state.notice')), ); // 19b. The picture is actually drawn – a sprite wearing it is no longer one flat colour. // // On a sprite of its own, deliberately: eleven steps of edits have been through the fixture's // objects by now (resized, re-parented, cloned forty times, covered by colliders and gizmos), // and a colour read off the screen must not depend on any of that. This one is white (so the // picture shows its own colours instead of a tint), large enough that a quarter-width sample // sits far from every quadrant border, and on top of everything else. // Both are clones of things that already exist rather than hand-written literals: a scene // object has fields a literal keeps forgetting (`editorOffset` was the one that bit here – // the position went NaN and the object was culled without an error). await mutate(` const project = __gm.state.project; const sprite = structuredClone(project.assets.find((a) => a.type === 'sprite')); sprite.id = 'tex-sprite'; sprite.name = 'Textured'; sprite.props = { ...sprite.props, width: 160, height: 120, color: 0xffffff, texture: ${JSON.stringify(texture.id)} }; project.assets.push(sprite); const scene = __gm.state.activeScene; const object = structuredClone(scene.objects.find((o) => o.id === 'o1')); object.id = 'otex'; object.name = 'Textured'; object.assetId = 'tex-sprite'; object.templateId = ''; object.overrides = {}; object.position = { ...object.position, z: 900 }; scene.objects.push(object); __gm.state.select(null)`); await sleep(1200); const box = await json("__gm.objectRect('otex')"); check('the textured sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box)); if (box) { // The quadrants sit at the corners of the sprite's box; sample well inside each one. const at = async (fx, fy) => json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`); const [topLeft, topRight, bottomLeft, bottomRight] = [ 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 sampled = { topLeft, topRight, bottomLeft, bottomRight }; check( 'each quadrant of the picture lands where the picture has it', colourDistance(topLeft, '#e63c3c') < 60 && colourDistance(topRight, '#3cc85a') < 60 && colourDistance(bottomLeft, '#466ef0') < 60 && colourDistance(bottomRight, '#f5f5f5') < 60, JSON.stringify(sampled), ); check( 'the sprite is a picture rather than one flat colour', new Set(Object.values(sampled)).size === 4, JSON.stringify(sampled), ); await shot('32-textured-sprite'); // 19b-i. Switching the Filter property takes effect at once, without a reload. // // Read off the screen rather than off `scaleMode`, because the property was always assigned // correctly – what used to survive the change was the GPU sampler behind it. The measurement // is what "smooth" means: across the red/green border, nearest sampling shows only the two // pure colours, linear sampling blends them into something that is neither. const enlarge = (w, h) => ` const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite'); sprite.props.width = ${w}; sprite.props.height = ${h}`; await mutate(enlarge(320, 240)); await sleep(600); const wide = await json("__gm.objectRect('otex')"); // Sampled in the upper half so the line crosses only the red/green border, not the corner // where all four quadrants meet. const acrossBorder = async () => { const y = Math.round(wide.y - wide.h * 0.25); const points = []; for (let dx = -2; dx <= 2; dx++) { points.push(await json(`__gm.pixelAt(${Math.round(wide.x + dx)}, ${y})`)); } return points; }; const setFilter = (value) => mutate( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = ${JSON.stringify(value)}`, ); await setFilter('nearest'); await sleep(600); const hard = await acrossBorder(); const pure = (c) => colourDistance(c, '#e63c3c') < 60 || colourDistance(c, '#3cc85a') < 60; check( 'pixel-art filtering keeps the border between two quadrants hard', hard.every(pure), JSON.stringify(hard), ); await setFilter('linear'); await sleep(600); const soft = await acrossBorder(); check( 'switching the filter back to smooth blends the border without a reload', soft.some((c) => !pure(c)), JSON.stringify({ nearest: hard, linear: soft }), ); await shot('33-texture-filter-smooth'); await mutate(enlarge(160, 120)); await sleep(400); // 19c. The colour multiplies the picture, so a tinted white quadrant stops being white. await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0x3366ff"); await sleep(600); const tinted = await at(0.3, 0.3); check( 'the sprite colour tints the picture', colourDistance(tinted, '#f5f5f5') > 100 && colourDistance(tinted, '#3366ff') < 90, JSON.stringify({ was: bottomRight, tinted }), ); // 19d. A reference that no longer resolves falls back to the plain colour instead of failing. await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.texture = 'gone'"); await sleep(600); const fallback = await at(0.3, 0.3); check( 'a dangling texture falls back to the plain colour', colourDistance(fallback, '#3366ff') < 60, JSON.stringify({ fallback }), ); } }