littlejsengine 1.19.3 → 1.21.0

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.
Files changed (54) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2857 -955
  4. package/dist/littlejs.esm.js +33428 -20995
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +33386 -20996
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +27841 -20422
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +28 -24
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +786 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +148 -27
  17. package/plugins/medalSystem.js +326 -280
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +25 -0
  21. package/plugins/postProcess.js +96 -24
  22. package/plugins/render3d.js +2067 -1632
  23. package/plugins/render3dDebug.js +110 -0
  24. package/plugins/render3dEditor.js +1812 -0
  25. package/plugins/render3dExtras.js +1948 -0
  26. package/plugins/render3dLevel.js +189 -0
  27. package/plugins/render3dVoxels.js +514 -0
  28. package/plugins/sceneSystem.js +58 -0
  29. package/plugins/textureSheet.js +496 -458
  30. package/plugins/threejs.js +183 -163
  31. package/plugins/tweakables.js +411 -0
  32. package/plugins/tweenSystem.js +265 -145
  33. package/plugins/uiSystem.js +2024 -1687
  34. package/plugins/zzfxm.js +159 -159
  35. package/src/engine.js +378 -97
  36. package/src/engineAudio.js +231 -127
  37. package/src/engineBuild.mjs +292 -256
  38. package/src/engineDebug.js +967 -743
  39. package/src/engineDraw.js +391 -198
  40. package/src/engineEditor.js +2452 -0
  41. package/src/engineExport.js +18 -0
  42. package/src/engineInput.js +1466 -1293
  43. package/src/engineLogo.js +19 -0
  44. package/src/engineMath.js +1274 -1252
  45. package/src/engineObject.js +197 -82
  46. package/src/engineParticles.js +583 -532
  47. package/src/engineRelease.js +79 -45
  48. package/src/engineSettings.js +115 -29
  49. package/src/engineTileLayer.js +995 -741
  50. package/src/engineUtilities.js +19 -13
  51. package/src/engineWebGL.js +239 -82
  52. package/src/jsconfig.json +17 -13
  53. package/plugins/box2d.wasm.js +0 -630
  54. package/plugins/box2d.wasm.wasm +0 -0
