littlejsengine 1.25.0 → 1.26.1

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 +99 -0
  2. package/FAQ.md +49 -11
  3. package/README.md +6 -2
  4. package/REFERENCE.md +129 -21
  5. package/dist/littlejs.d.ts +487 -102
  6. package/dist/littlejs.esm.js +2898 -719
  7. package/dist/littlejs.esm.min.js +3 -3
  8. package/dist/littlejs.js +2888 -716
  9. package/dist/littlejs.min.js +2 -2
  10. package/dist/littlejs.release.js +2702 -527
  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 +9 -3
  23. package/plugins/render3d.js +499 -2389
  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 +199 -40
  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 +142 -34
  41. package/src/engineEditor.js +79 -18
  42. package/src/engineExport.js +1 -3
  43. package/src/engineInput.js +34 -24
  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
@@ -198,9 +198,11 @@ function inputClear()
198
198
  * @memberof Input */
199
199
  function keyIsDown(key, device=0)
200
200
  {
201
- ASSERT(isStringLike(key), 'key must be a number or string');
202
- ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
203
- ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
201
+ ASSERT(isStringLike(key), 'keyIsDown: key must be a code like \'KeyA\' or a number', key);
202
+ ASSERT(typeof key !== 'string' || key.length > 1,
203
+ "keyIsDown: keys are codes like 'KeyW' or 'Space', not characters", key);
204
+ ASSERT(device > 0 || typeof key !== 'number' || key < 5,
205
+ "keyIsDown: keyboard keys are codes like 'KeyA' or 'Space', the numbers 0 to 4 are mouse buttons", key);
204
206
  return !!(inputData[device]?.[key] & 1) && !inputCaptureHides(device);
205
207
  }
206
208
 
@@ -211,9 +213,11 @@ function keyIsDown(key, device=0)
211
213
  * @memberof Input */
212
214
  function keyWasPressed(key, device=0)
213
215
  {
214
- ASSERT(isStringLike(key), 'key must be a number or string');
215
- ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
216
- ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
216
+ ASSERT(isStringLike(key), 'keyWasPressed: key must be a code like \'KeyA\' or a number', key);
217
+ ASSERT(typeof key !== 'string' || key.length > 1,
218
+ "keyWasPressed: keys are codes like 'KeyW' or 'Space', not characters", key);
219
+ ASSERT(device > 0 || typeof key !== 'number' || key < 5,
220
+ "keyWasPressed: keyboard keys are codes like 'KeyA' or 'Space', the numbers 0 to 4 are mouse buttons", key);
217
221
  return !!(inputData[device]?.[key] & 2) && !inputCaptureHides(device);
218
222
  }
219
223
 
@@ -224,9 +228,11 @@ function keyWasPressed(key, device=0)
224
228
  * @memberof Input */
225
229
  function keyWasReleased(key, device=0)
226
230
  {
227
- ASSERT(isStringLike(key), 'key must be a number or string');
228
- ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
229
- ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
231
+ ASSERT(isStringLike(key), 'keyWasReleased: key must be a code like \'KeyA\' or a number', key);
232
+ ASSERT(typeof key !== 'string' || key.length > 1,
233
+ "keyWasReleased: keys are codes like 'KeyW' or 'Space', not characters", key);
234
+ ASSERT(device > 0 || typeof key !== 'number' || key < 5,
235
+ "keyWasReleased: keyboard keys are codes like 'KeyA' or 'Space', the numbers 0 to 4 are mouse buttons", key);
230
236
  return !!(inputData[device]?.[key] & 4) && !inputCaptureHides(device);
231
237
  }
232
238
 
@@ -253,7 +259,7 @@ function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='A
253
259
  * @memberof Input */
254
260
  function mouseIsDown(button)
255
261
  {
256
- ASSERT(isNumber(button), 'mouse button must be a number');
262
+ ASSERT(isNumber(button), 'mouseIsDown: button is 0 (left), 1 (middle) or 2 (right)', button);
257
263
  return keyIsDown(button);
258
264
  }
259
265
 
@@ -263,7 +269,7 @@ function mouseIsDown(button)
263
269
  * @memberof Input */
264
270
  function mouseWasPressed(button)
265
271
  {
266
- ASSERT(isNumber(button), 'mouse button must be a number');
272
+ ASSERT(isNumber(button), 'mouseWasPressed: button is 0 (left), 1 (middle) or 2 (right)', button);
267
273
  return keyWasPressed(button);
268
274
  }
