littlejsengine 1.24.3 → 1.26.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 (53) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/EDITOR.md +2 -2
  3. package/FAQ.md +55 -11
  4. package/README.md +6 -2
  5. package/REFERENCE.md +203 -57
  6. package/dist/littlejs.d.ts +933 -313
  7. package/dist/littlejs.esm.js +5143 -2419
  8. package/dist/littlejs.esm.min.js +38 -24
  9. package/dist/littlejs.js +5132 -2416
  10. package/dist/littlejs.min.js +37 -23
  11. package/dist/littlejs.release.js +4905 -2232
  12. package/package.json +4 -4
  13. package/plugins/box2d.js +141 -56
  14. package/plugins/drawUtilities.js +374 -380
  15. package/plugins/gltf.js +383 -60
  16. package/plugins/lightSystem.js +412 -30
  17. package/plugins/math3d.js +1015 -1014
  18. package/plugins/medalSystem.js +19 -7
  19. package/plugins/newgrounds.js +5 -6
  20. package/plugins/parallax.js +1 -0
  21. package/plugins/particleEffects.js +47 -14
  22. package/plugins/pathFinder.js +60 -38
  23. package/plugins/pluginExport.js +11 -0
  24. package/plugins/postProcess.js +200 -67
  25. package/plugins/render3d.js +493 -2391
  26. package/plugins/render3dDebug.js +5 -8
  27. package/plugins/render3dEditor.js +84 -9
  28. package/plugins/render3dExtras.js +122 -33
  29. package/plugins/render3dLevel.js +67 -8
  30. package/plugins/render3dMesh.js +1158 -0
  31. package/plugins/render3dObject.js +1451 -0
  32. package/plugins/render3dVoxels.js +15 -3
  33. package/plugins/textureSheet.js +9 -8
  34. package/plugins/threejs.js +1 -1
  35. package/plugins/tweenSystem.js +117 -87
  36. package/plugins/uiSystem.js +62 -28
  37. package/plugins/wavedash.js +229 -0
  38. package/src/engine.js +192 -40
  39. package/src/engineAudio.js +47 -27
  40. package/src/engineBuild.mjs +6 -1
  41. package/src/engineDebug.js +23 -162
  42. package/src/engineDraw.js +148 -43
  43. package/src/engineEditor.js +153 -40
  44. package/src/engineExport.js +0 -3
  45. package/src/engineInput.js +31 -25
  46. package/src/engineMath.js +38 -20
  47. package/src/engineObject.js +68 -50
  48. package/src/engineParticles.js +66 -18
  49. package/src/engineRelease.js +18 -15
  50. package/src/engineSettings.js +48 -9
  51. package/src/engineTileLayer.js +44 -18
  52. package/src/engineUtilities.js +56 -11
  53. package/src/engineWebGL.js +68 -22
@@ -6,7 +6,7 @@
6
6
  * - setDebugKeysAlways lets those keys work with the overlay closed too
7
7
  * - ASSERT and LOG macros for development (removed in release builds)
8
8
  * - Debug primitive rendering (rectangles, circles, lines, points, text)
9
- * - Screenshot and video capture support
9
+ * - Screenshot support
10
10
  * - FPS counter and performance watermark
11
11
  * - Debug overlay shows mouse position and picked objects
12
12
  * @namespace Debug
@@ -84,7 +84,24 @@ function ASSERT(assert, ...output)
84
84
  {
85
85
  if (assert) return;
86
86
  console.assert(assert, ...output)
87
- throw new Error('Assert failed!'); // halt execution
87
+ // the error says what went wrong, so an error box or overlay that shows only the error shows the message too: a
88
+ // value with a toString of its own as that, a vec2 or a color, a plain object as JSON, an object of a class with
89
+ // none by its class's name, each cut at 200 characters, so a whole level is not the message
90
+ const text = output.map((value)=>
91
+ {
92
+ let text;
93
+ try
94
+ {
95
+ const prototype = value && typeof value === 'object' ? Object.getPrototypeOf(value) : undefined;
96
+ text = prototype === Object.prototype || prototype === null ? JSON.stringify(value) :
97
+ prototype && value.toString === Object.prototype.toString ?
98
+ '[' + (value.constructor?.name || 'object') + ']' : String(value);
99
+ }
100
+ catch { text = Object.prototype.toString.call(value); }
101
+ text = String(text); // JSON of nothing, as from a toJSON that returns nothing, is undefined
102
+ return text.length > 200 ? text.slice(0, 199) + '…' : text;
103
+ }).join(' ');
104
+ throw new Error(text ? 'Assert failed: ' + text : 'Assert failed!'); // halt execution
88
105
  }
