littlejsengine 1.25.0 → 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 (51) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/FAQ.md +49 -11
  3. package/README.md +6 -2
  4. package/REFERENCE.md +120 -18
  5. package/dist/littlejs.d.ts +464 -99
  6. package/dist/littlejs.esm.js +2853 -704
  7. package/dist/littlejs.esm.min.js +3 -3
  8. package/dist/littlejs.js +2844 -701
  9. package/dist/littlejs.min.js +2 -2
  10. package/dist/littlejs.release.js +2658 -512
  11. package/package.json +4 -4
  12. package/plugins/box2d.js +110 -34
  13. package/plugins/gltf.js +74 -22
  14. package/plugins/lightSystem.js +410 -28
  15. package/plugins/math3d.js +4 -2
  16. package/plugins/medalSystem.js +19 -7
  17. package/plugins/newgrounds.js +4 -2
  18. package/plugins/parallax.js +1 -0
  19. package/plugins/particleEffects.js +44 -10
  20. package/plugins/pathFinder.js +9 -11
  21. package/plugins/pluginExport.js +9 -0
  22. package/plugins/postProcess.js +4 -2
  23. package/plugins/render3d.js +485 -2385
  24. package/plugins/render3dDebug.js +5 -8
  25. package/plugins/render3dEditor.js +81 -6
  26. package/plugins/render3dExtras.js +103 -31
  27. package/plugins/render3dLevel.js +53 -5
  28. package/plugins/render3dMesh.js +1158 -0
  29. package/plugins/render3dObject.js +1451 -0
  30. package/plugins/render3dVoxels.js +15 -3
  31. package/plugins/textureSheet.js +9 -8
  32. package/plugins/threejs.js +1 -1
  33. package/plugins/tweenSystem.js +76 -51
  34. package/plugins/uiSystem.js +37 -8
  35. package/plugins/wavedash.js +229 -0
  36. package/src/engine.js +184 -36
  37. package/src/engineAudio.js +37 -21
  38. package/src/engineBuild.mjs +6 -1
  39. package/src/engineDebug.js +22 -162
  40. package/src/engineDraw.js +139 -34
  41. package/src/engineEditor.js +79 -18
  42. package/src/engineExport.js +0 -3
  43. package/src/engineInput.js +27 -18
  44. package/src/engineMath.js +35 -14
  45. package/src/engineObject.js +60 -41
  46. package/src/engineParticles.js +58 -11
  47. package/src/engineRelease.js +1 -5
  48. package/src/engineSettings.js +48 -9
  49. package/src/engineTileLayer.js +11 -4
  50. package/src/engineUtilities.js +41 -9
  51. package/src/engineWebGL.js +37 -13
@@ -88,9 +88,11 @@ class VoxelMap extends EngineObject3D
88
88
  this.chunkTransparentMeshes = []; // each chunk's transparent blocks, drawn blended
89
89
  /** @type {Array<Vector3>} */
90
90
  this.chunkCenters = []; // a chunk mesh's points are around its center, which it is drawn at
91
+ /** @type {Set<number>} */
91
92
  this.chunksChanged = new Set;
92
93
  /** @type {Array<{faces: Array<number>, seeThrough: boolean, transparent: boolean, doubleSided: boolean}|undefined>} */
93
94
  this.blockTypes = [];
95
+ /** @type {Map<number, Array<Vector2>>} */
94
96
  this.tiles = new Map; // tile index to the uvs of its corners
95
97
 
96
98
  // the transparent blocks draw in the transparent stage, blended and sorted, through a child that draws them
@@ -98,6 +100,7 @@ class VoxelMap extends EngineObject3D
98
100
  glass.size3D = vec3(); // not a thing to collect either
99
101
  glass.transparent = true;
100
102
  glass.render3D = ()=> this.renderChunks(true);
103
+ render3DShareSettings(glass, this); // it draws as the map does, its material, stage and render order
101
104
  this.addChild(glass);
102
105
  render3DLevel.push(this);
103
106
  }
@@ -473,15 +476,24 @@ class VoxelMap extends EngineObject3D
473
476
  levelRaycast3D(ray) { return this.raycast(ray)?.distance; }
474
477
 
