@bloopjs/toodle 0.0.102 → 0.0.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,541 @@
1
+ /**
2
+ * Bundles - A renderer-agnostic class for managing texture bundles and atlas coordinates.
3
+ *
4
+ * This class can be used standalone (without WebGPU) for:
5
+ * - Registering pre-baked texture atlases (Pixi/AssetPack format)
6
+ * - Looking up texture regions and UV coordinates
7
+ * - Managing bundle state
8
+ *
9
+ * For WebGPU rendering, use AssetManager which wraps this class and handles GPU operations.
10
+ */
11
+
12
+ import type { Size } from "../coreTypes/Size";
13
+ import type { Vec2 } from "../coreTypes/Vec2";
14
+ import type {
15
+ AtlasBundleOpts,
16
+ AtlasCoords,
17
+ CpuTextureAtlas,
18
+ PixiRegion,
19
+ TextureRegion,
20
+ } from "./types";
21
+
22
+ export type TextureId = string;
23
+ export type BundleId = string;
24
+
25
+ type CpuBundle = {
26
+ atlases: CpuTextureAtlas[];
27
+ isLoaded: boolean;
28
+ atlasIndices: number[];
29
+ };
30
+
31
+ /**
32
+ * Options for creating a Bundles instance
33
+ */
34
+ export type BundlesOptions = {
35
+ /** The size of the texture atlas (default: 4096) */
36
+ atlasSize?: number;
37
+ };
38
+
39
+ /**
40
+ * Bundles manages texture bundle registration and atlas coordinate lookups.
41
+ *
42
+ * This is a pure TypeScript class with no WebGPU dependencies, suitable for
43
+ * use with custom renderers (e.g., WebGL fallbacks).
44
+ */
45
+ export class Bundles {
46
+ #bundles: Map<BundleId, CpuBundle> = new Map();
47
+ #textures: Map<TextureId, AtlasCoords[]> = new Map();
48
+ #atlasSize: number;
49
+
50
+ constructor(options: BundlesOptions = {}) {
51
+ this.#atlasSize = options.atlasSize ?? 4096;
52
+ }
53
+
54
+ /**
55
+ * Register a bundle of pre-baked texture atlases.
56
+ *
57
+ * @param bundleId - Unique identifier for this bundle
58
+ * @param opts - Atlas bundle options containing atlas definitions
59
+ * @returns The bundle ID
60
+ */
61
+ async registerAtlasBundle(
62
+ bundleId: BundleId,
63
+ opts: AtlasBundleOpts,
64
+ ): Promise<BundleId> {
65
+ const atlases: CpuTextureAtlas[] = [];
66
+
67
+ for (const atlas of opts.atlases) {
68
+ const jsonUrl =
69
+ atlas.json ??
70
+ new URL(
71
+ atlas.png!.toString().replace(".png", ".json"),
72
+ atlas.png!.origin,
73
+ );
74
+ const pngUrl =
75
+ atlas.png ??
76
+ new URL(
77
+ atlas.json!.toString().replace(".json", ".png"),
78
+ atlas.json!.origin,
79
+ );
80
+
81
+ const atlasDef = await (await fetch(jsonUrl)).json();
82
+
83
+ // For CPU-only usage, we may not need the actual bitmap
84
+ // but we fetch it for compatibility and to get dimensions
85
+ const bitmap = !opts.rg8
86
+ ? await this.#getBitmapFromUrl(pngUrl)
87
+ : await createImageBitmap(new ImageData(1, 1)); // placeholder if using rg8
88
+
89
+ let rg8Bytes: Uint8Array<ArrayBuffer> | undefined;
90
+ if (opts.rg8) {
91
+ const rg8url = new URL(
92
+ pngUrl.toString().replace(".png", ".rg8.gz"),
93
+ pngUrl.origin,
94
+ );
95
+ rg8Bytes = await this.#fetchRg8Bytes(rg8url);
96
+ }
97
+
98
+ const cpuTextureAtlas: CpuTextureAtlas = {
99
+ texture: bitmap,
100
+ rg8Bytes,
101
+ textureRegions: new Map(),
102
+ width: opts.rg8 ? this.#atlasSize : bitmap.width,
103
+ height: opts.rg8 ? this.#atlasSize : bitmap.height,
104
+ };
105
+
106
+ // Parse Pixi JSON format into TextureRegions
107
+ for (const [assetId, frame] of Object.entries(atlasDef.frames) as [
108
+ string,
109
+ PixiRegion,
110
+ ][]) {
111
+ const textureRegion = this.#parsePixiFrame(
112
+ frame,
113
+ cpuTextureAtlas.width,
114
+ cpuTextureAtlas.height,
115
+ );
116
+ cpuTextureAtlas.textureRegions.set(assetId, textureRegion);
117
+ }
118
+
119
+ atlases.push(cpuTextureAtlas);
120
+ }
121
+
122
+ this.#bundles.set(bundleId, {
123
+ atlases,
124
+ atlasIndices: [],
125
+ isLoaded: false,
126
+ });
127
+
128
+ return bundleId;
129
+ }
130
+
131
+ /**
132
+ * Register a bundle with pre-built CPU texture atlases.
133
+ * Used internally by AssetManager for texture bundles that require GPU packing.
134
+ *
135
+ * @param bundleId - Unique identifier for this bundle
136
+ * @param atlases - Pre-built CPU texture atlases
137
+ */
138
+ registerDynamicBundle(bundleId: BundleId, atlases: CpuTextureAtlas[]): void {
139
+ this.#bundles.set(bundleId, {
140
+ atlases,
141
+ atlasIndices: [],
142
+ isLoaded: false,
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Check if a bundle is registered.
148
+ *
149
+ * @param bundleId - The bundle ID to check
150
+ */
151
+ hasBundle(bundleId: BundleId): boolean {
152
+ return this.#bundles.has(bundleId);
153
+ }
154
+
155
+ /**
156
+ * Check if a bundle is loaded.
157
+ *
158
+ * @param bundleId - The bundle ID to check
159
+ */
160
+ isBundleLoaded(bundleId: BundleId): boolean {
161
+ const bundle = this.#bundles.get(bundleId);
162
+ return bundle?.isLoaded ?? false;
163
+ }
164
+
165
+ /**
166
+ * Get the atlas indices for a loaded bundle.
167
+ *
168
+ * @param bundleId - The bundle ID
169
+ * @returns Array of atlas indices, or empty array if not loaded
170
+ */
171
+ getBundleAtlasIndices(bundleId: BundleId): number[] {
172
+ const bundle = this.#bundles.get(bundleId);
173
+ return bundle?.atlasIndices ?? [];
174
+ }
175
+
176
+ /**
177
+ * Mark a bundle as loaded without populating texture lookups.
178
+ * Used when texture lookups are already populated via loadAtlas.
179
+ *
180
+ * @param bundleId - The bundle to mark as loaded
181
+ * @param atlasIndices - Array of atlas indices, one per atlas
182
+ */
183
+ setBundleLoaded(bundleId: BundleId, atlasIndices: number[]): void {
184
+ const bundle = this.#bundles.get(bundleId);
185
+ if (!bundle) {
186
+ throw new Error(`Bundle ${bundleId} not found`);
187
+ }
188
+ bundle.atlasIndices = atlasIndices;
189
+ bundle.isLoaded = true;
190
+ }
191
+
192
+ /**
193
+ * Mark a bundle as loaded and populate texture lookups.
194
+ * For standalone usage (without AssetManager).
195
+ *
196
+ * @param bundleId - The bundle to mark as loaded
197
+ * @param atlasIndices - Array of atlas indices, one per atlas. If not provided, indices are auto-assigned sequentially.
198
+ */
199
+ markBundleLoaded(bundleId: BundleId, atlasIndices?: number[]): void {
200
+ const bundle = this.#bundles.get(bundleId);
201
+ if (!bundle) {
202
+ throw new Error(`Bundle ${bundleId} not found`);
203
+ }
204
+
205
+ if (bundle.isLoaded) {
206
+ console.warn(`Bundle ${bundleId} is already loaded.`);
207
+ return;
208
+ }
209
+
210
+ // Use provided indices or auto-assign sequential ones
211
+ const indices =
212
+ atlasIndices ?? bundle.atlases.map(() => this.#getNextAtlasIndex());
213
+
214
+ if (indices.length !== bundle.atlases.length) {
215
+ throw new Error(
216
+ `Expected ${bundle.atlases.length} atlas indices, got ${indices.length}`,
217
+ );
218
+ }
219
+
220
+ for (let i = 0; i < bundle.atlases.length; i++) {
221
+ const atlas = bundle.atlases[i];
222
+ const atlasIndex = indices[i];
223
+ bundle.atlasIndices.push(atlasIndex);
224
+
225
+ for (const [id, region] of atlas.textureRegions) {
226
+ const coords: AtlasCoords = { ...region, atlasIndex };
227
+ const existing = this.#textures.get(id);
228
+ if (existing) {
229
+ existing.push(coords);
230
+ } else {
231
+ this.#textures.set(id, [coords]);
232
+ }
233
+ }
234
+ }
235
+
236
+ bundle.isLoaded = true;
237
+ }
238
+
239
+ /**
240
+ * Unmark a bundle as loaded and remove texture lookups.
241
+ *
242
+ * @param bundleId - The bundle to unload
243
+ */
244
+ unloadBundle(bundleId: BundleId): void {
245
+ const bundle = this.#bundles.get(bundleId);
246
+ if (!bundle) {
247
+ throw new Error(`Bundle ${bundleId} not found`);
248
+ }
249
+
250
+ if (!bundle.isLoaded) {
251
+ console.warn(`Bundle ${bundleId} is not loaded.`);
252
+ return;
253
+ }
254
+
255
+ // Remove texture entries for this bundle's atlas indices
256
+ for (const atlasIndex of bundle.atlasIndices) {
257
+ for (const [id, coords] of this.#textures.entries()) {
258
+ const indexToRemove = coords.findIndex(
259
+ (coord) => coord.atlasIndex === atlasIndex,
260
+ );
261
+ if (indexToRemove !== -1) {
262
+ coords.splice(indexToRemove, 1);
263
+ }
264
+ if (!coords.length) {
265
+ this.#textures.delete(id);
266
+ }
267
+ }
268
+ }
269
+
270
+ bundle.isLoaded = false;
271
+ bundle.atlasIndices = [];
272
+ }
273
+
274
+ /**
275
+ * A read-only map of all currently loaded textures.
276
+ */
277
+ get textures(): ReadonlyMap<TextureId, AtlasCoords[]> {
278
+ return this.#textures;
279
+ }
280
+
281
+ /**
282
+ * A read-only array of all currently loaded texture ids.
283
+ */
284
+ get textureIds(): TextureId[] {
285
+ return Array.from(this.#textures.keys());
286
+ }
287
+
288
+ /**
289
+ * Get the atlas coordinates for a texture.
290
+ *
291
+ * @param id - The texture ID
292
+ * @returns Array of atlas coordinates (may have multiple if texture exists in multiple atlases)
293
+ */
294
+ getAtlasCoords(id: TextureId): AtlasCoords[] {
295
+ const coords = this.#textures.get(id);
296
+ if (!coords) {
297
+ throw new Error(
298
+ `Texture ${id} not found. Have you registered and loaded a bundle containing this texture?`,
299
+ );
300
+ }
301
+ return coords;
302
+ }
303
+
304
+ /**
305
+ * Set the atlas coordinates for a texture.
306
+ * This allows for UV precision adjustments.
307
+ *
308
+ * @param id - The texture ID
309
+ * @param coords - The atlas coordinates to set
310
+ */
311
+ setAtlasCoords(id: TextureId, coords: AtlasCoords): void {
312
+ const oldCoords = this.#textures.get(id);
313
+ if (!oldCoords) return;
314
+ const indexToModify = oldCoords.findIndex(
315
+ (coord) => coord.atlasIndex === coords.atlasIndex,
316
+ );
317
+ if (indexToModify === -1) return;
318
+ oldCoords[indexToModify] = coords;
319
+ this.#textures.set(id, oldCoords);
320
+ }
321
+
322
+ /**
323
+ * Add atlas coordinates for a texture entry.
324
+ * Used by AssetManager.loadAtlas for textures loaded outside of bundles.
325
+ *
326
+ * @param id - The texture ID
327
+ * @param coords - The atlas coordinates to add
328
+ */
329
+ addTextureEntry(id: TextureId, coords: AtlasCoords): void {
330
+ const existing = this.#textures.get(id);
331
+ if (existing) {
332
+ existing.push(coords);
333
+ } else {
334
+ this.#textures.set(id, [coords]);
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Remove texture entries for a specific atlas index.
340
+ * Used by AssetManager.unloadAtlas.
341
+ *
342
+ * @param atlasIndex - The atlas index to remove entries for
343
+ */
344
+ removeTextureEntriesForAtlas(atlasIndex: number): void {
345
+ for (const [id, coords] of this.#textures.entries()) {
346
+ const indexToRemove = coords.findIndex(
347
+ (coord) => coord.atlasIndex === atlasIndex,
348
+ );
349
+ if (indexToRemove !== -1) {
350
+ coords.splice(indexToRemove, 1);
351
+ }
352
+ if (!coords.length) {
353
+ this.#textures.delete(id);
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Get the texture region (without atlas index) for a texture.
360
+ *
361
+ * @param id - The texture ID
362
+ * @returns The texture region, or undefined if not found
363
+ */
364
+ getTextureRegion(id: TextureId): TextureRegion | undefined {
365
+ const coords = this.#textures.get(id);
366
+ if (!coords || coords.length === 0) return undefined;
367
+
368
+ const { atlasIndex: _, ...region } = coords[0];
369
+ return region;
370
+ }
371
+
372
+ /**
373
+ * Get the crop offset for a texture.
374
+ *
375
+ * @param id - The texture ID
376
+ * @returns The crop offset vector
377
+ */
378
+ getTextureOffset(id: TextureId): Vec2 {
379
+ const coords = this.#textures.get(id);
380
+ if (!coords) {
381
+ throw new Error(
382
+ `Texture ${id} not found. Have you registered and loaded a bundle containing this texture?`,
383
+ );
384
+ }
385
+ return coords[0].cropOffset;
386
+ }
387
+
388
+ /**
389
+ * Get the original (uncropped) size of a texture.
390
+ *
391
+ * @param id - The texture ID
392
+ * @returns The original size in pixels
393
+ */
394
+ getSize(id: TextureId): Size {
395
+ const coords = this.getAtlasCoords(id);
396
+ const uvScale = coords[0].uvScale;
397
+ return {
398
+ width: uvScale.width * this.#atlasSize,
399
+ height: uvScale.height * this.#atlasSize,
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Get the cropped size of a texture.
405
+ *
406
+ * @param id - The texture ID
407
+ * @returns The cropped size in pixels
408
+ */
409
+ getCroppedSize(id: TextureId): Size {
410
+ const coords = this.getAtlasCoords(id);
411
+ const uvScaleCropped = coords[0].uvScaleCropped;
412
+ if (uvScaleCropped) {
413
+ return {
414
+ width: uvScaleCropped.width * this.#atlasSize,
415
+ height: uvScaleCropped.height * this.#atlasSize,
416
+ };
417
+ }
418
+ return this.getSize(id);
419
+ }
420
+
421
+ /**
422
+ * Check if a texture exists.
423
+ *
424
+ * @param id - The texture ID
425
+ * @returns True if the texture is registered
426
+ */
427
+ hasTexture(id: TextureId): boolean {
428
+ return this.#textures.has(id);
429
+ }
430
+
431
+ /**
432
+ * Get all registered bundle IDs.
433
+ */
434
+ getRegisteredBundleIds(): BundleId[] {
435
+ return Array.from(this.#bundles.keys());
436
+ }
437
+
438
+ /**
439
+ * Get all loaded bundle IDs.
440
+ */
441
+ getLoadedBundleIds(): BundleId[] {
442
+ return Array.from(this.#bundles.entries())
443
+ .filter(([, bundle]) => bundle.isLoaded)
444
+ .map(([id]) => id);
445
+ }
446
+
447
+ /**
448
+ * Get the CPU-side atlas data for a bundle.
449
+ * Useful for custom renderers that need access to the raw atlas data.
450
+ *
451
+ * @param bundleId - The bundle ID
452
+ * @returns Array of CPU texture atlases
453
+ */
454
+ getBundleAtlases(bundleId: BundleId): CpuTextureAtlas[] {
455
+ const bundle = this.#bundles.get(bundleId);
456
+ if (!bundle) {
457
+ throw new Error(`Bundle ${bundleId} not found`);
458
+ }
459
+ return bundle.atlases;
460
+ }
461
+
462
+ /**
463
+ * The atlas size used for coordinate calculations.
464
+ */
465
+ get atlasSize(): number {
466
+ return this.#atlasSize;
467
+ }
468
+
469
+ // --- Private helpers ---
470
+
471
+ #parsePixiFrame(
472
+ frame: PixiRegion,
473
+ atlasWidth: number,
474
+ atlasHeight: number,
475
+ ): TextureRegion {
476
+ const leftCrop = frame.spriteSourceSize.x;
477
+ const rightCrop =
478
+ frame.sourceSize.w - frame.spriteSourceSize.x - frame.spriteSourceSize.w;
479
+ const topCrop = frame.spriteSourceSize.y;
480
+ const bottomCrop =
481
+ frame.sourceSize.h - frame.spriteSourceSize.y - frame.spriteSourceSize.h;
482
+
483
+ return {
484
+ cropOffset: {
485
+ x: leftCrop - rightCrop,
486
+ y: bottomCrop - topCrop,
487
+ },
488
+ originalSize: {
489
+ width: frame.sourceSize.w,
490
+ height: frame.sourceSize.h,
491
+ },
492
+ uvOffset: {
493
+ x: frame.frame.x / atlasWidth,
494
+ y: frame.frame.y / atlasHeight,
495
+ },
496
+ uvScale: {
497
+ width: frame.sourceSize.w / atlasWidth,
498
+ height: frame.sourceSize.h / atlasHeight,
499
+ },
500
+ uvScaleCropped: {
501
+ width: frame.frame.w / atlasWidth,
502
+ height: frame.frame.h / atlasHeight,
503
+ },
504
+ };
505
+ }
506
+
507
+ async #getBitmapFromUrl(url: URL): Promise<ImageBitmap> {
508
+ const response = await fetch(url);
509
+ const blob = await response.blob();
510
+ return createImageBitmap(blob);
511
+ }
512
+
513
+ async #fetchRg8Bytes(url: URL): Promise<Uint8Array<ArrayBuffer>> {
514
+ const response = await fetch(url);
515
+ const enc = (response.headers.get("content-encoding") || "").toLowerCase();
516
+
517
+ // If server/CDN already set Content-Encoding, Fetch returns decompressed bytes
518
+ if (enc.includes("gzip") || enc.includes("br") || enc.includes("deflate")) {
519
+ return new Uint8Array(await response.arrayBuffer());
520
+ }
521
+
522
+ if (!response.body) {
523
+ throw new Error("Response body of rg8 file is null");
524
+ }
525
+
526
+ const ds = new DecompressionStream("gzip");
527
+ const ab = await new Response(response.body.pipeThrough(ds)).arrayBuffer();
528
+ return new Uint8Array(ab);
529
+ }
530
+
531
+ #getNextAtlasIndex(): number {
532
+ // Find the highest used atlas index and return the next one
533
+ let maxIndex = -1;
534
+ for (const bundle of this.#bundles.values()) {
535
+ for (const idx of bundle.atlasIndices) {
536
+ if (idx > maxIndex) maxIndex = idx;
537
+ }
538
+ }
539
+ return maxIndex + 1;
540
+ }
541
+ }
@@ -1 +1,3 @@
1
+ export type { BundlesOptions } from "./Bundles";
2
+ export { Bundles } from "./Bundles";
1
3
  export type * from "./types";
@@ -107,7 +107,8 @@ export type TextureBundleOpts = {
107
107
  */
108
108
  cropTransparentPixels?: boolean;
109
109
  /**
110
- * Whether the bundle should be loaded automatically on registration
110
+ * Whether the bundle should be loaded automatically on registration.
111
+ * @default true
111
112
  */
112
113
  autoLoad?: boolean;
113
114
  };
@@ -135,7 +136,8 @@ export type AtlasBundleOpts = {
135
136
  atlases: AtlasDef[];
136
137
 
137
138
  /**
138
- * Whether the bundle should be loaded automatically on registration
139
+ * Whether the bundle should be loaded automatically on registration.
140
+ * @default true
139
141
  */
140
142
  autoLoad?: boolean;
141
143