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]{12}\/[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 }), ); // 30i-iii. A shader's Vertex output moves a text's corners as it does a sprite's (#79), in the // text's own units: six of them to the right, and the filled quad stands six further on. await filled(0); await mutate(` const graph = ${shaderGraph}; graph.nodes.push(${JSON.stringify(node('ft-red-by', 'ShaderNumber', 0, 320, { value: 6 }))}); graph.nodes.push(${JSON.stringify(node('ft-red-vout', 'ShaderVertexOutput', 260, 320, { subdivisions: 4 }))}); graph.edges.push(${JSON.stringify(edge('ft-red-e3', 'ft-red-by', 'value', 'ft-red-vout', 'x'))});`); const textMoved = await redSpan(); check( 'a Vertex output fed 6 draws the text six units to the right of where it stands', Math.abs(textMoved.first - textTight.first - reach) <= 2 && Math.abs(textMoved.last - textTight.last - reach) <= 2, JSON.stringify({ textTight, textMoved, reach }), ); await mutate(` const graph = ${shaderGraph}; graph.nodes = graph.nodes.filter((n) => !['ft-red-full', 'ft-red-by', 'ft-red-vout'].includes(n.id)); graph.edges = graph.edges.filter((e) => !['ft-red-e2', 'ft-red-e3'].includes(e.id)); 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')), ); }