Fonts: a new asset type, picked from the server or dropped in, worn by text objects (#74)
game-maker / suite (push) Successful in 9m10s
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:
1 parent
28c0789b19
commit
86b5363a01
35 files changed
+1451
-85
No files matched your search
@@ -1,6 +1,7 @@
|
||||
/data/projects/*.json
|
||||
/data/projects/*.tmp
|
||||
/data/textures/
|
||||
/data/fonts/
|
||||
/dist/
|
||||
/node_modules/
|
||||
/logs/
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)` : '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -17,6 +17,7 @@ export const LANES = {
|
||||
stateMachine: -700,
|
||||
console: -1100,
|
||||
transform: -1500,
|
||||
font: -1900,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user