Images are uploaded to a content-addressed blob endpoint and stored as data/textures/<sha256>.<ext>; the project only keeps the file name. The sprite points at a texture asset through an ordinary property of the new kind `asset`, so instance overrides, "Apply to container", "Reset to asset", undo and `Set property` work without any extra code. The new kind also replaces the ad-hoc asset picker the graph nodes faked as an enum. Import goes through + -> Texture, drag & drop onto the canvas or Ctrl+V. Images wider than 2048px are downscaled client-side and re-encoded to WebP if needed, because nginx caps uploads at 1MB; an image that already fits is uploaded byte for byte so pixel art is never resampled. The sprite colour default changes from red to white, since colour tints the texture multiplicatively and a tint of white shows an image as it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
7.0 KiB
JavaScript
162 lines
7.0 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.
|
||
*
|
||
* The image is built in the page and handed over as a paste, which is a real user gesture and
|
||
* exercises the whole chain: decode → upload → the server storing it under its content hash →
|
||
* the asset → the renderer. Nothing here is stubbed.
|
||
*/
|
||
|
||
/** 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 }));
|
||
})()`;
|
||
|
||
/** How far apart two `#rrggbb` colours are, summed over the channels. */
|
||
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. Both matter here: paste is
|
||
// deliberately ignored while an input has focus, so that Ctrl+V inside a field still edits
|
||
// text. Closing and blurring is what clicking the canvas would do.
|
||
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 is a no-op with an explanation: the server hands back
|
||
// the file it already has (the name is the hash), so the existing asset is reused rather
|
||
// than a second one made for the same bytes.
|
||
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 sprite of its own, deliberately: eleven steps of edits have been through the fixture's
|
||
// objects by now (resized, re-parented, cloned forty times, covered by colliders and gizmos),
|
||
// and a colour read off the screen must not depend on any of that. This one is white (so the
|
||
// picture shows its own colours instead of a tint), large enough that a quarter-width sample
|
||
// sits far from every quadrant border, and on top of everything else.
|
||
// Both are clones of things that already exist rather than hand-written literals: a scene
|
||
// object has fields a literal keeps forgetting (`editorOffset` was the one that bit here –
|
||
// the position went NaN and the object was culled 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 === 'o1'));
|
||
object.id = 'otex';
|
||
object.name = 'Textured';
|
||
object.assetId = 'tex-sprite';
|
||
object.templateId = '';
|
||
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');
|
||
|
||
// 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 }),
|
||
);
|
||
}
|
||
}
|