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
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
@@ -413,8 +413,10 @@ function lineTest(posStart, posEnd, testFunction, normal)
413
413
  const totalLength = (dx*dx + dy*dy)**.5;
414
414
  if (!totalLength) return;
415
415
 
416
- // current integer cell we are in
416
+ // current integer cell we are in, the one below a whole start heading down the grid, which it goes into
417
417
  const pos = posStart.floor();
418
+ if (dx < 0 && pos.x === posStart.x) --pos.x;
419
+ if (dy < 0 && pos.y === posStart.y) --pos.y;
418
420
 
419
421
  // normalize ray direction
420
422
  const dirX = dx / totalLength;
@@ -685,13 +687,8 @@ function vec2(x=0, y) { return new Vector2(x, y ?? x); }
685
687
  function isVector2(v) { return v instanceof Vector2 && v.isValid(); }
686
688
 
687
689
  // vector2 asserts
688
- function ASSERT_VECTOR2_VALID(v) { ASSERT(isVector2(v), 'Vector2 is invalid.', v); }
689
- function ASSERT_NUMBER_VALID(n) { ASSERT(isNumber(n), 'Number is invalid.', n); }
690
- function ASSERT_VECTOR2_NORMAL(v)
691
- {
692
- ASSERT_VECTOR2_VALID(v);
693
- ASSERT(abs(v.lengthSquared()-1) < .01, 'Vector2 is not normal.', v);
694
- }
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); }
695
692
 
696
693
  /**
697
694
  * 2D Vector object with vector math library
@@ -714,7 +711,8 @@ class Vector2
714
711
  this.x = x;
715
712
  /** @property {number} - Y axis location */
716
713
  this.y = y;
717
- 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
718
716
  }
719
717
 
720
718
  /** Sets values of this vector and returns self
@@ -945,7 +943,21 @@ class Vector2
945
943
  * @return {Color}
946
944
  * @memberof Math
947
945
  */
948
- 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
+ }
949
961
 
950
962
  /**
951
963
  * Create a color object with HSLA values, white by default
@@ -955,7 +967,11 @@ function rgb(r, g, b, a) { return new Color(r, g, b, a); }
955
967
  * @param {number} [a=1] - alpha
956
968
  * @return {Color}
957
969
  * @memberof Math */
958
- 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
+ }
959
975
 
960
976
  /**
961
977
  * Check if object is a valid Color
@@ -965,7 +981,7 @@ function hsl(h, s, l, a) { return new Color().setHSLA(h, s, l, a); }
965
981
  function isColor(c) { return c instanceof Color && c.isValid(); }
966
982
 
967
983
  // color asserts
968
- 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); }
969
985
 
970
986
  /**
971
987
  * Color object (red, green, blue, alpha) with some helpful functions
@@ -994,7 +1010,7 @@ class Color
994
1010
  this.b = b;
995
1011
  /** @property {number} - Alpha */
996
1012
  this.a = a;
997
- ASSERT(this.isValid(), 'Constructed Color is invalid.', this);
1013
+ ASSERT(this.isValid(), 'Color: r, g, b and a must be numbers', this);
998
1014
  }
999
1015
 
1000
1016
  /** Sets values of this color and returns self
@@ -1156,10 +1172,12 @@ class Color
1156
1172
  * @return {string} */
1157
1173
  toString(useAlpha = true)
1158
1174
  {
1159
- if (debug && !this.isValid())
1160
- return '#000';
1161
- const toHex = (c)=> ((c=round(clamp(c)*255))<16 ? '0' : '') + c.toString(16);
1162
- 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) : '');
1163
1181
  }
1164
1182
 
1165
1183
  /** Set this color from a hex code
@@ -1169,7 +1187,7 @@ class Color
1169
1187
  {
1170
1188
  ASSERT(isStringLike(hex), 'Color hex code must be a string');
1171
1189
  ASSERT(hex[0] === '#', 'Color hex code must start with #');
1172
- 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);
1173
1191
 
1174
1192
  if (hex.length < 6)
1175
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(); }
@@ -483,35 +497,43 @@ class EngineObject
483
497
  }
484
498
 
485
499
  /** Convert from local space to world space
486
- * @param {Vector2} pos - local space point */
500
+ * @param {Vector2} pos - local space point
501
+ * @return {Vector2} */
487
502
  localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); }