269
275
 
@@ -273,7 +279,7 @@ function mouseWasPressed(button)
273
279
  * @memberof Input */
274
280
  function mouseWasReleased(button)
275
281
  {
276
- ASSERT(isNumber(button), 'mouse button must be a number');
282
+ ASSERT(isNumber(button), 'mouseWasReleased: button is 0 (left), 1 (middle) or 2 (right)', button);
277
283
  return keyWasReleased(button);
278
284
  }
279
285
 
@@ -500,16 +506,19 @@ function inputInit()
500
506
  // keys typed into an html text field are the player's typing, not game input;
501
507
  // a key already down still releases on keyup, which only lets go of keys that are down
502
508
  const typing = isTextInput(e.target) || isTextInput(document.activeElement);
503
- if (!e.repeat && !typing)
509
+ // a key already held, a keydown the browser did not mark as a repeat, is not pressed again, but with Cmd down
510
+ // it is: a Mac sends no keyup for a key let go while Cmd is held, so each tap of Cmd+Z comes as a keydown alone
511
+ if (!e.repeat && !typing && (!inputKeysHeld.has(e.code) || e.metaKey))
504
512
  {
505
513
  inputKeysHeld.add(e.code);
506
- // an arrow its alias already holds down is not pressed again, like its release waits for both
514
+ // an arrow its alias already holds down is not pressed again, like its release waits for both; a release
515
+ // earlier this frame is kept, so a quick release and press reads as both
507
516
  if (!(inputWASDEmulateDirection && inputKeysHeld.has(inputArrowToWASD[e.code]) && inputData[0][e.code] & 1))
508
- inputData[0][e.code] = 3;
517
+ inputData[0][e.code] = 3 | inputData[0][e.code] & 4;
509
518
  // an alias presses its arrow's slot too, unless the arrow itself already holds it down
510
519
  const remap = remapKey(e.code);
511
520
  if (remap !== e.code && !(inputData[0][remap] & 1))
512
- inputData[0][remap] = 3;
521
+ inputData[0][remap] = 3 | inputData[0][remap] & 4;
513
522
  }
514
523
 
515
524
  // try to prevent default browser handling of input
@@ -587,7 +596,7 @@ function inputInit()
587
596
  // pointer lock the mouse stays where the lock began, which may be in a bar, and every click is the game's
588
597
  if (!pointerLockIsActive() && !inCanvas(e.x, e.y))
589
598
  return;
590
- inputData[0][e.button] = 3;
599
+ inputData[0][e.button] = 3 | inputData[0][e.button] & 4; // a release earlier this frame is kept
591
600
 
592
601
  const mousePosScreenLast = mousePosScreen;
593
602
  mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
@@ -647,8 +656,9 @@ function inputInit()
647
656
  }
648
657
  function onContextMenu(e)
649
658
  {
650
- // prevent right click menu, but a text field keeps its copy and paste menu
651
- if (inputPreventDefault && !isTextInput(e.target))
659
+ // prevent right click menu, but a text field keeps its copy and paste menu and a link its open in new tab
660
+ const target = /** @type {HTMLElement} */ (e.target);
661
+ if (inputPreventDefault && !isTextInput(target) && !target?.closest?.('a[href]'))
652
662
  e.preventDefault();
653
663
  }
654
664
  function onBlur()
@@ -669,6 +679,10 @@ function inputInit()
669
679
  // handle all touch events the same way
670
680
  function handleTouch(e)
671
681
  {
682
+ // fix stalled audio requiring user interaction, with touch input off too since the page's sound needs it
683
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
684
+ audioResume();
685
+
672
686
  if (!touchInputEnable)
673
687
  {
674
688
  // turned off mid touch, the finger that drove the mouse lets go of it
@@ -679,10 +693,6 @@ function inputInit()
679
693
  }
680
694
  inputLastTouchTime = performance.now();
681
695
 
682
- // fix stalled audio requiring user interaction
683
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
684
- audioResume();
685
-
686
696
  // when the touch gamepad is enabled it owns touch input: suppress the
687
697
  // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
688
698
  // (its own zones drive gameplay via pointer events)
@@ -741,7 +751,7 @@ function inputInit()
741
751
  else if (inputWasTouching && touch.identifier === inputTouchIdentifier)
742
752
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
743
753
  else if (pressTouch && inCanvas(pressTouch.clientX, pressTouch.clientY))
744
- inputData[0][button] = 3; // a tap in the bars around the canvas is not a press
754
+ inputData[0][button] = 3 | inputData[0][button] & 4; // a tap in the bars is not a press
745
755
  // the finger left after a pinch moves the mouse from where it is, with no jump and no press
746
756
  inputTouchIdentifier = pinching ? undefined : touch.identifier;
747
757
  }
