diff --git a/README.md b/README.md index 1007a9df..4c190214 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2065 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2066 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 33a8cb4c..5e93b278 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -1068,6 +1068,8 @@ const ASSET_SCHEMAS: Record = { // Empty is the editor's own typeface, the one every text was drawn in before fonts existed. { key: FONT_KEY, label: 'Font', kind: 'asset', default: '', assetTypes: ['font'] }, { key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1, decimals: 0 }, + // In units like the font size, added between two characters; negative draws them closer. + { key: 'letterSpacing', label: 'Letter spacing', kind: 'number', default: 0, step: 0.1, decimals: 1 }, { key: 'color', label: 'Color', kind: 'color', default: 0xffffff }, { key: 'align', diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index 8edf8195..12e699f1 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -39,8 +39,9 @@ export function textSize(props: PropertyBag): { w: number; h: number } { const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize)); const lines = textOf(props.text ?? TEXT_DEFAULTS.text).split('\n'); const longest = lines.reduce((max, line) => Math.max(max, line.length), 0); + const spacing = Number(props.letterSpacing ?? TEXT_DEFAULTS.letterSpacing) || 0; return { - w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH), + w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH + Math.max(0, longest - 1) * spacing), h: lines.length * size * LINE_HEIGHT, }; } diff --git a/src/viewport/textPicture.ts b/src/viewport/textPicture.ts index 3ba12c2f..38ca608c 100644 --- a/src/viewport/textPicture.ts +++ b/src/viewport/textPicture.ts @@ -98,16 +98,21 @@ export class TextPicture { const family = fontLibrary.family(item.font) || Theme.font; const text = textOf(item.props.text ?? ''); const size = Math.max(1, Number(item.props.fontSize ?? 32)); + const spacing = Number(item.props.letterSpacing ?? 0) || 0; const color = Number(item.props.color ?? 0xffffff); const resolution = textResolution(scale, item.fontSmooth); - const key = JSON.stringify([text, family, size, color, resolution]); + const key = JSON.stringify([text, family, size, spacing, color, resolution]); shownTexts.set(this.mesh, text); const stale = key !== this.rasterKey; const previous = stale ? this.texture : null; if (stale) { this.rasterKey = key; - if (item.fontSmooth) this.raster(text, family, size, color, resolution); - else this.rasterSharp(text, family, size, color); + this.style.fontFamily = family; + this.style.fontSize = size; + this.style.letterSpacing = spacing; + this.style.fill = color; + if (item.fontSmooth) this.raster(text, resolution); + else this.rasterSharp(text, color); } const texture = this.texture!; const worn = wornShader(item.props, shaders); @@ -162,10 +167,7 @@ export class TextPicture { this.texture = null; } - private raster(text: string, family: string, size: number, color: number, resolution: number): void { - this.style.fontFamily = family; - this.style.fontSize = size; - this.style.fill = color; + private raster(text: string, resolution: number): void { const { canvasAndContext, frame } = CanvasTextGenerator.getCanvasAndContext({ text, style: this.style, @@ -187,10 +189,7 @@ export class TextPicture { } /** A sharp face: rasterised `SHARP_SUPERSAMPLE` times finer and cut down to hard texels. */ - private rasterSharp(text: string, family: string, size: number, color: number): void { - this.style.fontFamily = family; - this.style.fontSize = size; - this.style.fill = color; + private rasterSharp(text: string, color: number): void { const { canvasAndContext, frame } = CanvasTextGenerator.getCanvasAndContext({ text, style: this.style, diff --git a/tests/core/text-and-pointer.test.ts b/tests/core/text-and-pointer.test.ts index 9c7e74c6..6999ac8c 100644 --- a/tests/core/text-and-pointer.test.ts +++ b/tests/core/text-and-pointer.test.ts @@ -11,6 +11,12 @@ describe('text objects', () => { expect(textSize({ text: 'ab', fontSize: 100 }).h).toBeCloseTo(125, 5); expect(footprintOf('text', { text: 'abcd', fontSize: 100 }).w).toBeCloseTo(220, 5); }); + + it('widens or narrows the footprint by the letter spacing between its characters', () => { + expect(textSize({ text: 'abcd\nab', fontSize: 100, letterSpacing: 10 }).w).toBeCloseTo(250, 5); + expect(textSize({ text: 'abcd', fontSize: 100, letterSpacing: -6 }).w).toBeCloseTo(202, 5); + expect(textSize({ text: 'a', fontSize: 100, letterSpacing: 10 }).w).toBeCloseTo(55, 5); + }); }); describe('the mouse', () => { diff --git a/tools/e2e/30-font.mjs b/tools/e2e/30-font.mjs index d83f802c..8d37cd14 100644 --- a/tools/e2e/30-font.mjs +++ b/tools/e2e/30-font.mjs @@ -205,6 +205,19 @@ export async function font(t) { JSON.stringify(dangling), ); + // 30f-ii. Letter spacing goes between the ten characters of 'Handgloves', so nine times. + const textOverrides = `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides`; + await mutate(`${textOverrides}.letterSpacing = 5`); + const wide = await waitFor(info, (value) => value?.width !== plain.width); + await mutate(`${textOverrides}.letterSpacing = -3`); + const tight = await waitFor(info, (value) => value?.width !== wide?.width); + await mutate(`delete ${textOverrides}.letterSpacing`); + check( + 'letter spacing widens and narrows the text by the spacing between its characters', + Math.abs(wide?.width - plain.width - 45) <= 1 && Math.abs(tight?.width - plain.width + 27) <= 1, + JSON.stringify({ plain: plain.width, wide: wide?.width, tight: tight?.width }), + ); + // 30g. Dropping a font file onto the editor uploads it and makes an asset named after the file. const dropped = await evaluate(` (async () => {