89
106
 
90
107
  /** Log to console if debug is enabled, does nothing in release builds
@@ -94,7 +111,7 @@ function LOG(...output) { console.log(...output); }
94
111
 
95
112
  /** Draw a debug rectangle in world space, or on the screen with screenSpace
96
113
  * @param {Vector2} pos
97
- * @param {Vector2} [size=vec2(0)]
114
+ * @param {Vector2} [size=vec2()]
98
115
  * @param {Color|string} [color]
99
116
  * @param {number} [time]
100
117
  * @param {number} [angle]
@@ -329,19 +346,6 @@ function debugUpdate()
329
346
  if (keyWasPressed('Digit0'))
330
347
  levelEditor.isOpen ? levelEditor.close() : levelEditor.open();
331
348
  }
332
- if (debugVideoCaptureIsActive())
333
- {
334
- // control to stop video capture, a capture the overlay started also stops when the overlay closes,
335
- // one the game started from code runs until it calls debugVideoCaptureStop
336
- if (debugKeys ? keyWasPressed('Digit8') : debugVideoCapture.fromOverlay)
337
- debugVideoCaptureStop();
338
- }
339
- else if (debugKeys && keyWasPressed('Digit8'))
340
- {
341
- debugVideoCaptureStart();
342
- if (debugVideoCapture)
343
- debugVideoCapture.fromOverlay = true;
344
- }
345
349
  }
346
350
 
347
351
  // the text beside the mouse, with the same shadow as the overlay
@@ -440,20 +444,13 @@ function debugRender()
440
444
  {
441
445
  if (debugTakeScreenshot)
442
446
  {
443
- // combine canvases, remove alpha and save, before the capture check so it is taken during video capture too
447
+ // combine canvases, remove alpha and save
444
448
  glFlush();
445
449
  combineCanvases();
446
450
  saveCanvas(mainCanvas);
447
451
  debugTakeScreenshot = 0;
448
452
  }
449
453
 
450
- if (debugVideoCaptureIsActive())
451
- {
452
- // don't show debug info when capturing video, but still drop the expired primitives so they don't pile up
453
- debugPrimitives = debugPrimitives.filter(r=>r.timer<0);
454
- return;
455
- }
456
-
457
454
  // flush any gl sprites before drawing debug info
458
455
  glFlush();
459
456
 
@@ -682,7 +679,6 @@ function debugRender()
682
679
  debugContext.fillText('6: Debug Sound', x, y += h);
683
680
  debugContext.fillStyle = '#fff';
684
681
  debugContext.fillText('7: Save Screenshot', x, y += h);
685
- debugContext.fillText('8: Toggle Video Capture', x, y += h);
686
682
  debugContext.fillStyle = debugTweakables ? '#f00' : '#fff';
687
683
  debugContext.fillText('9: Tweakables', x, y += h);
688
684
  debugContext.fillStyle = levelEditor.isOpen ? '#f00' : '#fff';
@@ -757,142 +753,6 @@ function debugRender()
757
753
  }
758
754
  }
759
755
 
760
- function debugRenderPost() { debugVideoCaptureIsActive() && debugVideoCaptureUpdate(); }
761
-
762
- ///////////////////////////////////////////////////////////////////////////////
763
- // video capture - records video and audio at 60 fps using MediaRecorder API
764
-
765
- // internal variables used to capture video
766
- let debugVideoCapture, debugVideoCaptureIcon;
767
-
768
- /** Check if video capture is active
769
- * @memberof Debug */
770
- function debugVideoCaptureIsActive() { return !!debugVideoCapture; }
771
-
772
- /** Start capturing video
773
- * @memberof Debug */
774
- function debugVideoCaptureStart()
775
- {
776
- ASSERT(!debugVideoCaptureIsActive(), 'Already capturing video!');
777
-
778
- // everything that can fail where recording is unsupported is in the try, a missing captureStream
779
- // or a MediaRecorder that refuses webm throws here and is cleaned up rather than stopping the game
780
- const captureTimer = new Timer(0, true);
781
- const chunks = [];
782
- let videoTrack, mediaRecorder, audioStreamDestination, silentAudioSource, audioTapNode;
783
- try
784
- {
785
- // setup captureStream to capture manually by passing 0
786
- const stream = mainCanvas.captureStream(0);
787
- videoTrack = stream.getVideoTracks()[0];
788
- videoTrack.applyConstraints({frameRate:frameRate}).catch(()=>{});
789
-
790
- // set up the media recorder
791
- mediaRecorder = new MediaRecorder(stream,
792
- {mimeType:'video/webm;codecs=vp8'});
793
- mediaRecorder.ondataavailable = (e)=> chunks.push(e.data);
794
- mediaRecorder.onstop = ()=>
795
- {
796
- const blob = new Blob(chunks, {type: 'video/webm'});
797
- const url = URL.createObjectURL(blob);
798
- saveDataURL(url, 'capture.webm', 1e3);
799
- };
800
-
801
- if (soundEnable)
802
- {
803
- // create silent audio source
804
- // fixes issue where video can not start recording without audio
805
- silentAudioSource = new ConstantSourceNode(audioContext, { offset: 0 });
806
- silentAudioSource.connect(audioMasterGain);
807
- silentAudioSource.start();
808
-
809
- // tap the end of the master chain so a master effect is in the recording
810
- // (a master effect swapped mid-capture drops the tap, the rest records silent)
811
- audioTapNode = audioMasterEffectOutput || audioMasterGain;
812
- audioStreamDestination = audioContext.createMediaStreamDestination();
813
- audioTapNode.connect(audioStreamDestination);
814
- for (const track of audioStreamDestination.stream.getAudioTracks())
815
- stream.addTrack(track); // add audio tracks to capture stream
816
- }
817
-
818
- // start recording
819
- mediaRecorder.start();
820
- }
821
- catch(e)
822
- {
823
- LOG('Video capture not supported in this browser!');
824
- videoTrack?.stop();
825
- silentAudioSource?.stop();
826
- if (audioStreamDestination)
827
- {
828
- // the tap may not have been made before the failure
829
- try { audioTapNode.disconnect(audioStreamDestination); }
830
- catch { }
831
- }
832
- return;
833
- }
834
-
835
- if (!debugVideoCaptureIcon)
836
- {
837
- // create recording icon to show it is capturing video
838
- debugVideoCaptureIcon = document.createElement('div');
839
- debugVideoCaptureIcon.style.position = 'absolute';
840
- debugVideoCaptureIcon.style.padding = '9px';
841
- debugVideoCaptureIcon.style.color = '#f00';
842
- debugVideoCaptureIcon.style.font = '50px monospace';
843
- document.body.appendChild(debugVideoCaptureIcon);
844
- }
845
- // show recording icon, only once recording has started
846
- debugVideoCaptureIcon.textContent = '';
847
- debugVideoCaptureIcon.style.display = '';
848
-
849
- LOG('Video capture started.');
850
-
851
- // save debug video info
852
- debugVideoCapture =
853
- {
854
- mediaRecorder,
855
- captureTimer,
856
- videoTrack,
857
- silentAudioSource,
858
- audioStreamDestination,
859
- audioTapNode
860
- };
861
- }
862
-
863
- /** Stop capturing video and save to disk
864
- * @memberof Debug */
865
- function debugVideoCaptureStop()
866
- {
867
- ASSERT(debugVideoCaptureIsActive(), 'Not capturing video!');
868
-
869
- // stop recording
870
- LOG(`Video capture ended. ${debugVideoCapture.captureTimer.get().toFixed(2)} seconds recorded.`);
871
- debugVideoCaptureIcon.style.display = 'none';
872
- debugVideoCapture.silentAudioSource?.stop();
873
- debugVideoCapture.mediaRecorder?.stop();
874
- debugVideoCapture.videoTrack?.stop();
875
- if (debugVideoCapture.audioStreamDestination)
876
- {
877
- // the tap is already gone if the master effect changed during the capture
878
- try { debugVideoCapture.audioTapNode.disconnect(debugVideoCapture.audioStreamDestination); }
879
- catch { }
880
- }
881
- debugVideoCapture = undefined;
882
- }
883
-
884
- // update video capture, called automatically by engine
885
- function debugVideoCaptureUpdate()
886
- {
887
- ASSERT(debugVideoCaptureIsActive(), 'Not capturing video!');
888
-
889
- // save the video frame
890
- combineCanvases();
891
- debugVideoCapture.videoTrack.requestFrame();
892
- debugVideoCaptureIcon.textContent = '● REC '
893
- + formatTime(debugVideoCapture.captureTimer.get());
894
- }
895
-
896
756
  ///////////////////////////////////////////////////////////////////////////////
