packages/core/src/features/sprites/loader.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.