A texture was fetched only when it was drawn for the first time. Until the file arrived, the tilemap skipped the cell for that frame. An animated tile whose next step sat in another picture therefore blinked away once on its first cycle, and sprite animations did the same. - FileLibrary gets preload(files). It requests every file that is not held, loading or known to have failed. Each library reports what it holds through holds(). - src/core/model/projectFiles.ts lists the files a project names per library: uploaded textures, fonts and pixel art cels. - App.ts runs the preload on every project event. That covers opening a project, new assets and edits from a collaborator. - A new test hook __gm.heldPictures() reports which textures of the project are in memory. Not done on purpose: - The tilemap keeps no fallback frame while a step is still loading. - Sounds still load on first play. Not checked: the flicker itself was not reproduced in the browser, the e2e check measures "held in memory". Modules 42, 44 and 47 read textures from the project and were not run locally. Tests: - ./check.sh: 174 files, 2518 tests, CHECK EXIT 0 - e2e 39 + 40: ALL PASSED, 96 checks - 3 new unit tests for preload and the project file list - New e2e check 40k-ii: an unplaced picture is held after a reload - Counter-probe: without the preload 40k-ii fails with "Autotiles: held false, unworn: held false" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
81 lines
2.7 KiB
TypeScript
81 lines
2.7 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
|
||
/** Runs the listeners: something they draw from has changed without a file arriving here. */
|
||
notifyChange(): void {
|
||
// One listener that throws must not keep the file from the ones after it (#196).
|
||
for (const listener of this.listeners)
|
||
try {
|
||
listener();
|
||
} catch (error) {
|
||
console.error(`[${this.tag}] a listener failed`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Starts loading every file not held yet. Waiting for the first draw left a gap of a frame or
|
||
* more where the picture should be – an animated tile blinked out the first time it reached a
|
||
* step from a sheet of its own.
|
||
*/
|
||
preload(files: Iterable<string>): void {
|
||
for (const file of files) if (file && !this.holds(file)) this.startLoading(file);
|
||
}
|
||
|
||
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;
|
||
|
||
/** True once the file is in memory, whether it came from the server or was made here. */
|
||
protected abstract holds(file: string): boolean;
|
||
|
||
/** A file that could not be loaded; it is not asked for again. */
|
||
protected hasFailed(file: string): boolean {
|
||
return this.failed.has(file);
|
||
}
|
||
|
||
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);
|
||
this.notifyChange();
|
||
},
|
||
(error: unknown) => {
|
||
this.loading.delete(file);
|
||
this.failed.add(file);
|
||
console.error(`[${this.tag}] cannot load ${file}`, error);
|
||
},
|
||
);
|
||
}
|
||
}
|