package/src/engineMath.js CHANGED
@@ -68,7 +68,7 @@ const sign = (x) => Math.sign(x);
68
68
  * @param {...number} values
69
69
  * @return {number}
70
70
  * @memberof Math */
71
- const hypot = (...values) => Math.hypot(...values);
71
+ const hypot = Math.hypot; // not a function of its own, which would gather its arguments into an array each call
72
72
 
73
73
  /** Returns log2 of value passed in
74
74
  * @param {number} x
@@ -366,7 +366,7 @@ function oscillate(frequency=1, amplitude=1, t=time, offset=0, type=0)
366
366
  * @param {any} n
367
367
  * @return {boolean}
368
368
  * @memberof Math */
369
- function isNumber(n) { return typeof n === 'number' && !isNaN(n); }
369
+ function isNumber(n) { return typeof n === 'number' && n === n; } // NaN is the one number not equal to itself
370
370
 
371
371
  /**
372
372
  * Check if a value is stringifiable — i.e. it has a toString that returns
@@ -687,8 +687,8 @@ function vec2(x=0, y) { return new Vector2(x, y ?? x); }
687
687
  function isVector2(v) { return v instanceof Vector2 && v.isValid(); }
688
688
 
689
689
  // vector2 asserts
690
- function ASSERT_VECTOR2_VALID(v) { ASSERT(isVector2(v), 'Vector2 is invalid.', v); }
691
- function ASSERT_NUMBER_VALID(n) { ASSERT(isNumber(n), 'Number is invalid.', n); }
690
+ function ASSERT_VECTOR2_VALID(v) { ASSERT(isVector2(v), 'expected a vec2', v); }
691
+ function ASSERT_NUMBER_VALID(n) { ASSERT(isNumber(n), 'expected a number', n); }
692
692
 
693
693
  /**
694
694
  * 2D Vector object with vector math library
@@ -711,7 +711,8 @@ class Vector2
711
711
  this.x = x;
712
712
  /** @property {number} - Y axis location */
713
713
  this.y = y;
714
- ASSERT(this.isValid(), 'Constructed Vector2 is invalid.', this);
714
+ ASSERT(this.isValid(), 'vec2: x and y must be numbers (add, subtract, multiply and divide take a vec2, scale a number)',
715
+ this); // one literal, as it is built at every vector made in a debug build
715
716
  }
716
717
 
717
718
  /** Sets values of this vector and returns self
@@ -942,7 +943,21 @@ class Vector2
942
943
  * @return {Color}
943
944
  * @memberof Math
944
945
  */
945
- function rgb(r, g, b, a) { return new Color(r, g, b, a); }
946
+ function rgb(r, g, b, a)
947
+ {
948
+ debug && (r > 2 || g > 2 || b > 2) && colorRangeWarn('rgb(255, 0, 0) is rgb(1, 0, 0)');
949
+ return new Color(r, g, b, a);
950
+ }
951
+
952
+ // a color given as 0 to 255 or as percents, as other tools take them, said once in a debug build; a value a little
953
+ // past 1 is a bright color, which is why only past 2 counts
954
+ let colorRangeWarned = false;
955
+ function colorRangeWarn(example)
956
+ {
957
+ if (colorRangeWarned) return;
958
+ colorRangeWarned = true;
959
+ console.warn('color values are 0 to 1 here, not 0 to 255 or percents: ' + example);
960
+ }
946
961
 
947
962
  /**
948
963
  * Create a color object with HSLA values, white by default
@@ -952,7 +967,11 @@ function rgb(r, g, b, a) { return new Color(r, g, b, a); }
952
967
  * @param {number} [a=1] - alpha
953
968
  * @return {Color}
954
969
  * @memberof Math */
955
- function hsl(h, s, l, a) { return new Color().setHSLA(h, s, l, a); }
970
+ function hsl(h, s, l, a)
971
+ {
972
+ debug && (s > 2 || l > 2) && colorRangeWarn('hsl(.5, 100, 50) is hsl(.5, 1, .5)');
973
+ return new Color().setHSLA(h, s, l, a);
974
+ }
956
975
 
