Files
feuerwerk/tools/e2e/30-font.mjs
T
adminandClaude Opus 5.5 89aa92d2e3
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Failing after 11m40s
Put a shader on a text and draw pixel fonts sharp (#169)
A text could not wear a shader, and a pixel font was always smoothed.
Pixi's Text takes no custom shader. It also shares one texture between
texts of the same content and style, and the filter is not part of that
key, so two equal texts with different filters would overwrite each
other.

- TextPicture rasters a text into a texture of its own and draws it
  like a sprite. Shader, time animation and edge mode work without a
  special path. A new text, font or size rasters again and keeps the
  shader.
- The old texture is dropped only after the shader holds the new one.
  Before, Pixi warned about a destroyed texture that was still bound.
- A text has a Shader row with the shader's property rows below, as a
  sprite has. Overrides, saving, animation and the Properties node
  (type Text) know them. A shader parameter named like an own row of
  the text loses to that row, as on the particle source.
- The font asset has a Filter row, Linear or Nearest. The row existed
  three times already (texture, vector art, pixel art) and is now one
  helper filterRow.
- Nearest rasters the text 8 times finer and cuts every 8x8 block to
  one texel: at least half covered is full, else empty. Chrome on
  Windows smooths canvas text sideways through DirectWrite whatever
  filter we set later. The cut removes those half pixels on every
  system, and the texture stays one texel per world unit.

Found on the way:

- On a font that is no pixel font thin strokes break apart under
  Nearest. For a pixel font at its own size this does not matter.

Not done:

- The preview in the Shader tab does not take a selected text as its
  picture, it shows the test picture then.

Tests:

- ./check.sh: 189 files, 2759 tests, CHECK EXIT 0.
- text-shader.test.ts with 10 tests: rows and name precedence, a
  clamped override, save and load, animation, the font filter, and
  sharpCoverage on a smoothed edge and at the texture border.
- e2e 30 has 7 new checks: red glyphs through the shader picked with
  the mouse, the shader survives a new text, red in the game, soft
  edges under Linear, clean 4x4 blocks under Nearest, and half covered
  texels (Linear 238, Nearest 0).
- Full local run before the Nearest cut: all passed, 1543 checks.
  GM_STEPS=1,30 on the final bundle: all passed, 32 checks. The sum
  1544 in the README is counted from these two runs, not measured.
- Counter-checks fail as expected: in the browser no shader on a text
  fails 30h-30j, an always linear sampler fails the Nearest check, the
  old 1:1 raster leaves 107 half covered texels and fails 30k-ii.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 06:01:28 +02:00

382 lines
17 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 }),
);
// 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')),
);
}