Files
feuerwerk/tools/e2e/30-font.mjs
T
adminandClaude Opus 5.5 e933fad446 Give a shader room to draw around the picture it wears (#69)
A shader ran only inside the rectangle of what wears it: the quad
has the size of the sprite, and outside of it no fragment runs. An
outline or a glow built with Sample texture was cut off at the
border. The issue still spoke of Filter.padding, but since #77 the
shader is no filter any more, the limit is the geometry.

- The Output node has a number Padding next to Edge: so many pixels
  of the picture per side the wearer draws beyond it. 0 is the
  default and draws as before, 256 is the most.
- The quad grows and its UVs grow with it, so the picture keeps its
  place and size. In the margin the UVs run on below 0 and above 1.
- Transparent is a fourth Edge mode: outside of the picture there
  is nothing. It is cut in the fragment (texelAt), so it needs no
  copy of the picture with an address mode of its own.
- Edge holds everywhere alike, also for Texture color, Texture
  alpha and unconnected inputs.
- Padding above 0 with Edge on Clamp puts the hint "Padding with
  Clamp smears the border" on the Output. The shader still
  compiles. A chosen Clamp and the default cannot be told apart.
- All four wearers draw the margin. A sprite counts in pixels of
  its frame, a text in its units, a particle source grows every
  particle about its centre.
- A nine-slice sprite gets a ring around its nine fields, as wide
  as so many border pixels. The fields themselves stay.
- Without a picture a pixel is one unit of the wearer's own size,
  or of the particle size.
- Particles now take the Edge mode of their shader. It had no
  effect on them before.
- An object wearing a padded shader is never culled, or its margin
  would vanish at the edge of the screen.
- The preview in the shader tab shows the margin and leaves room
  for it.
- Picking, the selection frame, the size and the collider stay with
  the rectangle without the margin.

Tests:
- Gate on melektaus-atx right before this commit: 216 files,
  3336 tests (3319 + 17), exit 0.
- e2e 28,30,31,37,41 on melektaus-atx: 178 checks, all pass.
  e2e 1-37,47,48,58 ran before the last change (no culling with
  padding): 1281 checks, all pass.
- 17 new unit tests: the compiler (padding, Transparent, the hint)
  and the geometry (quad, nine-slice ring).
- Seven new e2e checks: four at the sprite in module 28, one each
  for text, particles and nine-slice.
- 17 unit counter-probes all fail their tests. Three browser
  counter-probes fail their checks (5, 3 and 1 of them).
- Three screenshots looked at: the sprite with Clamp and margin in
  the scene, the preview in the shader tab, the text.
- The e2e sum of 1687 is counted (1680 + 7), the full suite and
  the smokes did not run.
- Not seen: the hint at the node in a picture, particles and
  nine-slice in a picture, the game view while playing, sprites
  without a picture, vector and pixel art pictures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:33:20 +02:00

425 lines
20 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.
import { edge, node } from '../lib/builders.mjs';
import { LANES, pickAssetType } from './lib/scaffold.mjs';
/**
* Font assets (#74): uploaded like a picture – picked from what the server holds through "+ → Font",
* or dropped onto the editor – and worn by a text object through its Font row. No font means the
* editor's own typeface, which is also what a reference that no longer resolves falls back to.
*
* The seed puts two DejaVu faces on the server without making assets of them: this step makes them.
* Everything lives in its own lane (`__gm.lookAt`), because symbols draw above objects and the
* origin is crowded by what the earlier steps left behind.
*/
const LANE = LANES.font;
const TEXT_ASSET = 'ft-text';
const TEXT_OBJECT = 'ft-obj';
/** The two files the seed uploads, told apart by their exact size rather than by their name. */
const SERIF_BYTES = 380660;
const MONO_BYTES = 343140;
/** How many pixels across the text a check reads. */
const SAMPLE = 120;
const RED_SHADER = {
id: 'ft-red',
type: 'shader',
name: 'Red glyphs',
props: {},
graph: {
nodes: [
node('ft-red-color', 'ShaderColor', 0, 0, { value: 0xe63c3c }),
node('ft-red-out', 'ShaderOutput', 260, 0),
],
edges: [edge('ft-red-e1', 'ft-red-color', 'color', 'ft-red-out', 'color')],
},
};
export async function font(t) {
const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot, showTab } =
t;
const { select } = t;
const assetsOfType = (type) =>
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)})
.map((a) => ({ id: a.id, name: a.name, file: a.file }))`);
const info = () => json(`__gm.textInfo(${JSON.stringify(TEXT_OBJECT)})`);
/** The overlay is drawn last, so a menu entry wins over a row of the asset list saying the same. */
const clickLast = async (text) => {
const p = await locate(text, true);
await click(p.x, p.y);
};
/** A face arrives over the network, so what it changes is awaited rather than slept for. */
const waitFor = async (read, done, tries = 20) => {
let value = await read();
for (let i = 0; i < tries && !done(value); i++) {
await sleep(150);
value = await read();
}
return value;
};
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)');
await showTab('scene');
// 30a. What the server holds, straight from the API: the two files the seed put there.
// `evaluate`, not `json`: the latter stringifies the promise itself rather than what it yields.
const stored = await evaluate(`fetch('/game-maker/api/index.php?r=fonts').then((r) => r.json())`);
const serif = stored.find((item) => item.bytes === SERIF_BYTES);
const mono = stored.find((item) => item.bytes === MONO_BYTES);
check(
'the server holds the two seeded typefaces, named by their content',
Boolean(serif && mono) && /^[a-f0-9]{64}\.ttf$/.test(serif.file),
JSON.stringify({ serif: serif?.file, mono: mono?.file, count: stored.length }),
);
if (!serif || !mono) return;
// The hash says nothing, so the name the file was uploaded under is kept beside it (#83).
check(
'the listing carries the name each file was uploaded under',
serif.name === 'DejaVuSerif.ttf' && mono.name === 'DejaVuSansMono.ttf',
JSON.stringify({ serif: serif.name, mono: mono.name }),
);
// 30b. "+ → Font" offers them; a font is picked rather than created empty, like a texture.
await evaluate('__gm.console.clear()');
await clickText('+');
await pickAssetType(t, 'Font');
await sleep(500);
const picker = await json('__gm.fontPicker()');
check(
'the + menu opens the font picker on what the server holds, none of it in the project yet',
Boolean(picker) && picker.files.includes(serif.file) && picker.used.length === 0,
JSON.stringify(picker),
);
// A row the project does not wear yet is captioned by the file's own name (#83); without it
// there was nothing to read but its byte count.
check(
'a row nobody wears yet is captioned with the name the file was uploaded under',
picker.captions[picker.files.indexOf(serif.file)] === 'DejaVuSerif.ttf',
JSON.stringify(picker.captions),
);
await shot('30-font-picker');
// A row is a container holding its background, not a Graphics with children: Pixi v8 warns
// about the latter, and the warning is the only sign that the rows are built the wrong way.
const logged = await json('__gm.console.lines()');
check(
'building the rows draws no complaint from Pixi',
!logged.some((line) => /Deprecation/i.test(JSON.stringify(line))),
JSON.stringify(logged.slice(0, 3)),
);
const row = await json(`__gm.fontPickerRow(${JSON.stringify(serif.file)})`);
await click(row.x, row.y);
await clickText('Use font');
const fonts = await assetsOfType('font');
check(
'picking a row makes a font asset of that file, named after the file rather than "Font"',
fonts.length === 1 && fonts[0].file === serif.file && fonts[0].name === 'DejaVuSerif',
JSON.stringify(fonts),
);
if (fonts.length !== 1) return;
const fontId = fonts[0].id;
// Renamed away from "Font": the inspector row is called that too, and a lookup by text would
// find the asset's row in the panel instead of the row being tested.
await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fontId)}).name = 'Bookish'`);
// 30c. A text in its own lane, drawn in the editor's own typeface while it wears no font.
await mutate(`
const project = __gm.state.project;
project.assets.push({ id: ${JSON.stringify(TEXT_ASSET)}, type: 'text', name: 'Title',
props: { enabled: true, scale: 1, rotation: 0, mirrorX: false, mirrorY: false,
text: 'Handgloves', font: '', fontSize: 48, color: 0xffffff, align: 'center' } });
__gm.state.activeScene.objects.push({ id: ${JSON.stringify(TEXT_OBJECT)}, assetId: ${JSON.stringify(TEXT_ASSET)},
name: '', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 },
overrides: {}, parentId: '', instanceEdits: {} });
__gm.lookAt(0, ${LANE});`);
const plain = await info();
check(
'a text wearing no font is drawn in the editor’s own typeface',
Boolean(plain) && !plain.family.startsWith('gm-font-') && plain.width > 0,
JSON.stringify(plain),
);
if (!plain) return;
// 30d. Wearing the font, chosen the way a user chooses it: the Font row of the text object.
await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT });
// The label is no anchor on its own: the assets panel has a row per font. Parent and Shader (#169)
// say "None" as well, so the Font row's dropdown is the "None" at its height.
const fontRow = await json("__gm.inspectorField('Font', true)");
const nones = await evaluate(`(() => {
const out = [];
const walk = (node) => {
if (!node.visible) return;
if (node.text === 'None') out.push(Math.round(node.getBounds().y + node.getBounds().height / 2));
for (const child of node.children ?? []) walk(child);
};
walk(__gm.app.stage);
return out;
})()`);
check(
'a text has a Font row, and it reads None',
Boolean(fontRow) && nones.some((y) => Math.abs(y - fontRow.y) < 12),
JSON.stringify({ fontRow, nones }),
);
await click(fontRow.x, fontRow.y);
await clickLast('Bookish (font)');
const chosen = await json(
`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides.font`,
);
const worn = await waitFor(info, (value) => Boolean(value?.family.startsWith('gm-font-')));
check(
'picking it in the Font row redraws the text in that face, and it reflows',
chosen === fontId && Boolean(worn) && worn.width !== plain.width,
JSON.stringify({ chosen, plain, worn }),
);
await shot('30-font-scene');
// The asset's own inspector is where a font is looked at; it has nothing else to show.
await select({ kind: 'asset', assetId: fontId });
await shot('30-font-asset');
// 30e. The game draws through the same path, so the face is in play as well. The camera frames
// the origin, so the text has to leave its lane for as long as the game runs.
const place = (expression) =>
mutate(`
const scene = __gm.state.activeScene;
const cam = scene.objects.find((o) => o.id === 'o3').position;
const text = scene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)});
Object.assign(text.position, ${expression});`);
await place('{ x: cam.x, y: cam.y }');
await play();
await sleep(600);
const played = await json(`__gm.gameTextInfo(${JSON.stringify(TEXT_OBJECT)})`);
check(
'the running game draws the text in the same face',
Boolean(played?.family.startsWith('gm-font-')) && played.family === worn?.family,
JSON.stringify(played),
);
await stop();
await place(`{ x: 0, y: ${LANE} }`);
// 30f. An id that no longer resolves is no font at all – the text falls back rather than vanishing.
await mutate(
`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides.font = 'no-such-asset'`,
);
const dangling = await waitFor(info, (value) => !value?.family.startsWith('gm-font-'));
check(
'a dangling font id falls back to the editor’s own typeface, at the width from before',
dangling?.family === plain.family && dangling?.width === plain.width,
JSON.stringify(dangling),
);
// 30g. Dropping a font file onto the editor uploads it and makes an asset named after the file.
const dropped = await evaluate(`
(async () => {
const bytes = await fetch('/game-maker/data/fonts/${mono.file}').then((r) => r.arrayBuffer());
const file = new File([bytes], 'DejaVuSansMono.ttf', { type: 'font/ttf' });
const data = new DataTransfer();
data.items.add(file);
const canvas = document.querySelector('canvas');
canvas.dispatchEvent(new DragEvent('drop', { dataTransfer: data, bubbles: true, cancelable: true }));
return file.size;
})()`);
await waitFor(
() => assetsOfType('font'),
(list) => list.length === 2,
);
const both = await assetsOfType('font');
check(
'a dropped typeface is uploaded and becomes an asset named after the file',
dropped === MONO_BYTES &&
both.length === 2 &&
both.some((a) => a.file === mono.file && a.name === 'DejaVuSansMono'),
JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }),
);
// 30h. A text wears a shader like a sprite (#169), picked in its own Shader row. The graph paints
// every glyph pixel one red and keeps the text's alpha, so no white may survive it.
const overrides = `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides`;
await mutate(`
Object.assign(${overrides}, { font: ${JSON.stringify(fontId)} });
__gm.state.project.assets.push(${JSON.stringify(RED_SHADER)});`);
await waitFor(info, (value) => Boolean(value?.family.startsWith('gm-font-')));
/** A run of pixels across the middle of the text, where every glyph is cut. */
const rowAcross = async (view = 'objectRect') => {
const r = await json(`__gm.${view}(${JSON.stringify(TEXT_OBJECT)})`);
return r ? json(`__gm.pixelRow(${Math.round(r.x - SAMPLE / 2)}, ${Math.round(r.y)}, ${SAMPLE})`) : [];
};
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
// Red is counted by hue, not by the exact colour: drawn small, a glyph's pixels blend into the background.
const reddish = (hex) => {
const [r, g, b] = channels(hex);
return r > 0x60 && r > 2 * g && r > 2 * b;
};
const summary = (row) => ({
red: row.filter(reddish).length,
white: row.filter((hex) => channels(hex).every((c) => c > 0xd0)).length,
});
const white = summary(await rowAcross());
await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT });
const shaderRow = await json("__gm.inspectorField('Shader', true)");
const noShader = await locate('None', true);
check(
'a text has a Shader row below its own, and it reads None',
Boolean(shaderRow) && Math.abs(noShader.y - shaderRow.y) < 12 && white.white > 0 && white.red === 0,
JSON.stringify({ shaderRow, noShader, white }),
);
await click(shaderRow.x, shaderRow.y);
await clickLast('Red glyphs (shader)');
const redInfo = await info();
const red = summary(await rowAcross());
check(
'picking a shader draws the text through it: its glyphs red, none of them white',
redInfo?.shader === RED_SHADER.id && red.red > 0 && red.white === 0,
JSON.stringify({ redInfo, red }),
);
await shot('30-font-text-shader');
// 30i. New content is rasterised anew, and the shader stays on it.
await mutate(`${overrides}.text = 'Handgloves Handgloves'`);
const longer = await info();
const redLonger = summary(await rowAcross());
check(
'changing the text keeps the shader on the new glyphs',
longer?.shader === RED_SHADER.id &&
longer.width > redInfo.width &&
redLonger.red > 0 &&
redLonger.white === 0,
JSON.stringify({ longer, redLonger }),
);
// 30i-ii. A shader's Padding gives a text room around its rectangle (#69), in the text's own
// units. The graph fills the whole quad here, so the margin shows as red left of the text.
const shaderGraph = `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(RED_SHADER.id)}).graph`;
const filled = (padding) =>
mutate(`
const graph = ${shaderGraph};
graph.nodes = graph.nodes.filter((n) => n.id !== 'ft-red-full');
graph.edges = graph.edges.filter((e) => e.id !== 'ft-red-e2');
graph.nodes.push(${JSON.stringify(node('ft-red-full', 'ShaderNumber', 0, 160, { value: 1 }))});
graph.edges.push(${JSON.stringify(edge('ft-red-e2', 'ft-red-full', 'value', 'ft-red-out', 'alpha'))});
Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'transparent', padding: ${padding} });`);
// The red run on the row through the text: where the filled quad begins and ends on screen.
const redSpan = async () => {
const r = await json(`__gm.objectRect(${JSON.stringify(TEXT_OBJECT)})`);
const from = Math.max(0, Math.round(r.x) - 600);
const row = await json(`__gm.pixelRow(${from}, ${Math.round(r.y)}, 1200)`);
const red = row.map(reddish);
return {
first: from + red.indexOf(true),
last: from + red.lastIndexOf(true),
unit: r.w / (await info()).width,
};
};
await evaluate('__gm.state.select(null)');
await filled(0);
const textTight = await redSpan();
await filled(6);
const textRoomy = await redSpan();
await shot('30-font-text-padding');
const reach = 6 * textTight.unit;
check(
'a padded shader draws six units past the text’s rectangle on either side, and none further',
textTight.last > textTight.first &&
Math.abs(textTight.first - textRoomy.first - reach) <= 2 &&
Math.abs(textRoomy.last - textTight.last - reach) <= 2,
JSON.stringify({ textTight, textRoomy, reach }),
);
await mutate(`
const graph = ${shaderGraph};
graph.nodes = graph.nodes.filter((n) => n.id !== 'ft-red-full');
graph.edges = graph.edges.filter((e) => e.id !== 'ft-red-e2');
Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'clamp', padding: 0 });`);
// 30j. The game draws a text through the same picture, shader included. In front of everything:
// in the chained run, earlier steps leave pictures of their own at the camera.
await place('{ x: cam.x, y: cam.y, z: 1000 }');
await play();
await sleep(600);
const playedShaded = await json(`__gm.gameTextInfo(${JSON.stringify(TEXT_OBJECT)})`);
const playedPixels = await rowAcross('gameRect');
const playedRow = summary(playedPixels);
check(
'the running game draws the text through its shader as well',
playedShaded?.shader === RED_SHADER.id && playedRow.red > 0 && playedRow.white === 0,
JSON.stringify({
playedShaded,
playedRow,
rect: await json(`__gm.gameRect(${JSON.stringify(TEXT_OBJECT)})`),
scale: await json('__gm.gameScale()'),
playedPixels,
}),
);
await stop();
await place(`{ x: 0, y: ${LANE}, z: 0 }`);
// 30k. The font's Filter (#169 comment): Nearest rasterises a pixel per unit and magnifies without
// smoothing. At scale 4 a sharp glyph is blocks of four screen pixels, a smooth one blends
// its edges – so the shortest run of one colour inside the row tells them apart.
// Nothing selected: the outline of a selected text would cross the row with runs of its own.
await mutate(`Object.assign(${overrides}, { shader: '', text: 'Hg', fontSize: 16, scale: 4 })`);
await evaluate('__gm.state.select(null)');
// The scene's grid shows through where the text is transparent, in runs of its own, so everything
// darker than the glyphs counts as one background.
const shortestRun = (pixels) => {
const row = pixels.map((hex) => (channels(hex)[0] > 0x60 ? hex : 'background'));
const runs = [];
let length = 1;
for (let i = 1; i < row.length; i++) {
if (row[i] === row[i - 1]) length++;
else {
runs.push(length);
length = 1;
}
}
// The first and the last run are cut off by the ends of the row.
return Math.min(...runs.slice(1));
};
const smoothInfo = await info();
const smoothRow = await rowAcross();
const smoothRun = shortestRun(smoothRow);
await select({ kind: 'asset', assetId: fontId });
const filterRow = await json("__gm.inspectorField('Filter', true)");
const linear = await locate('Linear', true);
check(
'a font has a Filter row, Linear by default, and its text blends the edges of its glyphs',
Boolean(filterRow) && Math.abs(linear.y - filterRow.y) < 12 && smoothInfo?.smooth && smoothRun <= 2,
JSON.stringify({ filterRow, linear, smoothInfo, smoothRun, smoothRow }),
);
await click(filterRow.x, filterRow.y);
await clickLast('Nearest');
const filter = await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fontId)}).props.filter`,
);
const sharpInfo = await info();
const sharpRow = await rowAcross();
const sharpRun = shortestRun(sharpRow);
check(
'Nearest draws its text a pixel per unit, magnified in blocks',
filter === 'nearest' && sharpInfo?.smooth === false && sharpInfo.resolution === 1 && sharpRun >= 3,
JSON.stringify({ filter, sharpInfo, sharpRun, sharpRow }),
);
await shot('30-font-nearest');
// 30k-ii. …and its texels are hard: rasterised finer and cut at half coverage, so no glyph edge is
// left half covered – Chrome on Windows smooths canvas text sideways, whatever the filter.
// The fixture's face is no pixel font, so rasterised 1:1 its edges would be half covered here too.
check(
'Nearest leaves no half-covered texel in the text, the smooth face does',
smoothInfo?.partial > 0 && sharpInfo?.partial === 0,
JSON.stringify({ smooth: smoothInfo?.partial, sharp: sharpInfo?.partial }),
);
// 30l. The step takes back everything it made, so the steps after it see the fixture again.
await mutate(`
const gone = new Set([${JSON.stringify(TEXT_ASSET)}, ${JSON.stringify(RED_SHADER.id)},
...${JSON.stringify(both.map((a) => a.id))}]);
const project = __gm.state.project;
project.assets = project.assets.filter((a) => !gone.has(a.id));
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter(
(o) => o.id !== ${JSON.stringify(TEXT_OBJECT)});
__gm.state.select(null);
__gm.lookAt(0, 0);`);
check(
'the step leaves nothing of its own behind',
(await assetsOfType('font')).length === 0 && (await info()) === null,
JSON.stringify(await assetsOfType('font')),
);
}