import { colourDistance } from '../lib/harness.mjs'; import { inView } from './lib/scaffold.mjs'; /** * 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 })); })()`; /** * A second, plainly different picture. Its only job is to exist on the server under a hash of * its own, so the picker has something to offer that the project does not already wear. */ const PASTE_SPARE = `(async () => { const canvas = document.createElement('canvas'); canvas.width = 24; canvas.height = 24; const g = canvas.getContext('2d'); g.fillStyle = '#ffcc00'; g.fillRect(0, 0, 24, 24); g.fillStyle = '#202020'; g.fillRect(6, 6, 12, 12); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); const transfer = new DataTransfer(); transfer.items.add(new File([blob], 'spare.png', { type: 'image/png' })); window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); })()`; export async function textures(t) { const { check, sleep, json, evaluate, shot, mutate, press } = t; const { api, click, clickText, doubleClick, locate, undo, play, stop } = 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); // 12a. 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 }), ); // 12b. 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')), ); // 12c. 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`, because the GPU sampler can miss a correctly assigned value. 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; }; // Pixels plus what the driver has, so a failure says whether the model, the GPU sampler or the // measurement is wrong. const sample = async (value) => { await mutate( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = ${JSON.stringify(value)}`, ); await sleep(600); const pixels = await acrossBorder(); return { pixels, filter: await json(`__gm.textureFilter(${JSON.stringify(texture.file)})`) }; }; const hard = await sample('nearest'); const pure = (c) => colourDistance(c, '#e63c3c') < 60 || colourDistance(c, '#3cc85a') < 60; check( 'nearest filtering keeps the border between two quadrants hard', hard.pixels.every(pure), JSON.stringify(hard), ); const soft = await sample('linear'); // The drift proves the switch: with a hung one, "some pixel is blended" alone would pass. Measured: // a working switch drifts 425, a hung one 0, and 30 even if every channel were off by two. const drift = hard.pixels.reduce((sum, c, i) => sum + colourDistance(c, soft.pixels[i]), 0); check( 'switching the filter back to linear blends the border without a reload', soft.pixels.some((c) => !pure(c)) && drift > 150, JSON.stringify({ drift, nearest: hard, linear: soft }), ); await shot('33-texture-filter-smooth'); // 12c-ii. A shader draws in the sprite's own pass (#77) and samples the texture itself. At a // camera zoom that puts the picture on a fractional pixel grid, it must find the very same // nearest-filtered border as the plain draw. Measured in play, where the camera's zoom is the // world scale; one play per setting, since the play world is built from the project when it starts. await mutate( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = 'nearest'`, ); const placed = await json( "(() => { const o = __gm.state.activeScene.objects.find((o) => o.id === 'otex'); return { x: o.position.x, y: o.position.y }; })()", ); await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === 'otex'); o.position.x = 0; o.position.y = 0; __gm.state.project.assets.find((a) => a.id === 'cam').props.zoom = 1.37`); const played = async (patch) => { await mutate( `Object.assign(__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props, ${JSON.stringify(patch)})`, ); await play(); await sleep(500); const rect = await json("__gm.gameRect('otex')"); const y = Math.round(rect.y - rect.h * 0.25); const pixels = []; for (let dx = -3; dx <= 3; dx++) pixels.push(await json(`__gm.pixelAt(${Math.round(rect.x + dx)}, ${y})`)); await stop(); return { pixels, scale: rect.w / 320 }; }; const bare = await played({ shader: '' }); const filtered = await played({ shader: 'adjust-colors' }); const darkened = await played({ shader: 'adjust-colors', brightness: 0.5 }); check( 'at a fractional camera zoom the nearest border is still hard in play', Number.isInteger(bare.scale) === false && bare.pixels.every(pure), JSON.stringify(bare), ); check( 'a neutral shader draws the very same pixels there: the shader does not resample', JSON.stringify(filtered.pixels) === JSON.stringify(bare.pixels), JSON.stringify({ bare: bare.pixels, filtered: filtered.pixels }), ); // Without this, "the same" could also mean the shader never ran on that path. check( 'and the shader does run on that path: brightness 0.5 darkens every one of them', darkened.pixels.every((c, i) => colourDistance(c, bare.pixels[i]) > 100 && !pure(c)), JSON.stringify(darkened), ); // The sprite's picture is a mesh next to its node: put under the Graphics instead, Pixi warns // once per page, through `groupCollapsed`, which the panel mirrors since step 27b-ii. const complaints = (await json('__gm.console.lines()')).filter((line) => /Deprecation/.test(line.text)); check('drawing sprites this way raises no Pixi deprecation warning', complaints.length === 0, complaints); await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === 'otex'); o.position.x = ${placed.x}; o.position.y = ${placed.y}; __gm.state.project.assets.find((a) => a.id === 'cam').props.zoom = 1; const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite'); sprite.props.shader = ''; delete sprite.props.brightness; __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = 'linear'`); await mutate(enlarge(160, 120)); // 12d. 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 }), ); // 12e. 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 }), ); } // 12e-ii. A texture field offers pictures only (#143). Since #97 an animation is played, not worn, // so the Texture row of a sprite no longer lists one, and an animation id left in the // field by an older project reads as dangling rather than passing for a picture. await mutate(` __gm.state.project.assets.push( { id: 'tex-anim', type: 'animation', name: 'Walk', props: {}, root: '', tracks: [] }); const worn = __gm.state.project.assets.find((a) => a.id === 'tex-sprite').props; window.__texBefore = worn.texture; worn.texture = 'tex-anim'; __gm.state.select({ kind: 'asset', assetId: 'tex-sprite' })`); await sleep(400); const textureField = await json("__gm.inspectorField('Texture', true)"); const dangling = await json("__gm.locateAll('tex-anim?')"); check( 'an animation id in the Texture row reads as a dangling reference', Boolean(textureField) && dangling.length === 1 && Math.abs(dangling[0].y - textureField.y) < 12, JSON.stringify({ textureField, dangling }), ); await click(textureField.x, textureField.y); await sleep(300); const offered = (await json('__gm.menuRows()')) ?? []; check( 'the Texture row offers the picture and no animation', offered.includes(texture.name) && !offered.some((row) => row?.includes('(animation)') || row === 'Walk'), JSON.stringify(offered), ); await press('Escape'); await mutate(` const assets = __gm.state.project.assets; assets.splice(assets.findIndex((a) => a.id === 'tex-anim'), 1); assets.find((a) => a.id === 'tex-sprite').props.texture = window.__texBefore; __gm.state.select(null)`); await sleep(300); // 12f. "+ → Texture" offers the pictures the server already holds, unreferenced ones included: // paste a second picture, undo the asset (the file stays), then pick that file in the dialog. await evaluate(PASTE_SPARE); await sleep(2500); const two = await json("__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)"); const spare = two[1] ?? ''; check( 'a second picture is stored under a hash of its own', two.length === 2 && spare !== two[0], JSON.stringify(two), ); await undo(); const afterUndo = await json( "__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)", ); check( 'undoing the import leaves the file on the server with no asset naming it', afterUndo.length === 1 && !afterUndo.includes(spare), JSON.stringify(afterUndo), ); await evaluate('__gm.console.clear()'); await clickText('+'); // By position, not by text: the menu entry "Texture" shares its label with the centre tab, and // `locate` would hand back whichever it walked into first. const entry = await locate('Texture', true); await click(entry.x, entry.y); await sleep(1500); const picker = await json('__gm.imagePicker()'); const stored = (await api('textures')).map((item) => item.file); check( 'the Texture entry opens the image picker instead of the file dialog', picker !== null, JSON.stringify(picker), ); check( 'it offers exactly the files the server holds', JSON.stringify(picker?.files ?? []) === JSON.stringify(stored), JSON.stringify({ shown: picker?.files.length, stored: stored.length }), ); check( 'the picture the project wears is marked as already in it, the dropped one is not', (picker?.used ?? []).includes(texture.file) && !(picker?.used ?? []).includes(spare), JSON.stringify(picker?.used), ); // The caption: what the project calls a picture it wears, otherwise the name the file was // uploaded under (#83). Before that a file nobody wore read as its pixel size and nothing else. const captionOf = (file) => (picker?.captions ?? [])[(picker?.files ?? []).indexOf(file)]; check( 'an unused file is captioned by its original name, a used one by its asset name', captionOf(spare) === 'spare.png' && captionOf(texture.file) === texture.name, JSON.stringify({ spare: captionOf(spare), used: captionOf(texture.file), asset: texture.name }), ); await shot('34-image-picker'); // A tile is a container holding its background, not a Graphics with children: Pixi v8 warns // about the latter, and the warning is the only sign that the tiles are built the wrong way. const tileComplaints = (await json('__gm.console.lines()')).filter((line) => /Deprecation/i.test(JSON.stringify(line)), ); check('building the tiles draws no complaint from Pixi', tileComplaints.length === 0, tileComplaints); // Scrolled into view first: the server holds every picture ever uploaded, so on a long-lived // one the spare's tile sits below the dialog's fold, where a click lands on nothing. const tileOf = (file) => inView(t, '__gm.imagePickerRect()', `__gm.imagePickerTile(${JSON.stringify(file)})`); const tile = await tileOf(spare); await click(tile.x, tile.y); check( 'clicking a tile picks that image', (await json('__gm.imagePicker()'))?.selected === spare, JSON.stringify(await json('__gm.imagePicker()')), ); await shot('35-image-picked'); await clickText('Use image'); await sleep(600); const adopted = await json( "__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => ({ name: a.name, file: a.file, w: a.width, h: a.height }))", ); check('the dialog closes on the pick', (await json('__gm.imagePicker()')) === null); check( 'the picked file becomes a texture asset, sized from the server record', adopted.length === 2 && adopted[1]?.file === spare && adopted[1]?.w === 24 && adopted[1]?.h === 24, JSON.stringify(adopted), ); check( 'and it is named after the file it was uploaded as, not "Texture"', adopted[1]?.name === 'spare', JSON.stringify(adopted.map((a) => a.name)), ); // 12g. The name beside the file, straight against the API: uploading the same bytes again under // a new name renames it (the user's rule), a path is stripped to its last part, and an // upload carrying no name at all leaves the known one alone rather than erasing it. const renamed = await evaluate(`(async () => { const bytes = await fetch('/game-maker/data/textures/${spare}').then((r) => r.blob()); const post = (query) => fetch('/game-maker/api/index.php?r=textures' + query, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: bytes }).then((r) => r.json()); const evil = await post('&name=' + encodeURIComponent('../../evil/renamed.png')); const nameless = await post(''); await post('&name=spare.png'); return { evil: evil.name, file: evil.file, nameless: nameless.name }; })()`); check( 'a second upload renames the file, a path is stripped, and a nameless upload keeps the name', renamed.evil === 'renamed.png' && renamed.file === spare && renamed.nameless === 'renamed.png', JSON.stringify(renamed), ); check( 'and the notice says it was added rather than imported', (await json('__gm.state.notice')) === 'Added the image to the project.', JSON.stringify(await json('__gm.state.notice')), ); // Bytes that are no picture are the caller's mistake: a 422 that says so, not a server error. const refusedUpload = await evaluate(`fetch('/game-maker/api/index.php?r=textures&name=x.png', { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: 'hello' }) .then(async (r) => ({ status: r.status, error: (await r.json()).error }))`); check( 'bytes that are no picture are turned away with 422, not a server error', refusedUpload.status === 422 && refusedUpload.error === 'Not a readable image', JSON.stringify(refusedUpload), ); // Reusing a file already in the project must not make a second asset on it – two assets on one // picture would disagree about its filter. await clickText('+'); const entryAgain = await locate('Texture', true); await click(entryAgain.x, entryAgain.y); await sleep(1500); const reopened = await json('__gm.imagePicker()'); check( 'the adopted file is marked as in the project when the dialog is opened again', (reopened?.used ?? []).includes(spare), JSON.stringify(reopened?.used), ); const sameTile = await tileOf(spare); await doubleClick(sameTile.x, sameTile.y); await sleep(600); const afterSecondPick = await json( `__gm.state.project.assets.filter((a) => a.file === ${JSON.stringify(spare)}).length`, ); check( 'double-clicking a picture the project already has keeps it at one asset', afterSecondPick === 1 && (await json('__gm.state.notice')) === 'That image is already in the project.', JSON.stringify({ assets: afterSecondPick, notice: await json('__gm.state.notice') }), ); // Back to one texture asset, the way the later steps expect the fixture. await undo(); check( 'undo takes the adopted asset back out', (await json("__gm.state.project.assets.filter((a) => a.type === 'texture').length")) === 1, ); // 12g. Right-clicking a texture asset makes a sprite wearing it at the picture's pixel size. const entryText = 'Create sprite from texture'; const spriteRow = await json("__gm.assetRow('crate')"); await click(spriteRow.x, spriteRow.y, 'right'); check( 'a sprite asset does not offer making a sprite from it', (await json(`__gm.locate(${JSON.stringify(entryText)}, false)`)) === null, ); await press('Escape'); const spritesBefore = await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length"); const textureRow = await json(`__gm.assetRow(${JSON.stringify(texture.id)})`); await click(textureRow.x, textureRow.y, 'right'); const createEntry = await json(`__gm.locate(${JSON.stringify(entryText)}, false)`); check('a texture asset offers making a sprite from it', createEntry !== null); if (createEntry) { await click(createEntry.x, createEntry.y); const made = await json(`(() => { const s = __gm.state.selection; const a = s && s.kind === 'asset' ? __gm.state.project.assets.find((x) => x.id === s.assetId) : null; return a ? { type: a.type, name: a.name, props: a.props } : null; })()`); check( 'the new sprite is selected and wears the texture at its pixel size', made?.type === 'sprite' && made.props.texture === texture.id && made.props.width === 64 && made.props.height === 48, JSON.stringify(made), ); check('it is named after the texture', made?.name === `${texture.name} 2`, JSON.stringify(made?.name)); await shot('35b-sprite-from-texture'); await undo(); check( 'undo takes the new sprite back out', (await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length")) === spritesBefore, ); } // Saved before the next step: one that navigates would hang at the unsaved-changes prompt. await t.saved(); }