957
976
  /**
958
977
  * Check if object is a valid Color
@@ -962,7 +981,7 @@ function hsl(h, s, l, a) { return new Color().setHSLA(h, s, l, a); }
962
981
  function isColor(c) { return c instanceof Color && c.isValid(); }
963
982
 
964
983
  // color asserts
965
- function ASSERT_COLOR_VALID(c) { ASSERT(isColor(c), 'Color is invalid.', c); }
984
+ function ASSERT_COLOR_VALID(c) { ASSERT(isColor(c), 'expected a color', c); }
966
985
 
967
986
  /**
968
987
  * Color object (red, green, blue, alpha) with some helpful functions
@@ -991,7 +1010,7 @@ class Color
991
1010
  this.b = b;
992
1011
  /** @property {number} - Alpha */
993
1012
  this.a = a;
994
- ASSERT(this.isValid(), 'Constructed Color is invalid.', this);
1013
+ ASSERT(this.isValid(), 'Color: r, g, b and a must be numbers', this);
995
1014
  }
996
1015
 
997
1016
  /** Sets values of this color and returns self
@@ -1153,10 +1172,12 @@ class Color
1153
1172
  * @return {string} */
1154
1173
  toString(useAlpha = true)
1155
1174
  {
1156
- if (debug && !this.isValid())
1157
- return '#000';
1158
- const toHex = (c)=> ((c=round(clamp(c)*255))<16 ? '0' : '') + c.toString(16);
1159
- return '#' + toHex(this.r) + toHex(this.g) + toHex(this.b) + (useAlpha ? toHex(this.a) : '');
1175
+ if (!this.isValid())
1176
+ return '#000'; // in release too, as a canvas would keep its last color for #NaN
1177
+
1178
+ // two hex digits of a channel of 0 to 1, kept in here as the script build shares its top level with the game
1179
+ const colorHex = (c)=> ((c = round(clamp(c) * 255)) < 16 ? '0' : '') + c.toString(16);
1180
+ return '#' + colorHex(this.r) + colorHex(this.g) + colorHex(this.b) + (useAlpha ? colorHex(this.a) : '');
1160
1181
  }
1161
1182
 
1162
1183
  /** Set this color from a hex code
@@ -1166,7 +1187,7 @@ class Color
1166
1187
  {
1167
1188
  ASSERT(isStringLike(hex), 'Color hex code must be a string');
1168
1189
  ASSERT(hex[0] === '#', 'Color hex code must start with #');
1169
- ASSERT([4,5,7,9].includes(hex.length), 'Invalid hex');
1190
+ ASSERT([4,5,7,9].includes(hex.length), 'setHex: use #rgb, #rgba, #rrggbb or #rrggbbaa', hex);
1170
1191
 
1171
1192
  if (hex.length < 6)
1172
1193
  {
@@ -80,6 +80,9 @@ class EngineObject
80
80
  this.shader = undefined;
81
81
  /** @property {boolean} - Does this object draw into the light system's shadow map; false for a floor layer, a background, a pickup */
82
82
  this.castShadow = true;
83
+ /** @property {boolean} - Does this object draw into the light system's background map, which a
84
+ * DirectionalLight lights only at its edges facing the light; for a background layer, with castShadow false */
85
+ this.castBackgroundShadow = false;
83
86
  /** @property {number} - With the light system, how much it lights itself: 0 lit only by the lights, 1 full
84
87
  * brightness in its own colors whatever the lights do, between partly; drawn into the lightmap through
85
88
  * renderEmissive, as 3D's emissive. Exact for solid pixels; a partly transparent one is self lit by its alpha
@@ -179,23 +182,18 @@ class EngineObject
179
182
  child.updateTransforms();
180
183
  }
181
184
 
182
- /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
185
+ /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object
186
+ * - With many solids each mover finds those near it through a grid, which follows a solid after its own physics
187
+ * update: a solid an override moves for another object, a lift carrying a crate, is found there from the next
188
+ * update on */
183
189
  updatePhysics()
