game-maker / suite (push) Successful in 9m10s
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.
The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
9.9 KiB
JavaScript
220 lines
9.9 KiB
JavaScript
import { LANES } 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;
|
||
|
||
export async function font(t) {
|
||
const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot } = 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);
|
||
await sleep(200);
|
||
};
|
||
/** 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 evaluate("__gm.showTab('scene')");
|
||
await sleep(200);
|
||
|
||
// 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;
|
||
|
||
// 30b. "+ → Font" offers them; a font is picked rather than created empty, like a texture.
|
||
await evaluate('__gm.console.clear()');
|
||
await clickText('+');
|
||
await sleep(200);
|
||
await clickLast('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),
|
||
);
|
||
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 sleep(200);
|
||
await clickText('Use font');
|
||
await sleep(400);
|
||
const fonts = await assetsOfType('font');
|
||
check(
|
||
'picking a row makes a font asset of that file, and nothing else',
|
||
fonts.length === 1 && fonts[0].file === serif.file && fonts[0].name === 'Font',
|
||
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});`);
|
||
await sleep(400);
|
||
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 });
|
||
await sleep(300);
|
||
// The label is no anchor on its own: the assets panel has a row per font. The dropdown of the
|
||
// Font row is the last "None" in the window – Parent says it above, nothing says it below.
|
||
const fontRow = await json("__gm.inspectorField('Font', true)");
|
||
const none = await locate('None', true);
|
||
check(
|
||
'a text has a Font row, and it reads None',
|
||
Boolean(fontRow) && Math.abs(none.y - fontRow.y) < 12,
|
||
JSON.stringify({ fontRow, none }),
|
||
);
|
||
await click(fontRow.x, fontRow.y);
|
||
await sleep(250);
|
||
await clickLast('Bookish (font)');
|
||
await sleep(400);
|
||
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 sleep(300);
|
||
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} }`);
|
||
await sleep(300);
|
||
|
||
// 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. 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(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);`);
|
||
await sleep(300);
|
||
check(
|
||
'the step leaves nothing of its own behind',
|
||
(await assetsOfType('font')).length === 0 && (await info()) === null,
|
||
JSON.stringify(await assetsOfType('font')),
|
||
);
|
||
}
|