@@ -1,458 +1,496 @@
1
- /**
2
- * LittleJS Texture Sheet Plugin
3
- * - Packs images into texture sheets as they are loaded
4
- * - Sprites are placed automatically, callers get a TileInfo
5
- * - Sheets are created and filled as needed
6
- * - Sheets fill in call order, images decode in parallel
7
- * - Animation frames keep layout and wrap across rows as needed
8
- * - WebGL textures upload once per batch of loads
9
- * - loadAtlas imports pre-packed atlases (TexturePacker and Aseprite json)
10
- * @namespace TextureSheets
11
- */
12
-
13
- 'use strict';
14
-
15
- /** Width and height in pixels of texture sheets created by loadSprite
16
- * @type {number}
17
- * @default
18
- * @memberof Settings */
19
- let textureSheetSize = 2048;
20
-
21
- /** Default padding pixels around each frame packed by loadSprite
22
- * @type {number}
23
- * @default
24
- * @memberof Settings */
25
- let textureSheetPadding = 1;
26
-
27
- /** Array of texture sheets created by loadSprite
28
- * @type {Array<TextureSheet>}
29
- * @memberof TextureSheets */
30
- let textureSheets = [];
31
-
32
- // pending loads pack through a queue so sheets fill in call order
33
- let textureSheetQueue = Promise.resolve();
34
- let textureSheetPendingCount = 0;
35
-
36
- /**
37
- * Texture Sheet - A texture that images are packed into as they load
38
- * Uses shelf packing, images are placed left to right then wrap to a new row
39
- * @memberof TextureSheets
40
- */
41
- class TextureSheet
42
- {
43
- /** Create a texture sheet, called automatically by loadSprite
44
- * @param {number} [size] - Width and height of the sheet in pixels */
45
- constructor(size=textureSheetSize)
46
- {
47
- ASSERT(size > 0, 'texture sheet size must be positive');
48
-
49
- /** @property {number} - Width and height of the sheet in pixels */
50
- this.size = size;
51
- /** @property {OffscreenCanvasRenderingContext2D} - 2d context for the canvas */
52
- this.context = headlessMode ? undefined : createCanvasContext(size);
53
- /** @property {OffscreenCanvas} - Canvas holding the packed images */
54
- this.canvas = this.context?.canvas;
55
- /** @property {TextureInfo} - The texture info for this sheet */
56
- this.textureInfo = new TextureInfo(this.canvas);
57
- /** @property {Vector2} - Where the next image will be packed */
58
- this.cursor = vec2();
59
- /** @property {number} - Height of the row being packed */
60
- this.rowHeight = 0;
61
- /** @property {boolean} - Has the canvas changed since the last webgl upload? */
62
- this.glDirty = false;
63
-
64
- if (headlessMode)
65
- {
66
- // tiles still need bounds when there is no canvas to measure
67
- this.textureInfo.size = vec2(size);
68
- this.textureInfo.sizeInverse = vec2(1/size);
69
- }
70
- }
71
-
72
- /** Find a spot for an image on this sheet without drawing it
73
- * @param {Vector2} imageSize - Size of the source image in pixels
74
- * @param {Vector2} [frameSize] - Size of each frame, or the whole image if not passed
75
- * @param {number} [padding] - How many pixels padding around each frame
76
- * @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image
77
- * @return {TileInfo} Tile for the packed image, or undefined if the sheet is full */
78
- tryAdd(imageSize, frameSize=imageSize, padding=textureSheetPadding, sourcePadding=0)
79
- {
80
- ASSERT(isVector2(imageSize) && isVector2(frameSize), 'sizes must be vec2');
81
- ASSERT(frameSize.x > 0 && frameSize.y > 0, 'frame size must be positive');
82
-
83
- if (isNumber(sourcePadding))
84
- sourcePadding = vec2(sourcePadding);
85
- ASSERT(isVector2(sourcePadding) && sourcePadding.x >= 0 && sourcePadding.y >= 0,
86
- 'sourcePadding must be a number or vec2 >= 0');
87
-
88
- // the source may have its own padding baked in around each frame
89
- const sourceCellWidth = frameSize.x + sourcePadding.x*2;
90
- const sourceCellHeight = frameSize.y + sourcePadding.y*2;
91
- ASSERT(imageSize.x % sourceCellWidth === 0 && imageSize.y % sourceCellHeight === 0,
92
- 'image size must be a multiple of the padded frame size');
93
-
94
- const cellWidth = frameSize.x + padding*2;
95
- const cellHeight = frameSize.y + padding*2;
96
- const maxColumns = this.size / cellWidth | 0;
97
- ASSERT(maxColumns > 0, 'frame is too wide to fit on a texture sheet');
98
-
99
- // keep the layout of the source image, but narrow it if a row is too wide
100
- // frames wrap down to the next row, which TileInfo.frame handles via columns
101
- const sourceColumns = imageSize.x / sourceCellWidth;
102
- const frameCount = sourceColumns * (imageSize.y / sourceCellHeight);
103
- const columns = min(sourceColumns, maxColumns);
104
- const blockWidth = columns * cellWidth;
105
- const blockHeight = ceil(frameCount / columns) * cellHeight;
106
-
107
- // probe the placement using locals so a failed try leaves the sheet unchanged
108
- let x = this.cursor.x, y = this.cursor.y, rowHeight = this.rowHeight;
109
- if (x + blockWidth > this.size)
110
- {
111
- // start a new row if this one does not have enough space left
112
- x = 0;
113
- y += rowHeight;
114
- rowHeight = 0;
115
- }
116
-
117
- // out of space, the caller needs to use a different sheet
118
- if (y + blockHeight > this.size)
119
- return undefined;
120
-
121
- // commit the placement, tile pos points inside the padding to match how tile() works
122
- this.cursor.x = x + blockWidth;
123
- this.cursor.y = y;
124
- this.rowHeight = max(rowHeight, blockHeight);
125
- return new TileInfo(vec2(x + padding, y + padding), frameSize, this.textureInfo, padding, 0, columns);
126
- }
127
-
128
- /** Draw an image into this sheet at a tile returned by tryAdd
129
- * @param {HTMLImageElement} image - Source image to copy from
130
- * @param {TileInfo} tileInfo - Where to put it, from tryAdd
131
- * @param {boolean} [update] - Upload to webgl now, pass false when batching
132
- * @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image */
133
- drawImage(image, tileInfo, update=true, sourcePadding=0)
134
- {
135
- ASSERT(!!this.context, 'texture sheet has no canvas');
136
-
137
- if (isNumber(sourcePadding))
138
- sourcePadding = vec2(sourcePadding);
139
-
140
- // copy frames in order, reading the source left to right, top to bottom
141
- // the destination wraps at tileInfo.columns which may be narrower than the source
142
- const frameSize = tileInfo.size;
143
- const sourceCellWidth = frameSize.x + sourcePadding.x*2;
144
- const sourceCellHeight = frameSize.y + sourcePadding.y*2;
145
- const sourceColumns = image.width / sourceCellWidth;
146
- const frameCount = sourceColumns * (image.height / sourceCellHeight);
147
- const columns = tileInfo.columns || frameCount;
148
- const cellWidth = frameSize.x + tileInfo.padding*2;
149
- const cellHeight = frameSize.y + tileInfo.padding*2;
150
- for (let i = frameCount; i--;)
151
- {
152
- const sourceX = (i % sourceColumns) * sourceCellWidth + sourcePadding.x;
153
- const sourceY = (i / sourceColumns | 0) * sourceCellHeight + sourcePadding.y;
154
- this.context.drawImage(image,
155
- sourceX, sourceY, frameSize.x, frameSize.y,
156
- tileInfo.pos.x + (i % columns) * cellWidth,
157
- tileInfo.pos.y + (i / columns | 0) * cellHeight,
158
- frameSize.x, frameSize.y);
159
- }
160
-
161
- // upload now unless the caller is batching more images
162
- this.glDirty = true;
163
- update && this.updateTexture();
164
- }
165
-
166
- /** Upload the canvas to webgl if it has changed since the last upload
167
- * Only needed after batching, drawImage uploads automatically by default */
168
- updateTexture()
169
- {
170
- if (!this.glDirty) return;
171
- this.glDirty = false;
172
- this.textureInfo.createWebGLTexture();
173
- }
174
- }
175
-
176
- ///////////////////////////////////////////////////////////////////////////////
177
-
178
- /** Load an image and pack it into a texture sheet
179
- * - Returns a TileInfo immediately which is filled in when the image loads
180
- * - Nothing is visible until it loads, use spritesReady to wait for it
181
- * - Pass frameSize for animations, then step through them with TileInfo.frame
182
- * - Grid images keep their layout and frames wrap down to the next row
183
- * - Pass sourcePadding if the source image has padding baked in around frames
184
- * @param {string} src - Image source path
185
- * @param {Vector2|number} [frameSize] - Size of each animation frame in pixels
186
- * @param {number} [padding] - How many pixels padding around each frame
187
- * @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image
188
- * @return {TileInfo}
189
- * @example
190
- * const playerTile = loadSprite('player.png'); // a single sprite
191
- * const runTile = loadSprite('run.png', vec2(16)); // a 16x16 frame animation
192
- * @memberof TextureSheets */
193
- function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0)
194
- {
195
- ASSERT(isStringLike(src), 'image src must be a string');
196
- ASSERT(!frameSize || isVector2(frameSize) || isNumber(frameSize), 'frameSize must be a vec2 or number');
197
- ASSERT(isNumber(padding), 'padding must be a number');
198
- ASSERT(isNumber(sourcePadding) || isVector2(sourcePadding), 'sourcePadding must be a number or vec2');
199
-
200
- if (isNumber(frameSize))
201
- frameSize = vec2(frameSize);
202
-
203
- // start with an empty tile that gets filled in when the image loads
204
- const tileInfo = new TileInfo(vec2(), vec2(), undefined, padding, 0);
205
- if (headlessMode) return tileInfo;
206
-
207
- // point at a sheet right away so drawing before it loads picks up empty pixels
208
- tileInfo.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
209
-
210
- // start decoding right away, images decode in parallel
211
- const image = new Image;
212
- const imagePromise = new Promise(resolve =>
213
- {
214
- image.onerror = image.onload = resolve;
215
- image.crossOrigin = 'anonymous';
216
- image.src = src;
217
- });
218
-
219
- // pack through a queue so sheets fill in call order, not decode order
220
- ++textureSheetPendingCount;
221
- textureSheetQueue = textureSheetQueue.then(async ()=>
222
- {
223
- await imagePromise;
224
- if (image.width)
225
- {
226
- // pack onto a sheet, then fill in the tile that was already handed out,
227
- // copying every field so nothing is missed if TileInfo gains more of them
228
- const imageSize = vec2(image.width, image.height);
229
- const {sheet, tile} = textureSheetAdd(imageSize, frameSize, padding, sourcePadding);
230
- Object.assign(tileInfo, tile);
231
- sheet.drawImage(image, tileInfo, false, sourcePadding); // upload once per batch below
232
- }
233
- else
234
- {
235
- // leave the tile empty if the image failed to load
236
- LOG('loadSprite failed to load image:', src);
237
- }
238
-
239
- // upload to webgl once per batch, when the last pending load finishes
240
- if (!--textureSheetPendingCount)
241
- textureSheets.forEach(s=> s.updateTexture());
242
- });
243
-
244
- return tileInfo;
245
- }
246
-
247
- /** Load a pre-packed texture atlas and repack it onto texture sheets
248
- * - Supports TexturePacker json (hash and array) and Aseprite json
249
- * - Returns an empty object which is filled with TileInfos when loaded
250
- * - Frames are named by the json, animations are grouped automatically
251
- * - Aseprite frame tags become animations, so do names like run_0, run_1
252
- * - Trimmed frames are restored to their full source size when packed
253
- * - Rotated frames are rotated back upright when packed
254
- * @param {string} imageSrc - Atlas image path
255
- * @param {string|Object} jsonSrc - Atlas json path, or already parsed json data
256
- * @param {number} [padding] - How many pixels padding around each frame
257
- * @return {Object} Object mapping frame and animation names to TileInfos
258
- * @example
259
- * const atlas = loadAtlas('sprites.png', 'sprites.json');
260
- * await spritesReady();
261
- * drawTile(pos, size, atlas.player); // a single frame
262
- * drawTile(pos, size, atlas.run.frame(2)); // frame 2 of the run animation
263
- * @memberof TextureSheets */
264
- function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
265
- {
266
- ASSERT(isStringLike(imageSrc), 'atlas image src must be a string');
267
- ASSERT(isStringLike(jsonSrc) || typeof jsonSrc === 'object', 'atlas json must be a path or object');
268
- ASSERT(isNumber(padding), 'padding must be a number');
269
-
270
- const atlas = {};
271
- if (headlessMode) return atlas;
272
-
273
- // start fetching the json and decoding the image right away, in parallel
274
- const jsonPromise = typeof jsonSrc === 'object' ? Promise.resolve(jsonSrc) :
275
- fetch(jsonSrc).then(r=> r.ok && r.json()).catch(()=> undefined);
276
- const image = new Image;
277
- const imagePromise = new Promise(resolve =>
278
- {
279
- image.onerror = image.onload = resolve;
280
- image.crossOrigin = 'anonymous';
281
- image.src = imageSrc;
282
- });
283
-
284
- // pack through a queue so sheets fill in call order, not decode order
285
- ++textureSheetPendingCount;
286
- textureSheetQueue = textureSheetQueue.then(async ()=>
287
- {
288
- const data = await jsonPromise;
289
- await imagePromise;
290
- if (image.width && data)
291
- {
292
- for (const group of parseAtlas(data))
293
- {
294
- // reserve a block of full size cells, one per frame
295
- const sourceSize = group.frames[0].sourceSize;
296
- const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
297
- const {sheet, tile} = textureSheetAdd(blockSize, sourceSize, padding);
298
-
299
- // draw each frame untrimmed into its cell
300
- const context = sheet.context;
301
- const cellWidth = sourceSize.x + padding*2;
302
- const cellHeight = sourceSize.y + padding*2;
303
- group.frames.forEach((f, i)=>
304
- {
305
- const x = tile.pos.x + (i % tile.columns)*cellWidth + f.offset.x;
306
- const y = tile.pos.y + (i / tile.columns |0)*cellHeight + f.offset.y;
307
- if (f.rotated)
308
- {
309
- // stored rotated 90 degrees clockwise, draw it back upright
310
- context.save();
311
- context.translate(x, y);
312
- context.rotate(-PI/2);
313
- context.drawImage(image, f.pos.x, f.pos.y, f.size.y, f.size.x,
314
- -f.size.y, 0, f.size.y, f.size.x);
315
- context.restore();
316
- }
317
- else
318
- context.drawImage(image, f.pos.x, f.pos.y, f.size.x, f.size.y,
319
- x, y, f.size.x, f.size.y);
320
- });
321
- sheet.glDirty = true;
322
- atlas[group.name] = tile;
323
- }
324
- }
325
- else
326
- {
327
- // leave the atlas empty if either file failed to load
328
- LOG('loadAtlas failed to load:', imageSrc, jsonSrc);
329
- }
330
-
331
- // upload to webgl once per batch, when the last pending load finishes
332
- if (!--textureSheetPendingCount)
333
- textureSheets.forEach(s=> s.updateTexture());
334
- });
335
-
336
- return atlas;
337
- }
338
-
339
- /** Parse atlas json into a list of named frame groups, used by loadAtlas
340
- * - Accepts TexturePacker json (hash and array) and Aseprite json
341
- * - Frames tagged in Aseprite or named like run_0, run_1 group into animations
342
- * @param {Object} data - Parsed atlas json data
343
- * @return {Array<Object>} List of {name, frames} groups in atlas order
344
- * @memberof TextureSheets */
345
- function parseAtlas(data)
346
- {
347
- ASSERT(!!data?.frames, 'unrecognized atlas format, expected TexturePacker or Aseprite json');
348
-
349
- // normalize both hash and array frame layouts into a single list
350
- const frames = (isArray(data.frames) ?
351
- data.frames.map(f=> [f.filename, f]) : Object.entries(data.frames))
352
- .map(([name, f])=> ({
353
- name: name.replace(/\.[^.\\/]+$/, ''), // strip file extension
354
- pos: vec2(f.frame.x, f.frame.y),
355
- size: vec2(f.frame.w, f.frame.h),
356
- offset: vec2(f.spriteSourceSize?.x ?? 0, f.spriteSourceSize?.y ?? 0),
357
- sourceSize: vec2(f.sourceSize?.w ?? f.frame.w, f.sourceSize?.h ?? f.frame.h),
358
- rotated: !!f.rotated,
359
- }));
360
-
361
- const groups = [];
362
- const tags = data.meta?.frameTags;
363
- if (tags?.length)
364
- {
365
- // aseprite tags are authoritative, untagged frames stay individual
366
- const tagged = new Set;
367
- for (const tag of tags)
368
- {
369
- groups.push({name: tag.name, frames: frames.slice(tag.from, tag.to + 1)});
370
- for (let i = tag.from; i <= tag.to; ++i)
371
- tagged.add(i);
372
- }
373
- frames.forEach((f, i)=> tagged.has(i) || groups.push({name: f.name, frames: [f]}));
374
- return groups;
375
- }
376
-
377
- // group frames that share a name stem with contiguous trailing numbers
378
- // run_0.png and run_1.png become a 2 frame animation named run
379
- const stems = new Map;
380
- for (const f of frames)
381
- {
382
- let match = f.name.match(/^(.+?)([-_ ])?(\d+)$/);
383
- if (match && !match[2] && /\d$/.test(match[1]))
384
- match = undefined; // all digit tails like 10 are a name, not frame 0 of 1
385
- const stem = match ? match[1] : f.name;
386
- f.groupIndex = match ? Number(match[3]) : undefined;
387
- stems.has(stem) || stems.set(stem, []);
388
- stems.get(stem).push(f);
389
- }
390
- for (const [stem, list] of stems)
391
- {
392
- // only group 2 or more frames with contiguous indices and matching sizes
393
- list.sort((a, b)=> a.groupIndex - b.groupIndex);
394
- const grouped = list.length > 1 &&
395
- list.every((f, i)=> f.groupIndex === list[0].groupIndex + i) &&
396
- list.every(f=> f.sourceSize.x === list[0].sourceSize.x &&
397
- f.sourceSize.y === list[0].sourceSize.y);
398
- if (grouped)
399
- groups.push({name: stem, frames: list});
400
- else
401
- list.forEach(f=> groups.push({name: f.name, frames: [f]}));
402
- }
403
- return groups;
404
- }
405
-
406
- /** Wait for everything started by loadSprite and loadAtlas to finish packing
407
- * @return {Promise}
408
- * @example
409
- * async function gameInit()
410
- * {
411
- * playerTile = loadSprite('player.png');
412
- * runTile = loadSprite('run.png', vec2(16));
413
- * await spritesReady();
414
- * }
415
- * @memberof TextureSheets */
416
- async function spritesReady()
417
- {
418
- // keep waiting until the queue drains, more sprites may load while waiting
419
- while (textureSheetPendingCount)
420
- await textureSheetQueue;
421
- }
422
-
423
- // create a new texture sheet and add it to the list
424
- function textureSheetCreate()
425
- {
426
- const sheet = new TextureSheet;
427
- textureSheets.push(sheet);
428
- return sheet;
429
- }
430
-
431
- // use the first sheet with enough space, or make a new one
432
- function textureSheetAdd(imageSize, frameSize, padding, sourcePadding)
433
- {
434
- let sheet, tile;
435
- for (sheet of textureSheets)
436
- if (tile = sheet.tryAdd(imageSize, frameSize, padding, sourcePadding))
437
- break;
438
- if (!tile)
439
- {
440
- sheet = textureSheetCreate();
441
- tile = sheet.tryAdd(imageSize, frameSize, padding, sourcePadding);
442
- ASSERT(!!tile, 'image is too large to fit on a texture sheet');
443
- }
444
- return {sheet, tile};
445
- }
446
-
447
- ///////////////////////////////////////////////////////////////////////////////
448
- // Texture sheet setting setters
449
-
450
- /** Set width and height in pixels of texture sheets created by loadSprite
451
- * @param {number} size
452
- * @memberof Settings */
453
- function setTextureSheetSize(size) { textureSheetSize = size; }
454
-
455
- /** Set default padding pixels around each frame packed by loadSprite
456
- * @param {number} padding
457
- * @memberof Settings */
458
- function setTextureSheetPadding(padding) { textureSheetPadding = padding; }
1
+ /**
2
+ * LittleJS Texture Sheet Plugin
3
+ * - Packs images into texture sheets as they are loaded
4
+ * - Sprites are placed automatically, callers get a TileInfo
5
+ * - Sheets are created and filled as needed
6
+ * - Sheets fill in call order, images decode in parallel
7
+ * - Animation frames keep layout and wrap across rows as needed
8
+ * - WebGL textures upload once per batch of loads
9
+ * - loadAtlas imports pre-packed atlases (TexturePacker and Aseprite json)
10
+ * @namespace TextureSheets
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ /** Width and height in pixels of texture sheets created by loadSprite
16
+ * @type {number}
17
+ * @default
18
+ * @memberof Settings */
19
+ let textureSheetSize = 2048;
20
+
21
+ /** Default padding pixels around each frame packed by loadSprite
22
+ * @type {number}
23
+ * @default
24
+ * @memberof Settings */
25
+ let textureSheetPadding = 1;
26
+
27
+ /** Array of texture sheets created by loadSprite
28
+ * @type {Array<TextureSheet>}
29
+ * @memberof TextureSheets */
30
+ let textureSheets = [];
31
+
32
+ // pending loads pack through a queue so sheets fill in call order
33
+ let textureSheetQueue = Promise.resolve();
34
+ let textureSheetPendingCount = 0;
35
+
36
+ /**
37
+ * Texture Sheet - A texture that images are packed into as they load
38
+ * Uses shelf packing, images are placed left to right then wrap to a new row
39
+ * @memberof TextureSheets
40
+ */
41
+ class TextureSheet
42
+ {
43
+ /** Create a texture sheet, called automatically by loadSprite
44
+ * @param {number} [size] - Width and height of the sheet in pixels */
45
+ constructor(size=textureSheetSize)
46
+ {
47
+ ASSERT(size > 0, 'texture sheet size must be positive');
48
+
49
+ /** @property {number} - Width and height of the sheet in pixels */
50
+ this.size = size;
51
+ /** @property {OffscreenCanvasRenderingContext2D} - 2d context for the canvas */
52
+ this.context = headlessMode ? undefined : createCanvasContext(size);
53
+ /** @property {OffscreenCanvas} - Canvas holding the packed images */
54
+ this.canvas = this.context?.canvas;
55
+ /** @property {TextureInfo} - The texture info for this sheet */
56
+ this.textureInfo = new TextureInfo(this.canvas);
57
+ /** @property {Vector2} - Where the next image will be packed */
58
+ this.cursor = vec2();
59
+ /** @property {number} - Height of the row being packed */
60
+ this.rowHeight = 0;
61
+ /** @property {boolean} - Has the canvas changed since the last webgl upload? */
62
+ this.glDirty = false;
63
+
64
+ if (headlessMode)
65
+ {
66
+ // tiles still need bounds when there is no canvas to measure
67
+ this.textureInfo.size = vec2(size);
68
+ this.textureInfo.sizeInverse = vec2(1/size);
69
+ }
70
+ }
71
+
72
+ /** Find a spot for an image on this sheet without drawing it
73
+ * @param {Vector2} imageSize - Size of the source image in pixels
74
+ * @param {Vector2} [frameSize] - Size of each frame, or the whole image less its source padding if not passed
75
+ * @param {number} [padding] - How many pixels padding around each frame
76
+ * @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image
77
+ * @return {TileInfo|undefined} Tile for the packed image, or undefined if the sheet is full */
78
+ tryAdd(imageSize, frameSize, padding=textureSheetPadding, sourcePadding=0)
79
+ {
80
+ // a number pads both axes the same
81
+ const sourcePad = isNumber(sourcePadding) ?
82
+ vec2(/** @type {number} */ (sourcePadding)) : /** @type {Vector2} */ (sourcePadding);
83
+ ASSERT(isVector2(sourcePad) && sourcePad.x >= 0 && sourcePad.y >= 0,
84
+ 'sourcePadding must be a number or vec2 >= 0');
85
+ ASSERT(isVector2(imageSize), 'sizes must be vec2');
86
+ frameSize ||= imageSize.subtract(sourcePad.scale(2)); // the whole image, less the padding baked into it
87
+ ASSERT(isVector2(frameSize), 'sizes must be vec2');
88
+ ASSERT(frameSize.x > 0 && frameSize.y > 0, 'frame size must be positive');
89
+
90
+ // the source may have its own padding baked in around each frame
91
+ const sourceCellWidth = frameSize.x + sourcePad.x*2;
92
+ const sourceCellHeight = frameSize.y + sourcePad.y*2;
93
+ ASSERT(imageSize.x % sourceCellWidth === 0 && imageSize.y % sourceCellHeight === 0,
94
+ 'image size must be a multiple of the padded frame size');
95
+
96
+ const cellWidth = frameSize.x + padding*2;
97
+ const cellHeight = frameSize.y + padding*2;
98
+ const maxColumns = this.size / cellWidth | 0;
99
+ ASSERT(maxColumns > 0, 'frame is too wide to fit on a texture sheet');
100
+
101
+ // keep the layout of the source image, but narrow it if a row is too wide
102
+ // frames wrap down to the next row, which TileInfo.frame handles via columns
103
+ // whole frames only, a partial one left over at an edge is ignored
104
+ const sourceColumns = imageSize.x / sourceCellWidth | 0;
105
+ const frameCount = sourceColumns * (imageSize.y / sourceCellHeight | 0);
106
+ if (!frameCount)
107
+ return undefined; // smaller than a frame, there is nothing to pack
108
+ const columns = min(sourceColumns, maxColumns);
109
+ const blockWidth = columns * cellWidth;
110
+ const blockHeight = ceil(frameCount / columns) * cellHeight;
111
+
112
+ // probe the placement using locals so a failed try leaves the sheet unchanged
113
+ let x = this.cursor.x, y = this.cursor.y, rowHeight = this.rowHeight;
114
+ if (x + blockWidth > this.size)
115
+ {
116
+ // start a new row if this one does not have enough space left
117
+ x = 0;
118
+ y += rowHeight;
119
+ rowHeight = 0;
120
+ }
121
+
122
+ // out of space, the caller needs to use a different sheet
123
+ if (y + blockHeight > this.size)
124
+ return undefined;
125
+
126
+ // commit the placement, tile pos points inside the padding to match how tile() works
127
+ this.cursor.x = x + blockWidth;
128
+ this.cursor.y = y;
129
+ this.rowHeight = max(rowHeight, blockHeight);
130
+ return new TileInfo(vec2(x + padding, y + padding), frameSize, this.textureInfo, padding, 0, columns);
131
+ }
132
+
133
+ /** Draw an image into this sheet at a tile returned by tryAdd
134
+ * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - Source image to copy from
135
+ * @param {TileInfo} tileInfo - Where to put it, from tryAdd
136
+ * @param {boolean} [update] - Upload to webgl now, pass false when batching
137
+ * @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image */
138
+ drawImage(image, tileInfo, update=true, sourcePadding=0)
139
+ {
140
+ ASSERT(!!this.context, 'texture sheet has no canvas');
141
+
142
+ // a number pads both axes the same
143
+ const sourcePad = isNumber(sourcePadding) ?
144
+ vec2(/** @type {number} */ (sourcePadding)) : /** @type {Vector2} */ (sourcePadding);
145
+
146
+ // copy frames in order, reading the source left to right, top to bottom
147
+ // the destination wraps at tileInfo.columns which may be narrower than the source
148
+ const frameSize = tileInfo.size;
149
+ const sourceCellWidth = frameSize.x + sourcePad.x*2;
150
+ const sourceCellHeight = frameSize.y + sourcePad.y*2;
151
+ // whole frames only as tryAdd packed them, a fractional count would never end the loop
152
+ const sourceColumns = image.width / sourceCellWidth | 0;
153
+ const frameCount = sourceColumns * (image.height / sourceCellHeight | 0);
154
+ const columns = tileInfo.columns || frameCount;
155
+ const cellWidth = frameSize.x + tileInfo.padding*2;
156
+ const cellHeight = frameSize.y + tileInfo.padding*2;
157
+ for (let i = frameCount; i--;)
158
+ {
159
+ const sourceX = (i % sourceColumns) * sourceCellWidth + sourcePad.x;
160
+ const sourceY = (i / sourceColumns | 0) * sourceCellHeight + sourcePad.y;
161
+ this.context.drawImage(image,
162
+ sourceX, sourceY, frameSize.x, frameSize.y,
163
+ tileInfo.pos.x + (i % columns) * cellWidth,
164
+ tileInfo.pos.y + (i / columns | 0) * cellHeight,
165
+ frameSize.x, frameSize.y);
166
+ }
167
+
168
+ // upload now unless the caller is batching more images
169
+ this.glDirty = true;
170
+ update && this.updateTexture();
171
+ }
172
+
173
+ /** Upload the canvas to webgl if it has changed since the last upload
174
+ * Only needed after batching, drawImage uploads automatically by default */
175
+ updateTexture()
176
+ {
177
+ if (!this.glDirty) return;
178
+ this.glDirty = false;
179
+ this.textureInfo.createWebGLTexture();
180
+ }
181
+ }
182
+
183
+ ///////////////////////////////////////////////////////////////////////////////
184
+
185
+ /** Load an image and pack it into a texture sheet
186
+ * - Returns a TileInfo immediately which is filled in when the image loads
187
+ * - Nothing is visible until it loads, use spritesReady to wait for it
188
+ * - Pass frameSize for animations, then step through them with TileInfo.frame
189
+ * - Grid images keep their layout and frames wrap down to the next row
190
+ * - Pass sourcePadding if the source image has padding baked in around frames
191
+ * @param {string} src - Image source path
192
+ * @param {Vector2|number} [frameSize] - Size of each animation frame in pixels, or the whole image less its
193
+ * source padding if not passed
194
+ * @param {number} [padding] - How many pixels padding around each frame
195
+ * @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image
196
+ * @return {TileInfo}
197
+ * @example
198
+ * const playerTile = loadSprite('player.png'); // a single sprite
199
+ * const runTile = loadSprite('run.png', vec2(16)); // a 16x16 frame animation
200
+ * @memberof TextureSheets */
201
+ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0)
202
+ {
203
+ ASSERT(isStringLike(src), 'image src must be a string');
204
+ ASSERT(!frameSize || isVector2(frameSize) || isNumber(frameSize), 'frameSize must be a vec2 or number');
205
+ ASSERT(isNumber(padding), 'padding must be a number');
206
+ ASSERT(isNumber(sourcePadding) || isVector2(sourcePadding), 'sourcePadding must be a number or vec2');
207
+ ASSERT(engineInitialized || headlessMode, 'call loadSprite after engineInit, e.g. in gameInit');
208
+
209
+ if (isNumber(frameSize))
210
+ frameSize = vec2(/** @type {number} */ (frameSize));
211
+
212
+ // start with an empty tile that gets filled in when the image loads
213
+ const tileInfo = new TileInfo(vec2(), vec2(), undefined, padding, 0);
214
+ if (headlessMode) return tileInfo;
215
+
216
+ // point at a sheet right away so drawing before it loads picks up empty pixels
217
+ tileInfo.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
218
+
219
+ // start decoding right away, images decode in parallel
220
+ const image = new Image;
221
+ const imagePromise = new Promise(resolve =>
222
+ {
223
+ image.onerror = image.onload = resolve;
224
+ image.crossOrigin = 'anonymous';
225
+ image.src = src;
226
+ });
227
+
228
+ // pack through a queue so sheets fill in call order, not decode order
229
+ textureSheetQueueJob('loadSprite ' + src, async ()=>
230
+ {
231
+ await imagePromise;
232
+ if (image.width)
233
+ {
234
+ // pack onto a sheet, then fill in the tile that was already handed out,
235
+ // copying every field so nothing is missed if TileInfo gains more of them
236
+ const imageSize = vec2(image.width, image.height);
237
+ const added = textureSheetAdd(imageSize, frameSize, padding, sourcePadding);
238
+ if (!added)
239
+ {
240
+ // leave the tile empty, no sheet can hold it
241
+ LOG('loadSprite image is too large to fit on a texture sheet:', src);
242
+ return;
243
+ }
244
+ Object.assign(tileInfo, added.tile);
245
+ added.sheet.drawImage(image, tileInfo, false, sourcePadding); // upload once per batch below
246
+ }
247
+ else
248
+ {
249
+ // leave the tile empty if the image failed to load
250
+ LOG('loadSprite failed to load image:', src);
251
+ }
252
+ });
253
+
254
+ return tileInfo;
255
+ }
256
+
257
+ /** Load a pre-packed texture atlas and repack it onto texture sheets
258
+ * - Supports TexturePacker json (hash and array) and Aseprite json
259
+ * - Returns an empty object which is filled with TileInfos when loaded
260
+ * - Frames are named by the json, animations are grouped automatically
261
+ * - Aseprite frame tags become animations, so do names like run_0, run_1
262
+ * - Trimmed frames are restored to their full source size when packed
263
+ * - Rotated frames are rotated back upright when packed
264
+ * @param {string} imageSrc - Atlas image path
265
+ * @param {string|Object} jsonSrc - Atlas json path, or already parsed json data
266
+ * @param {number} [padding] - How many pixels padding around each frame
267
+ * @return {Object<string, TileInfo>} Object mapping frame and animation names to TileInfos
268
+ * @example
269
+ * const atlas = loadAtlas('sprites.png', 'sprites.json');
270
+ * await spritesReady();
271
+ * drawTile(pos, size, atlas.player); // a single frame
272
+ * drawTile(pos, size, atlas.run.frame(2)); // frame 2 of the run animation
273
+ * @memberof TextureSheets */
274
+ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
275
+ {
276
+ ASSERT(isStringLike(imageSrc), 'atlas image src must be a string');
277
+ ASSERT(isStringLike(jsonSrc) || typeof jsonSrc === 'object', 'atlas json must be a path or object');
278
+ ASSERT(isNumber(padding), 'padding must be a number');
279
+ ASSERT(engineInitialized || headlessMode, 'call loadAtlas after engineInit, e.g. in gameInit');
280
+
281
+ /** @type {Object<string, TileInfo>} */
282
+ const atlas = {};
283
+ if (headlessMode) return atlas;
284
+
285
+ // start fetching the json and decoding the image right away, in parallel
286
+ const jsonPromise = typeof jsonSrc === 'object' ? Promise.resolve(jsonSrc) :
287
+ fetch(jsonSrc).then(r=> r.ok && r.json()).catch(()=> undefined);
288
+ const image = new Image;
289
+ const imagePromise = new Promise(resolve =>
290
+ {
291
+ image.onerror = image.onload = resolve;
292
+ image.crossOrigin = 'anonymous';
293
+ image.src = imageSrc;
294
+ });
295
+
296
+ // pack through a queue so sheets fill in call order, not decode order
297
+ textureSheetQueueJob('loadAtlas ' + imageSrc, async ()=>
298
+ {
299
+ const data = await jsonPromise;
300
+ await imagePromise;
301
+ if (image.width && data)
302
+ {
303
+ for (const group of parseAtlas(data))
304
+ {
305
+ // reserve a block of full size cells, one per frame
306
+ const sourceSize = group.frames[0].sourceSize;
307
+ const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
308
+ const added = textureSheetAdd(blockSize, sourceSize, padding);
309
+ if (!added)
310
+ {
311
+ LOG('loadAtlas frames are too large to fit on a texture sheet:', group.name);
312
+ continue;
313
+ }
314
+ const {sheet, tile} = added;
315
+
316
+ // draw each frame untrimmed into its cell
317
+ const context = sheet.context;
318
+ const cellWidth = sourceSize.x + padding*2;
319
+ const cellHeight = sourceSize.y + padding*2;
320
+ group.frames.forEach((f, i)=>
321
+ {
322
+ const x = tile.pos.x + (i % tile.columns)*cellWidth + f.offset.x;
323
+ const y = tile.pos.y + (i / tile.columns |0)*cellHeight + f.offset.y;
324
+ if (f.rotated)
325
+ {
326
+ // stored rotated 90 degrees clockwise, draw it back upright
327
+ context.save();
328
+ context.translate(x, y);
329
+ context.rotate(-PI/2);
330
+ context.drawImage(image, f.pos.x, f.pos.y, f.size.y, f.size.x,
331
+ -f.size.y, 0, f.size.y, f.size.x);
332
+ context.restore();
333
+ }
334
+ else
335
+ context.drawImage(image, f.pos.x, f.pos.y, f.size.x, f.size.y,
336
+ x, y, f.size.x, f.size.y);
337
+ });
338
+ sheet.glDirty = true;
339
+ atlas[group.name] = tile;
340
+ }
341
+ }
342
+ else
343
+ {
344
+ // leave the atlas empty if either file failed to load
345
+ LOG('loadAtlas failed to load:', imageSrc, jsonSrc);
346
+ }
347
+ });
348
+
349
+ return atlas;
350
+ }
351
+
352
+ // run a load in the queue: a load that throws is reported and the loads after it carry on, the pending count
353
+ // always comes back down, and the sheets upload to webgl once per batch, when the last pending load finishes
354
+ function textureSheetQueueJob(name, job)
355
+ {
356
+ ++textureSheetPendingCount;
357
+ textureSheetQueue = textureSheetQueue.then(async ()=>
358
+ {
359
+ try { await job(); }
360
+ catch (e) { console.error(name + ' failed:', e); }
361
+ finally
362
+ {
363
+ if (!--textureSheetPendingCount)
364
+ for (const sheet of textureSheets)
365
+ {
366
+ try { sheet.updateTexture(); }
367
+ catch (e) { console.error('texture sheet upload failed:', e); }
368
+ }
369
+ }
370
+ });
371
+ }
372
+
373
+ /** Parse atlas json into a list of named frame groups, used by loadAtlas
374
+ * - Accepts TexturePacker json (hash and array) and Aseprite json
375
+ * - Frames tagged in Aseprite or named like run_0, run_1 group into animations
376
+ * @param {Object} data - Parsed atlas json data
377
+ * @return {Array<Object>} List of {name, frames} groups in atlas order
378
+ * @memberof TextureSheets */
379
+ function parseAtlas(data)
380
+ {
381
+ ASSERT(!!data?.frames, 'unrecognized atlas format, expected TexturePacker or Aseprite json');
382
+
383
+ // normalize both hash and array frame layouts into a single list
384
+ const frames = (isArray(data.frames) ?
385
+ data.frames.map(f=> [f.filename, f]) : Object.entries(data.frames))
386
+ .map(([name, f])=> ({
387
+ name: name.replace(/\.[^.\\/]+$/, ''), // strip file extension
388
+ pos: vec2(f.frame.x, f.frame.y),
389
+ size: vec2(f.frame.w, f.frame.h),
390
+ offset: vec2(f.spriteSourceSize?.x ?? 0, f.spriteSourceSize?.y ?? 0),
391
+ sourceSize: vec2(f.sourceSize?.w ?? f.frame.w, f.sourceSize?.h ?? f.frame.h),
392
+ rotated: !!f.rotated,
393
+ }));
394
+
395
+ const groups = [];
396
+ const tags = data.meta?.frameTags;
397
+ if (tags?.length)
398
+ {
399
+ // aseprite tags are authoritative, untagged frames stay individual
400
+ const tagged = new Set;
401
+ for (const tag of tags)
402
+ {
403
+ groups.push({name: tag.name, frames: frames.slice(tag.from, tag.to + 1)});
404
+ for (let i = tag.from; i <= tag.to; ++i)
405
+ tagged.add(i);
406
+ }
407
+ frames.forEach((f, i)=> tagged.has(i) || groups.push({name: f.name, frames: [f]}));
408
+ return groups;
409
+ }
410
+
411
+ // group frames that share a name stem with contiguous trailing numbers
412
+ // run_0.png and run_1.png become a 2 frame animation named run, and so do the folder style run/0.png and run/1.png
413
+ const stems = new Map;
414
+ for (const f of frames)
415
+ {
416
+ let match = f.name.match(/^(.+?)([-_ /])?(\d+)$/);
417
+ if (match && !match[2] && /\d$/.test(match[1]))
418
+ match = undefined; // all digit tails like 10 are a name, not frame 0 of 1
419
+ const stem = match ? match[1] : f.name;
420
+ f.groupIndex = match ? Number(match[3]) : undefined;
421
+ stems.has(stem) || stems.set(stem, []);
422
+ stems.get(stem).push(f);
423
+ }
424
+ for (const [stem, list] of stems)
425
+ {
426
+ // only group 2 or more frames with contiguous indices and matching sizes
427
+ list.sort((a, b)=> a.groupIndex - b.groupIndex);
428
+ const grouped = list.length > 1 &&
429
+ list.every((f, i)=> f.groupIndex === list[0].groupIndex + i) &&
430
+ list.every(f=> f.sourceSize.x === list[0].sourceSize.x &&
431
+ f.sourceSize.y === list[0].sourceSize.y);
432
+ if (grouped)
433
+ groups.push({name: stem, frames: list});
434
+ else
435
+ list.forEach(f=> groups.push({name: f.name, frames: [f]}));
436
+ }
437
+ return groups;
438
+ }
439
+
440
+ /** Wait for everything started by loadSprite and loadAtlas to finish packing
441
+ * @return {Promise}
442
+ * @example
443
+ * async function gameInit()
444
+ * {
445
+ * playerTile = loadSprite('player.png');
446
+ * runTile = loadSprite('run.png', vec2(16));
447
+ * await spritesReady();
448
+ * }
449
+ * @memberof TextureSheets */
450
+ async function spritesReady()
451
+ {
452
+ // keep waiting until the queue drains, more sprites may load while waiting
453
+ while (textureSheetPendingCount)
454
+ await textureSheetQueue;
455
+ }
456
+
457
+ // create a new texture sheet and add it to the list
458
+ function textureSheetCreate()
459
+ {
460
+ const sheet = new TextureSheet;
461
+ textureSheets.push(sheet);
462
+ return sheet;
463
+ }
464
+
465
+ // use the first sheet with enough space, or make a new one,
466
+ // returns undefined if it would not fit even on an empty sheet
467
+ function textureSheetAdd(imageSize, frameSize, padding, sourcePadding)
468
+ {
469
+ let sheet, tile;
470
+ for (sheet of textureSheets)
471
+ if (tile = sheet.tryAdd(imageSize, frameSize, padding, sourcePadding))
472
+ break;
473
+ if (!tile)
474
+ {
475
+ // probe an empty sheet first, a new one is a canvas and a texture kept for good
476
+ const emptySheet = {size: textureSheetSize, cursor: vec2(), rowHeight: 0};
477
+ if (!TextureSheet.prototype.tryAdd.call(emptySheet, imageSize, frameSize, padding, sourcePadding))
478
+ return;
479
+ sheet = textureSheetCreate();
480
+ tile = sheet.tryAdd(imageSize, frameSize, padding, sourcePadding);
481
+ }
482
+ return {sheet, tile};
483
+ }
484
+
485
+ ///////////////////////////////////////////////////////////////////////////////
486
+ // Texture sheet setting setters
487
+
488
+ /** Set width and height in pixels of texture sheets created by loadSprite
489
+ * @param {number} size
490
+ * @memberof Settings */
491
+ function setTextureSheetSize(size) { textureSheetSize = size; }
492
+
493
+ /** Set default padding pixels around each frame packed by loadSprite
494
+ * @param {number} padding
495
+ * @memberof Settings */
496
+ function setTextureSheetPadding(padding) { textureSheetPadding = padding; }