897
757
  // debug utility functions
898
758
 
@@ -912,7 +772,8 @@ function debugProtectConstant(obj)
912
772
  get: ()=> values[prop],
913
773
  set: (value)=>
914
774
  {
915
- ASSERT(false, `Cannot modify engine constant. Attempted to set constant (${obj}) property '${prop}' to '${value}'.`);
775
+ ASSERT(false, `engine constants like RED can not be changed, change a copy made with copy(); ` +
776
+ `tried to set ${prop} of ${obj} to ${value}`);
916
777
  },
917
778
  enumerable: true
918
779
  });
package/src/engineDraw.js CHANGED
@@ -105,7 +105,7 @@ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
105
105
  * Create a tile info object using a grid based system
106
106
  * - This can take vecs or floats for easier use and conversion
107
107
  * - If an index is passed in, the tile size and index will determine the position
108
- * @param {Vector2|number} [index=0] - Index of the tile in 1d or 2d form
108
+ * @param {Vector2|number} [index] - Index of the tile in 1d or 2d form
109
109
  * @param {Vector2|number} [size] - Size of tile in pixels
110
110
  * @param {TextureInfo|number} [texture] - Texture index or info to use
111
111
  * @param {number} [padding] - How many pixels padding around tiles
@@ -127,7 +127,7 @@ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPaddi
127
127
  if (typeof size === 'number')
