littlejsengine 1.24.1 → 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 (42) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/EDITOR.md +2 -0
  3. package/FAQ.md +13 -2
  4. package/README.md +1 -1
  5. package/REFERENCE.md +20 -6
  6. package/dist/littlejs.d.ts +77 -13
  7. package/dist/littlejs.esm.js +2395 -1939
  8. package/dist/littlejs.esm.min.js +3 -3
  9. package/dist/littlejs.js +2392 -1939
  10. package/dist/littlejs.min.js +2 -2
  11. package/dist/littlejs.release.js +2356 -1952
  12. package/package.json +1 -1
  13. package/plugins/gltf.js +62 -9
  14. package/plugins/lightSystem.js +1 -1
  15. package/plugins/math3d.js +1014 -1013
  16. package/plugins/medalSystem.js +7 -11
  17. package/plugins/particleEffects.js +2 -2
  18. package/plugins/pathFinder.js +2 -2
  19. package/plugins/pluginExport.js +1 -0
  20. package/plugins/render3d.js +131 -55
  21. package/plugins/render3dEditor.js +24 -10
  22. package/plugins/render3dExtras.js +29 -17
  23. package/plugins/render3dLevel.js +21 -12
  24. package/plugins/render3dVoxels.js +2 -2
  25. package/plugins/textureSheet.js +97 -3
  26. package/plugins/tweakables.js +1 -1
  27. package/plugins/tweenSystem.js +2 -2
  28. package/plugins/uiSystem.js +6 -2
  29. package/src/engine.js +34 -11
  30. package/src/engineAudio.js +13 -3
  31. package/src/engineDebug.js +4 -1
  32. package/src/engineDraw.js +29 -16
  33. package/src/engineEditor.js +82 -50
  34. package/src/engineExport.js +2 -0
  35. package/src/engineInput.js +17 -3
  36. package/src/engineMath.js +6 -2
  37. package/src/engineObject.js +675 -675
  38. package/src/engineParticles.js +1 -1
  39. package/src/engineSettings.js +16 -0
  40. package/src/engineTileLayer.js +85 -22
  41. package/src/engineUtilities.js +20 -8
  42. 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}
@@ -819,6 +826,15 @@ function setVibrateEnable(enable) { vibrateEnable = enable; }
819
826
  * @memberof Settings */
820
827
  function setSoundEnable(enable) { soundEnable = enable; }