184
190
  {
185
191
  // child objects do not have physics
186
- ASSERT(!this.parent);
192
+ ASSERT(!this.parent, 'updatePhysics: a child has no physics of its own, its parent moves it');
187
193
 
188
194
  // bail if a collision callback destroyed us mid-frame
189
195
  if (this.destroyed) return;
190
196
 
191
- // limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
192
- // has a mass, which tile collision needs; anything else moves as fast as it is told
193
- if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
194
- {
195
- this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
196
- this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
197
- }
198
-
199
197
  // physics sanity checks
200
198
  ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame');
201
199
  ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame');
@@ -211,6 +209,14 @@ class EngineObject
211
209
  this.velocity.x += gravity.x * this.gravityScale;
212
210
  this.velocity.y += gravity.y * this.gravityScale;
213
211
  }
212
+ // limit max speed to prevent missing collisions, after gravity so no move is past it, only for what collides:
213
+ // with solids, or with tiles while it has a mass, which tile collision needs; anything else moves as fast as
214
+ // it is told
215
+ if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
216
+ {
217
+ this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
218
+ this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
219
+ }
214
220
  this.pos.x += this.velocity.x;
215
221
  this.pos.y += this.velocity.y;
216
222
  this.angle += this.angleVelocity *= this.angleDamping;
@@ -235,7 +241,8 @@ class EngineObject
235
241
  {
236
242
  // check collisions against solid objects
237
243
  const epsilon = .001; // necessary to push slightly outside of the collision
238
- for (const o of engineObjectsCollideStaticLast)
244
+ // with many solids, only those near it, as a grid finds them, in the same order
245
+ for (const o of engineCollideGrid ? engineCollideGridWalk(this) : engineObjectsCollideStaticLast)
239
246
  {
240
247
  // skip destroyed, child objects, self collision, or objects with no box
241
248
  if (o.destroyed || o.parent || o === this || !o.size.x || !o.size.y) continue;
@@ -256,6 +263,8 @@ class EngineObject
256
263
  // notify objects of collision and check if should be resolved
257
264
  const collide1 = this.collideWithObject(o);
258
265
  const collide2 = o.collideWithObject(this);
266
+ // a callback may have moved either one, the grid follows
267
+ engineCollideGrid?.built && engineCollideGridPlace(engineCollideGrid.built, o);
259
268
  if (!collide1 || !collide2)
260
269
  {
261
270
  engineObjectsCollidePairAdd(this, o);
@@ -406,34 +415,38 @@ class EngineObject
406
415
  return;
407
416
  }
408
417
 
409
- // move to previous X position and bounce
410
- this.pos.x = oldPos.x;
418
+ // move against the wall and bounce, its side on the tile edge it moved toward, rounded in the
419
+ // layer's space as its collision test is; back to its previous X when that spot is not clear
420
+ const snapEpsilon = .0001, layerX = hitLayer.pos.x, offsetX = this.size.x/2 + snapEpsilon;
421
+ // never back past where it was: a leading edge already on a grid line would step back by
422
+ // the epsilon and take its trailing edge into the tile behind, which nothing tests, since
423
+ // only X is blocked; between there and the wall it covers no column it did not cover before
424
+ const movingLeft = this.pos.x < oldPos.x;
425
+ const snap = layerX + (movingLeft ?
426
+ floor(oldPos.x - layerX - this.size.x/2) + offsetX :
427
+ ceil( oldPos.x - layerX + this.size.x/2) - offsetX);
428
+ const x = movingLeft ? min(snap, oldPos.x) : max(snap, oldPos.x);
429
+ this.pos.x = tileCollisionTest(vec2(x, oldPos.y), this.size, this) ? oldPos.x : x;
411
430
  this.velocity.x *= -restitution;
412
431
  }
413
432
  if (isBlockedY || !isBlockedX)
414
433
  {
415
- if (wasFalling)
416
- {
417
- // adjust position to slightly away from nearest tile
418
- // this prevents gap between object and ground
419
- const epsilon = .0001;
420
- const offset = this.size.y/2 + epsilon;
421
- // rounded in the layer's space as its collision test is, or a bottom a hair below a
422
- // grid line would round to the row under it, inside the floor, and fall through
423
- const layerY = hitLayer.pos.y;
424
- this.pos.y = layerY + (gravityY < 0 ?
425
- floor(oldPos.y - layerY - this.size.y/2) + offset :
426
- ceil( oldPos.y - layerY + this.size.y/2) - offset);
427
-
428
- // set ground object for tile collision
429
- this.groundObject = hitLayer;
430
- }
431
- else
432
- {
433
- // move to previous Y position
434
- this.pos.y = oldPos.y;
435
- this.groundObject = undefined;
436
- }
434
+ // adjust position to slightly away from the nearest tile, the floor or the ceiling it moved
435
+ // toward, which prevents a gap between them; rounded in the layer's space as its collision
436
+ // test is, or a bottom a hair below a grid line would round to the row under it, inside the
437
+ // floor, and fall through; back to its previous Y when that spot is not clear
438
+ const epsilon = .0001;
439
+ const offset = this.size.y/2 + epsilon;
440
+ const layerY = hitLayer.pos.y;
441
+ const y = layerY + (this.pos.y < oldPos.y ?
442
+ floor(oldPos.y - layerY - this.size.y/2) + offset :
443
+ ceil( oldPos.y - layerY + this.size.y/2) - offset);
444
+ const isClear = this.pos.y !== oldPos.y && !tileCollisionTest(vec2(this.pos.x, y), this.size, this);
445
+ this.pos.y = isClear ? y : oldPos.y;
446
+
447
+ // set ground object for tile collision
448
+ this.groundObject = wasFalling ? hitLayer : undefined;
449
+
437
450
  // bounce velocity
438
451
  this.velocity.y *= -restitution;
439
452
  }
@@ -443,7 +456,7 @@ class EngineObject
443
456
  }
