littlejsengine 1.24.0 → 1.24.2

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 (43) hide show
  1. package/CHANGELOG.md +86 -1
  2. package/EDITOR.md +2 -0
  3. package/FAQ.md +13 -2
  4. package/README.md +2 -2
  5. package/REFERENCE.md +27 -10
  6. package/dist/littlejs.d.ts +104 -29
  7. package/dist/littlejs.esm.js +2499 -1979
  8. package/dist/littlejs.esm.min.js +3 -3
  9. package/dist/littlejs.js +2496 -1979
  10. package/dist/littlejs.min.js +2 -2
  11. package/dist/littlejs.release.js +2438 -1983
  12. package/package.json +1 -1
  13. package/plugins/box2d.js +1 -1
  14. package/plugins/gltf.js +62 -9
  15. package/plugins/lightSystem.js +1 -1
  16. package/plugins/math3d.js +1014 -1013
  17. package/plugins/medalSystem.js +7 -11
  18. package/plugins/particleEffects.js +2 -2
  19. package/plugins/pathFinder.js +2 -2
  20. package/plugins/pluginExport.js +1 -0
  21. package/plugins/render3d.js +134 -57
  22. package/plugins/render3dEditor.js +28 -12
  23. package/plugins/render3dExtras.js +32 -17
  24. package/plugins/render3dLevel.js +21 -12
  25. package/plugins/render3dVoxels.js +15 -8
  26. package/plugins/textureSheet.js +97 -3
  27. package/plugins/tweakables.js +1 -1
  28. package/plugins/tweenSystem.js +2 -2
  29. package/plugins/uiSystem.js +6 -2
  30. package/src/engine.js +34 -11
  31. package/src/engineAudio.js +13 -3
  32. package/src/engineDebug.js +4 -1
  33. package/src/engineDraw.js +29 -16
  34. package/src/engineEditor.js +99 -56
  35. package/src/engineExport.js +2 -0
  36. package/src/engineInput.js +17 -3
  37. package/src/engineMath.js +6 -2
  38. package/src/engineObject.js +675 -675
  39. package/src/engineParticles.js +1 -1
  40. package/src/engineSettings.js +21 -1
  41. package/src/engineTileLayer.js +142 -43
  42. package/src/engineUtilities.js +20 -8
  43. package/src/engineWebGL.js +12 -8
@@ -43,7 +43,7 @@
43
43
  * rgb(1,1,1,0), rgb(0,0,0,0), // colorEndA, colorEndB
44
44
  * 1, .2, .2, .1, .05, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed
45
45
  * .99, 1, 1, PI, .05, // damping, angleDamping, gravityScale, particleCone, fadeRate
46
- * .5, 1 // randomness, collide
46
+ * .5, true // randomness, collide
47
47
  * );
48
48
  */
49
49
  class ParticleEmitter extends EngineObject
@@ -418,6 +418,13 @@ let vibrateEnable = true;
418
418
  * @memberof Settings */
419
419
  let soundEnable = true;
420
420
 
421
+ /** Play sound with an iPhone's silent switch on, as media does; off by default, so the switch mutes the game as it
422
+ * does a ringtone, and the player's own music keeps playing beside it; Safari 16.4 and up, elsewhere it does nothing
423
+ * @type {boolean}
424
+ * @default
425
+ * @memberof Settings */
426
+ let soundIgnoreSilentSwitch = false;
427
+
421
428
  /** Volume scale to apply to all sound, music and speech
422
429
  * Use setSoundVolume to also update the audio master gain immediately
423
430
  * @type {number}
@@ -625,7 +632,11 @@ function setGLEnable(enable)
625
632
  /** Set how many sided polygons to use when drawing circles and ellipses with WebGL
626
633
  * @param {number} sides
627
634
  * @memberof Settings */
628
- function setGLCircleSides(sides) { glCircleSides = sides; }
635
+ function setGLCircleSides(sides)
636
+ {
637
+ ASSERT(isNumber(sides) && sides >= 3, 'circles need at least 3 sides');
638
+ glCircleSides = sides;
639
+ }
629
640
 
630
641
  /** Set default size of tiles in pixels
631
642
  * @param {Vector2} size
@@ -815,6 +826,15 @@ function setVibrateEnable(enable) { vibrateEnable = enable; }
815
826
  * @memberof Settings */
816
827
  function setSoundEnable(enable) { soundEnable = enable; }
