Two compilers that walk a graph the same way walk it through one emitter (#85)
Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection, duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own contract. The three Create*Command share ImportFileCommand, the three renames share RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in portSection.ts replace in-file clones, and three comments that told history now state the rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bf0be66c9d
commit
d3563897d8
16 files changed
+376
-379
No files matched your search
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Uploaded files the view draws with, keyed by file name. Nothing is evicted: file names are
|
||||
* content addressed, so a loaded file can never go stale. A miss is not an error – the subclass
|
||||
* starts the load and reports "not yet", and `onChange` fires when the file lands.
|
||||
*/
|
||||
export abstract class FileLibrary<T> {
|
||||
private base: 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>();
|
||||
|
||||
constructor(
|
||||
base: string,
|
||||
private readonly tag: string,
|
||||
) {
|
||||
this.base = base;
|
||||
}
|
||||
|
||||
/** Where the files are served from. Set once at start-up, before anything draws. */
|
||||
configure(base: string): void {
|
||||
this.base = base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** Runs `fn` whenever a file has arrived; returns the unsubscribe. */
|
||||
onChange(fn: () => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
protected abstract fetchFile(url: string, file: string): Promise<T>;
|
||||
|
||||
/** Keeps what arrived; the listeners run right after. */
|
||||
protected abstract keep(file: string, loaded: T): void;
|
||||
|
||||
protected startLoading(file: string): void {
|
||||
if (this.loading.has(file) || this.failed.has(file)) return;
|
||||
this.loading.add(file);
|
||||
void this.fetchFile(`${this.base}/${file}`, file).then(
|
||||
(loaded) => {
|
||||
this.loading.delete(file);
|
||||
this.keep(file, loaded);
|
||||
for (const listener of this.listeners) listener();
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.loading.delete(file);
|
||||
this.failed.add(file);
|
||||
console.error(`[${this.tag}] cannot load ${file}`, error);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
-38
@@ -1,24 +1,15 @@
|
||||
import { fontFamilyOf } from '../core/model/font';
|
||||
|
||||
const DEFAULT_BASE = '/game-maker/data/fonts';
|
||||
import { FileLibrary } from './FileLibrary';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Registers uploaded typefaces with the browser. Text appears in the default typeface for a frame
|
||||
* or two and then reflows when the face lands.
|
||||
*/
|
||||
export class FontLibrary {
|
||||
private base = DEFAULT_BASE;
|
||||
export class FontLibrary extends FileLibrary<FontFace> {
|
||||
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(/\/+$/, '');
|
||||
constructor() {
|
||||
super('/game-maker/data/fonts', 'fonts');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,31 +23,15 @@ export class FontLibrary {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Runs `fn` whenever a typeface has arrived; returns the unsubscribe. */
|
||||
onChange(fn: () => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
// `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.
|
||||
protected fetchFile(url: string, file: string): Promise<FontFace> {
|
||||
return new FontFace(fontFamilyOf(file), `url("${url}")`).load();
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
protected keep(file: string, loaded: FontFace): void {
|
||||
document.fonts.add(loaded);
|
||||
this.ready.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Assets, CanvasSource, Texture } from 'pixi.js';
|
||||
import type { FrameRect, TextureRef } from '../core/model/texture';
|
||||
import { type EdgeMode, SHEET_EDGE } from '../core/shader/nodes';
|
||||
|
||||
const DEFAULT_BASE = '/game-maker/data/textures';
|
||||
import { FileLibrary } from './FileLibrary';
|
||||
|
||||
/**
|
||||
* Pixi turns `clamp-to-edge` into `repeat` on every texture a Graphics fill draws with
|
||||
@@ -22,24 +21,14 @@ const ADDRESS_MODE: Record<EdgeMode, 'repeat' | 'mirror-repeat' | 'clamp-to-edge
|
||||
clamp: 'clamp-to-edge',
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps uploaded images on the GPU, keyed by file name. Nothing is evicted: file names are
|
||||
* content addressed, so a cached texture can never go stale. A miss is not an error – it starts
|
||||
* the load and reports "not yet", so callers never await and get a redraw when the picture lands.
|
||||
*/
|
||||
export class TextureLibrary {
|
||||
private base = DEFAULT_BASE;
|
||||
/** Keeps uploaded images on the GPU; callers never await and get a redraw when the picture lands. */
|
||||
export class TextureLibrary extends FileLibrary<Texture> {
|
||||
private readonly loaded = new Map<string, Texture>();
|
||||
/** Frames cut from their sheets, keyed by file and rectangle; kept like the files. */
|
||||
private readonly crops = new Map<string, Texture>();
|
||||
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(/\/+$/, '');
|
||||
constructor() {
|
||||
super('/game-maker/data/textures', 'textures');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,12 +72,6 @@ export class TextureLibrary {
|
||||
return this.loaded.get(file) ?? null;
|
||||
}
|
||||
|
||||
/** Runs `fn` whenever a picture has arrived; returns the unsubscribe. */
|
||||
onChange(fn: () => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame as a texture of its own, cut the first time it is asked for. Drawn from the sheet
|
||||
* through a fill matrix it showed a row of the neighbouring cell along any edge that fell
|
||||
@@ -110,23 +93,14 @@ export class TextureLibrary {
|
||||
return texture;
|
||||
}
|
||||
|
||||
private startLoading(file: string): void {
|
||||
if (this.loading.has(file) || this.failed.has(file)) return;
|
||||
this.loading.add(file);
|
||||
void Assets.load<Texture>(`${this.base}/${file}`).then(
|
||||
(texture) => {
|
||||
this.loading.delete(file);
|
||||
texture.source.style.addressMode = EDGE_MODE;
|
||||
texture.source.style.update();
|
||||
this.loaded.set(file, texture);
|
||||
for (const listener of this.listeners) listener();
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.loading.delete(file);
|
||||
this.failed.add(file);
|
||||
console.error(`[textures] cannot load ${file}`, error);
|
||||
},
|
||||
);
|
||||
protected fetchFile(url: string): Promise<Texture> {
|
||||
return Assets.load<Texture>(url);
|
||||
}
|
||||
|
||||
protected keep(file: string, texture: Texture): void {
|
||||
texture.source.style.addressMode = EDGE_MODE;
|
||||
texture.source.style.update();
|
||||
this.loaded.set(file, texture);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user