128
128
  {
129
129
  // if size is a number, make it a vector
130
- ASSERT(size > 0);
130
+ ASSERT(size > 0, 'tile: size must be above 0', size);
131
131
  size = new Vector2(size, size);
132
132
  }
133
133
 
@@ -138,7 +138,8 @@ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPaddi
138
138
  {
139
139
  // no image: headless loads none and keeps the size; an image that failed to load, whose warning named it, gives
140
140
  // a tile of no size, which draws nothing; a slot never given an image is a mistake a debug build points out
141
- ASSERT(headlessMode || textureInfo instanceof TextureInfo, 'tile texture is not loaded', texture);
141
+ ASSERT(headlessMode || textureInfo instanceof TextureInfo,
142
+ 'tile: no texture ' + texture + ', pass its image to engineInit and make tiles in gameInit or later');
142
143
  return new TileInfo(new Vector2, headlessMode ? size.copy() : new Vector2, textureInfo, padding, bleed);
143
144
  }
144
145
 
@@ -209,7 +210,7 @@ class TileInfo
209
210
  */
210
211
  frame(frame)
211
212
  {
212
- ASSERT(typeof frame === 'number');
213
+ ASSERT(typeof frame === 'number', 'TileInfo.frame: frame must be a number', frame);
213
214
  const w = this.size.x + this.padding*2;
214
215
  const h = this.size.y + this.padding*2;
215
216
  const x = (this.columns ? frame % this.columns : frame) * w;
@@ -578,6 +579,12 @@ class Shader
578
579
  this.program3D = undefined;
579
580
  glShaderObjects.push(this); // a lost context drops the programs of every one
580
581
  }
582
+
583
+ /** Let go of this shader: its compiled programs are freed and it leaves the engine's list of shaders, which
584
+ * keeps every one made for a lost context, so a scene made again and again, or an editor trying snippets, does
585
+ * not keep them all; calling it again does nothing, and a draw with it afterwards compiles it again and takes it
586
+ * back, so only let go of one no object still draws with when it should stay freed */
587
+ dispose() { glShaderDispose(this); }
581
588
  }
582
589
 
583
590
  ///////////////////////////////////////////////////////////////////////////////
@@ -589,10 +596,10 @@ class Shader
589
596
  * @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined
590
597
  * @param {Color} [color=WHITE] - Color to modulate with
591
598
  * @param {number} [angle] - Angle to rotate by
592
- * @param {boolean} [mirror] - Is image flipped along the Y axis?
599
+ * @param {boolean} [mirror] - Is the image flipped left to right?
593
600
  * @param {Color} [additiveColor] - Additive color to be applied if any
594
601
  * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
595
- * @param {boolean} [screenSpace=drawScreenSpace] - Are the pos and size are in screen space?
602
+ * @param {boolean} [screenSpace=drawScreenSpace] - Are the pos and size in screen space?
596
603
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
597
604
  * @memberof Draw */
598
605
  function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
@@ -600,6 +607,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
600
607
  {
601
608
  ASSERT(isVector2(pos), 'pos must be a vec2');
602
609
  ASSERT(isVector2(size), 'size must be a vec2');
610
+ ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'drawTile: tileInfo must be a TileInfo, color comes after it',
611
+ tileInfo);
603
612
  ASSERT(isColor(color), 'color is invalid');
604
613
  ASSERT(isNumber(angle), 'angle must be a number');
605
614
  ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color');