475
478
  /** Where a short move goes into a block, for a particle's move in one frame: the part of the move made before it,
476
- * 0 to 1, and the normal of the face it comes in through; undefined when it hits none, or starts inside one
479
+ * 0 to 1, and the normal of the face it comes in through; undefined when it hits none, or starts inside one; a
480
+ * move that starts on a block's face, coming in, hits it at 0
477
481
  * @param {Vector3} from
478
482
  * @param {Vector3} to
479
483
  * @return {{distance: number, normal: Vector3}|undefined}
480
484
  * @ignore */
481
485
  levelSegment3D(from, to)
482
486
  {
483
- const hit = this.raycast(new Ray3D(from, to.subtract(from)), 1);
484
- return hit && hit.distance > 0 ? {distance: hit.distance, normal: hit.normal} : undefined;
487
+ const move = to.subtract(from), hit = this.raycast(new Ray3D(from, move), 1);
488
+ if (!hit) return;
489
+ if (!hit.distance)
490
+ {
491
+ // met at its start: on a face when the point just behind the start is clear, inside a block when not
492
+ const behind = from.subtract(move.normalize(1e-6)).subtract(this.pos3D);
493
+ if (this.voxelAt(floor(behind.x), floor(behind.y), floor(behind.z)))
494
+ return;
495
+ }
496
+ return {distance: hit.distance, normal: hit.normal};
485
497
  }
486
498
 
487
499
  /** Keeps an eye on its placement, called automatically each frame */
@@ -263,7 +263,7 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
263
263
  console.warn('loadSprite failed to load image:', src);
264
264
  textureSheetLoadFailed(key, tileInfo);
265
265
  }
266
- });
266
+ }, ()=> textureSheetLoadFailed(key, tileInfo));
267
267
 
268
268
  return tileInfo;
269
269
  }
@@ -329,7 +329,7 @@ function loadTiles(sources, tileSize=tileDefaultSize, padding=textureSheetPaddin
329
329
  added.sheet.drawImage(image, added.tile, false); // upload once per batch
330
330
  for (let k = 0; k < count; ++k)
331
331
  set.tiles.push(added.tile.frame(k));
332
- });
332
+ }, ()=> textureSheetLoadFailed(key, set));
333
333
  }
334
334
  return set;
335
335
  }
@@ -434,20 +434,21 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
434
434
  console.warn('loadAtlas failed to load:', imageSrc, jsonSrc);
435
435
  textureSheetLoadFailed(key, atlas);
436
436
  }
437
- });
437
+ }, ()=> textureSheetLoadFailed(key, atlas));
438
438
 
439
439
  return atlas;
440
440
  }
441
441
 
