An upload sends its file name next to the raw bytes, and the server keeps it in a sidecar next to the content-addressed file. Both pickers caption every tile and row with it, and an asset made there is named after the file instead of "Texture" / "Font". Last upload of the same content wins. The API's own set_error_handler ignores error_reporting(), so @-silenced warnings still throw: a missing sidecar took the whole listing down with a 500. Existence is checked instead of suppressed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
452 lines
20 KiB
JavaScript
452 lines
20 KiB
JavaScript
import { colourDistance } from '../lib/harness.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 sleep(400);
|
||
await mutate(enlarge(160, 120));
|
||
await sleep(400);
|
||
|
||
// 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 }),
|
||
);
|
||
}
|
||
|
||
// 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();
|
||
await sleep(300);
|
||
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 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');
|
||
|
||
const tile = await json(`__gm.imagePickerTile(${JSON.stringify(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')),
|
||
);
|
||
|
||
// 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 json(`__gm.imagePickerTile(${JSON.stringify(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();
|
||
await sleep(300);
|
||
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');
|
||
await sleep(200);
|
||
check(
|
||
'a sprite asset does not offer making a sprite from it',
|
||
(await json(`__gm.locate(${JSON.stringify(entryText)}, false)`)) === null,
|
||
);
|
||
await press('Escape');
|
||
await sleep(150);
|
||
|
||
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');
|
||
await sleep(200);
|
||
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);
|
||
await sleep(400);
|
||
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,
|
||
);
|
||
}
|
||
}
|