817
828
 
829
+ /** Set if sound plays with an iPhone's silent switch on, as media does, which also pauses the player's own music
830
+ * @param {boolean} ignore
831
+ * @memberof Settings */
832
+ function setSoundIgnoreSilentSwitch(ignore)
833
+ {
834
+ soundIgnoreSilentSwitch = ignore;
835
+ audioSetSession();
836
+ }
837
+
818
838
  /** Set volume scale to apply to all sound, music and speech
819
839
  * @param {number} volume
820
840
  * @memberof Settings */
@@ -124,6 +124,19 @@ function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnl
124
124
  // Tiled's flip flags, horizontal, vertical and diagonal as bits 2, 1 and 0, as [direction, mirror]
125
125
  const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [1,1]];
126
126
 
127
+ // the tile info a layer's tile info gives a tile: a tile set's tile, a frame of a sheet read by its columns, or a
128
+ // tile of its grid; undefined past the end of a tile set, or of a sheet read by its columns, as values kept only for
129
+ // collision can be, which the level editor reads the same way
130
+ function tileLayerTileInfo(t, tile)
131
+ {
132
+ if (!t) return;
133
+ if (t.tiles) return t.tiles[tile];
134
+ if (!t.columns) return t.index(tile);
135
+ if (t.textureInfo && t.pos.y + (tile / t.columns | 0) * (t.size.y + t.padding*2) + t.size.y > t.textureInfo.size.y)
136
+ return;
137
+ return t.frame(tile);
138
+ }
139
+
127
140
  /**
128
141
  * Load tile layers from exported data
129
142
  * - Tiled maps come in as they are, flipped and turned tiles included, from one tileset image (a second tileset's
@@ -141,9 +154,10 @@ const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [
141
154
  * @param {Object} tileMapData - Level data from exported data
142
155
  * @param {TileInfo} [tileInfo] - Default tile info (used for size and texture), tile() by default, none when no image is loaded
143
156
  * @param {number} [renderOrder] - Render order of the top layer
144
- * @param {number} [collisionLayer] - Layer to use for collision if any
157
+ * @param {number|string} [collisionLayer] - Layer to use for collision if any, by its index or its name
145
158
  * @param {boolean} [draw] - Should the layer be drawn automatically
146
- * @return {Array<TileCollisionLayer>}
159
+ * @return {Array<TileCollisionLayer>} - It throws for a map that is not whole cells, or a layer whose tiles do not
160
+ * fill it, before it makes any layer
147
161
  * @memberof TileLayers */
148
162
  function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrder=0, collisionLayer, draw=true)
149
163
  {
@@ -160,20 +174,26 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
160
174
  // the editor, in debug builds, keeps the map as the source of its edits and brings back autosaved ones
161
175
  tileMapData = editorMapRestore(tileMapData);
162
176
 
163
- // validate the tile map data
164
- ASSERT(tileMapData.width && tileMapData.height);
165
- ASSERT(tileMapData.layers && tileMapData.layers.length);
177
+ // validate the tile map data: a size that is not whole cells, or tiles that do not fill it, is said in any build,
178
+ // since the layers and their loops are made from that size
179
+ const {width: mapWidth, height: mapHeight} = tileMapData;
180
+ if (!(Number.isInteger(mapWidth) && Number.isInteger(mapHeight) && mapWidth > 0 && mapHeight > 0))
181
+ throw new Error(`tileLayersLoad: a map is a whole number of cells across and down, not ${mapWidth} by ${mapHeight}`);
182
+ if (!isArray(tileMapData.layers) || !tileMapData.layers.length)
183
+ throw new Error('tileLayersLoad: a map has a list of layers');
166
184
 
167
185
  // a sheet with a margin around its tiles or a spacing between them: the first tile is at the margin and each
168
186
  // cell is a tile and a spacing, which is a padding of half the spacing counted from there, by the tileset's
169
187
  // own count of columns, since the image's width does not say with a spacing that is not all around
188
+ // a tile set from loadTiles has each tile where it was packed, whatever the map says of its own image
170
189
  const tileset = tileMapData.tilesets?.[0];
171
- if (tileInfo && tileset && (tileset.margin > 0 || tileset.spacing > 0))
190
+ if (tileInfo && !tileInfo.tiles && tileset && (tileset.margin > 0 || tileset.spacing > 0))
172
191
  {
173
192
  const margin = tileset.margin || 0, spacing = tileset.spacing || 0, size = tileInfo.size;
174
193
  const width = tileInfo.textureInfo?.size.x || 0;
175
194
  const columns = tileset.columns || max(1, floor((width - margin*2 + spacing) / (size.x + spacing)));
176
- tileInfo = new TileInfo(vec2(margin), size, tileInfo.textureInfo, spacing / 2, tileInfo.bleed, columns);
195
+ const origin = tileInfo.pos.subtract(vec2(tileInfo.padding)); // where the sheet starts, in an atlas too
196
+ tileInfo = new TileInfo(origin.add(vec2(margin)), size, tileInfo.textureInfo, spacing / 2, tileInfo.bleed, columns);
177
197
  }
178
198
 
179
199
  // flatten group layers in order, a group's color and visibility carry to the layers inside it
@@ -204,17 +224,30 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
204
224
  };