@@ -663,6 +672,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
663
672
  {
664
673
  // un-flip Y so the image renders right-side up under drawCanvas2D's Y flip
665
674
  context.scale(1, -1);
675
+ // smooth or pixelated as the texture says, as WebGL draws it, inside drawCanvas2D's save
676
+ textureInfo.pixelated === undefined || (context.imageSmoothingEnabled = !textureInfo.pixelated);
666
677
  // calculate uvs and render
667
678
  const x = tileInfo.pos.x, y = tileInfo.pos.y;
668
679
  const w = tileInfo.size.x, h = tileInfo.size.y;
@@ -762,9 +773,9 @@ function drawRectGradient(pos, size=vec2(1), colorTop=WHITE, colorBottom=CLEAR_W
762
773
  * @param {Vector2} pos - Center of the rect in world space
763
774
  * @param {Vector2} size - Size of the rect in world space
764
775
  * @param {Vector2} wrapCount - How many times the texture repeats (x, y)
765
- * @param {TextureInfo|number} [texture=0] - TextureInfo or texture index into textureInfos
776
+ * @param {TextureInfo|number} [texture] - TextureInfo or texture index into textureInfos
766
777
  * @param {Color} [color=WHITE] - Color to modulate with
767
- * @param {number} [angle=0] - Angle to rotate by
778
+ * @param {number} [angle] - Angle to rotate by
768
779
  * @param {Color} [additiveColor] - Additive color to be applied if any
769
780
  * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
770
781
  * @param {boolean} [screenSpace=drawScreenSpace] - Are pos and size in screen space?
@@ -826,12 +837,29 @@ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE,
826
837
  // alpha is baked into pixels by bakeTintedImage's additive branch;
827
838
  // in that case globalAlpha must NOT also apply color.a
828
839
  const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor);
829
- const source = noTint
830
- ? textureInfo.image
831
- : bakeTintedImage(textureInfo.image, color, additiveColor);
840
+ let source = textureInfo.image;
841
+ if (!noTint)
842
+ {
843
+ // a bake is a pass over every pixel, so it is kept for the image until its tint changes; a canvas, which has
844
+ // getContext, may have been drawn into since, so it is baked again at every draw, its copy kept at its size
845
+ const key = color.r + ',' + color.g + ',' + color.b + ',' + color.a +
846
+ (additiveColor ? ',' + additiveColor.r + ',' + additiveColor.g + ',' + additiveColor.b + ',' + additiveColor.a : '');
847
+ let baked = drawTextureWrappedBakes.get(source);
848
+ if (baked?.key !== key || 'getContext' in source)
849
+ {
850
+ const kept = baked?.context, fits = kept?.canvas.width === (source.width|0) &&
851
+ kept?.canvas.height === (source.height|0);
852
+ const bakeContext = fits ? kept : createCanvasContext(source.width|0, source.height|0, true);
853
+ bakeTintedImage(source, color, additiveColor, bakeContext);
854
+ drawTextureWrappedBakes.set(source, baked = {key, context: bakeContext});
855
+ }
856
+ source = baked.context.canvas;
857
+ }
832
858
 
833
859
  context = context || drawContext;
834
860
  context.save();
861
+ // smooth or pixelated as the texture says, as WebGL draws it
862
+ textureInfo.pixelated === undefined || (context.imageSmoothingEnabled = !textureInfo.pixelated);
835
863
  context.translate(pos.x + .5, pos.y + .5);
836
864
  context.rotate(angle);
837
865
  context.globalAlpha = alphaBaked ? 1 : color.a;
@@ -1106,9 +1134,9 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
1106
1134
 
1107
1135
  /** Draw colored circle using passed in point
1108
1136
  * @param {Vector2} pos
1109
- * @param {number} [size=1] - Diameter
1137
+ * @param {number} [size] - Diameter
1110
1138
  * @param {Color} [color=WHITE]
1111
- * @param {number} [lineWidth=0]
1139
+ * @param {number} [lineWidth]
1112
1140
  * @param {Color} [lineColor=BLACK]
1113
1141
  * @param {boolean} [useWebGL=glEnable]
1114
1142
  * @param {boolean} [screenSpace=drawScreenSpace]
@@ -1203,7 +1231,7 @@ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLE
1203
1231
  * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
1204
1232
  * - Stacking gradients at the exact same position may show a faint vertical artifact
1205
1233
  * @param {Vector2} pos
1206
- * @param {number} [size=1] - Diameter
1234
+ * @param {number} [size] - Diameter
1207
1235
  * @param {Color} [colorInner=WHITE]
1208
1236
  * @param {Color} [colorOuter=CLEAR_WHITE]
1209
1237
  * @param {boolean} [useWebGL=glEnable]
@@ -1268,7 +1296,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
1268
1296
  * @param {Color} [color=WHITE]
1269
1297
  * @param {number} [lineWidth]
1270
1298
  * @param {Color} [lineColor=BLACK]
1271
- * @param {'left'|'center'|'right'} [textAlign='center']
1299
+ * @param {'left'|'center'|'right'} [textAlign]
1272
1300
  * @param {string} [font=fontDefault]
1273
1301
  * @param {string} [fontStyle]
1274
1302
  * @param {number} [maxWidth]
@@ -1277,6 +1305,10 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
1277
1305
  * @memberof Draw */
1278
1306
  function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
1279
1307
  {
1308
+ // checked before it is scaled, a vec2 size would come out NaN
1309
+ ASSERT(isVector2(pos), 'drawText: pos must be a vec2', pos);
1310
+ ASSERT(isNumber(size), 'drawText: size is a number, the height of the text in world units', size);
1311
+
1280
1312
  // convert to screen space
1281
1313
  pos = worldToScreen(pos);
1282
1314
  size *= cameraScale;
@@ -1288,6 +1320,73 @@ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK,
1288
1320
  drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context);
1289
1321
  }
