diff --git a/README.md b/README.md index 7f1f8e13..5b9a4df5 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,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, 1422 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1423 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/model/projectFiles.ts b/src/core/model/projectFiles.ts new file mode 100644 index 00000000..a6906e9f --- /dev/null +++ b/src/core/model/projectFiles.ts @@ -0,0 +1,26 @@ +import { isFontFile } from './font'; +import { celFilesOf } from './pixelart'; +import type { Project } from './Project'; +import { isTextureFile } from './texture'; + +/** The stored files a project draws with, per library – what is loaded before anything asks. */ +export interface ProjectFiles { + textures: string[]; + fonts: string[]; + cels: string[]; +} + +/** + * Every file an asset names, used or not: which frame an animation or a tile reaches next is only + * known while it plays, and a file fetched then shows as a gap. A name that is no stored file – + * an upload that failed – is left out, as the libraries would never get it anyway. + */ +export function projectFiles(project: Project): ProjectFiles { + const files: ProjectFiles = { textures: [], fonts: [], cels: [] }; + for (const asset of project.assets) { + if (asset.type === 'texture' && isTextureFile(asset.file)) files.textures.push(asset.file); + else if (asset.type === 'font' && isFontFile(asset.file)) files.fonts.push(asset.file); + else if (asset.type === 'pixelart') files.cels.push(...celFilesOf(asset)); + } + return files; +} diff --git a/src/editor/App.ts b/src/editor/App.ts index 38a8ac5c..9863d5c7 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -1,4 +1,5 @@ import type { Application } from 'pixi.js'; +import { projectFiles } from '../core/model/projectFiles'; import type { AuthStore, SignedIn } from '../core/store/AuthStore'; import { HttpAuthStore } from '../core/store/HttpAuthStore'; import { HttpFileStore } from '../core/store/HttpFileStore'; @@ -35,6 +36,13 @@ const FONT_BASE = '/game-maker/data/fonts'; const SOUND_BASE = '/game-maker/data/sounds'; const PIXEL_BASE = '/game-maker/data/pixels'; +function preloadProjectFiles(state: EditorState): void { + const files = projectFiles(state.project); + textureLibrary.preload(files.textures); + fontLibrary.preload(files.fonts); + pixelCels.preload(files.cels); +} + /** * Shows the sign-in screen until somebody is signed in, and answers with their name. Returns * right away when the session from an earlier visit is still good – which is what lets a @@ -97,6 +105,10 @@ export async function startEditor(app: Application, root: VStack): Promise // Drawn pictures (#185) are looked up in the project this session holds; the next session // installs its own, which is also what lets go of everything the old one had drawn. textureLibrary.setPictureFactory(projectPictureFactory(() => state.project)); + // Everything the project names is fetched as soon as it is named, not when it is first drawn: + // an animation reaching a frame from another sheet would otherwise blink out once. Asked on + // every change, which is cheap – a file that is held or on its way is skipped. + state.on('project', () => preloadProjectFiles(state)); const editor = new Editor( app, root, diff --git a/src/editor/debug/renderHooks.ts b/src/editor/debug/renderHooks.ts index 4de50415..5a77c8c2 100644 --- a/src/editor/debug/renderHooks.ts +++ b/src/editor/debug/renderHooks.ts @@ -1,4 +1,4 @@ -import { type FrameRect, resolvePicture } from '../../core/model/texture'; +import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture'; import { effectiveScale, gameClock } from '../../core/time'; import { afterFrames, isRendering, renderCount, requestRender } from '../../ui'; import { particleLibrary } from '../../viewport/ParticleLibrary'; @@ -34,6 +34,13 @@ export function renderHooks({ app, state, editor }: HookContext) { for (let i = 0; i < width; i++) row.push(pixelAt(x + i, y)); return row; }, + /** Every uploaded picture the project names and whether it is in memory, drawn anywhere or not. */ + heldPictures: () => + state.project.assets.flatMap((asset) => + asset.type === 'texture' && isTextureFile(asset.file) + ? [{ name: asset.name, held: textureLibrary.peek(asset.file) !== null }] + : [], + ), /** The filter and wrap the GPU sampler actually got, next to the model's – only the driver knows the former. */ textureFilter: (file: string, rect: FrameRect | null = null) => { const texture = textureLibrary.peek(file, rect); diff --git a/src/viewport/FileLibrary.ts b/src/viewport/FileLibrary.ts index b8a76c52..521eae94 100644 --- a/src/viewport/FileLibrary.ts +++ b/src/viewport/FileLibrary.ts @@ -39,11 +39,23 @@ export abstract class FileLibrary { } } + /** + * 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): void { + for (const file of files) if (file && !this.holds(file)) this.startLoading(file); + } + protected abstract fetchFile(url: string, file: string): Promise; /** 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); diff --git a/src/viewport/FontLibrary.ts b/src/viewport/FontLibrary.ts index ea1a3159..eaae22a9 100644 --- a/src/viewport/FontLibrary.ts +++ b/src/viewport/FontLibrary.ts @@ -33,6 +33,10 @@ export class FontLibrary extends FileLibrary { document.fonts.add(loaded); this.ready.add(file); } + + protected holds(file: string): boolean { + return this.ready.has(file); + } } export const fontLibrary = new FontLibrary(); diff --git a/src/viewport/PixelCels.ts b/src/viewport/PixelCels.ts index 52adc0c0..b8741969 100644 --- a/src/viewport/PixelCels.ts +++ b/src/viewport/PixelCels.ts @@ -83,6 +83,10 @@ export class PixelCels extends FileLibrary { 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(); diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index 8a7f486f..bbfd4c49 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -154,6 +154,10 @@ export class TextureLibrary extends FileLibrary { texture.source.style.update(); this.loaded.set(file, texture); } + + protected holds(file: string): boolean { + return this.loaded.has(file); + } } function cropKey(file: string, rect: FrameRect): string { diff --git a/tests/core/project-files.test.ts b/tests/core/project-files.test.ts new file mode 100644 index 00000000..3fa8b940 --- /dev/null +++ b/tests/core/project-files.test.ts @@ -0,0 +1,71 @@ +/** + * What the editor loads ahead of the first draw: every stored file a project's assets name, so an + * animation or an animated tile does not blink out the first time it reaches a frame of its own. + */ +import { describe, expect, it } from 'vitest'; +import type { Asset } from '../../src/core/model/Asset'; +import { createEmptyProject } from '../../src/core/model/Project'; +import { projectFiles } from '../../src/core/model/projectFiles'; + +const PNG_A = `${'a'.repeat(64)}.png`; +const PNG_B = `${'b'.repeat(64)}.webp`; +const FONT = `${'c'.repeat(64)}.woff2`; +const CEL_A = `${'d'.repeat(64)}.cel`; +const CEL_B = `${'e'.repeat(64)}.cel`; + +function projectWith(assets: unknown[]) { + const project = createEmptyProject('p', 'P'); + project.assets.push(...(assets as Asset[])); + return project; +} + +describe('the files a project names', () => { + it('lists every texture, font and cel, drawn somewhere or not', () => { + const project = projectWith([ + { + id: 't1', + type: 'texture', + name: 'Water 1', + props: {}, + file: PNG_A, + width: 16, + height: 16, + frames: [], + }, + { + id: 't2', + type: 'texture', + name: 'Water 2', + props: {}, + file: PNG_B, + width: 16, + height: 16, + frames: [], + }, + { id: 'f', type: 'font', name: 'Font', props: {}, file: FONT }, + { + id: 'px', + type: 'pixelart', + name: 'Px', + props: {}, + width: 8, + height: 8, + layers: [{ id: 'l', name: 'L', opacity: 1 }], + frames: [ + { id: 'f1', cels: { l: CEL_A } }, + { id: 'f2', cels: { l: CEL_B } }, + { id: 'f3', cels: { l: CEL_A } }, + ], + }, + ]); + expect(projectFiles(project)).toEqual({ textures: [PNG_A, PNG_B], fonts: [FONT], cels: [CEL_A, CEL_B] }); + }); + + it('leaves out a name that is no stored file', () => { + const project = projectWith([ + { id: 't', type: 'texture', name: 'Failed', props: {}, file: '', width: 0, height: 0, frames: [] }, + { id: 'f', type: 'font', name: 'Failed', props: {}, file: 'x.woff2' }, + ]); + expect(projectFiles(project)).toEqual({ textures: [], fonts: [], cels: [] }); + }); +}); diff --git a/tests/viewport/file-library.test.ts b/tests/viewport/file-library.test.ts index 5332eb7b..48b97829 100644 --- a/tests/viewport/file-library.test.ts +++ b/tests/viewport/file-library.test.ts @@ -31,6 +31,14 @@ class HandLibrary extends FileLibrary { protected keep(file: string, loaded: string): void { this.kept.set(file, loaded); } + + protected holds(file: string): boolean { + return this.kept.has(file); + } + + get waiting(): number { + return this.pending.length; + } } describe('the file library', () => { @@ -64,4 +72,17 @@ describe('the file library', () => { await library.land(); expect(gone).not.toHaveBeenCalled(); }); + + it('preloads every file once, before anything draws with it', async () => { + const library = new HandLibrary(); + library.preload(['a.png', 'b.png', 'a.png', '']); + expect(library.waiting).toBe(2); + // Asked again while on its way, and again once it is held: no second fetch either time. + library.preload(['a.png', 'b.png']); + expect(library.waiting).toBe(2); + await library.land(); + expect([...library.kept.keys()]).toEqual(['a.png', 'b.png']); + library.preload(['a.png', 'b.png', 'c.png']); + expect(library.waiting).toBe(1); + }); }); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 3657e6fd..163596c6 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -962,6 +962,19 @@ export async function tilemap(t) { const inMemory = await json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.map((l) => l.cells)`, ); + // For 40k-ii: a picture of its own that nothing wears, so only loading ahead can bring it in. + await evaluate(`(async () => { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 8; + const g = canvas.getContext('2d'); + g.fillStyle = 'rgb(201, 17, 99)'; + g.fillRect(0, 0, 8, 8); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + const transfer = new DataTransfer(); + transfer.items.add(new File([blob], 'unworn.png', { type: 'image/png' })); + window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); + })()`); + await t.sleep(2500); await saved(); const storedMap = (await api(`projects/${projectId}`)).project.assets.find((a) => a.id === made.id); const storedLayers = storedMap?.layers ?? []; @@ -984,6 +997,20 @@ export async function tilemap(t) { JSON.stringify({ reloaded, inMemory }), ); + // 40k-ii. Every picture the project names is loaded right after opening, not on its first draw: + // a tile stepping to a picture fetched only then blinked out once. "unworn" was pasted + // above and is placed nowhere. + let pictures = await json('__gm.heldPictures()'); + for (let i = 0; i < 20 && !pictures.every((p) => p.held); i++) { + await t.sleep(150); + pictures = await json('__gm.heldPictures()'); + } + check( + 'every picture of the project is in memory after opening, drawn anywhere or not', + pictures.some((p) => p.name === 'unworn') && pictures.every((p) => p.held), + JSON.stringify(pictures), + ); + // 40l. Collision (#132). The switch sits on the tile, and the map outlines where it now blocks. // The map holds a 3 × 3 block and, on the second layer, one cell at (4, 0). await openTab(t, 'scene');