Space the letters of a text object closer or wider apart

The Feuerwerk wordmark is set with -0.03em tracking. Text in the
engine had no letter spacing, so the wordmark came out about 3 % wider
than the logo.

- Text has a new property Letter spacing, default 0, in the same unit
  as the font size. It goes between each two characters, so ten
  characters get it nine times. Negative values pull them together.
- TextPicture hands the value to Pixi's text style. Both rasters, the
  smooth and the sharp one, use it, and it is part of the raster key.
- The estimated text width in bounds.ts adds the spacing too, so
  selection, picking and "Add collider" fit the drawn text.
- Like every number property it can be animated and set from a graph.
  That is not tried in the browser.

Tests:
- New unit test for the footprint with spacing. 102 tests in the
  three text files green.
- e2e 30f-ii measures the width of "Handgloves" on screen: 237 without
  spacing, 282 at +5 (expected +45) and 210 at -3 (expected -27).
  e2e 30 on melektaus-atx: ALL PASSED, 25 checks.
- Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4757 tests.
- No counter-check run for 30f-ii.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 14:46:38 +02:00
1 parent 2d3b957551
commit 9445f39c8d
6 files changed
+34 -13

No files matched your search

+1 -1
View File
@@ -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
+2
View File
@@ -1068,6 +1068,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
// 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',
+2 -1
View File
@@ -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,
};
}
+10 -11
View File
@@ -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,
+6
View File
@@ -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', () => {
+13
View File
@@ -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 () => {