The sprite takes the whole image at its pixel size and the texture's name, numbered like any other clash. CreateAssetCommand now accepts starting properties; they go through the schema in execute, so an unknown key or a bad value is refused before the project is touched, and one undo removes the asset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
16 KiB
JavaScript
373 lines
16 KiB
JavaScript
/**
|
||
* 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 }));
|
||
})()`;
|
||
|
||
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;
|
||
const { api, click, clickText, doubleClick, locate, undo } = 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;
|
||
};
|
||
// Read the pixels first, then ask the driver what it actually has: a failure then says by
|
||
// itself whether the model never switched, the switch missed the GPU sampler, or the pixels
|
||
// were measured wrong. This check has fallen over sporadically without any of the three ever
|
||
// being distinguishable from the message.
|
||
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(
|
||
'pixel-art filtering keeps the border between two quadrants hard',
|
||
hard.pixels.every(pure),
|
||
JSON.stringify(hard),
|
||
);
|
||
|
||
const soft = await sample('linear');
|
||
// The drift — that the picture actually changed — is what makes this a test of the switch.
|
||
// Were the switch to hang, both readings would be the blurred one, and "some pixel is blended"
|
||
// alone would pass and hide the failure of the check above. Measured on the recorded readings:
|
||
// 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 smooth 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');
|
||
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 }),
|
||
);
|
||
}
|
||
|
||
// 19e. "+ → Texture" offers the pictures the server already holds instead of the machine's
|
||
// file system. Nothing collects unreferenced files, so a picture one project dropped is
|
||
// still there for the next – which is exactly what this drives: paste a second picture,
|
||
// undo the asset (the file stays), then pick that file out of 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),
|
||
);
|
||
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 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,
|
||
);
|
||
|
||
// 19f. 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,
|
||
);
|
||
}
|
||
}
|