Files
feuerwerk/tools/e2e/12-textures.mjs
T
rootandClaude Opus 5 ead649eec3 Resolve container content from the template instead of copying it
A scene now stores only the container object; its content is resolved from
the template on every read, with deviations kept as sparse instanceEdits on
the container object. This drops the propagation bookkeeping entirely --
templateSync.ts is gone and the three structural gaps (add, remove, reparent)
cannot diverge any more.

Values stay editable on an instance, structure does not; Unpack writes the
content into the scene, keeping ids. Play mode bakes containers into the
throwaway copy before entities are built. No migration: formatVersion is 2
and the seeders write the new format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:09:53 +02:00

195 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 }),
);
}
}