1290
1322
 
1323
+ // the fonts a debug build has checked the canvas takes, by style and family, as a size never decides it and a zooming
1324
+ // camera draws at a new size each frame, and a font it does not, which would draw as 10px sans-serif with no word,
1325
+ // warned of once; a family name with spaces and digits, as Press Start 2P, needs quotes
1326
+ const drawFontsChecked = new Set;
1327
+ function drawFontCheck(context, fontText, font, fontStyle)
1328
+ {
1329
+ const key = fontStyle + '|' + font;
1330
+ if (drawFontsChecked.has(key)) return;
1331
+ drawFontsChecked.add(key);
1332
+ const sentinel = '1px littlejs-font-check'; // no font a game draws with
1333
+ context.font = sentinel;
1334
+ context.font = fontText;
1335
+ if (context.font === sentinel)
1336
+ console.warn(`the canvas does not take the font ${font}, so text draws as 10px sans-serif; ` +
1337
+ `a family name with spaces or digits goes in quotes, as "'Press Start 2P'"`);
1338
+ }
1339
+
1340
+ // the lines of a text, split at a Windows line ending too
1341
+ const textLines = (text)=> (text += '').includes('\r') ? text.split(/\r?\n/) : text.split('\n');
1342
+
1343
+ // how many lines a text has, counted without splitting it
1344
+ function textLineCount(text)
1345
+ {
1346
+ let count = 1;
1347
+ for (let i = -1; (i = text.indexOf('\n', i + 1)) >= 0;)
1348
+ ++count;
1349
+ return count;
1350
+ }
1351
+
1352
+ // the characters of a line of text as a reader counts them, each as its first code point: an emoji with its joiners
1353
+ // is one, and a letter with combining accents its letter; plain text takes the quick way, it is most of what is drawn,
1354
+ // so read character i with textCharacterCode
1355
+ const textIntl = /** @type {any} */ (globalThis.Intl); // Segmenter is newer than the library the source is checked with
1356
+ const textSegmenter = textIntl?.Segmenter && new textIntl.Segmenter;
1357
+ // a line with no mark, joiner or character past the 16 bit range, plain or accented letters of their own, Greek or
1358
+ // Japanese, has one code unit a character, so it is handed back as it is, read with charCodeAt, and nothing is made;
1359
+ // the class also holds what the segmenter joins that is not a mark: joiners, prepend, Thai and Lao SARA AM, Hangul
1360
+ // jamo extended and the halfwidth katakana voiced marks
1361
+ const textJoins = /[\p{M}\u200c\u200d\u0600-\u0605\u06dd\u070f\u0890\u0891\u08e2\u0d4e\u0e33\u0eb3\u1100-\u11ff\ua960-\ua97c\ud7b0-\ud7fb\uff9e\uff9f\u{10000}-\u{10ffff}]/u;
1362
+ /** @return {string|Array<number>} */
1363
+ function textCharacters(line)
1364
+ {
1365
+ if (!textJoins.test(line))
1366
+ return line;
1367
+ // where graphemes can not be split, code points with the joiners, variation selectors and accents left out
1368
+ return textSegmenter ? Array.from(textSegmenter.segment(line), (s)=> s.segment.codePointAt(0)) :
1369
+ Array.from(line, (c)=> c.codePointAt(0)).filter((c)=> c !== 0x200d && c !== 0xfe0f && !(c >= 0x300 && c < 0x370));
1370
+ }
1371
+
1372
+ // the characters of a text as a reader counts them, each a string, an emoji with its joiners one; code points where
1373
+ // graphemes can not be split, and code units for a text that needs nothing joined, the quick way
1374
+ const textGraphemes = (text)=> !textJoins.test(text) ? text.split('') :
1375
+ textSegmenter ? Array.from(textSegmenter.segment(text), (s)=> s.segment) : [...text];
1376
+
1377
+ // character i of what textCharacters gave, its code; a Latin letter with an accent of its own, as é, is its letter, as
1378
+ // an e with a combining accent is, for the fonts that draw only the plain letters
1379
+ const textCharacterCode = (characters, i)=>
1380
+ textBaseLetter(typeof characters == 'string' ? characters.charCodeAt(i) : characters[i]);
1381
+ const textBaseLetters = new Map;
1382
+ function textBaseLetter(code)
1383
+ {
1384
+ if (code < 0xc0 || code >= 0x250) return code;
1385
+ let base = textBaseLetters.get(code);
1386
+ base === undefined && textBaseLetters.set(code, base = String.fromCharCode(code).normalize('NFD').charCodeAt(0));
1387
+ return base;
1388
+ }
1389
+
1291
1390
  /** Draw text in screen space
1292
1391
  * Automatically splits new lines into rows
1293
1392
  * @param {string|number} text
@@ -1305,27 +1404,29 @@ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK,
1305
1404
  * @memberof Draw */
