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:
1 parent
2d3b957551
commit
9445f39c8d
6 files changed
+34
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user