Fonts: new page, and the font asset in Concepts, API, Architecture and Development

root committed 2026-09-13 20:10:04 +02:00
1 parent 11209c047e
commit eea795fe36
6 files changed
+71 -11

No files matched your search

+7 -2
@@ -10,6 +10,9 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| GET | `textures` | `[{file,width,height,bytes,updatedAt}]` — what the image picker offers |
| POST | `textures` | **raw image bytes** → `{file,width,height,bytes}` (201) |
| DELETE | `textures/{file}` | 204 |
| GET | `fonts` | `[{file,bytes,updatedAt}]` — what the font picker offers |
| POST | `fonts` | **raw font bytes** → `{file,bytes}` (201) |
| DELETE | `fonts/{file}` | 204 |
Every project document carries `formatVersion` (`PROJECT_FORMAT_VERSION` in `core/model/Project.ts`,
currently 2). There are no load-time migrations: `serialize.ts` reads what it recognises and drops
@@ -17,7 +20,9 @@ the rest.
Uploads send the bytes as the body rather than `multipart/form-data`: no boundary parsing in
PHP, and PHP's `upload_max_filesize` (2 MB) never applies — only `post_max_size`. The API has its
own caps behind nginx's: 8 MB per request, 4 MB per image (413). The stored
files are served statically from `data/textures/`, so reading an image never runs PHP.
own caps behind nginx's: 8 MB per request, 4 MB per image or font (413). The stored
files are served statically from `data/textures/` and `data/fonts/`, so reading one never runs
PHP. An image is downscaled and re-encoded in the browser when it would not fit; a font cannot
be, so an oversized typeface is refused rather than shrunk.
There is no authentication yet; `data/` is world-readable. Both are phase-2 topics.
+5 -3
@@ -47,13 +47,15 @@ src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base,
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter, dropTarget, nested (behaviors as nodes),
stateMachine (the State node, a machine's states, the ports they become), variablePorts, propertyPorts,
lookup (what a node may look up: behaviors, state machines), keyRepeat; nodes/{events,flow,data,action,variable}/<Name>.ts
src/core/store/ serialize (untrusted JSON → Project), http (one API call), ProjectStore/HttpProjectStore, TextureStore/HttpTextureStore
src/core/store/ serialize (untrusted JSON → Project), http (one API call), ProjectStore/HttpProjectStore,
TextureStore/HttpTextureStore, FontStore/HttpFontStore
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, AnimationSystem, BehaviorSystem
src/ui/ widget toolkit (generic, knows nothing about assets)
src/viewport/ view (transforms), items (what to draw), symbols (how to draw it),
TextureLibrary, activeCamera — no editor concepts, so tests reach it freely
TextureLibrary, FontLibrary, activeCamera — no editor concepts, so tests reach it freely
src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create),
EditorRun (run in editor), state, actions, shortcuts, debugHook, autosave, textureImport,
EditorRun (run in editor), state, actions, shortcuts, debugHook, autosave,
fileImport (drop/paste) → textureImport / fontImport,
framing (fit/marquee/snap maths), AssetTabEditor (the shared shell of the asset tabs)
src/editor/viewport/ WorldViewport base + SceneViewport/GameViewport — editing tools, hence not in src/viewport/
src/editor/inspector/ Inspector panel, host (what a section builds into), field factory, previewHandlers (scrub → preview → command), valueSpecs
+4 -3
@@ -1,10 +1,11 @@
| Term | Meaning |
|---|---|
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `text`, `behavior`, `collider`, `container`, `texture`, `animation`, `statemachine`, `shader`) and typed properties. Assets have **no position**. |
| **Texture** | An uploaded image. Scenes, textures, animations, state machines and shaders are the five types that cannot be placed: a texture has no position, it is *worn* by a sprite through the ordinary property `texture`. See [Textures](Textures). |
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `text`, `behavior`, `collider`, `container`, `texture`, `animation`, `statemachine`, `shader`, `font`) and typed properties. Assets have **no position**. |
| **Texture** | An uploaded image. Scenes, textures, animations, state machines, shaders and fonts are the six types that cannot be placed: a texture has no position, it is *worn* by a sprite through the ordinary property `texture`. See [Textures](Textures). |
| **Font** | An uploaded typeface, worn by a text through the ordinary property `font`; no font means the editor's own. Uploaded and picked exactly like a texture, and stored the same way. See [Fonts](Fonts). |
| **State machine** | A named set of **states**, edited as nodes in its own tab. A behavior puts it into a state with `Set state` and branches on it with `State branch`; each behavior instance keeps its own current state. See [State machines](State-machines). |
| **Shader** | A node graph that becomes the GLSL a sprite is drawn with, edited in its own tab. A sprite wears it through `shader`; the numbers the graph declares are rows below that dropdown. See [Shaders](Shaders). |
| **Text** | An asset that draws a line of text (`text`, `fontSize`, `color`, `align`) — titles, HUD readouts, button labels. Its content is an ordinary property, so a `Properties` node of type Text with `text` switched to Set changes it while playing. |
| **Text** | An asset that draws a line of text (`text`, `font`, `fontSize`, `color`, `align`) — titles, HUD readouts, button labels. Its content is an ordinary property, so a `Properties` node of type Text with `text` switched to Set changes it while playing. |
| **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run — **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. From a graph, `Properties` with `enabled` switched to Set does it, and `Is enabled` asks (including the parents); a behavior hears the switch as `On enable` / `On disable`. |
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Drawn objects also carry **transform** rows (scale, rotation, mirror) — properties like any other, and they cascade to children the way a position does (see [Scene editing](Scene-editing)). Scenes cannot be placed. |
| **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container stores the container object **and nothing else** — its content is resolved from the template on every read, so a template edit reaches every instance at once. **Create container** turns the current selection into such an asset: the selected objects move into the template and the scene keeps one placed container at their centre (world positions unchanged). The template is edited in the **Container** tab (see [Containers](Containers)) or from an instance through **Apply to container**. |
+3 -3
@@ -12,7 +12,7 @@ npm run check # typecheck + lint + format:check + tests, the gate CI runs
# code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html
./build.sh # the same under `set -o pipefail`, for callers that pipe the output
node tools/seed-e2e.mjs && node tools/e2e.mjs # 763 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 775 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
@@ -63,7 +63,7 @@ to touch it.
The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing,
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable,
variable nodes, property nodes, animations, state machines, console, shaders, transforms) that build on each other's
variable nodes, property nodes, animations, state machines, console, shaders, transforms, fonts) that build on each other's
scene state and share the CDP driver in `tools/lib/harness.mjs` — `press`, `undo`, `play`,
`object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it
exercises.
@@ -151,4 +151,4 @@ widths to find click points. The **render device is not in this repo** — `/dev
and a 1 GB `/dev/shm` come from `container.options` in the runner's own `config.yaml`, which
applies to every job on that runner. Without them chromium falls back to llvmpipe, which gets
WebGL 1.0 only. Measured in the job container: `ANGLE (Intel, Vulkan 1.3.230, HD Graphics 630)`,
WebGL 2.0, and the same 763 checks as here.
WebGL 2.0, and the same 775 checks as here.
+51
@@ -0,0 +1,51 @@
A text object is drawn in the editor's own typeface until it is given one of its own. That
typeface is a **font asset**, and the text points at it through the ordinary property `font` —
the same design as a sprite and its `texture`: because it is an ordinary property, instance
overrides, **Apply to container**, **Reset to asset**, undo and `Properties` work on it without
a line written for them. One placed title can wear a different face than its siblings.
**Getting a typeface in.** **Dropping a `.ttf`, `.otf`, `.woff` or `.woff2` file onto the
canvas** uploads it and makes the asset in one go; several at once are imported one after
another, and a file that fails does not stop the rest. The asset is named after the file
("DejaVuSerif.ttf" becomes "DejaVuSerif"), which is worth more than the stored name — see below.
A dropped file is recognised by its **extension**, not by its MIME type: browsers report a
dropped font as `font/ttf`, `application/x-font-ttf` or as nothing at all.
**+ → Font** asks the other question and opens the picker: **every typeface the server already
holds**, one row each. There is no thumbnail to show for a font, so each row *is* the preview —
the sample line is drawn in that very face. Picking one and pressing **Use font** makes the
asset; **Upload…** is the way in for a face the server does not have yet. A row captioned with
an asset name is one this project already wears, and picking it uses that asset rather than
making a second one.
**Where it goes.** The bytes are uploaded to `POST ?r=fonts` and stored as
`data/fonts/{sha256}.{ext}` — named after their own content, exactly like an image, and with
the same four consequences (one file per typeface however often it is uploaded, cacheable
forever, a short string in the project document, an undoable delete). Unlike an image, a font
is **never re-encoded on the way**: what the server stores is the file that was picked. The
format is read from the file's own signature, so nothing a request claims can decide what is
written. Bitmap collections (`.ttc`) and SVG fonts are refused — a collection holds several
faces and `@font-face` cannot say which, and an SVG font can carry script.
**Wearing it.** A text object's inspector has a **Font** row between Text and Font size: a
dropdown of the project's font assets, with **None** for the editor's own typeface. That is
also what a reference falls back to when the asset behind it is gone, so a deleted font leaves
readable text rather than nothing. The row works in the scene view, in the game preview and
while playing — all three go through one place (`WorldViewport.syncText`).
**It arrives late.** A face is fetched over the network, so a text is drawn in the fallback for
the first frames and then reflows into the real one. That is the same "not yet, redraw when it
lands" contract the image library has, and the reason the width of a text can change shortly
after a project opens.
**The family name is derived, not read.** A face is registered with the browser as
`gm-font-<first 16 characters of the hash>`, never under the name inside the font: two uploads
of "Regular" cannot then fight over one family, and the same bytes always resolve to the same
face. The name *inside* the file is not read at all — the asset's name comes from the uploaded
file name and is yours to change.
**Known limits.** The selection box and the click area of a text are still estimated from the
character count (`bounds.ts`, `CHAR_WIDTH`), not measured, so a very wide or very narrow face
sits inside a box that does not quite fit it. Only text objects wear fonts; a sprite has no use
for one. There is no bold/italic pair: a font asset is one file, so a bold face is a second
asset.
+1
@@ -5,6 +5,7 @@
- [Behaviors](Behaviors)
- [Colliders](Colliders)
- [Textures](Textures)
- [Fonts](Fonts)
- [Animations](Animations)
- [State machines](State-machines)
- [Shaders](Shaders)