821
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
+
822
838
  /** Set volume scale to apply to all sound, music and speech
823
839
  * @param {number} volume
824
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
@@ -143,7 +156,8 @@ const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [
143
156
  * @param {number} [renderOrder] - Render order of the top layer
144
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,15 +174,20 @@ 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;
@@ -210,20 +229,25 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
210
229
  {
211
230
  const name = collisionLayer;
212
231
  collisionLayer = layers.findIndex((l)=> l.color && l.dataLayer.name === name);
213
- ASSERT(collisionLayer >= 0, 'no tile layer named ' + name);
232
+ collisionLayer < 0 && console.error('tileLayersLoad: no tile layer is named ' + name + ', none is solid');
214
233
  }
215
234
 
216
- // create tile layers and fill with data
235
+ // create tile layers and fill with data, every layer's tiles checked first, so a bad one leaves none made
217
236
  const tileLayers = [];
218
237
  const levelSize = vec2(tileMapData.width, tileMapData.height);
219
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
+ }
220
246
  for (let layerIndex=layerCount; layerIndex--;)
221
247
  {
222
248
  const {dataLayer, color: layerColor, visible} = layers[layerIndex];
223
249
  if (!layerColor)
224
250
  continue;
225
- ASSERT(dataLayer.data && dataLayer.data.length, 'tile layer has no data, infinite maps and compressed layers are not supported');
226
- ASSERT(levelSize.area() === dataLayer.data.length);
227
251
 
228
252
  const layerRenderOrder = renderOrder - (layerCount - 1 - layerIndex);
229
253
  const tileLayer = new TileCollisionLayer(vec2(), levelSize, tileInfo, layerRenderOrder);
@@ -232,8 +256,8 @@ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrde
232
256
  if (!visible)
233
257
  tileLayer.render = ()=> {}; // a hidden layer keeps its tiles and collision but is not drawn
234
258
 
235
- for (let x=levelSize.x; x--;)
236
- for (let y=levelSize.y; y--;)
259
+ for (let x=0; x<levelSize.x; ++x)
260
+ for (let y=0; y<levelSize.y; ++y)
237
261
  {
238
262
  const pos = vec2(x, levelSize.y-1-y);
239
263
  const data = dataLayer.data[x + y*levelSize.x];
@@ -305,6 +329,8 @@ function tileLayersFromLDtk(ldtk, level=0)
305
329
  const first = instances.find((l)=> tiled(l) && l.__tilesetDefUid != undefined) || instances.find(tiled) || instances[0];
306
330
  const grid = first?.__gridSize || 16;
307
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');
308
334
  const map = {width, height, tilewidth: grid, tileheight: grid, orientation: 'orthogonal', renderorder: 'right-down',
309
335
  infinite: false, layers: [], nextlayerid: 1, nextobjectid: 1};
310
336
 
@@ -322,7 +348,7 @@ function tileLayersFromLDtk(ldtk, level=0)
322
348
  const instance = instances[i], name = instance.__identifier, id = map.nextlayerid++;
323
349
  if (!tiled(instance))
324
350
  {
325
- const objects = (instance.entityInstances || []).map((entity)=>
351
+ const objects = (instance.entityInstances || []).filter((entity)=> isArray(entity.px)).map((entity)=>
326
352
  {
327
353
  // an entity is placed by its pivot, an object here by its middle
328
354
  const [pivotX=0, pivotY=0] = entity.__pivot || [], w = entity.width || 0, h = entity.height || 0;
@@ -338,12 +364,9 @@ function tileLayersFromLDtk(ldtk, level=0)
338
364
  map.layers.push({id, name, type: 'objectgroup', objects, opacity: 1, visible: true, x: 0, y: 0});
339
365
  continue;
340
366
  }
341
- const tiles = [...(instance.autoLayerTiles || []), ...(instance.gridTiles || [])];
342
- const ownTiles = !tiles.length || !tileset ||
343
- instance.__tilesetDefUid === tileset.uid && tileset.tileGridSize === grid;
344
- if (instance.__gridSize !== grid || instance.__cWid !== width || instance.__cHei !== height || !ownTiles)
367
+ if (instance.__gridSize !== grid || instance.__cWid !== width || instance.__cHei !== height)
345
368
  {
346
- debug && console.warn(`tileLayersFromLDtk: layer ${name} has another grid or tileset, left out`);
369
+ console.warn(`tileLayersFromLDtk: layer ${name} has another grid, left out`);
347
370
  continue;
348
371
  }
349
372
  // an IntGrid layer's values, hidden, under its own name, for collision whatever its tiles do
@@ -354,7 +377,20 @@ function tileLayersFromLDtk(ldtk, level=0)
354
377
  const data = empty();
355
378
  (instance.intGridCsv || []).forEach((value, k)=> k < data.length && (data[k] = value));
356
379
  map.layers.push({id, name, type: 'tilelayer', width, height, data, opacity, visible: false, x: 0, y: 0});
357
- if (!tiles.length) continue;
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))
391
+ {
392
+ console.warn(`tileLayersFromLDtk: the tiles of layer ${name} are on another tileset, left out`);
393
+ continue;
358
394
  }
359
395
 
360
396
  // a layer for each depth in a cell and each opacity, the first tiles drawn solid first
@@ -492,7 +528,15 @@ function objectLayersMake(tileMapData, object)
492
528
  const pos = vec2(object.x / tilewidth, height - object.y / tileheight);
493
529
  const {make} = type, result = make.prototype ? new make(pos) : make(pos);
494
530
  if (!result || typeof result !== 'object') return;
495
- 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;
496
540
  }
497
541
 
498
542
  ///////////////////////////////////////////////////////////////////////////////
@@ -678,6 +722,7 @@ class TileLayer extends CanvasLayer
678
722
  // set tile info
679
723
  this.tileInfo = tileInfo.frame(0);
680
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
681
726
  }
682
727
 
683
728
  // init tile data
@@ -857,8 +902,10 @@ class TileLayer extends CanvasLayer
857
902
  const d = this.getData(layerPos);
858
903
  if (!d || d.tile === undefined) return;
859
904
 
860
- // a tileset packed by loadSprite keeps its own columns, counted from its first tile, not the sheet's grid
861
- 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
862
909
  this.drawLayerTile(drawPos, cellPixels, tileInfo, d.color, d.direction*PI/2, d.mirror);
863
910
  }
864
911
 
@@ -1133,7 +1180,23 @@ class TileCollisionLayer extends TileLayer
1133
1180
  const tileData = this.getCollisionData(pos);
1134
1181
  return tileData && collisionTest(tileData, vec2(pos.x + offset.x, pos.y + offset.y));
1135
1182
  }
1136
- 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);
1137
1200
  if (hitPos)
1138
1201
  hitPos.x += offset.x, hitPos.y += offset.y;
1139
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
  {