game-maker / suite (push) Successful in 8m24s
A sprite's picture is now a Mesh (unit quad, sibling of its Graphics node, which keeps only the hit area); a worn shader replaces Pixi's fragment in that draw, so there is no intermediate texture and no extra render pass. Measured on the HD 630: 400 shaded sprites hold 60 fps (was 34), 800 hold 56 (was 20). Two Pixi facts the code leans on: a mesh's texture is bound only for Pixi's own shader, so GraphShader.wear hands it over; and MeshPipe caches whether a mesh batches and re-checks it only in a frame without structure changes, so switching a shader on or off calls mesh.unload(). The console panel mirrors console.group/groupCollapsed/groupEnd and fills the %s %d %o %c directives, so Pixi's deprecation warning shows its head, not only its stack. E2E 12 guards against the addChild-on-Graphics warning, 28 counts shaders instead of filters. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
421 lines
18 KiB
JavaScript
421 lines
18 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),
|
||
);
|
||
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,
|
||
);
|
||
|
||
// 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,
|
||
);
|
||
}
|
||
}
|