488
503
 
489
504
  /** Convert from world space to local space
490
- * @param {Vector2} pos - world space point */
505
+ * @param {Vector2} pos - world space point
506
+ * @return {Vector2} */
491
507
  worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); }
492
508
 
493
509
  /** Convert from local space to world space for a vector (rotation only)
494
- * @param {Vector2} vec - local space vector */
510
+ * @param {Vector2} vec - local space vector
511
+ * @return {Vector2} */
495
512
  localToWorldVector(vec) { return vec.rotate(this.angle); }
496
513
 
497
514
  /** Convert from world space to local space for a vector (rotation only)
498
- * @param {Vector2} vec - world space vector */
515
+ * @param {Vector2} vec - world space vector
516
+ * @return {Vector2} */
499
517
  worldToLocalVector(vec) { return vec.rotate(-this.angle); }
500
518
 
501
- /** 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
502
520
  * - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
503
521
  * positions it only tries, so keep it free of side effects or guard them to once a frame
504
522
  * - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
505
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
506
526
  * @param {number} tileData - the value of the tile at the position
507
527
  * @param {Vector2} pos - the tile's bottom left corner in world space
508
528
  * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
509
529
  collideWithTile(tileData, pos) { return tileData > 0; }
510
530
 
511
- /** 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
512
532
  * - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that
513
533
  * destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a
514
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
515
537
  * @param {EngineObject} object - the object to test against
516
538
  * @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D
517
539
  * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity
@@ -599,7 +621,8 @@ class EngineObject
599
621
  * @param {EngineObject} child */
600
622
  removeChild(child)
601
623
  {
602
- 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);
603
626
  const i = this.children.indexOf(child);
604
627
  if (i < 0) return; // not a child of this one, release has no assert
605
628
  this.children.splice(i, 1);
@@ -634,11 +657,6 @@ class EngineObject
634
657
  this.collideRaycast = collideRaycast;
635
658
  }
636
659
 
637
- /** @deprecated since 1.20, use collideLevel
638
- * @type {boolean} */
639
- get collideTiles() { return this.collideLevel; }
640
- set collideTiles(collide) { this.collideLevel = collide; }
641
-
642
660
  /** Returns string containing info about this object for debugging
643
661
  * @return {string} */
644
662
  toString()
@@ -37,13 +37,13 @@
37
37
  * let pos = vec2(2,3);
38
38
  * let particleEmitter = new ParticleEmitter
39
39
  * (
40
- * pos, 0, 1, 0, 500, PI, // pos, angle, emitSize, emitTime, emitRate, emitCone
40
+ * pos, 0, 1, 0, 500, PI, // pos, angle, emitSize, emitTime, emitRate, emitConeAngle
41
41
  * tile(0, 16), // tileInfo
42
42
  * rgb(1,1,1,1), rgb(0,0,0,1), // colorStartA, colorStartB
43
43
  * rgb(1,1,1,0), rgb(0,0,0,0), // colorEndA, colorEndB
44
- * 1, .2, .2, .1, .05, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed
45
- * .99, 1, 1, PI, .05, // damping, angleDamping, gravityScale, particleCone, fadeRate
46
- * .5, true // randomness, collide
44
+ * 1, .2, .2, .1, .05, // particleTime, sizeStart, sizeEnd, speed, angleSpeed
45
+ * .99, 1, 1, PI, .05, // damping, angleDamping, gravityScale, particleConeAngle, fadeRate
46
+ * .5, true // randomness, collideLevel
47
47
  * );
48
48
  */
49
49
  class ParticleEmitter extends EngineObject
@@ -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
 
@@ -336,11 +382,13 @@ class ParticleEmitter extends EngineObject
336
382
  this.additive && setAdditiveBlendMode(false);
337
383
  }
338
384
 