205
225
  addLayers(tileMapData.layers, WHITE, true);
206
226
 
207
- // create tile layers and fill with data
227
+ // a layer named for collision is the first tile layer of that name, which stays the same when layers are added
228
+ if (typeof collisionLayer === 'string')
229
+ {
230
+ const name = collisionLayer;
231
+ collisionLayer = layers.findIndex((l)=> l.color && l.dataLayer.name === name);
232
+ collisionLayer < 0 && console.error('tileLayersLoad: no tile layer is named ' + name + ', none is solid');
233
+ }
234
+
235
+ // create tile layers and fill with data, every layer's tiles checked first, so a bad one leaves none made
208
236
  const tileLayers = [];
209
237
  const levelSize = vec2(tileMapData.width, tileMapData.height);
210
238
  const layerCount = layers.length;
239
+ for (const [layerIndex, {dataLayer, color}] of layers.entries())
240
+ {
241
+ const tiles = dataLayer.data; // a list of gids, or a typed array of them
242
+ if (color && (!(isArray(tiles) || ArrayBuffer.isView(tiles)) || dataLayer.data.length !== levelSize.area()))
243
+ throw new Error(`tileLayersLoad: layer ${dataLayer.name ?? layerIndex} has ${dataLayer.data?.length} tiles for a map of ` +
244
+ `${levelSize.area()}; infinite maps and compressed layers are not read`);
245
+ }
211
246
  for (let layerIndex=layerCount; layerIndex--;)
