Skip to content

packages/core/src/features/sprites/loader.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import { defineSpriteAnimation, makeFrame, type SpriteFrame, type SpriteFrameData, type SpriteSheetFormat } from './model.js';
2 import type { PibblAnimationDefinition } from '../../lib/animation/types.js';
3 /** Shared decoded atlas and immutable frames/clips. Dispose after all consumers unmount. See {@link SpriteFrame}. */
4 export interface SpriteSheet {
5   /** Frames indexed by atlas name or numeric grid index. */ readonly frames: Readonly<Record<string | number, SpriteFrame>>;
6   /** Named, seekable animation definitions from the atlas. */ readonly animations: Readonly<Record<string, PibblAnimationDefinition<SpriteFrame>>>;
7   /** Release decoded images; unmount every consumer before calling. */ readonly dispose: () => void;
8 }
9 /** Dimensions of a regular grid, in image pixels. See {@link SpriteFrame}. */
10 export interface SpriteGrid { /** Positive cell width in pixels. */ readonly frameWidth: number; /** Positive cell height in pixels. */ readonly frameHeight: number; /** Outer border width in pixels; defaults to zero. */ readonly margin?: number; /** Gap between cells in pixels; defaults to zero. */ readonly spacing?: number }
11 /** Load a grid image or metadata through an explicitly supplied import adapter. See {@link SpriteFrame}. 
12  * @param url - Grid image URL or atlas metadata URL.
13  * @param options - Grid dimensions or an explicit metadata format adapter.
14  * @returns A decoded sprite sheet with explicit disposal ownership.
15  */
16 export async function loadSpriteSheet(url: string, options: { readonly grid: SpriteGrid } | { readonly format: SpriteSheetFormat }): Promise<SpriteSheet> {
17   const owner = { disposed: false };
18   const images: HTMLImageElement[] = [];
19   const dispose = () => { if (owner.disposed) return; owner.disposed = true; for (const image of images) image.removeAttribute('src'); };
20   const load = async (source: string) => {
21     const image = new globalThis.Image(); images.push(image); image.src = source; await image.decode(); return image;
22   };
23   try {
24     let specs: readonly SpriteFrameData[];
25     let clips: Readonly<Record<string, readonly { frame: string; duration: number }[]>> = {};
26     if ('grid' in options) {
27       const { frameWidth: w, frameHeight: h, margin = 0, spacing = 0 } = options.grid;
28       if (![w, h, margin, spacing].every(Number.isInteger) || w <= 0 || h <= 0 || margin < 0 || spacing < 0) throw new RangeError('Invalid sprite grid dimensions.');
29       const image = await load(url); const frames: SpriteFrameData[] = [];
30       for (let y = margin; y + h <= image.naturalHeight - margin; y += h + spacing)
31         for (let x = margin; x + w <= image.naturalWidth - margin; x += w + spacing)
32           frames.push({ name: String(frames.length), rect: { x, y, width: w, height: h } });
33       if (!frames.length) throw new RangeError('Sprite grid contains no complete frames.');
34       specs = frames;
35     } else {
36       const response = await fetch(url); if (!response.ok) throw new Error(`Sprite metadata failed: ${response.status}`);
37       const data = options.format.parse(await response.json());
38       for (const path of data.images) await load(new URL(path, new URL(url, document.baseURI)).href);
39       specs = data.frames; clips = data.animations ?? {};
40     }
41     const frames: Record<string, SpriteFrame> = Object.create(null);
42     for (const spec of specs) {
43       const image = images[spec.page ?? 0], r = spec.rect;
44       if (!image || ![r.x, r.y, r.width, r.height].every(Number.isFinite) || r.x < 0 || r.y < 0 || r.width <= 0 || r.height <= 0 || r.x + r.width > image.naturalWidth || r.y + r.height > image.naturalHeight) throw new RangeError(`Invalid sprite rectangle: ${spec.name}`);
45       if (frames[spec.name]) throw new Error(`Duplicate sprite frame: ${spec.name}`);
46       const width = spec.sourceSize?.width ?? (spec.rotated ? r.height : r.width);
47       const height = spec.sourceSize?.height ?? (spec.rotated ? r.width : r.height);
48       const trim = { x: spec.trim?.x ?? 0, y: spec.trim?.y ?? 0 };
49       if (![width, height, trim.x, trim.y].every(Number.isFinite) || width <= 0 || height <= 0 || trim.x < 0 || trim.y < 0 || trim.x + (spec.rotated ? r.height : r.width) > width || trim.y + (spec.rotated ? r.width : r.height) > height) throw new RangeError(`Invalid sprite logical bounds: ${spec.name}`);
50       frames[spec.name] = makeFrame({ ...spec, rect: Object.freeze({ ...r }), sourceSize: Object.freeze({ width, height }), trim: Object.freeze(trim), image, owner });
51     }
52     const animations: Record<string, PibblAnimationDefinition<SpriteFrame>> = Object.create(null);
53     for (const [name, entries] of Object.entries(clips)) animations[name] = defineSpriteAnimation({ frames: entries.map(entry => {
54       if (!frames[entry.frame]) throw new Error(`Unknown sprite frame: ${entry.frame}`);
55       return { frame: frames[entry.frame], duration: entry.duration };
56     }) });
57     return Object.freeze({ frames: Object.freeze(frames), animations: Object.freeze(animations), dispose });
58   } catch (error) { dispose(); throw error; }
59 }
60 

Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.