339
- /** is emitter actively spawning */
340
- isActive() { return !this.emitTime || this.getAliveTime() < this.emitTime; }
385
+ /** is emitter actively spawning
386
+ * @return {boolean} */
387
+ isActive() { return !this.emitTime || this.emitElapsed < this.emitTime; }
341
388
 
342
389
  /** Destroy the particle emitter
343
- * @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 */
344
392
  destroy(immediate=false)
345
393
  {
346
394
  if (this.destroyed) return;
@@ -548,8 +596,8 @@ class Particle
548
596
  }
549
597
  if (isBlockedY || !isBlockedX)
550
598
  {
551
- // down is the world's gravity, or the emitter's own fall in a world with none
552
- const down = gravity.y || emitter.gravity || 0;
599
+ // down is the way it falls, the world's gravity by its gravityScale and the emitter's own
600
+ const down = gravityY;
553
601
  const wasFalling = this.velocity.y < 0 && down < 0 || this.velocity.y > 0 && down > 0;
554
602
  if (wasFalling)
555
603
  this.groundObject = hitLayer;
@@ -10,18 +10,17 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- let debugWatermark = 0;
13
+ let debugWatermark = false;
14
14
  let debugKey = '';
15
15
  let debugKeysAlways = false;
16
16
  let debugTweakables = false;
17
- let debugClearCount = 0;
18
- const debug = 0;
19
- const debugOverlay = 0;
20
- const debugPhysics = 0;
21
- const debugParticles = 0;
22
- const debugRaycast = 0;
23
- const debugGamepads = 0;
24
- const debugSound = 0;
17
+ const debug = false;
18
+ const debugOverlay = false;
19
+ const debugPhysics = false;
20
+ const debugParticles = false;
21
+ const debugRaycast = false;
22
+ const debugGamepads = false;
23
+ const debugSound = false;
25
24
  const debugPointSize = .5;
26
25
 
27
26
  // debug commands are automatically removed from the final build
@@ -30,7 +29,6 @@ function LOG (){}
30
29
  function debugInit (){}
31
30
  function debugUpdate (){}
32
31
  function debugRender (){}
33
- function debugRenderPost (){}
34
32
  function debugRect (){}
35
33
  function debugPoly (){}
36
34
  function debugCircle (){}
@@ -42,16 +40,21 @@ function debugClear (){}
42
40
  function debugScreenshot (){}
43
41
  function debugShowErrors(){}
44
42
  function setDebugOverlay(){}
45
- function debugVideoCaptureIsActive(){ return false; }
46
- function debugVideoCaptureStart (){}
47
- function debugVideoCaptureStop (){}
48
- function debugProtectConstant(o){ return o; }
43
+ function debugProtectConstant(o){ return Object.freeze(o); } // a color constant stays as it is, in release too
49
44
 
50
45
  // the tweakables and the level editor are debug only
51
46
  function tweak(){}
52
47
  function tweakButton(){}
53
48
  function tweakDivider(){}
54
49
  function tweakEngineDefaults(){}
50
+ // the 2D editor's edit functions in a release build, which has no editor: there is nothing to edit and nothing
51
+ // changes, and a game that calls them needs no guard; edit3D stays undefined, as it is with no 3D plugins
52
+ const editorEdit2DRelease =
53
+ {
54
+ map: undefined, layer: undefined, hover: undefined, get objects() { return []; }, get selection() { return new Set; },
55
+ paint(){ return false; }, changeObjects(){ return false; }, strokeEnd(){}, strokeCancel(){}, bulk(){},
56
+ undo(){ return false; }, toJSON(){ return ''; },
57
+ };
55
58
  class LevelEditor
56
59
  {
57
60
  constructor()
@@ -61,7 +64,7 @@ class LevelEditor
61
64
  }
62
65
  get isOpen() { return false; }
63
66
  get is3D() { return false; }
64
- get edit2D() { return undefined; }
67
+ get edit2D() { return editorEdit2DRelease; }
65
68
  get edit3D() { return undefined; }
66
69
  open(){} close(){} addKey(){} addButton(){} addTool(){}
67
70
  onTile(){} onRestart(){} onPlayFrom(){} onOpen(){} onClose(){} onUpdate(){} onDraw(){} onPanel(){}