212
247
  {
213
248
  const {dataLayer, color: layerColor, visible} = layers[layerIndex];
214
249
  if (!layerColor)
215
250
  continue;
216
- ASSERT(dataLayer.data && dataLayer.data.length, 'tile layer has no data, infinite maps and compressed layers are not supported');
217
- ASSERT(levelSize.area() === dataLayer.data.length);
218
251
 
219
252
  const layerRenderOrder = renderOrder - (layerCount - 1 - layerIndex);
220
253
  const tileLayer = new TileCollisionLayer(vec2(), levelSize, tileInfo, layerRenderOrder);
@@ -223,8 +256,8 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
223
256
  if (!visible)
224
257
  tileLayer.render = ()=> {}; // a hidden layer keeps its tiles and collision but is not drawn
225
258
 
226
- for (let x=levelSize.x; x--;)
227
- for (let y=levelSize.y; y--;)
259
+ for (let x=0; x<levelSize.x; ++x)
260
+ for (let y=0; y<levelSize.y; ++y)
228
261
  {
229
262
  const pos = vec2(x, levelSize.y-1-y);
230
263
  const data = dataLayer.data[x + y*levelSize.x];
@@ -259,36 +292,45 @@ const tileLayersLDtkTypes = {Int: 'int', Float: 'float', Bool: 'bool', String: '
259
292
 
260
293
  /**
261
294
  * Make a Tiled map of a level of an LDtk project, to load with tileLayersLoad and objectLayersLoad
262
- * - Each Tiles, AutoLayer and IntGrid layer is a tile layer, the bottom one first as in Tiled, so the last layer
263
- * of the LDtk file is layer 0; where LDtk stacks tiles in a cell, an edge over a fill, each tile over another
264
- * goes in a layer of its own just above, named with (2), (3) and so on, so a layer of the first tiles has one
265
- * wherever the LDtk layer has any, which makes it the one for collision
266
- * - An IntGrid layer with no tiles is a hidden layer of its values, for collision: pass its index as collisionLayer
295
+ * - Each Tiles, AutoLayer and IntGrid layer is a tile layer, the bottom one first as in Tiled; where LDtk stacks
296
+ * tiles in a cell, an edge over a fill, each tile over another goes in a layer of its own just above, named with
297
+ * (2), (3) and so on, and a tile LDtk draws see-through goes in one of its own with that opacity, times the
298
+ * layer's, named with it, like Ground .25
299
+ * - An IntGrid layer is a hidden layer of its values under its own name, for collision, whatever tiles its rules
300
+ * make, which are layers over it named with tiles, like Collisions tiles; pass the name as collisionLayer, since
301
+ * the stacked and see-through layers change the indices from level to level:
302
+ * tileLayersLoad(map, tile(0, 16), 0, 'Collisions')
267
303
  * - An Entities layer is an object layer: an entity's name is its type for objectLayersAddType, it is placed at
268
304
  * its middle, and its Int, Float, Bool, String, Color and FilePath fields are its properties (an enum is a string)
269
305
  * - The tileset is the first tile layer's, with its padding and spacing; give tileLayersLoad a tile info of its image
270
306
  * - The level is in the project file (not saved as separate level files), its layers of one grid size; a layer of
271
- * another grid size or another tileset is left out, with a warning in debug builds
307
+ * another grid size or another tileset is left out, with a warning in debug builds, and a project with no tileset
308
+ * keeps its tiles for the tile info the game gives; a project of several worlds has its levels counted in order
309
+ * - Layer offsets are not read
272
310
  * - The level editor edits the map this returns, and saves it as a Tiled map
273
311
  * @param {Object} ldtk - The LDtk project, its JSON
274
312
  * @param {number|string} [level] - Which level, by its index or its identifier
275
313
  * @return {Object} - A Tiled map: width, height, tilewidth, tileheight, tilesets and layers
276
314
  * @example
277
315
  * const map = tileLayersFromLDtk(await fetchJSON('world.ldtk'), 'Level_0');
278
- * const layers = tileLayersLoad(map, tile(0, 16), 0, 1); // layer 1 is solid
316
+ * const layers = tileLayersLoad(map, tile(0, 16), 0, 'Collisions'); // its IntGrid layer is solid
279
317
  * objectLayersLoad(map);
280
318
  * @memberof TileLayers */
281
319
  function tileLayersFromLDtk(ldtk, level=0)
282
320
  {
283
- const levels = ldtk?.levels || [];
321
+ const levels = ldtk?.levels?.length ? ldtk.levels : (ldtk?.worlds || []).flatMap((w)=> w.levels || []);
284
322
  const data = typeof level === 'string' ? levels.find((l)=> l.identifier === level) : levels[level];
285
- ASSERT(data, 'LDtk level not found', level);
286
- ASSERT(isArray(data.layerInstances), 'LDtk level has no layers: levels saved as separate files are not read');
323
+ if (!data)
324
+ throw new Error('tileLayersFromLDtk: no level ' + level);
325
+ if (!isArray(data.layerInstances))
326
+ throw new Error('tileLayersFromLDtk: level ' + data.identifier + ' has no layers, levels saved as separate files are not read');
287
327
  const instances = data.layerInstances;
288
328
  const tiled = (l)=> l.__type !== 'Entities';
289
329
  const first = instances.find((l)=> tiled(l) && l.__tilesetDefUid != undefined) || instances.find(tiled) || instances[0];
290
330
  const grid = first?.__gridSize || 16;
291
331
  const width = first?.__cWid || ceil(data.pxWid / grid), height = first?.__cHei || ceil(data.pxHei / grid);
332
+ if (!(width > 0 && height > 0 && width * height <= 1 << 22))
333
+ throw new Error('tileLayersFromLDtk: level ' + data.identifier + ' is ' + width + ' by ' + height + ' cells');
292
334
  const map = {width, height, tilewidth: grid, tileheight: grid, orientation: 'orthogonal', renderorder: 'right-down',
293
335
  infinite: false, layers: [], nextlayerid: 1, nextobjectid: 1};
294
336
 
@@ -306,7 +348,7 @@ function tileLayersFromLDtk(ldtk, level=0)
306
348
  const instance = instances[i], name = instance.__identifier, id = map.nextlayerid++;
307
349
  if (!tiled(instance))
308
350
  {
309
- const objects = (instance.entityInstances || []).map((entity)=>
351
+ const objects = (instance.entityInstances || []).filter((entity)=> isArray(entity.px)).map((entity)=>
310
352
  {
311
353
  // an entity is placed by its pivot, an object here by its middle
312
354
  const [pivotX=0, pivotY=0] = entity.__pivot || [], w = entity.width || 0, h = entity.height || 0;
@@ -322,34 +364,64 @@ function tileLayersFromLDtk(ldtk, level=0)
322
364
  map.layers.push({id, name, type: 'objectgroup', objects, opacity: 1, visible: true, x: 0, y: 0});
323
365
  continue;
324
366
  }
325
- const tiles = [...(instance.autoLayerTiles || []), ...(instance.gridTiles || [])];
326
- const usable = instance.__gridSize === grid && instance.__cWid === width && instance.__cHei === height &&
327
- (!tiles.length || instance.__tilesetDefUid === tileset?.uid);
328
- if (!usable)
367
+ if (instance.__gridSize !== grid || instance.__cWid !== width || instance.__cHei !== height)
368
+ {
369
+ console.warn(`tileLayersFromLDtk: layer ${name} has another grid, left out`);
370
+ continue;
371
+ }
372
+ // an IntGrid layer's values, hidden, under its own name, for collision whatever its tiles do
373
+ const empty = ()=> new Array(width * height).fill(0), opacity = instance.__opacity ?? 1;
374
+ const values = instance.__type === 'IntGrid';
375
+ if (values)
376
+ {
377
+ const data = empty();
378
+ (instance.intGridCsv || []).forEach((value, k)=> k < data.length && (data[k] = value));
379
+ map.layers.push({id, name, type: 'tilelayer', width, height, data, opacity, visible: false, x: 0, y: 0});
380
+ }
381
+ // its tiles, those with a place, from the first layer's tileset
382
+ const tiles = [...(instance.autoLayerTiles || []), ...(instance.gridTiles || [])].filter((t)=> isArray(t.px));
383
+ if (!tiles.length)
384
+ {
385
+ // a tile layer with none yet is an empty layer, to paint in the editor, and a level of one still loads
386
+ values || map.layers.push({id, name, type: 'tilelayer', width, height, data: empty(), opacity,
387
+ visible: instance.visible !== false, x: 0, y: 0});
388
+ continue;
389
+ }
390
+ if (tileset && (instance.__tilesetDefUid !== tileset.uid || tileset.tileGridSize !== grid))
329
391
  {
330
- debug && console.warn(`tileLayersFromLDtk: layer ${name} has another grid or tileset, left out`);
392
+ console.warn(`tileLayersFromLDtk: the tiles of layer ${name} are on another tileset, left out`);
331
393
  continue;
332
394
  }
333
- const empty = ()=> new Array(width * height).fill(0), stacks = [empty()];
334
- if (tiles.length)
395
+
396
+ // a layer for each depth in a cell and each opacity, the first tiles drawn solid first
397
+ const tilesName = values ? name + ' tiles' : name;
398
+ const stacks = [{depth: 0, alpha: 1, data: empty()}], stack = (depth, alpha)=>
399
+ {
400
+ let s = stacks.find((s)=> s.depth === depth && s.alpha === alpha);
401
+ s || stacks.push(s = {depth, alpha, data: empty()});
402
+ return s.data;
403
+ };
335
404
  {
336
- // a tile's place in pixels, its tile in the sheet, and its flips: bit 0 across, bit 1 down, which are
337
- // Tiled's two top bits; LDtk lists them in the order it draws them, so the first in a cell is the
338
- // bottom one and each after it goes a layer higher
405
+ // a tile's place in pixels, its tile in the sheet, its flips, bit 0 across and bit 1 down, which are
406
+ // Tiled's two top bits, and its opacity; LDtk lists them in the order it draws them, so the first in a
407
+ // cell is the bottom one and each after it goes a layer higher
339
408
  const depth = new Uint16Array(width * height);
340
409
  for (const t of tiles)
341
410
  {
342
411
  const x = floor(t.px[0] / grid), y = floor(t.px[1] / grid), cell = x + y * width;
343
412
  if (x >= 0 && x < width && y >= 0 && y < height)
344
- (stacks[depth[cell]++] ||= empty())[cell] =
413
+ stack(depth[cell]++, round(clamp(t.a ?? 1) * 1e3) / 1e3)[cell] =
345
414
  (t.t + 1 | (t.f & 1 ? 0x80000000 : 0) | (t.f & 2 ? 0x40000000 : 0)) >>> 0;
346
415
  }
347
416
  }
348
- else
349
- (instance.intGridCsv || []).forEach((value, k)=> k < stacks[0].length && (stacks[0][k] = value));
350
- const visible = instance.__type === 'IntGrid' && !tiles.length ? false : instance.visible !== false;
351
- stacks.forEach((data, k)=> map.layers.push({id: k ? map.nextlayerid++ : id, name: k ? `${name} (${k + 1})` : name,
352
- type: 'tilelayer', width, height, data, opacity: instance.__opacity ?? 1, visible, x: 0, y: 0}));
417
+ const visible = instance.visible !== false;
418
+ stacks.sort((a, b)=> a.depth - b.depth || b.alpha - a.alpha);
419
+ for (const {depth, alpha, data} of stacks)
420
+ {
421
+ const layerName = tilesName + (depth ? ` (${depth + 1})` : '') + (alpha < 1 ? ' ' + String(alpha).replace(/^0/, '') : '');
422
+ map.layers.push({id: values || depth || alpha < 1 ? map.nextlayerid++ : id, name: layerName, type: 'tilelayer',
423
+ width, height, data, opacity: opacity * alpha, visible, x: 0, y: 0});
424
+ }
353
425
  }
354
426
  return map;
355
427
  }
@@ -456,7 +528,15 @@ function objectLayersMake(tileMapData, object)
456
528
  const pos = vec2(object.x / tilewidth, height - object.y / tileheight);
457
529
  const {make} = type, result = make.prototype ? new make(pos) : make(pos);
458
530
  if (!result || typeof result !== 'object') return;
459
- return Object.assign(result, objectLayersProperties(type, object));
531
+ for (const [key, value] of Object.entries(objectLayersProperties(type, object)))
532
+ {
533
+ // a property named as a method of the object, like update, would take the method's place, so it is left out
534
+ if (typeof result[key] === 'function' && typeof value !== 'function')
535
+ debug && console.warn(`objectLayersLoad: ${name} property ${key} has a method's name, left out`);
536
+ else
537
+ result[key] = value;
538
+ }
539
+ return result;
460
540
  }
461
541
 
462
542
  ///////////////////////////////////////////////////////////////////////////////
@@ -642,6 +722,7 @@ class TileLayer extends CanvasLayer
642
722
  // set tile info
643
723
  this.tileInfo = tileInfo.frame(0);
644
724
  this.tileInfo.bleed = 0; // disable bleed for tile layers
725
+ this.tileInfo.tiles = tileInfo.tiles; // a tile set's list, filled in as its images load
645
726
  }
646
727
 
647
728
  // init tile data
@@ -821,8 +902,10 @@ class TileLayer extends CanvasLayer
821
902
  const d = this.getData(layerPos);
822
903
  if (!d || d.tile === undefined) return;
823
904
 
824
- // a tileset packed by loadSprite keeps its own columns, counted from its first tile, not the sheet's grid
825
- const t = this.tileInfo, tileInfo = t && (t.columns ? t.frame(d.tile) : t.index(d.tile));
905
+ // a tile set from loadTiles has each tile where it was packed, a tileset packed by loadSprite keeps its own
906
+ // columns, counted from its first tile, not the sheet's grid
907
+ const t = this.tileInfo, tileInfo = tileLayerTileInfo(t, d.tile);
908
+ if (t && !tileInfo) return; // past the end of the sheet or the tile set, or loading
826
909
  this.drawLayerTile(drawPos, cellPixels, tileInfo, d.color, d.direction*PI/2, d.mirror);
827
910
  }
828
911
 
@@ -1097,7 +1180,23 @@ class TileCollisionLayer extends TileLayer
1097
1180
  const tileData = this.getCollisionData(pos);
1098
1181
  return tileData && collisionTest(tileData, vec2(pos.x + offset.x, pos.y + offset.y));
1099
1182
  }
1100
- const hitPos = lineTest(posStart.subtract(offset), posEnd.subtract(offset), testFunction, normal);
1183
+ // only the part of the line over the layer, and a cell around it, can meet a tile, so a ray toward a point
1184
+ // far beyond is walked across the layer and no farther; the cell around keeps the step into the layer as it was
1185
+ const a = posStart.subtract(offset), d = posEnd.subtract(posStart);
1186
+ let t0 = 0, t1 = 1;
1187
+ for (const [p, q, high] of [[a.x, d.x, this.size.x + 1], [a.y, d.y, this.size.y + 1]])
1188
+ {
1189
+ if (!q)
1190
+ {
1191
+ if (p < -1 || p > high) return;
1192
+ continue;
1193
+ }
1194
+ const u = (-1 - p) / q, v = (high - p) / q;
1195
+ t0 = max(t0, min(u, v)), t1 = min(t1, max(u, v));
1196
+ }
1197
+ if (t0 > t1) return;
1198
+ const hitPos = lineTest(t0 ? a.add(d.scale(t0)) : a, t1 < 1 ? a.add(d.scale(t1)) : posEnd.subtract(offset),
1199
+ testFunction, normal);
1101
1200
  if (hitPos)
1102
1201
  hitPos.x += offset.x, hitPos.y += offset.y;
1103
1202
  if (debugRaycast && hitPos)
@@ -141,6 +141,7 @@ function saveText(text, filename='text', type='text/plain')
141
141
 
142
142
  /** Create an offscreen canvas to draw into, and return its 2D context
143
143
  * - The canvas is context.canvas, which is what TextureInfo and the like take
144
+ * - A browser with no OffscreenCanvas, Safari before 16.4, gets a canvas element that is not on the page
144
145
  * @param {number} width - In pixels
145
146
  * @param {number} [height] - In pixels, defaults to the width for a square
146
147
  * @param {boolean} [willReadFrequently] - Keep it in software, faster when getImageData is called on it often
@@ -149,9 +150,16 @@ function saveText(text, filename='text', type='text/plain')
149
150
  function createCanvasContext(width, height=width, willReadFrequently=false)
150
151
  {
151
152
  ASSERT(isNumber(width) && isNumber(height), 'canvas width and height must be numbers', width, height);
152
- return new OffscreenCanvas(width, height).getContext('2d', {willReadFrequently});
153
+ const canvas = typeof OffscreenCanvas != 'undefined' ? new OffscreenCanvas(width, height) :
154
+ Object.assign(document.createElement('canvas'), {width, height});
155
+ return /** @type {OffscreenCanvasRenderingContext2D} */ (canvas.getContext('2d', {willReadFrequently}));
153
156
  }