444
457
  }
445
458
 
446
- /** Update the object, called automatically by engine once each frame. Does nothing by default. */
459
+ /** Update the object, called automatically by engine once each frame. Does nothing by default */
447
460
  update() {}
448
461
 
449
462
  /** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */
@@ -453,10 +466,11 @@ class EngineObject
453
466
  drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor, glEnable, false);
454
467
  }
455
468
 
456
- /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
469
+ /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default */
457
470
  renderLight() {}
458
471
 
459
- /** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set.
472
+ /** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set, and into
473
+ * its background map when castBackgroundShadow is set and a DirectionalLight is out.
460
474
  * Calls render() by default so the object casts its own shape; override to cast a different one, like a blob at a character's feet so its body stays lit;
461
475
  * screen space WebGL draws in render() are skipped during the pass */
462
476
  renderShadow() { this.render(); }
@@ -502,20 +516,24 @@ class EngineObject
502
516
  * @return {Vector2} */
503
517
  worldToLocalVector(vec) { return vec.rotate(-this.angle); }
504
518
 
505
- /** Called to check if a tile collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
519
+ /** Called to check if a tile collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually
506
520
  * - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
507
521
  * positions it only tries, so keep it free of side effects or guard them to once a frame
508
522
  * - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
509
523
  * saved in update, as the platformer example does
524
+ * - For the point of impact, like sparks where a bullet hit, raycast from where this frame's move started,
525
+ * this.pos minus this.velocity, to this.pos with tileCollisionRaycast, as the platformer's Bullet does
510
526
  * @param {number} tileData - the value of the tile at the position
511
527
  * @param {Vector2} pos - the tile's bottom left corner in world space
512
528
  * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
513
529
  collideWithTile(tileData, pos) { return tileData > 0; }
514
530
 
515
- /** Called by the engine to check if an object collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
531
+ /** Called by the engine to check if an object collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually
516
532
  * - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that
517
533
  * destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a
518
534
  * bullet that should hit one thing checks its own destroyed flag first
535
+ * - With many solids each mover finds those near it through a grid, which follows the other object after this
536
+ * returns; a third object moved here is found where it is now from the next update on
519
537
  * @param {EngineObject} object - the object to test against
520
538
  * @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D
521
539
  * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity
@@ -603,7 +621,8 @@ class EngineObject
603
621
  * @param {EngineObject} child */
604
622
  removeChild(child)
605
623
  {
606
- ASSERT(child.parent === this && this.children.includes(child));
624
+ ASSERT(child.parent === this && this.children.includes(child), 'removeChild: that object is not a child of this one',
625
+ child);
607
626
  const i = this.children.indexOf(child);
608
627
  if (i < 0) return; // not a child of this one, release has no assert
609
628
  this.children.splice(i, 1);
@@ -67,7 +67,7 @@ class ParticleEmitter extends EngineObject
67
67
  * @param {number} [angleSpeed] - How fast are particles rotating, in radians per frame (at 60fps)
68
68
  * @param {number} [damping] - How much to dampen particle speed, per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame)