442
- // run a load in the queue: a load that throws is reported and the loads after it carry on, the pending count
443
- // always comes back down, and the sheets upload to webgl once per batch, when the last pending load finishes
444
- function textureSheetQueueJob(name, job)
442
+ // run a load in the queue: a load that throws is reported, let go of so the next call loads it again (failed), as
443
+ // one whose file did not load is, and the loads after it carry on; the pending count always comes back down, and the
444
+ // sheets upload to webgl once per batch, when the last pending load finishes
445
+ function textureSheetQueueJob(name, job, failed)
445
446
  {
446
447
  ++textureSheetPendingCount;
447
448
  textureSheetQueue = textureSheetQueue.then(async ()=>
448
449
  {
449
450
  try { await job(); }
450
- catch (e) { console.error(name + ' failed:', e); }
451
+ catch (e) { console.error(name + ' failed:', e); failed?.(); }
451
452
  finally
452
453
  {
453
454
  if (!--textureSheetPendingCount)
@@ -464,7 +465,7 @@ function textureSheetQueueJob(name, job)
464
465
  * - Accepts TexturePacker json (hash and array) and Aseprite json
465
466
  * - Frames tagged in Aseprite or named like run_0, run_1 group into animations
466
467
  * @param {Object} data - Parsed atlas json data
467
- * @return {Array<Object>} List of {name, frames} groups in atlas order
468
+ * @return {Array<{name: string, frames: Array<Object>}>} List of {name, frames} groups in atlas order
468
469
  * @memberof TextureSheets */
469
470
  function parseAtlas(data)
470
471
  {
@@ -65,7 +65,7 @@ class ThreeJSPlugin
65
65
  rootElement.insertBefore(threeCanvas, rootElement.firstChild);
66
66
  threeCanvas.style.cssText = mainCanvas.style.cssText;
67
67
 
68
- // composite the 3D canvas into screenshots and video capture
68
+ // composite the 3D canvas into screenshots
69
69
  setBackgroundCanvas(threeCanvas);
70
70
 
71
71
  // render automatically each frame after the engine renders
@@ -28,6 +28,20 @@ function tweenActivate(tween)
28
28
  }
29
29
  function tweenDeactivate(tween) { tween.active = false; }
30
30
 
31
+ // call a tween's callback with its value at life; one that throws, its easing too, is ended, or it would be called and
32
+ // throw at every update, unless the callback itself started it again, which is its own new run (pass, the update it
33
+ // is in, undefined outside one)
34
+ function tweenCall(tween, life, pass)
35
+ {
36
+ try { tween.callback(tween.interp(life)); }
37
+ catch (error)
38
+ {
39
+ if (tween.active && (pass === undefined || tween.activePass < pass))
40
+ tweenDeactivate(tween);
41
+ throw error;
42
+ }
43
+ }
44
+
31
45
  // True if the value is an instance of a class that exposes a numeric-percent
32
46
  // `lerp(other, percent)` method (Vector2, Color, or any future class).
33
47
  function tweenIsLerpable(v) { return v && typeof v.lerp === 'function'; }
@@ -102,10 +116,10 @@ class Tween
102
116
  * @type {undefined|function():void} */
103
117
  this.onComplete = undefined;
104
118
 
105
- /** Continuation when a pass ends, set by loop() and pingPong() to start the next iteration.
119
+ /** Continuation when a pass ends, set by loop() and pingPong() to start the next iteration
106
120
  * @private */
107
121
  this.thenCallback = undefined;
108
- /** Remaining iterations including the current run (loop/pingPong only).
122
+ /** Remaining iterations including the current run (loop/pingPong only)
109
123
  * @private */
110
124
  this.loopRemaining = 0;
111
125
  /** Whether it is running, see isActive
@@ -128,11 +142,11 @@ class Tween
128
142
  this.target = undefined;
129
143
 
130
144
  tweenActivate(this);
131
- // Snap target to start immediately.
132
- callback(this.interp(duration));
145
+ // Snap target to start immediately; one that throws here is no tween, the game never gets it to stop
146
+ tweenCall(this, duration);
133
147
  }
134
148
 
135
- /** Set the easing curve and return this for chaining.
149
+ /** Set the easing curve and return this for chaining
136
150
  * @param {function(number):number} easeFn
137
151
  * @return {Tween<T>} */
138
152
  setEase(easeFn)
@@ -188,10 +202,10 @@ class Tween
188
202
  return this;
189
203
  }
190
204
 
191
- /** Pause this tween. While paused, tweenUpdate skips it. */
205
+ /** Pause this tween. While paused, tweenUpdate skips it */
192
206
  pause() { this.paused = true; }
193
207
 
194
- /** Resume a paused tween. */
208
+ /** Resume a paused tween */
195
209
  resume() { this.paused = false; }
196
210
 
197
211
  /** Reset this tween to the start: life back to duration, pause cleared,
@@ -209,10 +223,10 @@ class Tween
209
223
  this.lastTime = time;
210
224
  this.lastTimeReal = timeReal;
211
225
  tweenActivate(this);
212
- this.callback(this.interp(this.duration));
226
+ tweenCall(this, this.duration);
213
227
  }
214
228
 
215
- /** True if this tween is in the active list and not paused.
229
+ /** True if this tween is in the active list and not paused
216
230
  * @return {boolean} */
217
231
  isActive()
218
232
  {
@@ -283,7 +297,7 @@ class Tween
283
297
  */
284
298
  const Ease =
285
299
  {
286
- /** Linear (identity) curve.
300
+ /** Linear (identity) curve
287
301
  * @param {number} x
288
302
  * @return {number}
289
303
  * @memberof TweenSystem.Ease */
@@ -296,31 +310,31 @@ const Ease =
296
310
  * @memberof TweenSystem.Ease */
297
311
  POWER: (n) => (x) => x ** n,
298
312
 
299
- /** Sine ease-in curve: starts slow, ends fast.
313
+ /** Sine ease-in curve: starts slow, ends fast
300
314
  * @param {number} x
301
315
  * @return {number}
302
316
  * @memberof TweenSystem.Ease */
303
317
  SINE: (x) => 1 - cos(x * (PI / 2)),
304
318
 
305
- /** Circular ease-in curve.
319
+ /** Circular ease-in curve
306
320
  * @param {number} x
307
321
  * @return {number}
308
322
  * @memberof TweenSystem.Ease */
309
323
  CIRC: (x) => 1 - (1 - x * x)**.5,
310
324
 
311
- /** Exponential ease-in curve (`2^(10x-10)`).
325
+ /** Exponential ease-in curve (`2^(10x-10)`)
312
326
  * @param {number} x
313
327
  * @return {number}
314
328
  * @memberof TweenSystem.Ease */
315
329
  EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10),
316
330
 
317
- /** Back ease-in: overshoots backward at the start before snapping forward.
331
+ /** Back ease-in: overshoots backward at the start before snapping forward
318
332
  * @param {number} x
319
333
  * @return {number}
320
334
  * @memberof TweenSystem.Ease */
321
335
  BACK: (x) => x * x * (2.70158 * x - 1.70158),
322
336
 
323
- /** Elastic ease-in: oscillations that grow toward the end.
337
+ /** Elastic ease-in: oscillations that grow toward the end
324
338
  * @param {number} x
325
339
  * @return {number}
326
340
  * @memberof TweenSystem.Ease */
@@ -387,7 +401,7 @@ const Ease =
387
401
  */
388
402
  OUT: (f) => (x) => 1 - f(1 - x),
389
403
 
390
- /** Combine the first half of `f` with `Ease.OUT(f)` for a symmetric curve.
404
+ /** Combine the first half of `f` with `Ease.OUT(f)` for a symmetric curve
391
405
  * @param {function(number):number} f
392
406
  * @return {function(number):number}
393
407
  * @memberof TweenSystem.Ease */
@@ -527,7 +541,7 @@ function tweenNextIteration(tween, passed, continuation)
527
541
  tweenCarryOvershoot(tween);
528
542
  tween.thenCallback = continuation;
529
543
  tweenActivate(tween);
530
- tween.callback(tween.interp(tween.life)); // snap to where the new iteration is
544
+ tweenCall(tween, tween.life); // snap to where the new iteration is
531
545
  return true;
532
546
  }
533
547
 
@@ -600,48 +614,59 @@ function tweenUpdate(gameDelta, realDelta)
600
614
  tweenActive.length = kept;
601
615
  // the list is let go however the walk ends: a callback that throws must not leave it held, or every
602
616
  // update after would take it for an update still going and make a list of its own
617
+ let failed;
603
618
  try
604
619
  {
605
620
  for (let i = list.length; i--;)
606
621
  {
607
622
  const t = list[i];
608
- // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
609
- // counts on from this one
610
- if (!t.active || t.activePass >= pass) continue;
611
- let dt;
612
- if (enginePath)
613
- {
614
- // a paused tween keeps count too, so it does not jump when resumed
615
- dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
616
- t.lastTime = time;
617
- t.lastTimeReal = timeReal;
618
- }
619
- else
620
- dt = t.useRealTime ? realDelta : gameDelta;
621
- if (t.target?.destroyed) { t.stop(); continue; } // its object is gone, paused or not
622
- if (t.paused || dt <= 0) continue;
623
-
624
- t.life -= dt;
625
- if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
626
- {
627
- t.callback(t.interp(t.life));
628
- }
629
- else
630
- {
631
- // Completion: fire end value, remove from active, start the next iteration
632
- // of a loop or pingPong, or when there is none it has completed, fire onComplete
633
- t.callback(t.interp(0));
634
- if (!t.active || t.activePass >= pass)
635
- continue; // stopped or restarted by its own callback, the run it was on ends without completing
636
- tweenDeactivate(t);
637
- const next = t.thenCallback;
638
- t.thenCallback = undefined;
639
- if (!(next && next()) && t.onComplete)
640
- t.onComplete();
641
- }
623
+ // one that throws does not stop the others: its error comes out once they have all moved
624
+ try { tweenStep(t, pass, enginePath, gameDelta, realDelta); }
625
+ catch (error) { failed ??= {error}; }
642
626
  }
643
627
  }
644
628
  finally { list.length = 0; }
629
+ if (failed)
630
+ throw failed.error;
631
+ }
632
+
633
+ // move one tween by an update, as tweenUpdate walks them
634
+ function tweenStep(t, pass, enginePath, gameDelta, realDelta)
635
+ {
636
+ // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
637
+ // counts on from this one
638
+ if (!t.active || t.activePass >= pass) return;
639
+ let dt;
640
+ if (enginePath)
641
+ {
642
+ // a paused tween keeps count too, so it does not jump when resumed
643
+ dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime;
644
+ t.lastTime = time;
645
+ t.lastTimeReal = timeReal;
646
+ }
647
+ else
648
+ dt = t.useRealTime ? realDelta : gameDelta;
649
+ if (t.target?.destroyed) { t.stop(); return; } // its object is gone, paused or not
650
+ if (t.paused || dt <= 0) return;
651
+
652
+ t.life -= dt;
653
+ if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration
654
+ {
655
+ tweenCall(t, t.life, pass);
656
+ }
657
+ else
658
+ {
659
+ // Completion: fire end value, remove from active, start the next iteration
660
+ // of a loop or pingPong, or when there is none it has completed, fire onComplete
661
+ tweenCall(t, 0, pass); // one whose last call throws is ended all the same
662
+ if (!t.active || t.activePass >= pass)
663
+ return; // stopped or restarted by its own callback, the run it was on ends without completing
664
+ tweenDeactivate(t);
665
+ const next = t.thenCallback;
666
+ t.thenCallback = undefined;
667
+ if (!(next && next()) && t.onComplete)
668
+ t.onComplete();
669
+ }
645
670
  }
646
671
 
647
672
  /** Stop every active tween, ending loops too, without calling their then-callbacks.
@@ -28,6 +28,10 @@ let uiSystem;
28
28
  * @memberof UISystem */
29
29
  let uiDebug = 0;
30
30
 
31
+ // the active object's press was let go of in the frame it came, a tap, so it is clicked on the next with no release
32
+ // then; a click needs a release, which the mouse let go of by a window losing focus is not
33
+ let uiActiveReleased = false;
34
+
31
35
  /** Enable UI system debug drawing
32
36
  * 0=off, 1=normal, 2=show invisible
33
37
  * @param {number|boolean} debugMode
@@ -199,8 +203,10 @@ class UISystemPlugin
199
203
 
200
204
  // setup recursive update and render
201
205
  // update in reverse order to detect mouse enter/leave
206
+ let updatePass = 0; // marks the objects each update has reached, see updateObject
202
207
  function uiUpdate()
203
208
  {
209
+ ++updatePass;
204
210
  // a held or focused object that can no longer be used, itself or through a parent, lets go,
205
211
  // and one that was hidden or disabled is still released, a destroyed one stays silent
206
212
  const activeObject = uiSystem.activeObject;
@@ -345,7 +351,10 @@ class UISystemPlugin
345
351
 
346
352
  function updateObject(o)
347
353
  {
348
- if (o.destroyed || !o.visible) return;
354
+ // once a pass, though a callback may move it under a parent not yet reached, or detach it to the
355
+ // top level, where the loop reaches it again
356
+ if (o.destroyed || !o.visible || o.uiUpdatePass === updatePass) return;
357
+ o.uiUpdatePass = updatePass;
349
358
 
350
359
  // update in reverse order to detect mouse enter/leave, from a copy since a child may destroy
351
360
  // siblings mid-update (e.g. dialog close) and the ones after it would shift under the loop
@@ -821,7 +830,7 @@ class UISystemPlugin
821
830
  // close menu and clear the input that closed it
822
831
  function closeMenu()
823
832
  {
824
- ASSERT(uiSystem.confirmDialog === confirmMenu);
833
+ ASSERT(uiSystem.confirmDialog === confirmMenu, 'the confirm dialog closing is not the one open');
825
834
  confirmMenu.destroy();
826
835
  inputClear();
827
836
  }
@@ -961,6 +970,7 @@ class UIObject
961
970
  this.textLineWidth = 0;
962
971
  /** @property {boolean} - Should this object be drawn */
963
972
  this.visible = true;
973
+ this.uiUpdatePass = 0; // the UI update that last reached it, so a pass updates it once
964
974
  /** @property {Array<UIObject>} - A list of this object's children
965
975
  * @type {Array<UIObject>} */
966
976
  this.children = [];
@@ -1023,7 +1033,8 @@ class UIObject
1023
1033
  * @param {UIObject} child */
1024
1034
  removeChild(child)
1025
1035
  {
1026
- ASSERT(child.parent === this && this.children.includes(child));
1036
+ ASSERT(child.parent === this && this.children.includes(child), 'removeChild: that object is not a child of this one',
1037
+ child);
1027
1038
  this.children.splice(this.children.indexOf(child), 1);
1028
1039
  child.parent = undefined;
1029
1040
  }
@@ -1095,10 +1106,24 @@ class UIObject
1095
1106
  return;
1096
1107
 
1097
1108
  const wasHover = uiSystem.lastHoverObject === this;
1098
- const isActive = this.isActiveObject();
1109
+ let isActive = this.isActiveObject();
1099
1110
  const mouseDown = mouseIsDown(0);
1100
1111
  // a press and release in one frame is a press too, as it is for a button that is not drag activated
1101
1112
  const mousePress = mouseWasPressed(0) || this.dragActivate && mouseDown;
1113
+
1114
+ // a new press while this is still active from the one before means that one was let go of in between, by a
1115
+ // second quick tap or a release missed in a hitch: it is clicked and released first, so neither is lost
1116
+ if (isActive && mouseWasPressed(0))
1117
+ {
1118
+ if (!uiSystem.activateOnPress && this.interactive && !disabled)
1119
+ this.click();
1120
+ if (this.destroyed) return;
1121
+ uiSystem.activeObject = undefined;
1122
+ isActive = false;
1123
+ this.onRelease();
1124
+ this.soundRelease && this.soundRelease.play();
1125
+ if (this.destroyed) return;
1126
+ }
1102
1127
  if (this.canBeHover)
1103
1128
  if (!uiSystem.navigationMode) // no mouse hover in navigation mode
1104
1129
  if (mousePress || isActive || (!mouseDown && !isTouchDevice))
@@ -1130,7 +1155,10 @@ class UIObject
1130
1155
  this.soundRelease && this.soundRelease.play();
1131
1156
  }
1132
1157
  else
1158
+ {
1133
1159
  uiSystem.activeObject = this;
1160
+ uiActiveReleased = !mouseDown;
1161
+ }
1134
1162
 
1135
1163
  if (newPress && uiSystem.activateOnPress)
1136
1164
  this.click(!this.soundPress);
@@ -1142,6 +1170,7 @@ class UIObject
1142
1170
  // frame is clicked on the next, with its release, not on both
1143
1171
  if (!uiSystem.activateOnPress)
1144
1172
  if (!mouseDown && isActive && this.isActiveObject() && this.interactive)
1173
+ if (mouseWasReleased(0) || uiActiveReleased)
1145
1174
  this.click();
1146
1175
  if (this.destroyed) return;
1147
1176
  }