1306
1405
  function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext)
1307
1406
  {
1308
- ASSERT(isStringLike(text), 'text must be a string');
1309
- ASSERT(isVector2(pos), 'pos must be a vec2');
1310
- ASSERT(isNumber(size), 'size must be a number');
1311
- ASSERT(isColor(color), 'color must be a color');
1312
- ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
1313
- ASSERT(isColor(lineColor), 'lineColor must be a color');
1314
- ASSERT(['left','center','right'].includes(textAlign), 'align must be left, center, or right');
1407
+ ASSERT(isStringLike(text), 'drawTextScreen: text must be a string', text);
1408
+ ASSERT(isVector2(pos), 'drawTextScreen: pos must be a vec2', pos);
1409
+ ASSERT(isNumber(size), 'drawTextScreen: size is a number, the height of the text in pixels', size);
1410
+ ASSERT(isColor(color), 'drawTextScreen: color must be a color', color);
1411
+ ASSERT(isNumber(lineWidth), 'drawTextScreen: lineWidth must be a number', lineWidth);
1412
+ ASSERT(isColor(lineColor), 'drawTextScreen: lineColor must be a color', lineColor);
1413
+ ASSERT(['left','center','right'].includes(textAlign), 'drawTextScreen: textAlign must be left, center or right', textAlign);
1315
1414
  ASSERT(isStringLike(font), 'font must be a string');
1316
1415
  ASSERT(isStringLike(fontStyle), 'fontStyle must be a string');
1317
1416
  ASSERT(isNumber(angle), 'angle must be a number');
1318
1417
 
1319
1418
  if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
1320
1419
 
1321
- const lines = (text+'').split('\n');
1420
+ const lines = textLines(text); // a Windows line ending too
1322
1421
  // save before style mutations so caller's context state is preserved
1323
1422
  context.save();
1324
1423
  context.fillStyle = color.toString();
1325
1424
  context.strokeStyle = lineColor.toString();
1326
1425
  context.lineWidth = lineWidth;
1327
1426
  context.textAlign = textAlign;
1328
- context.font = fontStyle + ' ' + size + 'px '+ font;
1427
+ const fontText = fontStyle + ' ' + size + 'px '+ font;
1428
+ debug && drawFontCheck(context, fontText, font, fontStyle);
1429
+ context.font = fontText;
1329
1430
  context.textBaseline = 'middle';
1330
1431
  context.translate(pos.x + .5, pos.y + .5); // a screen position is the center of a pixel, as for every other draw
1331
1432
  context.rotate(angle);
@@ -1609,7 +1710,7 @@ function setShader(shader)
1609
1710
 
1610
1711
  /** Set an extra canvas to composite behind the engine canvases when combining
1611
1712
  * Plugins that insert their own canvas below the LittleJS canvases should set
1612
- * this so it appears in screenshots and video capture
1713
+ * this so it appears in screenshots
1613
1714
  * @param {HTMLCanvasElement} [canvas]
1614
1715
  * @memberof Draw */
1615
1716
  function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; }