154
157
 
158
+ // can a canvas be made to draw into: an OffscreenCanvas, or a canvas element where there is none, as in Safari before
159
+ // 16.4; neither in a headless test
160
+ function canvasAvailable()
161
+ { return typeof OffscreenCanvas != 'undefined' || typeof document != 'undefined' && !!document.createElement; }
162
+
155
163
  /** Save a canvas to disk
156
164
  * @param {HTMLCanvasElement|OffscreenCanvas} canvas
157
165
  * @param {string} [filename]
@@ -159,7 +167,7 @@ function createCanvasContext(width, height=width, willReadFrequently=false)
159
167
  * @memberof Utilities */
160
168
  function saveCanvas(canvas, filename='screenshot', type='image/png')
161
169
  {
162
- if (canvas instanceof OffscreenCanvas)
170
+ if (typeof OffscreenCanvas != 'undefined' && canvas instanceof OffscreenCanvas)
163
171
  {
164
172
  // copy to temporary canvas and save
165
173
  const saveCanvas = document.createElement('canvas');
@@ -169,7 +177,7 @@ function saveCanvas(canvas, filename='screenshot', type='image/png')
169
177
  saveDataURL(saveCanvas.toDataURL(type), filename);
170
178
  }
171
179
  else
172
- saveDataURL(canvas.toDataURL(type), filename);
180
+ saveDataURL(/** @type {HTMLCanvasElement} */ (canvas).toDataURL(type), filename);
173
181
  }
