/** * 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. */ /** 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 })); })()`; 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, and paste is deliberately // ignored while an input has focus, so that Ctrl+V inside a field still edits text. 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 reuses the asset: the server hands back the file it // already has, since the name is the content hash. 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 fresh white sprite of its own, so the sample does not depend on eleven steps of edits // to the fixture. Cloned rather than written as a literal: a literal missed `editorOffset`, // which sent the position to NaN and culled the object 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 === 'otext')); object.id = 'otex'; object.name = 'Textured'; object.assetId = 'tex-sprite'; 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`: the property was always assigned correctly, it was the // GPU sampler behind it that used to survive the change. 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 }), ); } }