@@ -1194,7 +1223,7 @@ class UIObject
1194
1223
  getTextSize()
1195
1224
  {
1196
1225
  // text fitted to the size shares its height between its lines, a set textHeight is the height of each line
1197
- const lines = this.textHeight ? 1 : (this.text + '').split('\n').length;
1226
+ const lines = this.textHeight ? 1 : textLineCount(this.text + '');
1198
1227
  return vec2(
1199
1228
  this.textWidth || this.textFitScale * this.size.x,
1200
1229
  this.textHeight || this.textFitScale * this.size.y / lines);
@@ -1376,7 +1405,7 @@ class UITextInput extends UIObject
1376
1405
 
1377
1406
  ASSERT(isStringLike(text), 'ui text must be a string');
1378
1407
 
1379
- /** @property {number} - Max length of input (0 = no limit) */
1408
+ /** @property {number} - Max length of input in characters as a reader counts them (0 = no limit) */
1380
1409
  this.maxLength = 0;
1381
1410
 
1382
1411
  // set properties, as a string, which typing adds to
@@ -1424,7 +1453,7 @@ class UITextInput extends UIObject
1424
1453
  return; // a key held when editing began repeats, it should not type or stop editing
1425
1454
  this.text += ''; // a game may have set a number
1426
1455
  if (key === 'Backspace')
1427
- this.text = [...this.text].slice(0, -1).join(''); // a whole character, an emoji is two code units
1456
+ this.text = textGraphemes(this.text).slice(0, -1).join(''); // a whole character, an emoji family too
1428
1457
  else if (key === 'Enter' || key === 'Escape')
1429
1458
  this.stopEditing();
1430
1459
  else if (key.length === 1) // printable characters
@@ -1432,7 +1461,7 @@ class UITextInput extends UIObject
1432
1461
  // ctrl and cmd shortcuts do not type, but AltGr reports ctrl and alt and types characters like @
1433
1462
  if ((e.ctrlKey || e.metaKey) && !e.getModifierState?.('AltGraph'))
1434
1463
  return;
1435
- if (!this.maxLength || this.text.length < this.maxLength)
1464
+ if (!this.maxLength || textGraphemes(this.text).length < this.maxLength)
1436
1465
  this.text += key;
1437
1466
  }
1438
1467
  }