/** * 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'; import type { Project } from './Project'; 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). The project is there * for a drawing that shows another asset – a vector shape filled with a texture (#186). */ export type PictureSource = (asset: Asset, project?: Project) => 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, project?: Project): GeneratedPicture | null { const picture = sources.get(asset.type)?.(asset, project) ?? null; if (!picture) return null; return picture.width > 0 && picture.height > 0 ? picture : null; } /** FNV-1a over the text, in base 36: short, stable across sessions, and free of `@`. */ export function fingerprint(text: string): string { let hash = 0x811c9dc5; for (let i = 0; i < text.length; i++) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 0x01000193) >>> 0; } return hash.toString(36); } 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 }; }