A picture in the editor may now be drawn instead of uploaded, which is the piece #129 (Vector art) and #130 (Pixel editor) both need and neither could build alone: TextureRef.file has been a file name on the server, isTextureFile() checking a sha256 hash plus an extension and TextureLibrary fetching it over HTTP, so an asset that paints its own image had nowhere to put itself - the key now also takes the form gen:<assetId>@<revision>, which an uploaded file can never look like, and everything that only passes a ref along (sprite, tile, tilemap mesh, particle source, shaders, aspect ratio) is untouched because it still sees a Texture; the revision is a string, not a counter, which is the one deviation from the plan in the issue and costs nothing - gen:a1@7 and gen:a1@c0ffee go through the same door, so a generator may fingerprint its own content instead of relying on every command to remember to bump a number; two registries keyed by asset type as a plain string, so a plugin may bring its own type - registerPictureSource(type, read) in the new core/model/generated.ts tells the model canvas size and revision and resolvePicture() builds the ref from it (no rect, no slice, smooth from props.filter like a texture), and registerPictureGenerator(type, draw) in the new viewport/generatedPictures.ts hands out the finished texture, projectPictureFactory(() => state.project) tying the two together per session in App.ts; invalidation is a rule instead of guesswork at twenty call sites: GeneratedPictures<T> holds one picture per asset keyed on the revision and frees the old one as soon as the new is drawn, TextureLibrary destroying it and dropping the crops cut from it, while a generator that cannot draw yet is not remembered but asked again next time - the class imports Pixi as a type only, so the whole bookkeeping is measurable without a GPU; carried along are the picture picker (a drawn asset sits among the textures by name, without frames), the delete warning (counts tiles and animations for it too) and the asset context menu, where "Create sprite from picture" now covers it while "Create animation from texture" stays texture-only because it needs frames; one finding while probing - crop() could not have cut a generated texture at all, a RenderTexture having no source.resource to draw back from and drawImage running onto null, so it is handed out whole, a drawn picture having no sheet whose neighbouring row could bleed in; 18 new unit tests, 10 in tests/core/generated-picture.test.ts (building and splitting the key, an uploaded file is not one, no registry means no picture, resolving to canvas size, a new revision is a new key, a canvas without a size, the filter, the sprite size, picker order) and 8 in tests/viewport/generated-pictures.test.ts (drawing once, freeing the old revision and only for its own asset, null is not remembered, no key means nothing drawn, a session change gives everything back, peek), and three counter-checks hold: dropping generated assets from resolvePicture fails 5 of the 10 core tests, not freeing the old revision fails 1 viewport test, and narrowing the picker back to textures fails 3; no e2e check and the check sum stays 1157, because without a generator the groundwork shows nothing in the browser - that evidence comes with #129 (#185)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eac50e4194
commit
cb1804dcf2
10 files changed
+548
-20
No files matched your search
@@ -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:<assetId>@<revision>`, which no uploaded file can look like (those are
|
||||
* `<sha256>.<ext>`, 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<string, PictureSource>();
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, `<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,
|
||||
|
||||
@@ -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<void>
|
||||
// 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,
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Texture> {
|
||||
private readonly loaded = new Map<string, Texture>();
|
||||
/** Frames cut from their sheets, keyed by file and rectangle; kept like the files. */
|
||||
private readonly crops = new Map<string, Texture>();
|
||||
/** Pictures drawn instead of uploaded (#185); a new revision replaces the one before it. */
|
||||
private readonly generated = new GeneratedPictures<Texture>((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<Texture> | 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<Texture> {
|
||||
* 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<Texture> {
|
||||
/** 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<Texture> {
|
||||
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<Texture> {
|
||||
return Assets.load<Texture>(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();
|
||||
|
||||
@@ -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:<assetId>@<revision>`. 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<T> = (assetId: string) => T | null;
|
||||
|
||||
export class GeneratedPictures<T> {
|
||||
private readonly kept = new Map<string, { assetId: string; picture: T }>();
|
||||
private factory: PictureFactory<T> | 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<T> | 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<string, PictureGenerator>();
|
||||
|
||||
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<Texture> {
|
||||
return (assetId) => {
|
||||
const project = projectOf();
|
||||
const asset = project ? findAsset(project, assetId) : null;
|
||||
return asset ? (pictureGeneratorFor(asset.type)?.(asset) ?? null) : null;
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string>((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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user