69
69
  * @param {number} [angleDamping] - How much to dampen particle angular speed, per-frame multiplier (1 = no damping)
70
- * @param {number} [gravityScale] - How much gravity effect particles
70
+ * @param {number} [gravityScale] - How much gravity affects particles
71
71
  * @param {number} [particleConeAngle] - Half angle each side of the emitter's angle for a particle's start angle, PI is any angle
72
72
  * @param {number} [fadeRate] - Fraction of life spent fading: half at fade-in (start), half at fade-out (end). e.g. .2 = 10% fade-in, 80% full opacity, 10% fade-out
73
73
  * @param {number} [randomness] - Apply extra randomness percent
@@ -109,13 +109,34 @@ class ParticleEmitter extends EngineObject
109
109
  )
110
110
  {
111
111
  super(pos, vec2(), tileInfo, angle, undefined, renderOrder);
112
+ if (debug)
113
+ {
114
+ // the arguments come in a long row, so one out of place is named rather than emitting NaN particles
115
+ ASSERT(typeof emitSize === 'number' || isVector2(emitSize), 'ParticleEmitter: emitSize must be a number or vec2',
116
+ emitSize);
117
+ ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'ParticleEmitter: tileInfo must be a TileInfo or undefined',
118
+ tileInfo);
119
+ const numbers = {emitTime, emitRate, emitConeAngle, particleTime, sizeStart, sizeEnd, speed, angleSpeed,
120
+ damping, angleDamping, gravityScale, particleConeAngle, fadeRate, randomness};
121
+ // plain strings, as a message is built at every emitter made in a debug build
122
+ for (const name in numbers)
123
+ ASSERT(isNumber(numbers[name]),
124
+ 'ParticleEmitter: an argument is not a number, they may be out of order; its name and value:',
125
+ name, numbers[name]);
126
+ const colors = {colorStartA, colorStartB, colorEndA, colorEndB};
127
+ for (const name in colors)
128
+ ASSERT(isColor(colors[name]),
129
+ 'ParticleEmitter: an argument is not a color, they may be out of order; its name and value:',
130
+ name, colors[name]);
131
+ }
112
132
 
113
133
  // emitter settings
114
134
  /** @property {boolean} - Should particles be emitted in a circle */
115
135
  this.emitCircle = typeof emitSize === 'number';
116
136
  /** @property {Vector2} - World space size of the emitter, x is the diameter when emitCircle is set */
117
137
  this.emitSize = typeof emitSize === 'number' ? vec2(emitSize) : emitSize.copy();
118
- /** @property {number} - How long to stay alive (0 is forever) */
138
+ /** @property {number} - How long to emit for in seconds, 0 is forever; raised while its particles are still
139
+ * alive, it emits again for the added time */
119
140
  this.emitTime = emitTime;
120
141
  /** @property {number} - How many particles per second to spawn, does not emit if 0 */
121
142
  this.emitRate = emitRate;
@@ -188,6 +209,9 @@ class ParticleEmitter extends EngineObject
188
209
  this.velocityInheritance = 0;
189
210
  /** @property {number} - Particles owed to the emit rate, starts at one so the first comes out at once */
190
211
  this.emitTimeBuffer = 1;
212
+ /** @property {number} - Seconds of its emit time it has emitted for, counted by its updates
213
+ * @type {number} */
214
+ this.emitElapsed = 0;
191
215
  /** @property {Array<Particle>} - Array of particles for this emitter
192
216
  * @type {Array<Particle>} */
193
217
  this.particles = [];
@@ -196,6 +220,10 @@ class ParticleEmitter extends EngineObject
196
220
  /** @type {Vector2|undefined} */
197
221
  this.previousPos = undefined;
198
222
  this.previousAngle = this.angle;
223
+ // where it was last update, and how far back toward it the particle being made starts, 0 to 1: a moving
224
+ // emitter spreads an update's particles along its move, where they came out in a clump where it is
225
+ this.emitFrom = vec2();
226
+ this.emitBehind = 0;
199
227
  }
200
228
 
201
229
  /** Update the emitter to spawn particles, called automatically by engine once each frame */
@@ -221,20 +249,37 @@ class ParticleEmitter extends EngineObject
221
249
  }
222
250
  // tracked even while velocityInheritance is off, so turning it on does not jump
223
251
  this.previousAngle = this.angle;