174
182
 
175
183
  /** Save a data url to disk
@@ -237,16 +245,19 @@ function readSaveData(saveName, defaultSaveData)
237
245
  return { .../** @type {object} */ (defaultSaveData), ...loadedData };
238
246
  }
239
247
 
240
- /** Write save data to local storage
248
+ /** Write save data to local storage, an object as readSaveData gives it back
241
249
  * @param {string} saveName - unique name for the game/save
242
250
  * @param {object} saveData - object containing data to be saved
251
+ * @return {boolean} - Whether it was written, false when storage is unavailable or full
243
252
  * @memberof Utilities */
244
253
  function writeSaveData(saveName, saveData)
245
254
  {
246
255
  ASSERT(isStringLike(saveName), 'writeSaveData requires saveName string');
256
+ ASSERT(typeof saveData === 'object' && saveData !== null && !isArray(saveData),
257
+ 'writeSaveData: save data must be an object, readSaveData reads it back into one');
247
258
  // tolerate localStorage being unavailable or quota exceeded
248
- try { localStorage.setItem(saveName, JSON.stringify(saveData)); }
249
- catch { LOG('writeSaveData: failed to write', saveName); }
259
+ try { localStorage.setItem(saveName, JSON.stringify(saveData)); return true; }
260
+ catch { LOG('writeSaveData: failed to write', saveName); return false; }
250
261
  }