@@ -1665,24 +1766,27 @@ function tintImageData(data, color, additiveColor)
1665
1766
  return false;
1666
1767
  }
1667
1768
 
1668
- // Internal: bake a color/additive-color tint into workReadCanvas at the
1669
- // image's native resolution. Returns the work canvas, suitable for
1670
- // passing to context.createPattern. Used by drawTextureWrapped's
1671
- // Canvas2D path. Caller is responsible for short-circuiting when no
1769
+ // Internal: bake a color/additive-color tint into a context's canvas, the work
1770
+ // canvas by default, at the image's native resolution. Returns that canvas,
1771
+ // suitable for passing to context.createPattern. Used by drawTextureWrapped's
1772
+ // Canvas2D path, which keeps a canvas of its own for each image. Caller is responsible for short-circuiting when no
1672
1773
  // tint is needed (i.e. color is white and additiveColor is black/none).
1673
- function bakeTintedImage(image, color, additiveColor)
1774
+ function bakeTintedImage(image, color, additiveColor, context=workReadContext)
1674
1775
  {
1675
1776
  const w = image.width|0, h = image.height|0;
1676
- workReadCanvas.width = w;
1677
- workReadCanvas.height = h;
1678
- workReadContext.drawImage(image, 0, 0);
1777
+ context.canvas.width = w;
1778
+ context.canvas.height = h;
1779
+ context.drawImage(image, 0, 0);
1679
1780
 
1680
- const imageData = workReadContext.getImageData(0, 0, w, h);
1781
+ const imageData = context.getImageData(0, 0, w, h);
1681
1782
  tintImageData(imageData.data, color, additiveColor);
1682
- workReadContext.putImageData(imageData, 0, 0);
1683
- return workReadCanvas;
1783
+ context.putImageData(imageData, 0, 0);
1784
+ return context.canvas;
1684
1785
  }
1685
1786
 
1787
+ // the tinted bake drawTextureWrapped keeps for each image, its tint and the context it is in
1788
+ const drawTextureWrappedBakes = new WeakMap;
1789
+
1686
1790
  /** Internal: draw an image with color and additive color applied in Canvas2D, drawTile calls it
1687
1791
  * This is slower than normal drawImage when color is applied
1688
1792
  * @ignore
@@ -1748,10 +1852,10 @@ function toggleFullscreen()
1748
1852
  if (isFullscreen())
1749
1853
  {
1750
1854
  if (document.exitFullscreen)
1751
- document.exitFullscreen();
1855
+ document.exitFullscreen()?.catch?.(()=> {}); // refused outside a click, it stays as it is
1752
1856
  }
1753
1857
  else if (rootElement.requestFullscreen)
1754
- rootElement.requestFullscreen();
1858
+ rootElement.requestFullscreen()?.catch?.(()=> {});
1755
1859
  }
1756
1860
 
1757
1861
  /** Set the cursor style
@@ -1815,7 +1919,7 @@ class ImageFont
1815
1919
  if (typeof size === 'number')
1816
1920
  {
1817
1921
  // if size is a number, make it a vector
1818
- ASSERT(size > 0);
1922
+ ASSERT(size > 0, 'ImageFont.drawText: size must be above 0', size);
1819
1923
  size *= cameraScale;
1820
1924
  size = new Vector2(size, size);
1821
1925
  }
@@ -1853,15 +1957,16 @@ class ImageFont
1853
1957
  const tileInfo = this.tileInfo.frame(0);
1854
1958
 
1855
1959
  // draw each line of text, centered vertically like drawTextScreen when center is set
1856
- const lines = (text+'').split('\n');
1960
+ const lines = textLines(text); // a Windows line ending too
1857
1961
  const centerOffsetY = center ? (lines.length-1) * glyphSize.y / 2 : 0;
1858
1962
  lines.forEach((line, j)=>
1859
1963
  {
1860
- const centerOffset = center ? (line.length-1) * glyphSize.x / 2 : 0;
1861
- for (let i=line.length; i--;)
1964
+ const characters = textCharacters(line);
1965
+ const centerOffset = center ? (characters.length-1) * glyphSize.x / 2 : 0;
1966
+ for (let i=characters.length; i--;)
1862
1967
  {
1863
1968
  // get the glyph, out of range characters use the last one
1864
- const charCode = line.charCodeAt(i);
1969
+ const charCode = textCharacterCode(characters, i);
1865
1970
  this.getGlyphPos(charCode < 32 || charCode > 127 ? 95 : charCode - 32, tileInfo.pos);
1866
1971
 
1867
1972
  // snap the glyph edges to whole pixels