Fonts: a new asset type, picked from the server or dropped in, worn by text objects (#74)
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>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-13 20:09:59 +02:00
1 parent 28c0789b19
commit 86b5363a01
35 files changed
+1451 -85

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
/data/projects/*.json
/data/projects/*.tmp
/data/textures/
/data/fonts/
/dist/
/node_modules/
/logs/
+1 -1
View File
@@ -37,7 +37,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, 763 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 775 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+44 -2
View File
@@ -17,10 +17,15 @@ declare(strict_types=1);
* POST ?r=textures raw image bytes → {file,width,height,bytes}
* DELETE ?r=textures/{file} → 204
*
* Stored images are served straight from `data/textures/` as static files; the API only
* writes them.
* GET ?r=fonts → [{file,bytes,updatedAt}]
* POST ?r=fonts raw font bytes → {file,bytes}
* DELETE ?r=fonts/{file} → 204
*
* Stored images and fonts are served straight from `data/textures/` and `data/fonts/` as
* static files; the API only writes them.
*/
require __DIR__ . '/lib/FontStore.php';
require __DIR__ . '/lib/Response.php';
require __DIR__ . '/lib/Store.php';
require __DIR__ . '/lib/TextureStore.php';
@@ -30,6 +35,9 @@ const MAX_BODY_BYTES = 8 * 1024 * 1024;
/** Upload ceiling for a single image. The editor downscales larger pictures before sending. */
const MAX_TEXTURE_BYTES = 4 * 1024 * 1024;
/** Upload ceiling for a single font. A font cannot be shrunk on the way, so this is a plain limit. */
const MAX_FONT_BYTES = 4 * 1024 * 1024;
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
throw new ErrorException($message, 0, $severity, $file, $line);
});
@@ -126,6 +134,39 @@ function routeTextures(array $parts, string $method): never
Response::error('Method not allowed', 405);
}
/** @param list<string> $parts */
function routeFonts(array $parts, string $method): never
{
$store = new FontStore(dirname(__DIR__) . '/data/fonts');
if (count($parts) === 1) {
if ($method === 'GET') {
Response::json($store->list());
}
if ($method === 'POST') {
$bytes = Response::rawBody();
if (strlen($bytes) > MAX_FONT_BYTES) {
Response::error('Font must be at most ' . (MAX_FONT_BYTES / 1024 / 1024) . ' MB', 413);
}
try {
Response::json($store->store($bytes), 201);
} catch (InvalidFontException $e) {
Response::error($e->getMessage(), 422);
}
}
Response::error('Method not allowed', 405);
}
if ($method === 'DELETE') {
if (!$store->delete($parts[1])) {
Response::error('Font not found', 404);
}
Response::noContent();
}
Response::error('Method not allowed', 405);
}
try {
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) {
Response::error('Request body too large', 413);
@@ -142,6 +183,7 @@ try {
match ($parts[0]) {
'projects' => routeProjects($parts, $method),
'textures' => routeTextures($parts, $method),
'fonts' => routeFonts($parts, $method),
default => Response::error('Not found', 404),
};
} catch (Throwable $e) {
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
/**
* Content-addressed font store: `data/fonts/{sha256}.{ext}` – the image store's twin, for the
* same reasons (see TextureStore): one file per typeface however often it is uploaded, a URL
* that can be cached forever, and deleting a font asset stays undoable because nothing here
* is removed when the last project stops referencing it.
*
* A font is stored byte for byte. There is no re-encoding step as there is for images, so what
* the browser loads is exactly what was picked.
*/
final class FontStore
{
/**
* Accepted formats, keyed by the four bytes every one of them starts with. Bitmap and
* collection formats are absent: a `.ttc` holds several faces and `@font-face` cannot say
* which, and SVG fonts can carry script.
*/
private const SIGNATURES = [
"\x00\x01\x00\x00" => 'ttf',
'true' => 'ttf',
'OTTO' => 'otf',
'wOFF' => 'woff',
'wOF2' => 'woff2',
];
public function __construct(private readonly string $dir)
{
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
throw new RuntimeException("Cannot create font directory {$this->dir}");
}
}
/** A stored file name, as it appears in a project document. */
public static function isValidName(string $name): bool
{
return (bool) preg_match('/^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/', $name);
}
/**
* Stores the raw bytes of a typeface and returns its record.
*
* The format is read from the file's own signature, never from anything the client says.
* `finfo` is not consulted the way it is for images: it reports fonts inconsistently across
* libmagic versions (`font/sfnt`, `application/vnd.ms-opentype`, plain `application/octet-stream`),
* so a second opinion from it would reject valid files rather than catch bad ones.
*
* @return array{file:string,bytes:int}
* @throws InvalidFontException when the bytes are not a supported font
*/
public function store(string $bytes): array
{
$extension = self::SIGNATURES[substr($bytes, 0, 4)] ?? '';
if ($extension === '') {
throw new InvalidFontException('Not a font file – use TTF, OTF, WOFF or WOFF2');
}
// The shortest thing that can still be a font: a table directory and one entry.
if (strlen($bytes) < 28) {
throw new InvalidFontException('Font file is truncated');
}
$file = hash('sha256', $bytes) . '.' . $extension;
$path = $this->dir . '/' . $file;
if (!is_file($path)) {
$this->writeFile($path, $bytes);
}
return ['file' => $file, 'bytes' => strlen($bytes)];
}
/**
* Every stored file, newest first – what the font picker offers. Unlike an image there is
* nothing to read out of the header here: the picker shows each face by rendering with it.
*
* @return list<array{file:string,bytes:int,updatedAt:string}>
*/
public function list(): array
{
$items = [];
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
$file = basename($path);
if (!self::isValidName($file)) {
continue;
}
$items[] = [
'file' => $file,
'bytes' => (int) filesize($path),
'updatedAt' => gmdate('c', (int) filemtime($path)),
];
}
usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt']));
return $items;
}
public function delete(string $file): bool
{
if (!self::isValidName($file)) {
return false;
}
$path = $this->dir . '/' . $file;
return is_file($path) && unlink($path);
}
/** Temp file plus rename, so a reader never sees a half-written font (see TextureStore). */
private function writeFile(string $path, string $bytes): void
{
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException('Cannot write font file');
}
@chmod($path, 0664);
}
}
/** Thrown by FontStore::store when the uploaded bytes are not a usable font. */
final class InvalidFontException extends RuntimeException
{
}
+29 -1
View File
@@ -1,7 +1,8 @@
import { createEmptyGraph } from '../graph/Graph';
import { newId } from '../ids';
import type { Asset, TextureAsset } from '../model/Asset';
import type { Asset, FontAsset, TextureAsset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
import { removeSubtrees } from '../model/hierarchy';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { objectHolders, type Project } from '../model/Project';
@@ -38,6 +39,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'statemachine', graph: createEmptyGraph() };
case 'shader':
return { ...base, type: 'shader', graph: createEmptyGraph() };
case 'font':
return { ...base, type: 'font', file: '' };
}
}
@@ -108,6 +111,31 @@ export class CreateTextureCommand implements Command {
}
}
export function buildFontAsset(name: string, file: string, id = newId()): FontAsset {
return { id, name, props: defaultsFor('font'), type: 'font', file };
}
/** The typeface is uploaded before the command runs, as an image is for `CreateTextureCommand`. */
export class CreateFontCommand implements Command {
readonly label: string;
readonly asset: FontAsset;
constructor(name: string, file: string) {
this.asset = buildFontAsset(name, file);
this.label = `Import font "${name}"`;
}
execute(project: Project): void {
if (!isFontFile(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored font file`);
project.assets.push(this.asset);
}
undo(project: Project): void {
project.assets = removeById(project.assets, this.asset.id);
}
}
/**
* Deletes an asset and, as a cascade, every scene object referencing it, together with the objects
* parented below those – in container templates as well as in scenes.
+12 -1
View File
@@ -100,6 +100,16 @@ export interface ShaderAsset extends AssetBase {
graph: Graph;
}
/**
* `file` names the typeface in `data/fonts/` the way a texture names its image: content-addressed
* `{sha256}.{ext}`, empty when the upload failed. A text object wears it through its `font`
* property; the family the browser knows it under is derived from the file name, see `model/font`.
*/
export interface FontAsset extends AssetBase {
type: 'font';
file: string;
}
export type Asset =
| SpriteAsset
| CameraAsset
@@ -111,7 +121,8 @@ export type Asset =
| TextureAsset
| AnimationAsset
| StateMachineAsset
| ShaderAsset;
| ShaderAsset
| FontAsset;
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset;
+6 -2
View File
@@ -10,6 +10,7 @@ export const ASSET_TYPES = [
'animation',
'statemachine',
'shader',
'font',
] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
@@ -26,15 +27,17 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
animation: 'Animation',
statemachine: 'State machine',
shader: 'Shader',
font: 'Font',
};
/**
* Textures and animations are not placeable: they are pictures a sprite wears through `texture`,
* as a shader is worn through `shader`. A state machine is not either: a behavior refers to it by id.
* as a shader is worn through `shader` and a font through `font`. A state machine is not either:
* a behavior refers to it by id.
*/
export type PlaceableAssetType = Exclude<
AssetType,
'scene' | 'texture' | 'animation' | 'statemachine' | 'shader'
'scene' | 'texture' | 'animation' | 'statemachine' | 'shader' | 'font'
>;
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
@@ -43,6 +46,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'animation',
'statemachine',
'shader',
'font',
]);
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
+29
View File
@@ -0,0 +1,29 @@
import type { FontAsset } from './Asset';
import { findAsset, type Project } from './Project';
/** Mirrors `FontStore::isValidName` on the server, where the name is derived from the bytes. */
const FILE_PATTERN = /^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/;
export function isFontFile(name: string): boolean {
return FILE_PATTERN.test(name);
}
/**
* The CSS family a file is registered under. Derived from the content-addressed name, so the same
* bytes are always one family and two uploads of one typeface cannot fight over a name – the name
* inside the font is never used for this, only for the asset's label.
*/
export function fontFamilyOf(file: string): string {
return `gm-font-${file.slice(0, 16)}`;
}
/** The file a `font` property points at, empty when the id is unset or no longer resolves. */
export function fontFileOf(project: Project, id: string): string {
if (id === '') return '';
const asset = findAsset(project, id);
return asset?.type === 'font' ? asset.file : '';
}
export function isFontAsset(asset: { type: string }): asset is FontAsset {
return asset.type === 'font';
}
+5
View File
@@ -22,6 +22,7 @@ import {
import { findAssetOf, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { flatListOf, listItemOf } from './listItems';
import { FONT_KEY } from './fonts';
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
/**
@@ -189,6 +190,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
statemachine: [],
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
shader: [],
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property.
font: [],
collider: [
{
key: 'shape',
@@ -209,6 +212,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
container: [],
text: [
{ key: 'text', label: 'Text', kind: 'text', default: 'Text' },
// 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 },
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{
+8
View File
@@ -0,0 +1,8 @@
import { type PropertyBag, textOf } from '../model/Properties';
/** The text property naming the font asset it wears; `''` is the editor's own typeface. */
export const FONT_KEY = 'font';
export function fontOf(bag: PropertyBag): string {
return textOf(bag[FONT_KEY]);
}
+22
View File
@@ -0,0 +1,22 @@
export interface UploadedFont {
/** Content-addressed name in `data/fonts/`: `{sha256}.{ttf|otf|woff|woff2}` (see `isFontFile`). */
file: string;
bytes: number;
}
/** A typeface the server holds, whether any project references it or not. */
export interface StoredFont {
file: string;
bytes: number;
updatedAt: string;
}
/**
* Raw bytes, like an image upload: the server derives name and format from the file itself. Unlike
* an image a font is never re-encoded on the way, so what the server stores is what was picked.
*/
export interface FontStore {
upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedFont>;
list(): Promise<StoredFont[]>;
delete(file: string): Promise<void>;
}
+29
View File
@@ -0,0 +1,29 @@
import type { FontStore, StoredFont, UploadedFont } from './FontStore';
import { apiRequest } from './http';
export class HttpFontStore implements FontStore {
constructor(private readonly endpoint: string) {}
async upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedFont> {
return this.request<UploadedFont>('POST', 'fonts', { bytes, type });
}
async list(): Promise<StoredFont[]> {
return this.request<StoredFont[]>('GET', 'fonts');
}
async delete(file: string): Promise<void> {
await this.request<void>('DELETE', `fonts/${encodeURIComponent(file)}`);
}
private async request<T>(
method: string,
route: string,
payload?: { bytes: Blob | ArrayBuffer; type: string },
): Promise<T> {
return apiRequest<T>(this.endpoint, method, route, {
headers: payload ? { 'Content-Type': payload.type } : {},
body: payload?.bytes,
});
}
}
+4
View File
@@ -15,6 +15,7 @@ import {
import { isShaderNodeDef } from '../shader/nodes';
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import {
type FieldValue,
@@ -116,6 +117,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
} else if (type === 'texture') {
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
} else if (type === 'font') {
const file = typeof a.file === 'string' && isFontFile(a.file) ? a.file : '';
assets.push({ id, type, name, props, file });
} else if (type === 'animation') {
// Kept even when they dangle, like a sprite's `texture`: a frame reads as no picture.
const frames = Array.isArray(a.frames) ? a.frames : [];
+6 -1
View File
@@ -1,7 +1,9 @@
import type { Application } from 'pixi.js';
import { HttpFontStore } from '../core/store/HttpFontStore';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
import { HttpTextureStore } from '../core/store/HttpTextureStore';
import type { VStack } from '../ui';
import { fontLibrary } from '../viewport/FontLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
import { installDebugHook } from './debugHook';
import { Editor } from './Editor';
@@ -11,20 +13,23 @@ const API_ENDPOINT = '/game-maker/api/index.php';
/**
* Where uploaded images live: the API writes them, the browser reads them straight off disk as
* static files named by their content hash.
* static files named by their content hash. Fonts work the same way, one directory over.
*/
const TEXTURE_BASE = '/game-maker/data/textures';
const FONT_BASE = '/game-maker/data/fonts';
/** Wires state, stores and the editor composition together and opens the first project. */
export async function startEditor(app: Application, root: VStack): Promise<void> {
const state = new EditorState();
textureLibrary.configure(TEXTURE_BASE);
fontLibrary.configure(FONT_BASE);
const editor = new Editor(
app,
root,
state,
new HttpProjectStore(API_ENDPOINT),
new HttpTextureStore(API_ENDPOINT),
new HttpFontStore(API_ENDPOINT),
);
installDebugHook(app, state, editor);
await editor.boot();
+5 -3
View File
@@ -2,6 +2,7 @@ import type { Application } from 'pixi.js';
import type { AssetType } from '../core/model/AssetTypes';
import { findAsset, findObjectHolder } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
import type { FontStore } from '../core/store/FontStore';
import type { TextureStore } from '../core/store/TextureStore';
import { Button, clamp, HStack, Panel, Splitter, Theme, ui, VStack, type Widget } from '../ui';
import { ConsolePanel } from './console/ConsolePanel';
@@ -24,7 +25,7 @@ import { StateMachineEditor } from './statemachine/StateMachineEditor';
import { installShortcuts } from './shortcuts';
import type { EditorState } from './state';
import { TextureEditor } from './texture/TextureEditor';
import { installTextureImport } from './textureImport';
import { installFileImport } from './fileImport';
export type CentreTab =
'scene' | 'graph' | 'statemachine' | 'shader' | 'game' | 'texture' | 'animation' | 'container' | 'list';
@@ -120,6 +121,7 @@ export class Editor implements PlayHost {
private readonly state: EditorState,
store: ProjectStore,
textures: TextureStore,
fonts: FontStore,
) {
this.viewport = new SceneViewport(state);
this.gameView = new GameViewport(state);
@@ -156,9 +158,9 @@ export class Editor implements PlayHost {
this.workspace = new HStack();
this.leftColumn = new VStack({ gap: 1 });
this.assets = new AssetPanel(state, textures);
this.assets = new AssetPanel(state, textures, fonts);
this.leftColumn.add(this.assets, 1);
installTextureImport(app.canvas, { state, textures });
installFileImport(app.canvas, { state, textures, fonts });
this.leftColumn.add(this.hierarchy, 1);
this.centre = new VStack();
+4 -2
View File
@@ -8,6 +8,7 @@ import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import type { AssetType } from '../core/model/AssetTypes';
import { isLiveInstance, isTemplateContent } from '../core/model/instance';
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import type { SceneObject } from '../core/model/SceneObject';
@@ -68,7 +69,7 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
: references > 0 || animations > 0
? wornDetail(references, animations)
? wornDetail(references, animations, asset.type)
: usages === 0
? 'This cannot be undone with Ctrl+Z after reload.'
: asset.type === 'container'
@@ -80,7 +81,8 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
]).openCentered();
}
function wornDetail(sprites: number, animations: number): string {
function wornDetail(sprites: number, animations: number, type: AssetType): string {
if (type === 'font') return `${sprites} text(s) reference it and fall back to the editor's own typeface.`;
const who = [
sprites > 0 ? `${sprites} sprite(s)` : '',
animations > 0 ? `${animations} animation(s)` : '',
+7
View File
@@ -6,6 +6,7 @@ import { activeDrag, Button, ui, Widget } from '../ui';
import { shaderLibrary } from '../viewport/ShaderLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
import type { CentreTab, Editor } from './Editor';
import { currentFontPicker } from './font/FontPicker';
import { currentImagePicker } from './texture/ImagePicker';
import type { EditorState } from './state';
@@ -303,6 +304,12 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** The open image picker: what it offers, what is picked, and where a tile sits. */
imagePicker: () => currentImagePicker()?.debugInfo() ?? null,
imagePickerTile: (file: string) => currentImagePicker()?.tileGlobal(file) ?? null,
/** The same for the font picker, whose rows are the preview. */
fontPicker: () => currentFontPicker()?.debugInfo() ?? null,
fontPickerRow: (file: string) => currentFontPicker()?.rowGlobal(file) ?? null,
/** Which typeface a text ended up drawn in, and how wide it made it. */
textInfo: (id: string) => viewport.textInfo(id),
gameTextInfo: (id: string) => gameView.textInfo(id),
texture: {
current: () => textureEditor.currentAssetId,
pickerWidth: () => textureEditor.picker.w,
+79
View File
@@ -0,0 +1,79 @@
import { type FontImportDeps, importFonts, isFontFileName } from './fontImport';
import { importImages, type TextureImportDeps } from './textureImport';
export type FileImportDeps = TextureImportDeps & FontImportDeps;
/**
* Wires drop-onto-`target` and paste, and returns the remover for both. Paste is bound to the
* window because the canvas cannot hold focus, and skipped while a DOM text field has it.
*
* Pictures and typefaces arrive the same way, so one pair of handlers sorts them apart.
*/
export function installFileImport(target: HTMLElement, deps: FileImportDeps): () => void {
const onDragOver = (event: DragEvent) => {
if (!hasImportableItems(event.dataTransfer)) return;
// Without this the browser navigates away to the dropped file.
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const onDrop = (event: DragEvent) => {
const images = imageFilesOf(event.dataTransfer);
const fonts = fontFilesOf(event.dataTransfer);
if (images.length === 0 && fonts.length === 0) {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
deps.state.setNotice('Only image and font files can be dropped into the editor.');
return;
}
event.preventDefault();
run(images, fonts, deps);
};
const onPaste = (event: ClipboardEvent) => {
if (isTextFieldFocused()) return;
const images = imageFilesOf(event.clipboardData);
const fonts = fontFilesOf(event.clipboardData);
if (images.length === 0 && fonts.length === 0) return;
event.preventDefault();
run(images, fonts, deps);
};
target.addEventListener('dragover', onDragOver);
target.addEventListener('drop', onDrop);
window.addEventListener('paste', onPaste);
return () => {
target.removeEventListener('dragover', onDragOver);
target.removeEventListener('drop', onDrop);
window.removeEventListener('paste', onPaste);
};
}
/** Images first, then fonts, so a mixed drop ends on one notice instead of two racing ones. */
function run(images: readonly File[], fonts: readonly File[], deps: FileImportDeps): void {
const work = async () => {
if (images.length) await importImages(images, deps);
if (fonts.length) await importFonts(fonts, deps);
};
void work().catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}
function imageFilesOf(data: DataTransfer | null): File[] {
return [...(data?.files ?? [])].filter((file) => file.type.startsWith('image/'));
}
function fontFilesOf(data: DataTransfer | null): File[] {
return [...(data?.files ?? [])].filter((file) => isFontFileName(file.name));
}
/**
* During a drag the files themselves are not readable yet – only kind and type are, and a font
* usually has no type at all. Anything that is not plainly text therefore counts as a candidate;
* the drop decides, and by then the names are readable.
*/
function hasImportableItems(data: DataTransfer | null): boolean {
return [...(data?.items ?? [])].some((item) => item.kind === 'file' && !item.type.startsWith('text/'));
}
function isTextFieldFocused(): boolean {
const active = document.activeElement;
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement;
}
+285
View File
@@ -0,0 +1,285 @@
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
import type { StoredFont } from '../../core/store/FontStore';
import {
Button,
drawControlBox,
formatBytes,
HStack,
Label,
Popup,
pressCount,
ScrollContainer,
type Size,
Spacer,
Theme,
textStyle,
truncate,
Widget,
} from '../../ui';
import { fontLibrary } from '../../viewport/FontLibrary';
import { type FontImportDeps, importStoredFont, pickAndImportFonts } from '../fontImport';
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
const LIST_WIDTH = 560;
const LIST_HEIGHT = 340;
const ROW_HEIGHT = 52;
const ROW_GAP = 4;
const ROW_PAD = 8;
/** Big enough to tell two typefaces apart at a glance, small enough for a long line to fit. */
const SAMPLE_SIZE = 22;
const SAMPLE_TEXT = 'Aa Bb Cc 0123 The quick brown fox';
/** One offered typeface, with the asset that already wears it (if any). */
interface PickerItem extends StoredFont {
assetName: string | null;
}
/**
* The rows. A typeface has no thumbnail to show, so each row *is* the preview: the sample line is
* drawn in the face itself, and reflows from the fallback once the library has loaded it.
*/
class FontList extends Widget {
private items: PickerItem[] = [];
private rows: { box: Container; background: Graphics; sample: Text; caption: Text }[] = [];
private selectedFile: string | null = null;
private readonly empty = new Text({ text: '', style: textStyle('muted') });
private readonly stopWatching: () => void;
onPick?: (file: string) => void;
onActivate?: (file: string) => void;
emptyText = '';
constructor() {
super();
this.addChild(this.empty);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
this.stopWatching = fontLibrary.onChange(() => this.redraw());
}
setItems(items: PickerItem[]): void {
this.items = items;
for (const row of this.rows) row.box.destroy({ children: true });
this.rows = items.map(() => {
// A Container holding the background, not a Graphics with children: Pixi v8 only lets
// containers have them (`addChild: Only Containers will be allowed to add children`).
const box = new Container();
const background = new Graphics();
// Its own style object, never `textStyle('body')`: those are shared across the whole
// editor, so writing a family into one would restyle every label in the window.
const sample = new Text({
style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }),
});
const caption = new Text({ style: textStyle('small') });
box.addChild(background, sample, caption);
this.addChild(box);
return { box, background, sample, caption };
});
if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null;
this.layout();
this.redraw();
}
get selected(): PickerItem | null {
return this.items.find((item) => item.file === this.selectedFile) ?? null;
}
get fileList(): string[] {
return this.items.map((item) => item.file);
}
get usedFiles(): string[] {
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
}
select(file: string | null): void {
if (file === this.selectedFile) return;
this.selectedFile = file;
this.redraw();
if (file) this.onPick?.(file);
}
/** Global centre of a row; null when the file is not offered (test hook). */
rowGlobal(file: string): { x: number; y: number } | null {
const index = this.items.findIndex((item) => item.file === file);
const row = this.rows[index];
if (!row) return null;
const p = row.box.getGlobalPosition();
return { x: p.x + this.w / 2, y: p.y + ROW_HEIGHT / 2 };
}
override measure(): Size {
return { w: this.minWidth, h: Math.max(this.minHeight, heightFor(this.items.length)) };
}
override layout(): void {
this.rows.forEach((row, index) => row.box.position.set(0, index * (ROW_HEIGHT + ROW_GAP)));
}
override redraw(): void {
this.empty.text = this.items.length === 0 ? this.emptyText : '';
this.empty.position.set(0, 0);
this.rows.forEach((node, index) => {
const item = this.items[index];
if (!item) return;
const selected = item.file === this.selectedFile;
node.background.clear();
drawControlBox(node.background, this.w, ROW_HEIGHT, {
fill: selected ? Theme.colors.selection : Theme.colors.control,
border: selected ? Theme.colors.accent : Theme.colors.border,
});
// Empty family = not loaded yet; the row then reads in the editor's own typeface and the
// library's change event brings it back here once the face has arrived.
node.sample.style.fontFamily = fontLibrary.family(item.file) || Theme.font;
node.sample.text = truncate(SAMPLE_TEXT, node.sample.style, this.w - ROW_PAD * 2);
node.sample.position.set(ROW_PAD, ROW_PAD - 2);
node.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
node.caption.text = truncate(
item.assetName ?? formatBytes(item.bytes),
node.caption.style,
this.w - ROW_PAD * 2,
);
node.caption.position.set(ROW_PAD, ROW_HEIGHT - 16);
});
}
override destroy(): void {
this.stopWatching();
super.destroy({ children: true });
}
private onPress(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const index = Math.floor(local.y / (ROW_HEIGHT + ROW_GAP));
const item = local.x >= 0 && local.x <= this.w ? this.items[index] : undefined;
if (!item) return;
this.select(item.file);
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
}
}
function heightFor(count: number): number {
return count === 0 ? 0 : count * ROW_HEIGHT + (count - 1) * ROW_GAP;
}
/**
* Offers every typeface the server holds, the way the image picker offers pictures: uploading the
* same font twice is wasteful, and nothing collects the files a project stopped referencing.
*/
export class FontPicker extends Popup {
private readonly list = new FontList();
private readonly scroll = new ScrollContainer();
private readonly status: Label;
private readonly detail: Label;
private readonly use: Button;
constructor(private readonly deps: FontImportDeps) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.content.add(new Label('Choose a font', { style: 'bodyBold' }));
this.status = new Label('Loading fonts…', { style: 'muted' });
this.content.add(this.status);
this.list.minWidth = LIST_WIDTH;
this.list.emptyText = 'No fonts on the server yet – upload one.';
this.list.onPick = () => this.refreshSelection();
this.list.onActivate = () => this.confirm();
this.scroll.minWidth = LIST_WIDTH;
this.scroll.minHeight = LIST_HEIGHT;
this.scroll.setContent(this.list);
this.content.add(this.scroll);
this.detail = new Label('', { style: 'muted' });
this.content.add(this.detail);
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
const upload = new Button('Upload…');
upload.onClick = this.closeThen(() => pickAndImportFonts(this.deps));
buttons.add(upload);
buttons.add(new Spacer(), 1);
const cancel = new Button('Cancel');
cancel.onClick = this.closeThen();
buttons.add(cancel);
this.use = new Button('Use font', { variant: 'primary' });
this.use.onClick = () => this.confirm();
this.use.setEnabled(false);
buttons.add(this.use);
this.content.add(buttons);
void this.loadList();
}
/** What the dialog offers, which of it the project already wears, and what is picked (test hook). */
debugInfo(): { files: string[]; used: string[]; selected: string | null } {
return {
files: this.list.fileList,
used: this.list.usedFiles,
selected: this.list.selected?.file ?? null,
};
}
rowGlobal(file: string): { x: number; y: number } | null {
return this.list.rowGlobal(file);
}
override close(): void {
super.close();
if (current === this) current = null;
// Escape, Cancel and a confirmed pick all land here, and `Popup.close` tolerates a second
// call; the list's listener must be dropped exactly once.
if (!this.list.destroyed) this.list.destroy();
}
private async loadList(): Promise<void> {
try {
const stored = await this.deps.fonts.list();
const named = new Map<string, string>();
for (const asset of this.deps.state.project.assets) {
if (asset.type === 'font') named.set(asset.file, asset.name);
}
this.list.setItems(stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null })));
this.scroll.minHeight = Math.min(LIST_HEIGHT, Math.max(ROW_HEIGHT, heightFor(stored.length)));
this.status.setText(
stored.length === 1 ? '1 font on the server' : `${stored.length} fonts on the server`,
);
} catch (error) {
this.status.setText(
`Could not list the fonts: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.refreshSelection();
}
private refreshSelection(): void {
const item = this.list.selected;
this.use.setEnabled(item !== null);
this.detail.setText(item ? describe(item) : '');
this.refreshLayout();
}
private confirm(): void {
const item = this.list.selected;
if (!item) return;
this.close();
importStoredFont(item, this.deps);
}
}
function describe(item: PickerItem): string {
const size = formatBytes(item.bytes);
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
}
let current: FontPicker | null = null;
export function openFontPicker(deps: FontImportDeps): FontPicker {
current?.close();
current = new FontPicker(deps);
current.openCentered();
return current;
}
/** The open dialog, for the test hook. */
export function currentFontPicker(): FontPicker | null {
return current;
}
+40
View File
@@ -0,0 +1,40 @@
import { Text, TextStyle } from 'pixi.js';
import { Theme, truncate, type Size, Widget } from '../../ui';
import { fontLibrary } from '../../viewport/FontLibrary';
const SAMPLE_SIZE = 26;
const SAMPLE_TEXT = 'Aa Bb Cc 0123';
/**
* What a font asset looks like: the only thing about it worth showing, since its file name is a
* hash. Reads in the editor's own typeface until the face has arrived, then redraws itself.
*/
export class FontPreview extends Widget {
private readonly sample = new Text({
style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }),
});
private readonly stopWatching: () => void;
constructor(private readonly file: string) {
super();
this.eventMode = 'none';
this.minHeight = SAMPLE_SIZE + Theme.padding * 2;
this.addChild(this.sample);
this.stopWatching = fontLibrary.onChange(() => this.invalidate());
}
override measure(): Size {
return { w: this.minWidth, h: this.minHeight };
}
override redraw(): void {
this.sample.style.fontFamily = fontLibrary.family(this.file) || Theme.font;
this.sample.text = truncate(SAMPLE_TEXT, this.sample.style, this.w);
this.sample.position.set(0, Theme.padding);
}
override destroy(): void {
this.stopWatching();
super.destroy({ children: true });
}
}
+110
View File
@@ -0,0 +1,110 @@
import { CreateFontCommand } from '../core/commands/assetCommands';
import { nextAssetName } from '../core/commands/naming';
import type { FontAsset } from '../core/model/Asset';
import type { FontStore, UploadedFont } from '../core/store/FontStore';
import type { EditorState } from './state';
import { type ImportOutcome, importNotice } from './textureImport';
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
export const FONT_ACCEPT = '.ttf,.otf,.woff,.woff2';
const FONT_EXTENSION = /\.(ttf|otf|woff|woff2)$/i;
/**
* Fonts are told apart by their name, not their MIME type: browsers report a dropped `.ttf` as
* `font/ttf`, `application/x-font-ttf` or nothing at all, depending on the platform.
*/
export function isFontFileName(name: string): boolean {
return FONT_EXTENSION.test(name);
}
export interface FontImportDeps {
state: EditorState;
fonts: FontStore;
}
/** Sequential like `importImages`, so each asset is named against the project as it then stands. */
export async function importFonts(files: readonly File[], deps: FontImportDeps): Promise<ImportOutcome[]> {
if (files.length === 0) return [];
const outcomes: ImportOutcome[] = [];
for (const file of files) outcomes.push(await importFont(file, deps));
const created = outcomes.filter((outcome) => outcome.assetId !== null);
const last = created[created.length - 1];
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
deps.state.setNotice(importNotice(outcomes, 'font'));
return outcomes;
}
async function importFont(file: File, deps: FontImportDeps): Promise<ImportOutcome> {
try {
if (!isFontFileName(file.name)) throw new Error('Not a font file – use TTF, OTF, WOFF or WOFF2.');
// The bytes go up untouched: unlike a picture, a typeface cannot be re-encoded smaller here.
const uploaded = await deps.fonts.upload(file, file.type || 'application/octet-stream');
return adopt(uploaded, assetNameFor(file), file.name, deps);
} catch (error) {
return {
name: file.name,
assetId: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
/** Makes an asset for a typeface the server holds, or reuses the one already naming those bytes. */
function adopt(font: UploadedFont, nameBase: string, label: string, deps: FontImportDeps): ImportOutcome {
const existing = deps.state.project.assets.find(
(asset): asset is FontAsset => asset.type === 'font' && asset.file === font.file,
);
if (existing) return { name: label, assetId: existing.id, error: null, reused: true };
const name = nextAssetName(deps.state.project, nameBase);
const command = new CreateFontCommand(name, font.file);
// A rejected command has already put its reason into the notice; that is the message here.
if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice };
return { name: label, assetId: command.asset.id, error: null, reused: false };
}
/**
* Adopts a typeface the server already holds – no upload, the bytes are there. Chosen in the
* picker, so this is "+ → Font" and the asset is selected the way a created one would be.
*/
export function importStoredFont(font: UploadedFont, deps: FontImportDeps): ImportOutcome {
const outcome = adopt(font, 'Font', font.file, deps);
if (outcome.assetId) deps.state.select({ kind: 'asset', assetId: outcome.assetId });
deps.state.setNotice(
outcome.error ??
(outcome.reused ? 'That font is already in the project.' : 'Added the font to the project.'),
);
return outcome;
}
/** The file's own name without its extension: "Roboto-Bold.ttf" reads better than "Font 3". */
function assetNameFor(file: File): string {
return file.name.replace(/\.[^.]+$/, '').trim() || 'Font';
}
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
export function pickFontFiles(): Promise<File[]> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = FONT_ACCEPT;
input.multiple = true;
input.style.display = 'none';
document.body.appendChild(input);
const finish = (files: File[]) => {
input.remove();
resolve(files);
};
input.addEventListener('change', () => finish([...(input.files ?? [])]));
// Without `cancel` a dismissed dialog leaves both the element and this promise behind.
input.addEventListener('cancel', () => finish([]));
input.click();
});
}
export function pickAndImportFonts(deps: FontImportDeps): void {
void pickFontFiles()
.then((files) => importFonts(files, deps))
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}
+6
View File
@@ -20,6 +20,7 @@ import { type PropertyDef, schemaForAsset, type StructFieldDef } from '../../cor
import { markedAssetSchema } from '../../core/properties/deviation';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Dropdown, HStack, Label, Menu, Theme } from '../../ui';
import { FontPreview } from '../font/FontPreview';
import { aspectActions, sizeCommand } from './aspectAction';
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
@@ -58,6 +59,11 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void {
buildProps(host, asset, markedAssetSchema(host.state.project, asset));
if (asset.type === 'behavior') buildVariables(host, asset);
// A font has no properties at all, so without this its inspector would be the name and nothing.
if (asset.type === 'font')
host.body.add(
asset.file ? new FontPreview(asset.file) : new Label('Not uploaded.', { style: 'warning' }),
);
}
/** Pushes the current asset values into the rows. False when the asset is gone (rebuild). */
+21 -3
View File
@@ -15,10 +15,12 @@ import type { PropertyBag } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { spriteFromTexture } from '../../core/properties/aspect';
import { assetDeviates } from '../../core/properties/deviation';
import type { FontStore } from '../../core/store/FontStore';
import type { TextureStore } from '../../core/store/TextureStore';
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer, startDrag } from '../../ui';
import { confirmDeleteAsset } from '../actions';
import type { EditorState } from '../state';
import { openFontPicker } from '../font/FontPicker';
import { openImagePicker } from '../texture/ImagePicker';
/**
@@ -35,6 +37,8 @@ export class AssetPanel extends Panel {
private readonly state: EditorState,
/** Where imported images go. Without it the Texture entry has nothing to upload to. */
private readonly textures: TextureStore | null = null,
/** The same for typefaces and the Font entry. */
private readonly fonts: FontStore | null = null,
) {
super({ title: 'Assets', padding: 0, gap: 0 });
this.minWidth = 160;
@@ -44,7 +48,7 @@ export class AssetPanel extends Panel {
new Menu(
ASSET_TYPES.map((type) => ({
label: ASSET_TYPE_LABELS[type],
onSelect: () => (type === 'texture' ? this.importTextures() : this.createAsset(type)),
onSelect: () => this.addAsset(type),
})),
).openBelow(add);
this.header.add(add);
@@ -96,9 +100,15 @@ export class AssetPanel extends Panel {
}
/**
* A texture is a picture, so its menu entry offers the ones the server already holds instead of
* creating an empty asset. Uploading a new one is a button inside that dialog.
* A texture is a picture and a font a file, so their menu entries offer what the server already
* holds instead of creating an empty asset. Uploading a new one is a button inside that dialog.
*/
private addAsset(type: AssetType): void {
if (type === 'texture') this.importTextures();
else if (type === 'font') this.importFonts();
else this.createAsset(type);
}
private importTextures(): void {
if (!this.textures) {
this.state.setNotice('Image upload is not available.');
@@ -107,6 +117,14 @@ export class AssetPanel extends Panel {
openImagePicker({ state: this.state, textures: this.textures });
}
private importFonts(): void {
if (!this.fonts) {
this.state.setNotice('Font upload is not available.');
return;
}
openFontPicker({ state: this.state, fonts: this.fonts });
}
/** Double-click / Enter opens the asset's tab; a scene also becomes the active one. Placing is a drag. */
private activate(id: string): void {
const asset = findAsset(this.state.project, id);
+8 -63
View File
@@ -173,8 +173,11 @@ function selectLast(outcomes: readonly ImportOutcome[], deps: TextureImportDeps)
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
}
/** One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie. */
export function importNotice(outcomes: readonly ImportOutcome[]): string {
/**
* One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie.
* `noun` names what was imported – fonts come through the same batch reporting.
*/
export function importNotice(outcomes: readonly ImportOutcome[], noun = 'image'): string {
const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length;
const reused = outcomes.filter((outcome) => outcome.reused).length;
const failed = outcomes.filter((outcome) => outcome.error !== null);
@@ -185,9 +188,9 @@ export function importNotice(outcomes: readonly ImportOutcome[]): string {
if (!first) {
if (added === 0 && reused > 0)
return reused === 1
? 'That image is already in the project.'
: `Those ${reused} images are already in the project.`;
return `Imported ${added} ${added === 1 ? 'image' : 'images'}.${known}`;
? `That ${noun} is already in the project.`
: `Those ${reused} ${noun}s are already in the project.`;
return `Imported ${added} ${added === 1 ? noun : `${noun}s`}.${known}`;
}
const more = failed.length > 1 ? ` (and ${failed.length - 1} more)` : '';
const problem = `Could not import "${first.name}": ${first.error ?? ''}${more}`;
@@ -224,61 +227,3 @@ export function pickAndImport(deps: TextureImportDeps): void {
.then((files) => importImages(files, deps))
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}
/**
* Wires drop-onto-`target` and paste, and returns the remover for both. Paste is bound to the
* window because the canvas cannot hold focus, and skipped while a DOM text field has it.
*/
export function installTextureImport(target: HTMLElement, deps: TextureImportDeps): () => void {
const onDragOver = (event: DragEvent) => {
if (!hasImageItems(event.dataTransfer)) return;
// Without this the browser navigates away to the dropped file.
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const onDrop = (event: DragEvent) => {
const files = imageFilesOf(event.dataTransfer);
if (files.length === 0) {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
deps.state.setNotice('Only image files can be dropped into the editor.');
return;
}
event.preventDefault();
run(files, deps);
};
const onPaste = (event: ClipboardEvent) => {
if (isTextFieldFocused()) return;
const files = imageFilesOf(event.clipboardData);
if (files.length === 0) return;
event.preventDefault();
run(files, deps);
};
target.addEventListener('dragover', onDragOver);
target.addEventListener('drop', onDrop);
window.addEventListener('paste', onPaste);
return () => {
target.removeEventListener('dragover', onDragOver);
target.removeEventListener('drop', onDrop);
window.removeEventListener('paste', onPaste);
};
}
function run(files: readonly File[], deps: TextureImportDeps): void {
void importImages(files, deps).catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}
function imageFilesOf(data: DataTransfer | null): File[] {
return [...(data?.files ?? [])].filter((file) => file.type.startsWith('image/'));
}
/** During a drag the files themselves are not readable yet – only the kinds and types are. */
function hasImageItems(data: DataTransfer | null): boolean {
return [...(data?.items ?? [])].some((item) => item.kind === 'file' && item.type.startsWith('image/'));
}
function isTextFieldFocused(): boolean {
const active = document.activeElement;
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement;
}
+17 -1
View File
@@ -6,6 +6,7 @@ import { footprintExtent, footprintOf, placementOf, textAnchor } from '../../cor
import { rectsIntersect } from '../../core/properties/camera';
import type { EditorState } from '../state';
import { type Size, Theme, Widget } from '../../ui';
import { fontLibrary } from '../../viewport/FontLibrary';
import type { DrawItem } from '../../viewport/items';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { type ShaderSource, wornShader } from '../../viewport/shaders';
@@ -57,8 +58,9 @@ export abstract class WorldViewport extends Widget {
state.on('runtime', () => {
if (this.visible && !this.animated) this.redraw();
});
// Unconditional: a picture arriving fires once per image, not per frame.
// Unconditional: a picture or a typeface arriving fires once per file, not per frame.
textureLibrary.onChange(() => this.redraw());
fontLibrary.onChange(() => this.redraw());
}
override measure(): Size {
@@ -247,6 +249,16 @@ export abstract class WorldViewport extends Widget {
syncSpriteMesh(sprite, item, size, textureLibrary, shaders);
}
/**
* The typeface a text is drawn in and how wide that makes it. Both are what a font asset is
* for, and both arrive asynchronously, so a test cannot read them off the model.
*/
textInfo(id: string): { family: string; width: number } | null {
const text = this.texts.get(id);
if (!text?.visible) return null;
return { family: String(text.style.fontFamily), width: Math.round(text.width) };
}
/** Resolution follows the zoom in coarse steps: re-rendering the glyphs per wheel notch is wasteful. */
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
let text = this.texts.get(item.id);
@@ -268,6 +280,10 @@ export abstract class WorldViewport extends Widget {
text.text = textOf(item.props.text ?? '');
const size = Math.max(1, Number(item.props.fontSize ?? 32));
if (text.style.fontSize !== size) text.style.fontSize = size;
// An uploaded face that has not arrived yet reads as `''`; until it does, the editor's own
// typeface stands in, and the library's change event brings the redraw that swaps it.
const family = fontLibrary.family(item.font) || Theme.font;
if (text.style.fontFamily !== family) text.style.fontFamily = family;
const color = Number(item.props.color ?? 0xffffff);
if (text.style.fill !== color) text.style.fill = color;
text.anchor.set(textAnchor(item.props), 0.5);
+63
View File
@@ -0,0 +1,63 @@
import { fontFamilyOf } from '../core/model/font';
const DEFAULT_BASE = '/game-maker/data/fonts';
/**
* Registers uploaded typefaces with the browser, keyed by file name – the image library's twin.
* Nothing is evicted: file names are content addressed, so a face can never go stale. A miss is
* not an error; it starts the load and reports the fallback, and the redraw comes when the face
* lands. Text therefore appears in the default typeface for a frame or two and then reflows.
*/
export class FontLibrary {
private base = DEFAULT_BASE;
private readonly ready = new Set<string>();
private readonly loading = new Set<string>();
/** Never retried, so one broken reference cannot spam the network. */
private readonly failed = new Set<string>();
private readonly listeners = new Set<() => void>();
/** Where the files are served from. Set once at start-up, before anything draws. */
configure(base: string): void {
this.base = base.replace(/\/+$/, '');
}
/**
* The CSS family for a stored file once the browser can draw with it, otherwise `''` while it
* loads or after it failed – the caller then keeps whatever typeface it was using.
*/
family(file: string): string {
if (file === '') return '';
if (this.ready.has(file)) return fontFamilyOf(file);
this.startLoading(file);
return '';
}
/** Runs `fn` whenever a typeface has arrived; returns the unsubscribe. */
onChange(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private startLoading(file: string): void {
if (this.loading.has(file) || this.failed.has(file)) return;
this.loading.add(file);
// `FontFace` rather than a stylesheet rule: it reports when the bytes are actually usable,
// and a family name is asked for by the same content-addressed key that fetched them.
const face = new FontFace(fontFamilyOf(file), `url("${this.base}/${file}")`);
void face.load().then(
(loaded) => {
this.loading.delete(file);
document.fonts.add(loaded);
this.ready.add(file);
for (const listener of this.listeners) listener();
},
(error: unknown) => {
this.loading.delete(file);
this.failed.add(file);
console.error(`[fonts] cannot load ${file}`, error);
},
);
}
}
export const fontLibrary = new FontLibrary();
+6
View File
@@ -5,8 +5,10 @@ import { resolvedHolder } from '../core/model/instance';
import type { Mat } from '../core/model/matrix';
import type { PropertyBag } from '../core/model/Properties';
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import { fontFileOf } from '../core/model/font';
import { resolveTexture, type TextureRef } from '../core/model/texture';
import { enabledAlongChain } from '../core/properties/enabled';
import { fontOf } from '../core/properties/fonts';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import { editorTransform } from '../core/properties/transform';
import { animationTime } from '../runtime/systems/AnimationSystem';
@@ -30,6 +32,8 @@ export interface DrawItem {
enabled: boolean;
/** Resolved here because drawing has no access to the project. */
texture: TextureRef | null;
/** The typeface file a text wears, resolved here for the same reason; `''` is the default one. */
font: string;
}
interface DrawList {
@@ -67,6 +71,7 @@ export function collectHolderItems(
parentId: object.parentId,
enabled: true,
texture: asset.type === 'sprite' ? resolveTexture(project, props) : null,
font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '',
});
}
const byId = new Map(items.map((i) => [i.id, i]));
@@ -100,6 +105,7 @@ export function collectWorldItems(world: World): DrawList {
enabled: true,
// Resolved per frame on purpose: a `Properties` node may swap `texture`, an animation runs.
texture: e.type === 'sprite' ? resolveTexture(world.project, e.props, animationTime(e)) : null,
font: e.type === 'text' ? fontFileOf(world.project, fontOf(e.props)) : '',
}));
return { items, background: world.backgroundColor };
}
+101
View File
@@ -0,0 +1,101 @@
/**
* A font is an asset holding an uploaded typeface, worn by a text object through its `font`
* property the way a sprite wears a texture. Empty, or dangling, means the editor's own typeface.
*/
import { describe, expect, it } from 'vitest';
import {
buildAsset,
CreateAssetCommand,
CreateFontCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import type { FontAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { fontFamilyOf, fontFileOf, isFontFile } from '../../src/core/model/font';
import { assetRefChoices } from '../../src/core/properties/assetRef';
import { FONT_KEY, fontOf } from '../../src/core/properties/fonts';
import { schemaFor } from '../../src/core/properties/PropertySchema';
import { deserializeProject } from '../../src/core/store/serialize';
import { makeProject, roundTrip } from '../helpers';
const FILE = `${'a1'.repeat(32)}.ttf`;
function makeFont(name = 'Serif', file = FILE) {
const fixture = makeProject();
const command = new CreateFontCommand(name, file);
fixture.stack.execute(command);
return { ...fixture, font: command.asset };
}
describe('font files', () => {
it('accepts only a content-addressed name in a supported format', () => {
expect(isFontFile(FILE)).toBe(true);
expect(isFontFile(`${'b3'.repeat(32)}.woff2`)).toBe(true);
expect(isFontFile(`${'a1'.repeat(32)}.png`)).toBe(false);
expect(isFontFile(`${'a1'.repeat(32)}.TTF`)).toBe(false);
expect(isFontFile('DejaVuSerif.ttf')).toBe(false);
expect(isFontFile('')).toBe(false);
});
it('names one family per file, and never two for the same bytes', () => {
expect(fontFamilyOf(FILE)).toBe(fontFamilyOf(FILE));
expect(fontFamilyOf(FILE)).not.toBe(fontFamilyOf(`${'b3'.repeat(32)}.ttf`));
// A CSS family, so it has to survive being written into a font shorthand unquoted.
expect(fontFamilyOf(FILE)).toMatch(/^[A-Za-z][\w-]*$/);
});
});
describe('the font asset', () => {
it('is not placeable and starts without a file', () => {
expect(isPlaceableType('font')).toBe(false);
expect(buildAsset('font', 'Font')).toMatchObject({ type: 'font', file: '' });
});
it('refuses a file name the server could not have produced', () => {
const fixture = makeProject();
// The stack lets the error through; the editor turns it into the notice the importer reports.
expect(() => fixture.stack.execute(new CreateFontCommand('Bad', 'DejaVuSerif.ttf'))).toThrow(
'is not a stored font file',
);
expect(fixture.project.assets).toHaveLength(0);
});
it('survives a round trip and drops a file name it cannot trust', () => {
const f = makeFont();
expect(roundTrip(f.project)).toEqual(f.project);
const repaired = deserializeProject({
name: 'P',
assets: [
{ id: 'f1', type: 'font', name: 'Local', file: '../../etc/passwd' },
{ id: 'f2', type: 'font', name: 'Gone' },
],
});
expect(repaired.assets.map((a) => (a as FontAsset).file)).toEqual(['', '']);
});
});
describe('the Font row on a text', () => {
it('offers font assets and nothing else', () => {
const f = makeFont();
f.stack.execute(new CreateAssetCommand('text', 'Title'));
const row = schemaFor('text').find((def) => def.key === FONT_KEY);
expect(row).toMatchObject({ kind: 'asset', default: '', assetTypes: ['font'] });
expect(assetRefChoices(f.project, ['font']).map((a) => a.id)).toEqual([f.font.id]);
});
it('resolves to a file, and to none when the id is unset or dangling', () => {
const f = makeFont();
const text = new CreateAssetCommand('text', 'Title');
f.stack.execute(text);
expect(fontOf(text.asset.props)).toBe('');
expect(fontFileOf(f.project, '')).toBe('');
f.stack.execute(new SetAssetPropertyCommand(text.asset.id, FONT_KEY, f.font.id));
expect(fontFileOf(f.project, fontOf(text.asset.props))).toBe(FILE);
expect(fontFileOf(f.project, 'no-such-asset')).toBe('');
// A text asset is not a font asset: an id pointing at the wrong type wears nothing.
expect(fontFileOf(f.project, text.asset.id)).toBe('');
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* Importing a typeface: the same two ways a picture arrives – picked from what the server already
* holds, or uploaded from a dropped file – with the same content-addressed reuse.
*/
import { describe, expect, it, vi } from 'vitest';
import type { FontAsset } from '../../src/core/model/Asset';
import { createEmptyProject } from '../../src/core/model/Project';
import type { FontStore } from '../../src/core/store/FontStore';
import { importFonts, importStoredFont, isFontFileName } from '../../src/editor/fontImport';
import { EditorState } from '../../src/editor/state';
import { importNotice } from '../../src/editor/textureImport';
const FILE = `${'a'.repeat(64)}.ttf`;
const font = { file: FILE, bytes: 380_660 };
function setup(): EditorState {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
return state;
}
describe('isFontFileName', () => {
it('goes by the extension, because a dropped font often has no MIME type at all', () => {
expect(isFontFileName('DejaVuSerif.ttf')).toBe(true);
expect(isFontFileName('Inter.WOFF2')).toBe(true);
expect(isFontFileName('cover.png')).toBe(false);
expect(isFontFileName('ttf')).toBe(false);
});
});
describe('importStoredFont', () => {
const fonts = { upload: vi.fn(), list: vi.fn(), delete: vi.fn() } as unknown as FontStore;
it('makes an asset out of a typeface the server already holds', () => {
const state = setup();
const outcome = importStoredFont(font, { state, fonts });
const asset = state.project.assets.find((a): a is FontAsset => a.type === 'font');
expect(asset).toMatchObject({ file: FILE, name: 'Font' });
expect(outcome).toMatchObject({ assetId: asset?.id, error: null, reused: false });
expect(state.selection).toEqual({ kind: 'asset', assetId: asset?.id });
expect(state.notice).toBe('Added the font to the project.');
state.undo();
expect(state.project.assets.filter((a) => a.type === 'font')).toEqual([]);
});
it('reuses the asset that already names the file instead of making a second one', () => {
const state = setup();
const first = importStoredFont(font, { state, fonts });
const again = importStoredFont(font, { state, fonts });
expect(again.assetId).toBe(first.assetId);
expect(again.reused).toBe(true);
expect(state.project.assets.filter((a) => a.type === 'font')).toHaveLength(1);
expect(state.notice).toBe('That font is already in the project.');
});
});
describe('importFonts', () => {
it('uploads the file untouched and names the asset after it', async () => {
const state = setup();
const upload = vi.fn(() => Promise.resolve(font));
const fonts = { upload, list: vi.fn(), delete: vi.fn() } as unknown as FontStore;
const file = new File([new Uint8Array(8)], 'DejaVuSerif.ttf', { type: 'font/ttf' });
const [outcome] = await importFonts([file], { state, fonts });
// The blob goes up as it came in: a typeface cannot be re-encoded smaller on the way.
expect(upload).toHaveBeenCalledWith(file, 'font/ttf');
expect(outcome).toMatchObject({ error: null, reused: false });
expect(state.project.assets.find((a): a is FontAsset => a.type === 'font')).toMatchObject({
file: FILE,
name: 'DejaVuSerif',
});
expect(state.notice).toBe('Imported 1 font.');
});
it('reports a file that is not a font without asking the server about it', async () => {
const state = setup();
const upload = vi.fn(() => Promise.resolve(font));
const fonts = { upload, list: vi.fn(), delete: vi.fn() } as unknown as FontStore;
const [outcome] = await importFonts([new File([new Uint8Array(8)], 'cover.png')], {
state,
fonts,
});
expect(upload).not.toHaveBeenCalled();
expect(outcome?.assetId).toBeNull();
expect(state.notice).toContain('Could not import "cover.png"');
});
});
describe('importNotice', () => {
it('says what was imported, so fonts do not report as images', () => {
const done = { name: 'a.ttf', assetId: 'id', error: null, reused: false };
expect(importNotice([done], 'font')).toBe('Imported 1 font.');
expect(importNotice([done, { ...done, name: 'b.ttf' }], 'font')).toBe('Imported 2 fonts.');
expect(importNotice([{ ...done, reused: true }], 'font')).toBe('That font is already in the project.');
});
});
+17 -4
View File
@@ -208,7 +208,7 @@ export async function shader(t) {
project.assets.push(sprite);
const scene = __gm.state.activeScene;
const cam = scene.objects.find((o) => o.id === 'o3').position;
scene.objects.push({ id: 'oshade', assetId: 'shade-sprite', name: '', position: { x: cam.x, y: cam.y, z: 950 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} });
scene.objects.push({ id: 'oshade', assetId: 'shade-sprite', name: '', position: { x: cam.x + 180, y: cam.y + 100, z: 950 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} });
__gm.state.select(null)`);
await sleep(600);
// The pixels are the scene view's; the tab is still the shader's.
@@ -218,11 +218,24 @@ export async function shader(t) {
const box = await json("__gm.objectRect('oshade')");
check('the shaded sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box));
if (!box) return;
// Off centre: the camera symbol sits on the very same spot, and symbols draw above objects.
const centre = () =>
json(`__gm.pixelAt(${Math.round(box.x + 0.3 * box.w)}, ${Math.round(box.y + 0.3 * box.h)})`);
const sample = (fx, fy) =>
json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`);
// Off centre: a symbol may sit on the sprite's middle, and symbols draw above objects.
const centre = () => sample(0.3, 0.3);
const base = await centre();
check('with no shader the sprite is its plain colour', colourDistance(base, BASE) < 12, base);
// Every later check reads pixels at its own spot on this sprite, so nothing may be drawn over it.
// The step parks the sprite away from the camera for that reason: symbols draw above objects, and
// by this point the earlier steps have left containers and colliders around the origin. Without
// this guard their outlines show up as a wrong colour in whichever check happens to sample them.
const dirty = [];
for (let fx = -0.4; fx <= 0.401; fx += 0.1)
for (let fy = -0.4; fy <= 0.401; fy += 0.1) {
const seen = await sample(fx, fy);
if (colourDistance(seen, BASE) >= 12) dirty.push(`${fx.toFixed(1)},${fy.toFixed(1)}:${seen}`);
}
check('nothing is drawn over the sprite the pixel checks read', dirty.length === 0, dirty.join(' '));
// The Shader row's dropdown reads "None"; it is the last such text in the inspector (Parent and
// Texture say it above). The label itself is no anchor: the tab button says "Shader" too.
const shaderField = await locate('None', true);
+219
View File
@@ -0,0 +1,219 @@
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')),
);
}
+2
View File
@@ -32,6 +32,7 @@ import { stateMachine } from './26-state-machine.mjs';
import { consolePanel } from './27-console.mjs';
import { shader } from './28-shader.mjs';
import { transform } from './29-transform.mjs';
import { font } from './30-font.mjs';
export const STEPS = [
hierarchy,
@@ -63,4 +64,5 @@ export const STEPS = [
consolePanel,
shader,
transform,
font,
];
+1
View File
@@ -17,6 +17,7 @@ export const LANES = {
stateMachine: -700,
console: -1100,
transform: -1500,
font: -1900,
};
/**
+32
View File
@@ -73,6 +73,38 @@ export async function uploadTexture(baseUrl, filePath) {
return { file: result.file, width: result.width, height: result.height };
}
const FONT_MIME = {
ttf: 'font/ttf',
otf: 'font/otf',
woff: 'font/woff',
woff2: 'font/woff2',
};
/**
* Uploads a typeface and returns `{file}` for a font asset. Content-addressed like an image, so
* re-seeding the same file is a no-op (`data/fonts/` is git-ignored).
*/
export async function uploadFont(baseUrl, filePath) {
const bytes = await readFile(filePath);
const type = FONT_MIME[extname(filePath).slice(1).toLowerCase()];
if (!type) {
console.error(`unsupported font type: ${filePath}`);
process.exit(1);
}
const res = await fetch(`${baseUrl}api/index.php?r=fonts`, {
method: 'POST',
headers: { 'Content-Type': type },
body: bytes,
signal: AbortSignal.timeout(20_000),
});
const result = await res.json();
if (!res.ok) {
console.error('font upload failed', res.status, result);
process.exit(1);
}
return { file: result.file };
}
/** Overwrites a project, keeping the name it already has on the server. */
export async function writeProject(projectId, baseUrl, project) {
const url = `${baseUrl}api/index.php?r=projects/${projectId}`;
+11 -1
View File
@@ -7,11 +7,21 @@
* Usage: node tools/seed-e2e.mjs [projectId] [baseUrl]
*/
import { edge, node, port, sceneObject, variable } from './lib/builders.mjs';
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
import { PROJECTS, cliArgs, uploadFont, writeProject } from './lib/projects.mjs';
import { adjustColorsShader } from './lib/shaders.mjs';
const { projectId, baseUrl } = cliArgs('seed-e2e.mjs', PROJECTS.fixture);
/**
* Two typefaces for the font step to pick and to drop. They are put on the server but not made
* into assets: the step is about the picker and the drop making them. The files come from the
* system rather than the repo – the CI image installs the same DejaVu package the host has,
* because the checks measure text widths (see tools/ci/Dockerfile).
*/
const SYSTEM_FONTS = '/usr/share/fonts/truetype/dejavu';
await uploadFont(baseUrl, `${SYSTEM_FONTS}/DejaVuSerif.ttf`);
await uploadFont(baseUrl, `${SYSTEM_FONTS}/DejaVuSansMono.ttf`);
/**
* "Mover" in five nodes: every frame, move the owner along the normalized arrow-key axis,
* scaled by the `speed` variable per second (On update's dt feeds the dt input). "Collide with