diff --git a/src/core/model/generated.ts b/src/core/model/generated.ts new file mode 100644 index 00000000..ac30c196 --- /dev/null +++ b/src/core/model/generated.ts @@ -0,0 +1,76 @@ +/** + * Pictures that are drawn rather than uploaded – vector art (#129), the pixel editor (#130). + * + * A picture is named by `TextureRef.file` everywhere: the sprite, the tile, the tilemap mesh, the + * particle source and the picker all pass that string around. A generated picture therefore gets a + * key of its own, `gen:@`, which no uploaded file can look like (those are + * `.`, see `isTextureFile`). Whoever draws it is asked for it under that key. + * + * The revision is the other half of the contract: it changes whenever the drawing changes, so a + * changed picture is a new key. Nothing has to ask "is this still valid" – the library rasterises + * the key it is given and lets the old one go. A counter and a fingerprint of the content both + * work; a fingerprint cannot be forgotten in a command, which is why the token is a string. + */ +import type { Asset } from './Asset'; + +const PREFIX = 'gen:'; + +/** Anything but `@`, which separates the two halves, and nothing empty. */ +const TOKEN = /^[^@]+$/; + +/** What an asset that draws its own picture tells the picture path. */ +export interface GeneratedPicture { + /** Canvas size in pixels; the sprite takes its size from it, as it does from an image. */ + width: number; + height: number; + /** Changes with the drawing – a counter or a fingerprint of the content. */ + revision: string | number; +} + +/** Reads the canvas of one asset type; null when this asset draws nothing (yet). */ +export type PictureSource = (asset: Asset) => GeneratedPicture | null; + +/** + * Keyed by asset type as a plain string, not by `AssetType`: a plugin may bring an asset type of + * its own (#126), and the picture path has no reason to know the closed list. + */ +const sources = new Map(); + +/** Registers the reader for an asset type; the returned function takes it back out again. */ +export function registerPictureSource(type: string, read: PictureSource): () => void { + sources.set(type, read); + return () => { + if (sources.get(type) === read) sources.delete(type); + }; +} + +export function generatesPictures(type: string): boolean { + return sources.has(type); +} + +/** The canvas this asset draws, or null when its type draws none. */ +export function generatedPictureOf(asset: Asset): GeneratedPicture | null { + const picture = sources.get(asset.type)?.(asset) ?? null; + if (!picture) return null; + return picture.width > 0 && picture.height > 0 ? picture : null; +} + +export function generatedKey(assetId: string, revision: string | number): string { + return `${PREFIX}${assetId}@${revision}`; +} + +export function isGeneratedFile(file: string): boolean { + return parseGeneratedKey(file) !== null; +} + +/** The two halves of a key, or null for anything that is not one – an uploaded file included. */ +export function parseGeneratedKey(file: string): { assetId: string; revision: string } | null { + if (!file.startsWith(PREFIX)) return null; + const rest = file.slice(PREFIX.length); + const at = rest.lastIndexOf('@'); + if (at <= 0) return null; + const assetId = rest.slice(0, at); + const revision = rest.slice(at + 1); + if (!TOKEN.test(assetId) || !TOKEN.test(revision)) return null; + return { assetId, revision }; +} diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 1c12eb4e..26efc0e6 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -1,4 +1,5 @@ -import type { FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset'; +import type { Asset, FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset'; +import { generatedKey, generatedPictureOf } from './generated'; import { type PropertyBag, textOf, type Vec2 } from './Properties'; import { findAsset, type Project } from './Project'; @@ -85,15 +86,33 @@ export function resolveTexture(project: Project, props: PropertyBag): TextureRef return resolvePicture(project, textOf(props.texture ?? '')); } -/** A texture or frame id; any other asset's id is no picture. */ +/** A texture, a frame or a generating asset; any other asset's id is no picture. */ export function resolvePicture(project: Project, id: string): TextureRef | null { if (!id) return null; const asset = findAsset(project, id); - if (asset) return asset.type === 'texture' ? refFor(asset, null) : null; + if (asset) return asset.type === 'texture' ? refFor(asset, null) : generatedRef(asset); const found = findFrame(project, id); return found ? refFor(found.asset, found.frame) : null; } +/** + * An asset that draws its own picture (#185) reads like a texture of its canvas size: the key + * stands where the file name stands, and the size is what a new sprite is measured by. No frames + * and no slicing – those are cut out of an uploaded sheet. + */ +function generatedRef(asset: Asset): TextureRef | null { + const picture = generatedPictureOf(asset); + if (!picture) return null; + return { + file: generatedKey(asset.id, picture.revision), + smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest', + rect: null, + sourceWidth: picture.width, + sourceHeight: picture.height, + slice: null, + }; +} + function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | null { if (!isTextureFile(asset.file)) return null; return { diff --git a/src/core/properties/textureRef.ts b/src/core/properties/textureRef.ts index 2857858e..d4dc461a 100644 --- a/src/core/properties/textureRef.ts +++ b/src/core/properties/textureRef.ts @@ -1,4 +1,5 @@ -import type { TextureAsset, TextureFrame } from '../model/Asset'; +import type { Asset, TextureFrame } from '../model/Asset'; +import { generatesPictures } from '../model/generated'; import { findAsset, type Project } from '../model/Project'; import { findFrame } from '../model/texture'; @@ -10,11 +11,17 @@ export interface TextureChoice { /** Asset id for the whole picture, frame id for one cut-out of it. */ id: string; label: string; - asset: TextureAsset; + /** The texture, or the asset that draws its own picture (#185). */ + asset: Asset; /** Null for the whole-picture entry. */ frame: TextureFrame | null; } +/** An asset a texture field may name: an uploaded picture or a drawn one. */ +function isPictureAsset(asset: Asset): boolean { + return asset.type === 'texture' || generatesPictures(asset.type); +} + /** * The texture's name, "Sheet / Frame" for a frame, "None" for the empty id, `?` when it no * longer resolves – a dangling reference shows its id rather than passing for an empty field. @@ -24,23 +31,22 @@ export function textureRefLabel(project: Project | null, id: string): string { if (id === '') return NO_TEXTURE_LABEL; if (!project) return `${id}?`; const asset = findAsset(project, id); - if (asset) return asset.type === 'texture' ? asset.name : `${id}?`; + if (asset) return isPictureAsset(asset) ? asset.name : `${id}?`; const found = findFrame(project, id); return found ? `${found.asset.name}${FRAME_SEPARATOR}${found.frame.name}` : `${id}?`; } /** - * Every still picture, the choices of a texture field: each texture followed by its own frames. Textures are sorted by name so the - * order does not depend on upload time; frames keep creation order, which for a sliced sheet is - * reading order. + * Every still picture, the choices of a texture field: each texture followed by its own frames, + * and every asset that draws its own picture (#185) among them. Sorted by name so the order does + * not depend on upload time; frames keep creation order, which for a sliced sheet is reading order. */ export function pictureChoices(project: Project): TextureChoice[] { - const textures = project.assets - .filter((a): a is TextureAsset => a.type === 'texture') - .sort((a, b) => a.name.localeCompare(b.name)); + const pictures = project.assets.filter(isPictureAsset).sort((a, b) => a.name.localeCompare(b.name)); const choices: TextureChoice[] = []; - for (const asset of textures) { + for (const asset of pictures) { choices.push({ id: asset.id, label: asset.name, asset, frame: null }); + if (asset.type !== 'texture') continue; for (const frame of asset.frames) choices.push({ id: frame.id, diff --git a/src/editor/App.ts b/src/editor/App.ts index 10291fba..918e9e4a 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -8,6 +8,7 @@ import { loadPhysics } from '../runtime/physics/engine'; import { requestRender, type VStack } from '../ui'; import { fontLibrary } from '../viewport/FontLibrary'; import { soundLibrary } from '../viewport/SoundLibrary'; +import { projectPictureFactory } from '../viewport/generatedPictures'; import { textureLibrary } from '../viewport/TextureLibrary'; import { LoginScreen } from './auth/LoginScreen'; import { installLoginHook } from './auth/loginHook'; @@ -81,6 +82,9 @@ export async function startEditor(app: Application, root: VStack): Promise // also what tells two tabs on two accounts apart. document.title = `Game Maker – ${user}`; const state = new EditorState(); + // 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)); const editor = new Editor( app, root, diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 57a54011..5e3cdfc7 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -10,6 +10,7 @@ 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 { generatesPictures } from '../core/model/generated'; import { isLiveInstance, isTemplateContent } from '../core/model/instance'; import type { Command } from '../core/commands/Command'; import { findAnnotation } from '../core/graph'; @@ -109,8 +110,10 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void { // report "unused". Its frames count as the texture itself. const ids = asset.type === 'texture' ? textureRefIds(asset) : [assetId]; const references = findPropertyUsages(state.project, ids).length; - const animations = asset.type === 'texture' ? animationsUsing(state.project, ids).length : 0; - const tiles = asset.type === 'texture' ? tilesUsing(state.project, ids).length : 0; + // An asset that draws its own picture (#185) is worn the same way, only without frames. + const picture = asset.type === 'texture' || generatesPictures(asset.type); + const animations = picture ? animationsUsing(state.project, ids).length : 0; + const tiles = picture ? tilesUsing(state.project, ids).length : 0; const detail = asset.type === 'scene' ? `The scene and its ${asset.objects.length} object(s) are removed.` diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 63309d29..9ab60d7a 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -12,6 +12,7 @@ import { oneStep } from '../../core/commands/CompoundCommand'; import { nextAssetName } from '../../core/commands/naming'; import { ASSET_TYPE_LABELS, type AssetType, isPlaceableType } from '../../core/model/AssetTypes'; import type { TextureAsset } from '../../core/model/Asset'; +import { generatesPictures } from '../../core/model/generated'; import type { PropertyBag } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; import { spriteFromTexture } from '../../core/properties/aspect'; @@ -305,12 +306,15 @@ export class AssetPanel extends Panel { onSelect: () => this.state.execute(new SetStartSceneCommand(isStart ? '' : id)), }); } - if (asset.type === 'texture') { + // A drawn picture (#185) is worn like a texture, so it makes a sprite the same way. Only the + // animation needs frames, which a drawn picture has none of. + if (asset.type === 'texture' || generatesPictures(asset.type)) { items.push({ - label: 'Create sprite from texture', + label: asset.type === 'texture' ? 'Create sprite from texture' : 'Create sprite from picture', onSelect: () => this.createAsset('sprite', asset.name, spriteFromTexture(this.state.project, id)), }); - items.push({ label: 'Create animation from texture', onSelect: () => this.createAnimation(asset) }); + if (asset.type === 'texture') + items.push({ label: 'Create animation from texture', onSelect: () => this.createAnimation(asset) }); } items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) }); const menu = new Menu(items); diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index a2509222..0f283c1d 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -1,7 +1,9 @@ import { Assets, CanvasSource, Texture } from 'pixi.js'; +import { isGeneratedFile } from '../core/model/generated'; import type { FrameRect, TextureRef } from '../core/model/texture'; import { type EdgeMode, SHEET_EDGE } from '../core/shader/nodes'; import { FileLibrary } from './FileLibrary'; +import { GeneratedPictures, type PictureFactory } from './generatedPictures'; /** * Pixi turns `clamp-to-edge` into `repeat` on every texture a Graphics fill draws with @@ -26,11 +28,21 @@ export class TextureLibrary extends FileLibrary { private readonly loaded = new Map(); /** Frames cut from their sheets, keyed by file and rectangle; kept like the files. */ private readonly crops = new Map(); + /** Pictures drawn instead of uploaded (#185); a new revision replaces the one before it. */ + private readonly generated = new GeneratedPictures((texture, key) => { + this.dropCropsOf(key); + texture.destroy(true); + }); constructor() { super('/game-maker/data/textures', 'textures'); } + /** Installs who draws generated pictures; without one, a generated key stays empty. */ + setPictureFactory(factory: PictureFactory | null): void { + this.generated.setFactory(factory); + } + /** * The picture if it is in memory, otherwise null while it loads. The scale mode belongs to the * shared source, so two assets naming the same file with different `smooth` fight over it and @@ -58,6 +70,7 @@ export class TextureLibrary extends FileLibrary { * a picture the scene is drawing with. */ load(file: string): Texture | null { + if (isGeneratedFile(file)) return this.generated.get(file); const texture = this.loaded.get(file); if (!texture) { this.startLoading(file); @@ -69,7 +82,7 @@ export class TextureLibrary extends FileLibrary { /** The picture, or one of its cut frames, if in memory – without a load or a scale-mode change. */ peek(file: string, rect: FrameRect | null = null): Texture | null { if (rect) return this.crops.get(cropKey(file, rect)) ?? null; - return this.loaded.get(file) ?? null; + return this.loaded.get(file) ?? this.generated.peek(file); } /** @@ -82,8 +95,11 @@ export class TextureLibrary extends FileLibrary { const key = edge === SHEET_EDGE ? cropKey(file, rect) : `${cropKey(file, rect)}|${edge}`; let texture = this.crops.get(key); if (!texture) { + // A generated picture lives on the GPU and has no image to read back, so it is handed out + // whole: a drawn picture has no sheet it could show a neighbouring row of anyway. + const picture = sheet.source.resource as CanvasImageSource | null; + if (!picture) return sheet; const canvas = new OffscreenCanvas(rect.w, rect.h); - const picture = sheet.source.resource as CanvasImageSource; canvas.getContext('2d')?.drawImage(picture, rect.x, rect.y, rect.w, rect.h, 0, 0, rect.w, rect.h); texture = new Texture({ source: new CanvasSource({ resource: canvas, addressMode: ADDRESS_MODE[edge] }), @@ -97,6 +113,15 @@ export class TextureLibrary extends FileLibrary { return Assets.load(url); } + /** Cuts taken from a picture that has moved on to a newer revision; the key is their prefix. */ + private dropCropsOf(file: string): void { + for (const [key, texture] of this.crops) + if (key.startsWith(`${file}|`)) { + this.crops.delete(key); + texture.destroy(true); + } + } + protected keep(file: string, texture: Texture): void { texture.source.style.addressMode = EDGE_MODE; texture.source.style.update(); diff --git a/src/viewport/generatedPictures.ts b/src/viewport/generatedPictures.ts new file mode 100644 index 00000000..c626ab45 --- /dev/null +++ b/src/viewport/generatedPictures.ts @@ -0,0 +1,104 @@ +/** + * The drawn half of the picture path (#185): everything that keeps a generated picture in memory, + * without touching the GPU, so the bookkeeping can be measured without a browser. + * + * One picture per asset, keyed by `gen:@`. A new revision is a new key, and + * making it frees the one the asset had before – that is the whole invalidation rule. The library + * owns what a generator hands it: it destroys the picture when the revision moves on. + */ +import type { Texture } from 'pixi.js'; +import type { Asset } from '../core/model/Asset'; +import { parseGeneratedKey } from '../core/model/generated'; +import { findAsset, type Project } from '../core/model/Project'; + +/** Draws the picture of one asset, or null while it cannot be drawn. */ +export type PictureFactory = (assetId: string) => T | null; + +export class GeneratedPictures { + private readonly kept = new Map(); + private factory: PictureFactory | null = null; + + /** `free` gives a picture back, by key, once its asset has moved on to a newer revision. */ + constructor(private readonly free: (picture: T, key: string) => void) {} + + /** Installs who draws; a session change swaps it, and everything drawn for the old one goes. */ + setFactory(factory: PictureFactory | null): void { + this.clear(); + this.factory = factory; + } + + /** The picture for this key, drawn on the first ask. Null for anything that is not a key. */ + get(key: string): T | null { + const kept = this.kept.get(key); + if (kept) return kept.picture; + const parsed = parseGeneratedKey(key); + if (!parsed || !this.factory) return null; + const picture = this.factory(parsed.assetId); + // No picture is not an error and is not remembered: an asset may be drawn a frame later. + if (picture === null) return null; + this.release(parsed.assetId); + this.kept.set(key, { assetId: parsed.assetId, picture }); + return picture; + } + + /** What is in memory, without drawing anything. */ + peek(key: string): T | null { + return this.kept.get(key)?.picture ?? null; + } + + has(key: string): boolean { + return this.kept.has(key); + } + + /** The keys held, in the order they were drawn – what a test reads to see a revision go. */ + keys(): string[] { + return [...this.kept.keys()]; + } + + clear(): void { + for (const key of this.keys()) this.drop(key); + } + + /** Every revision of one asset, however old. */ + private release(assetId: string): void { + for (const [key, held] of this.kept) if (held.assetId === assetId) this.drop(key); + } + + private drop(key: string): void { + const held = this.kept.get(key); + if (!held) return; + this.kept.delete(key); + this.free(held.picture, key); + } +} + +/** + * Who draws an asset type, keyed by type as a plain string for the same reason the readers in + * `core/model/generated.ts` are: a plugin may bring a type of its own. + */ +export type PictureGenerator = (asset: Asset) => Texture | null; + +const generators = new Map(); + +export function registerPictureGenerator(type: string, draw: PictureGenerator): () => void { + generators.set(type, draw); + return () => { + if (generators.get(type) === draw) generators.delete(type); + }; +} + +export function pictureGeneratorFor(type: string): PictureGenerator | null { + return generators.get(type) ?? null; +} + +/** + * The factory the library runs with: it looks the asset up in whatever project is open at that + * moment and hands it to the generator of its type. Nothing is drawn for an asset that is gone. + */ +export function projectPictureFactory(projectOf: () => Project | null): PictureFactory { + return (assetId) => { + const project = projectOf(); + const asset = project ? findAsset(project, assetId) : null; + return asset ? (pictureGeneratorFor(asset.type)?.(asset) ?? null) : null; + }; +} diff --git a/tests/core/generated-picture.test.ts b/tests/core/generated-picture.test.ts new file mode 100644 index 00000000..9c1d25ba --- /dev/null +++ b/tests/core/generated-picture.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands'; +import type { Asset } from '../../src/core/model/Asset'; +import type { PropertyBag } from '../../src/core/model/Properties'; +import { + generatedKey, + generatedPictureOf, + generatesPictures, + isGeneratedFile, + parseGeneratedKey, + registerPictureSource, +} from '../../src/core/model/generated'; +import { findAsset } from '../../src/core/model/Project'; +import { resolvePicture } from '../../src/core/model/texture'; +import { spriteFromTexture } from '../../src/core/properties/aspect'; +import { pictureChoices, textureRefLabel } from '../../src/core/properties/textureRef'; +import { makeSpriteScene } from '../helpers'; + +const FILE = `${'ab'.repeat(32)}.png`; + +/** + * Stands in for the first real generator (#129): an asset type outside the closed list, which is + * also what a plugin would bring. The drawing itself is beside the point here – what is measured + * is the path a canvas size and a revision take through the model. + */ +interface DrawnAsset { + id: string; + type: string; + name: string; + props: PropertyBag; + canvas: { w: number; h: number }; + revision: number; +} + +let unregister: (() => void) | null = null; + +function register(): void { + unregister?.(); + unregister = registerPictureSource('drawing', (asset) => { + const drawn = asset as unknown as DrawnAsset; + return { width: drawn.canvas.w, height: drawn.canvas.h, revision: drawn.revision }; + }); +} + +afterEach(() => { + unregister?.(); + unregister = null; +}); + +function sceneWithDrawing(name = 'Logo') { + const fixture = makeSpriteScene(); + const drawing: DrawnAsset = { + id: 'draw-1', + type: 'drawing', + name, + props: {}, + canvas: { w: 256, h: 128 }, + revision: 1, + }; + fixture.project.assets.push(drawing as unknown as Asset); + register(); + return { ...fixture, drawing }; +} + +describe('the key a drawn picture is named by', () => { + it('survives being built and taken apart again', () => { + expect(generatedKey('a1', 7)).toBe('gen:a1@7'); + expect(parseGeneratedKey('gen:a1@7')).toEqual({ assetId: 'a1', revision: '7' }); + // A fingerprint is a revision as good as a counter – it cannot be forgotten in a command. + expect(parseGeneratedKey(generatedKey('a1', 'c0ffee'))).toEqual({ assetId: 'a1', revision: 'c0ffee' }); + }); + + it('is nothing an uploaded file could be mistaken for', () => { + expect(isGeneratedFile(FILE)).toBe(false); + expect(isGeneratedFile('')).toBe(false); + expect(isGeneratedFile('gen:a1')).toBe(false); + expect(isGeneratedFile('gen:@7')).toBe(false); + expect(isGeneratedFile('gen:a1@')).toBe(false); + expect(isGeneratedFile('a1@7')).toBe(false); + }); +}); + +describe('an asset that draws its own picture', () => { + it('is no picture at all while nothing is registered for its type', () => { + const f = sceneWithDrawing(); + unregister?.(); + unregister = null; + expect(generatesPictures('drawing')).toBe(false); + expect(generatedPictureOf(f.drawing as unknown as Asset)).toBeNull(); + expect(resolvePicture(f.project, f.drawing.id)).toBeNull(); + expect(textureRefLabel(f.project, f.drawing.id)).toBe(`${f.drawing.id}?`); + }); + + it('resolves to its canvas, under the key of its revision', () => { + const f = sceneWithDrawing(); + expect(resolvePicture(f.project, f.drawing.id)).toEqual({ + file: 'gen:draw-1@1', + smooth: true, + rect: null, + sourceWidth: 256, + sourceHeight: 128, + slice: null, + }); + }); + + it('reads as a new picture once the drawing has moved on', () => { + const f = sceneWithDrawing(); + const before = resolvePicture(f.project, f.drawing.id)?.file; + f.drawing.revision = 2; + f.drawing.canvas = { w: 64, h: 64 }; + const after = resolvePicture(f.project, f.drawing.id); + expect(after?.file).not.toBe(before); + expect(after?.file).toBe('gen:draw-1@2'); + expect([after?.sourceWidth, after?.sourceHeight]).toEqual([64, 64]); + }); + + it('has nothing to show while its canvas has no size', () => { + const f = sceneWithDrawing(); + f.drawing.canvas = { w: 0, h: 128 }; + expect(resolvePicture(f.project, f.drawing.id)).toBeNull(); + }); + + it('takes the filter it is worn with, like an uploaded picture', () => { + const f = sceneWithDrawing(); + expect(resolvePicture(f.project, f.drawing.id)?.smooth).toBe(true); + f.drawing.props.filter = 'nearest'; + expect(resolvePicture(f.project, f.drawing.id)?.smooth).toBe(false); + }); + + it('measures a new sprite, as an uploaded picture does', () => { + const f = sceneWithDrawing(); + expect(spriteFromTexture(f.project, f.drawing.id)).toEqual({ + texture: f.drawing.id, + width: 256, + height: 128, + }); + }); + + it('stands among the textures in a texture field, by name and without frames', () => { + const f = sceneWithDrawing('Alpha'); + const texture = new CreateTextureCommand('Bricks', { file: FILE, width: 64, height: 48 }); + f.stack.execute(texture); + texture.asset.frames.push({ id: 'f1', name: 'Idle', x: 0, y: 0, w: 32, h: 24 }); + f.stack.execute(new CreateAssetCommand('animation', 'Walk')); + + expect(pictureChoices(f.project).map((c) => [c.id, c.label])).toEqual([ + [f.drawing.id, 'Alpha'], + [texture.asset.id, 'Bricks'], + ['f1', 'Bricks / Idle'], + ]); + expect(textureRefLabel(f.project, f.drawing.id)).toBe('Alpha'); + }); + + it('is worn by a sprite through the same property an uploaded picture is', () => { + const f = sceneWithDrawing(); + const sprite = findAsset(f.project, f.spriteId)!; + sprite.props.texture = f.drawing.id; + expect(resolvePicture(f.project, String(sprite.props.texture))?.file).toBe('gen:draw-1@1'); + }); +}); diff --git a/tests/viewport/generated-pictures.test.ts b/tests/viewport/generated-pictures.test.ts new file mode 100644 index 00000000..7526eddb --- /dev/null +++ b/tests/viewport/generated-pictures.test.ts @@ -0,0 +1,127 @@ +import type { Texture } from 'pixi.js'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { registerPictureSource } from '../../src/core/model/generated'; +import { + GeneratedPictures, + projectPictureFactory, + registerPictureGenerator, +} from '../../src/viewport/generatedPictures'; +import { makeSpriteScene } from '../helpers'; + +/** The pictures are plain strings here: what is measured is who is kept and who is let go. */ +function library() { + const freed: string[] = []; + const drawn: string[] = []; + const pictures = new GeneratedPictures((picture) => freed.push(picture)); + let answer: (assetId: string) => string | null = (assetId) => assetId; + pictures.setFactory((assetId) => { + drawn.push(assetId); + return answer(assetId); + }); + return { + pictures, + freed, + drawn, + answerWith: (fn: (assetId: string) => string | null) => { + answer = fn; + }, + }; +} + +describe('generated pictures kept in memory', () => { + it('draws a key once and hands the same picture out again', () => { + const f = library(); + f.answerWith(() => 'first'); + expect(f.pictures.get('gen:a@1')).toBe('first'); + expect(f.pictures.get('gen:a@1')).toBe('first'); + expect(f.drawn).toEqual(['a']); + expect(f.freed).toEqual([]); + }); + + it('lets the old revision go the moment the new one is drawn', () => { + const f = library(); + f.answerWith((id) => `${id}-one`); + f.pictures.get('gen:a@1'); + f.answerWith((id) => `${id}-two`); + expect(f.pictures.get('gen:a@2')).toBe('a-two'); + expect(f.freed).toEqual(['a-one']); + expect(f.pictures.keys()).toEqual(['gen:a@2']); + // Only that asset's own older picture goes; a neighbour keeps hers. + f.answerWith((id) => `${id}-b`); + f.pictures.get('gen:b@1'); + f.pictures.get('gen:a@3'); + expect(f.freed).toEqual(['a-one', 'a-two']); + expect(f.pictures.keys()).toEqual(['gen:b@1', 'gen:a@3']); + }); + + it('asks again for a picture that could not be drawn yet', () => { + const f = library(); + f.answerWith(() => null); + expect(f.pictures.get('gen:a@1')).toBeNull(); + expect(f.pictures.has('gen:a@1')).toBe(false); + f.answerWith(() => 'ready'); + expect(f.pictures.get('gen:a@1')).toBe('ready'); + expect(f.drawn).toEqual(['a', 'a']); + }); + + it('draws nothing for a name that is not a key', () => { + const f = library(); + expect(f.pictures.get(`${'ab'.repeat(32)}.png`)).toBeNull(); + expect(f.pictures.get('gen:a')).toBeNull(); + expect(f.drawn).toEqual([]); + }); + + it('gives everything back when another session takes over', () => { + const f = library(); + f.pictures.get('gen:a@1'); + f.pictures.get('gen:b@1'); + f.pictures.setFactory(null); + expect(f.freed).toEqual(['a', 'b']); + expect(f.pictures.keys()).toEqual([]); + expect(f.pictures.get('gen:a@1')).toBeNull(); + }); + + it('answers peek only with what has been drawn', () => { + const f = library(); + expect(f.pictures.peek('gen:a@1')).toBeNull(); + f.pictures.get('gen:a@1'); + expect(f.pictures.peek('gen:a@1')).toBe('a'); + expect(f.drawn).toEqual(['a']); + }); +}); + +describe('the factory the editor runs with', () => { + const registered: (() => void)[] = []; + afterEach(() => { + for (const undo of registered.splice(0)) undo(); + }); + + it('hands an asset to the generator of its type and nothing else to anyone', () => { + const f = makeSpriteScene(); + const sound = new CreateAssetCommand('sound', 'Beep'); + f.stack.execute(sound); + registered.push(registerPictureSource('sound', () => ({ width: 8, height: 8, revision: 1 }))); + const seen: string[] = []; + const picture = {} as Texture; + registered.push( + registerPictureGenerator('sound', (asset) => { + seen.push(asset.name); + return picture; + }), + ); + + const factory = projectPictureFactory(() => f.project); + expect(factory(sound.asset.id)).toBe(picture); + expect(seen).toEqual(['Beep']); + // A sprite has no generator, and an id that is gone has no asset. + expect(factory(f.spriteId)).toBeNull(); + expect(factory('nobody')).toBeNull(); + expect(seen).toEqual(['Beep']); + }); + + it('draws nothing while no project is open', () => { + const factory = projectPictureFactory(() => null); + expect(factory('a')).toBeNull(); + }); +});