251
262
 
252
263
  ///////////////////////////////////////////////////////////////////////////////
@@ -283,8 +294,9 @@ function noise2D(x, y)
283
294
  {
284
295
  const ix = floor(x), iy = floor(y);
285
296
  const fx = smoothStep(x - ix), fy = smoothStep(y - iy);
286
- // large prime decorrelates neighboring rows
287
- const h = (a, b) => noiseHash(a + b * 374761393);
297
+ // large prime decorrelates neighboring rows; multiplied as 32 bit integers, which is what the hash keeps of it,
298
+ // so the row stays exact however far out y is, where a plain product would lose its low bits
299
+ const h = (a, b) => noiseHash(a + Math.imul(b, 374761393));
288
300
  return lerp(
289
301
  lerp(h(ix, iy ), h(ix + 1, iy ), fx),
290
302
  lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx),
@@ -576,9 +576,9 @@ function glSetTextureData(texture, image, pixelated=tilesPixelated)
576
576
  {
577
577
  // WebGL ignores the unpack flag below for a bitmap and uploads it as it was made, so it is drawn onto a
578
578
  // canvas first, which reads either kind of bitmap right and uploads as the flag says
579
- const canvas = new OffscreenCanvas(image.width, image.height);
580
- canvas.getContext('2d').drawImage(image, 0, 0);
581
- image = canvas;
579
+ const context = createCanvasContext(image.width, image.height);
580
+ context.drawImage(image, 0, 0);
581
+ image = context.canvas;
582
582
  }
583
583
  // smooth filtering mixes a texel with its see through neighbors, right only for premultiplied color, or the
584
584
  // edges go dark; pixel art is sampled a texel at a time, and uploads straight color as it always has
@@ -961,6 +961,9 @@ function glClearRect(x, y, width, height)
961
961
 
962
962
  ///////////////////////////////////////////////////////////////////////////////
963
963
 
964
+ // the strip glMakeOutline makes and the vectors it writes, kept for the next, as it is read into the batch first
965
+ const glOutlineStrip = [], glOutlinePool = [];
966
+
964
967
  // WebGL internal function to convert polygon to outline triangle strip
965
968
  function glMakeOutline(points, width, wrap=true)
966
969
  {
@@ -968,7 +971,8 @@ function glMakeOutline(points, width, wrap=true)
968
971
  return [];
969
972
 
970
973
  const halfWidth = width / 2;
971
- const strip = [];
974
+ const strip = glOutlineStrip, pool = glOutlinePool;
975
+ strip.length = 0;
972
976
  const n = points.length;
973
977
  const e = 1e-6;
974
978
  // miter ratio cap (dimensionless, matches SVG/Canvas2D convention)
@@ -1025,10 +1029,10 @@ function glMakeOutline(points, width, wrap=true)
1025
1029
  }
1026
1030
 
1027
1031
  // create inner and outer points along the normal
1028
- const inner = vec2(curr.x - nx * halfWidth, curr.y - ny * halfWidth);
1029
- const outer = vec2(curr.x + nx * halfWidth, curr.y + ny * halfWidth);
1030
- strip.push(inner);
1031
- strip.push(outer);
1032
+ const inner = pool[strip.length] ||= vec2(), outer = pool[strip.length + 1] ||= vec2();
1033
+ inner.x = curr.x - nx * halfWidth, inner.y = curr.y - ny * halfWidth;
1034
+ outer.x = curr.x + nx * halfWidth, outer.y = curr.y + ny * halfWidth;
1035
+ strip.push(inner, outer);
1032
1036
  }
1033
1037
  if (strip.length > 1 && wrap)
1034
1038
  {