252
+ this.emitFrom.x = this.previousPos.x;
253
+ this.emitFrom.y = this.previousPos.y;
224
254
  this.previousPos.x = this.pos.x;
225
255
  this.previousPos.y = this.pos.y;
226
256
 
227
257
  // update emitter
228
258
  if (this.isActive())
229
259
  {
230
- // emit particles
260
+ // emit particles for the part of this update inside the emit time, counted by its own updates rather than
261
+ // the clock, so a one shot gives the same count whichever frame it is made on
231
262
  const rate = this.emitRate * particleEmitRateScale;
263
+ const step = this.emitTime ? min(timeDelta, this.emitTime - this.emitElapsed) : timeDelta;
264
+ this.emitElapsed += timeDelta;
232
265
  if (rate > 0 && rate < Infinity)
233
266
  {
234
- // counted in particles, so a new rate applies at once
235
- this.emitTimeBuffer += rate * timeDelta;
236
- for (; this.emitTimeBuffer >= 1; --this.emitTimeBuffer)
237
- this.emitParticle();
267
+ // counted in particles, so a new rate applies at once; the update that ends the emit time adds a
268
+ // hair, as the steps' sum comes out a rounding under it and lost the last particle
269
+ this.emitTimeBuffer += rate * step + (this.emitTime && this.emitElapsed >= this.emitTime ? 1e-6 : 0);
270
+ // spread along the move since the last update, the last one where it is now; a jump further than
271
+ // anything travels in an update, twice objectMaxSpeed, a teleport or a respawn, spreads nothing
272
+ const travelled = this.emitFrom && this.pos.distance(this.emitFrom) <= 2 * objectMaxSpeed;
273
+ const count = floor(this.emitTimeBuffer);
274
+ try
275
+ {
276
+ for (let i = count; i--; --this.emitTimeBuffer)
277
+ {
278
+ this.emitBehind = travelled ? i / count : 0;
279
+ this.emitParticle();
280
+ }
281
+ }
282
+ finally { this.emitBehind = 0; } // a create callback that throws leaves no offset for the next
238
283
  }
239
284
  }
240
285
  else if (this.particles.length === 0)
@@ -279,8 +324,9 @@ class ParticleEmitter extends EngineObject
279
324
  {
280
325
  // into the world: a local space particle is turned with the emitter when it draws instead
281
326
  this.emitCircle || (pos = pos.rotate(this.angle));
282
- pos.x += this.pos.x;
283
- pos.y += this.pos.y;
327
+ const behind = this.emitBehind; // back along its move, as the update spreads them
328
+ pos.x += this.pos.x + (this.emitFrom.x - this.pos.x) * behind;
329
+ pos.y += this.pos.y + (this.emitFrom.y - this.pos.y) * behind;
284
330
  angle += this.angle;
285
331
  }
286
332
 
@@ -338,10 +384,11 @@ class ParticleEmitter extends EngineObject
338
384
 
339
385
  /** is emitter actively spawning
340
386
  * @return {boolean} */
341
- isActive() { return !this.emitTime || this.getAliveTime() < this.emitTime; }
387
+ isActive() { return !this.emitTime || this.emitElapsed < this.emitTime; }
342
388
 
343
389
  /** Destroy the particle emitter
344
- * @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first */
390
+ * @param {boolean} [immediate] - true removes it and its particles at once, with no particleDestroyCallback for
391
+ * them, false stops emitting and lets the particles finish first */
345
392
  destroy(immediate=false)
346
393
  {
347
394
  if (this.destroyed) return;
@@ -29,7 +29,6 @@ function LOG (){}
29
29
  function debugInit (){}
30
30
  function debugUpdate (){}
31
31
  function debugRender (){}
32
- function debugRenderPost (){}
33
32
  function debugRect (){}
34
33
  function debugPoly (){}
35
34
  function debugCircle (){}
@@ -41,10 +40,7 @@ function debugClear (){}
41
40
  function debugScreenshot (){}
42
41
  function debugShowErrors(){}
43
42
  function setDebugOverlay(){}
44
- function debugVideoCaptureIsActive(){ return false; }
45
- function debugVideoCaptureStart (){}
46
- function debugVideoCaptureStop (){}
47
- function debugProtectConstant(o){ return o; }
43
+ function debugProtectConstant(o){ return Object.freeze(o); } // a color constant stays as it is, in release too
48
44
 
49
45
  // the tweakables and the level editor are debug only
50
46
  function tweak(){}