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>
93 lines
3.4 KiB
TypeScript
93 lines
3.4 KiB
TypeScript
import type { Project } from '../core/model/Project';
|
|
import { celFilesOf } from '../core/model/pixelart';
|
|
import { type CelPixels, celFileName, decodeCel, encodeCel, isEmptyCel } from '../core/model/pixelCel';
|
|
import { FileLibrary } from './FileLibrary';
|
|
|
|
/** Where cels go on the server: raw bytes in, the stored name back. */
|
|
export interface CelUploader {
|
|
upload(bytes: ArrayBuffer, type: string): Promise<{ file: string }>;
|
|
}
|
|
|
|
/**
|
|
* The pixels of pixel art (#130) in memory, keyed by cel file. A cel painted here is named from its
|
|
* bytes and kept at once, so the model can point at it while nothing has left the browser yet; the
|
|
* cels the server does not hold are uploaded before the project that names them is saved (answer 1).
|
|
* A cel from the server is loaded on the first ask, like a texture.
|
|
*/
|
|
export class PixelCels extends FileLibrary<CelPixels> {
|
|
private readonly cels = new Map<string, CelPixels>();
|
|
/** Painted here and not on the server yet. */
|
|
private readonly local = new Set<string>();
|
|
|
|
constructor() {
|
|
super('/game-maker/data/pixels', 'pixels');
|
|
}
|
|
|
|
/**
|
|
* The pixels of a cel: null for the empty name and for a file that cannot be read, undefined while
|
|
* it loads. A broken file reads as empty rather than holding the picture back for good.
|
|
*/
|
|
read(file: string): CelPixels | null | undefined {
|
|
if (!file) return null;
|
|
const cel = this.cels.get(file);
|
|
if (cel) return cel;
|
|
if (this.hasFailed(file)) return null;
|
|
this.startLoading(file);
|
|
return undefined;
|
|
}
|
|
|
|
/** Keeps painted pixels and returns the name they go by; an empty cel is `''`, no file at all. */
|
|
put(cel: CelPixels): string {
|
|
if (isEmptyCel(cel)) return '';
|
|
const bytes = encodeCel(cel);
|
|
const file = celFileName(bytes);
|
|
if (!this.cels.has(file)) {
|
|
this.cels.set(file, { width: cel.width, height: cel.height, data: cel.data.slice() });
|
|
this.local.add(file);
|
|
}
|
|
return file;
|
|
}
|
|
|
|
/** The cels this project names that only this browser holds. */
|
|
pending(project: Project): string[] {
|
|
const files = new Set<string>();
|
|
for (const asset of project.assets)
|
|
if (asset.type === 'pixelart')
|
|
for (const file of celFilesOf(asset)) if (this.local.has(file)) files.add(file);
|
|
return [...files];
|
|
}
|
|
|
|
/**
|
|
* Sends every cel the project needs and the server lacks. A name the server works out differently
|
|
* is an error: the project would point at a file that is not there.
|
|
*/
|
|
async upload(project: Project, store: CelUploader): Promise<void> {
|
|
for (const file of this.pending(project)) {
|
|
const cel = this.cels.get(file);
|
|
if (!cel) continue;
|
|
const bytes = encodeCel(cel);
|
|
const stored = await store.upload(bytes.buffer as ArrayBuffer, 'application/octet-stream');
|
|
if (stored.file !== file) throw new Error(`The server stored pixels as ${stored.file}, not ${file}`);
|
|
this.local.delete(file);
|
|
}
|
|
}
|
|
|
|
protected async fetchFile(url: string): Promise<CelPixels> {
|
|
const response = await fetch(url);
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
const cel = decodeCel(new Uint8Array(await response.arrayBuffer()));
|
|
if (!cel) throw new Error('not a cel');
|
|
return cel;
|
|
}
|
|
|
|
protected keep(file: string, cel: CelPixels): void {
|
|
this.cels.set(file, cel);
|
|
}
|
|
|
|
protected holds(file: string): boolean {
|
|
return this.cels.has(file);
|
|
}
|
|
}
|
|
|
|
export const pixelCels = new PixelCels();
|