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
@@ -4,8 +4,8 @@
4
4
  * - Medal class with name, description, icon, and unlock tracking
5
5
  * - Automatic saving to local storage, unless a service like Newgrounds holds the medal (see Medal.isLocal)
6
6
  * - Visual display queue with slide-in notifications
7
- * - The Newgrounds plugin extends it with NewgroundsMedal, held on the server while logged in
8
- * - Setting debugMedals = true in the game code before medalsInit skips the load and the save, and in the debug build logs the Newgrounds traffic; it is not exported, so only a script tag build can set it
7
+ * - The Newgrounds plugin extends it with NewgroundsMedal, held on the server while logged in, and the Wavedash plugin with WavedashMedal, saved here and sent to Wavedash
8
+ * - Setting debugMedals = true in the game code before medalsInit skips the load and the save, sends nothing to Wavedash, and in the debug build logs the Newgrounds traffic; it is not exported, so only a script tag build can set it
9
9
  * @namespace Medals
10
10
  */
11
11
 
@@ -54,13 +54,25 @@ let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast, medalsRender
54
54
  * - Call this after creating all medals
55
55
  * - Loads which medals are unlocked from the save, and writes the catalog back
56
56
  * - A medal a service like Newgrounds holds is left as it is, see Medal.isLocal
57
+ * - A medal unlocked before the first call stays unlocked and is saved by it
57
58
  * @param {string} saveName - The localStorage key the medals are kept under, a different one from the game's own
58
59
  * readSaveData and writeSaveData, or each would overwrite the other
59
60
  * @memberof Medals */
60
61
  function medalsInit(saveName)
61
62
  {
63
+ // unlocks from before the first call, which had nowhere to be saved, are kept: medals favour the player; a call
64
+ // after that, another player's save, starts from its own
65
+ const early = [];
66
+ medalsSaveName || medalsForEach((medal)=> medal.unlocked && medal.isLocal() && early.push(medal));
62
67
  medalsSaveName = saveName;
63
68
  medalsLoad();
69
+ if (early.length)
70
+ {
71
+ early.forEach((medal)=> medal.unlocked = true);
72
+ medalsSave();
73
+ }
74
+ // the saved unlocks of Wavedash medals go to Wavedash, when that plugin is in the build and started
75
+ typeof wavedash != 'undefined' && wavedash?.sendAchievements();
64
76
 
65
77
  // add the medal display once, however often this is called
66
78
  if (!medalsRenderAdded)
@@ -130,10 +142,11 @@ function medalsReset()
130
142
  medalsForEach(medal=> medal.isLocal() && (medal.unlocked = false));
131
143
  if (medalsSaveName && !debugMedals)
132
144
  {
133
- // the saved unlocks of medals not made yet are cleared too, they are read when those medals are made
145
+ // the saved unlocks of the local medals and of medals not made yet are cleared too, since a save keeps an
146
+ // unlock another tab wrote, and those not made yet read theirs when they are made
134
147
  const saved = readSaveData(medalsSaveName);
135
148
  for (const id in saved)
136
- if (!medals[id] && saved[id] && typeof saved[id] === 'object')
149
+ if ((!medals[id] || medals[id].isLocal()) && saved[id] && typeof saved[id] === 'object')
137
150
  saved[id].unlocked = false;
138
151
  writeSaveData(medalsSaveName, saved);
139
152
  }
@@ -159,7 +172,7 @@ function medalsSave()
159
172
  name: medal.name,
160
173
  description: medal.description,
161
174
  icon: medal.icon,
162
- unlocked: medal.unlocked,
175
+ unlocked: medal.unlocked || !!saved[medal.id]?.unlocked, // another tab may have unlocked it since
163
176
  };
164
177
  if (medal.image) entry.src = medal.image.src;
165
178
  data[medal.id] = entry;
@@ -229,8 +242,7 @@ class Medal
229
242
  {
230
243
  if (!medalsPreventUnlock && !this.unlocked)
231
244
  {
232
- ASSERT(medalsSaveName, 'save name must be set');
233
- this.unlocked = true;
245
+ this.unlocked = true; // saved now, or by medalsInit when it comes
234
246
  medalsSave();
235
247
  medalsDisplayQueue.push(this);
236
248
  }
@@ -157,9 +157,11 @@ class NewgroundsPlugin
157
157
  const hasLocation = typeof location != 'undefined';
158
158
  /** @property {string} - Hostname sent with the view the plugin logs when it starts */
159
159
  this.host = hasLocation ? location.hostname : '';
160
- /** @property {Array} - Medals fetched from Newgrounds, empty until ready, with the unlocks only when logged in */
160
+ /** @property {Array<Object>} - Medals fetched from Newgrounds, empty until ready, with the unlocks only when logged in
161
+ * @type {Array<Object>} */
161
162
  this.medals = [];
162
- /** @property {Array} - Scoreboards fetched from Newgrounds, empty until ready */
163
+ /** @property {Array<Object>} - Scoreboards fetched from Newgrounds, empty until ready
164
+ * @type {Array<Object>} */
163
165
  this.scoreboards = [];
164
166
  /** @property {{id: number, name: string, url: string, supporter: boolean}|null} - The logged in player once ready, null when not logged in
165
167
  * @type {{id: number, name: string, url: string, supporter: boolean}|null} */
@@ -47,6 +47,7 @@ class ParallaxLayer extends CanvasLayer
47
47
  ASSERT(isNumber(parallax) || isVector2(parallax), 'parallax must be a number or a Vector2');
48
48
  ASSERT(typeof drawFunction == 'function', 'drawFunction must be a function');
49
49
  super(pos, size, 0, renderOrder, canvasSize);
50
+ this.castShadow = false; // a backdrop, it would throw huge shadows in the light system, a sun's above all
50
51
 
51
52
  /** @property {Vector2} - How much of the camera's movement it follows on each axis, 0 stays with the world
52
53
  * and 1 with the screen */
@@ -85,9 +85,9 @@ particleEffectAddSetting('randomColorLinear', 'checkbox', true, 0, 0, 0,
85
85
 
86
86
  particleEffectSettingGroup = 'Motion';
87
87
  particleEffectAddSetting('speed', 'number', .1, 0, .5, .005,
88
- 'Start speed, world units per frame', 0, 1e9);
88
+ 'Start speed, world units per frame', -1e9, 1e9); // negative goes back against the cone, as the emitter takes it
89
89
  particleEffectAddSetting('angleSpeed', 'number', .05, 0, .5, .005,
90
- 'Spin speed, radians per frame', 0, 1e9);
90
+ 'Spin speed, radians per frame', -1e9, 1e9);
91
91
  particleEffectAddSetting('damping', 'number', 1, .8, 1, .001,
92
92
  'Speed kept each frame, 1 keeps it all', 0, 1);
93
93
  particleEffectAddSetting('angleDamping', 'number', 1, .8, 1, .001,
@@ -107,7 +107,7 @@ particleEffectAddSetting('collideLevel', 'checkbox', false, 0, 0, 0,
107
107
  particleEffectAddSetting('restitution', 'number', 0, 0, 1, .01,
108
108
  'Bounce when hitting tiles');
109
109
  particleEffectAddSetting('friction', 'number', .8, 0, 1, .01,
110
- 'Speed kept sliding along tiles');
110
+ 'Speed kept sliding along tiles, the larger of this and the friction of the layer, as for an object');
111
111
 
112
112
  particleEffectSettingGroup = 'Texture';
113
113
  particleEffectAddSetting('shape', 'shape', 'soft', 0, 0, 0,
@@ -160,7 +160,8 @@ function particleEffectSanitize(raw)
160
160
  {
161
161
  // one line of text, line breaks and control characters made spaces
162
162
  const text = typeof raw?.name === 'string' ? raw.name.replace(/[\x00-\x1f\x7f]+/g, ' ').trim() : '';
163
- const name = text ? text.slice(0, 60).trim() : 'Effect'; // no space left where it was cut
163
+ // whole characters, and no space left where it was cut
164
+ const name = text ? textGraphemes(text).slice(0, 60).join('').trim() : 'Effect';
164
165
  const input = raw?.settings && typeof raw.settings === 'object' ? {...raw.settings} : {};
165
166
  // a library saved before shapes names its tile and no shape, it keeps its tile
166
167
  if (input.shape === undefined && isNumber(input.tileIndex))
@@ -414,7 +415,7 @@ function particleEffectShapeTile(name)
414
415
  * @param {string} name
415
416
  * @param {function(Particle, number): void} update - Pushes a 2D particle, given the strength
416
417
  * @param {function(Particle3D, number): void} [update3D] - The same for a 3D particle, none leaves 3D alone
417
- * @param {number} [min] - Strength range the designer offers
418
+ * @param {number} [min] - Strength range the designer offers, and the range a strength is clamped to
418
419
  * @param {number} [max]
419
420
  * @param {number} [value] - Strength when first added
420
421
  * @param {string} [description]
@@ -478,17 +479,45 @@ function particleEffectSet(emitter, effect)
478
479
  emitter.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors);
479
480
  }
480
481
 
482
+ // the options a play takes besides the settings
483
+ const PARTICLE_EFFECT_OPTIONS = ['hue', 'saturation', 'scale', 'angle', 'tileInfo', 'flatten'];
484
+
485
+ // whether an option is a value its setting takes, as particleEffectSanitize keeps one
486
+ function particleEffectOptionValid(setting, value)
487
+ {
488
+ return setting.kind === 'checkbox' ? typeof value === 'boolean' || value === 0 || value === 1 :
489
+ setting.kind === 'color' ? !!particleEffectColor(value) :
490
+ setting.kind === 'shape' ? value === '' || particleEffectShapes.includes(value) : isNumber(value);
491
+ }
492
+
481
493
  // an effect from a name or an effect, recolored by the options
482
494
  function particleEffectResolve(nameOrEffect, options)
483
495
  {
484
496
  const found = typeof nameOrEffect == 'string' ? particleEffectsGet(nameOrEffect) : nameOrEffect;
485
497
  ASSERT(!!found, 'no particle effect named ' + nameOrEffect);
486
498
  if (!found) return;
487
- // any setting in the options replaces the effect's own for this play, emitTime for a burst or a loop
499
+ // a vec2 emitSize is a rectangle, as ParticleEmitter takes one, unless emitRect is false, which makes a circle as
500
+ // wide as its x
501
+ if (isVector2(options.emitSize))
502
+ options = {...options, emitSize: options.emitSize.x, emitHeight: options.emitSize.y,
503
+ emitRect: options.emitRect ?? true};
504
+ if (debug)
505
+ for (const key in options)
506
+ ASSERT(PARTICLE_EFFECT_OPTIONS.includes(key) || particleEffectSettings.some((setting)=> setting.name === key),
507
+ 'particleEffect: no option named ' + key);
508
+ // any setting in the options replaces the effect's own for this play, emitTime for a burst or a loop; one that is
509
+ // not a value of its kind keeps the effect's own, where the sanitizing would put the library's default
488
510
  const settings = {...found.settings};
489
511
  for (const setting of particleEffectSettings)
490
- if (options[setting.name] !== undefined)
491
- settings[setting.name] = options[setting.name];
512
+ {
513
+ const value = options[setting.name];
514
+ if (value === undefined) continue;
515
+ const valid = particleEffectOptionValid(setting, value);
516
+ ASSERT(valid, `particleEffect: ${setting.name} must be a ${setting.kind === 'checkbox' ? 'boolean' : setting.kind}`,
517
+ value);
518
+ if (valid)
519
+ settings[setting.name] = value;
520
+ }
492
521
  // sanitized each time, an effect may be written by hand or changed after it was added
493
522
  const effect = particleEffectSanitize({...found, settings}), {hue=0, saturation=1} = options;
494
523
  return hue || saturation != 1 ? particleEffectRecolor(effect, hue, saturation) : effect;
@@ -513,6 +542,7 @@ function particleEffectResolve(nameOrEffect, options)
513
542
  * @memberof ParticleEffects */
514
543
  function particleEffect(nameOrEffect, pos=vec2(), options={})
515
544
  {
545
+ options ||= {}; // null is no options too
516
546
  const effect = particleEffectResolve(nameOrEffect, options);
517
547
  if (!effect) return;
518
548
  const emitter = new ParticleEmitter(pos.copy());
@@ -527,8 +557,9 @@ function particleEffect(nameOrEffect, pos=vec2(), options={})
527
557
  }
528
558
 
529
559
  /** An effect with a 2D emitter's settings, to save, build again, or build in 3D with particleEffect3D; its tile is
530
- * left out, since a hand made emitter's tile is its own texture and not one an effect can name, and so is its
531
- * scale, which an effect does not keep: pass it again with options.scale
560
+ * left out, since a hand made emitter's tile is its own texture and not one an effect can name, and so are its
561
+ * scale, which an effect does not keep: pass it again with options.scale, and its renderOrder, which an effect takes
562
+ * from additive; a finished emitter's emit time is the time it emitted for
532
563
  * @param {ParticleEmitter} emitter
533
564
  * @param {string} [name]
534
565
  * @return {Object}
@@ -539,6 +570,8 @@ function particleEffectFromEmitter(emitter, name='Effect')
539
570
  for (const setting of particleEffectSettings)
540
571
  if (!particleEffectIndirect.includes(setting.name) && emitter[setting.name] !== undefined)
541
572
  settings[setting.name] = emitter[setting.name]; // a Color is taken as its channels by the sanitizer
573
+ if (emitter.emitTime < 0) // destroyed, its last particles going: as long as it emitted, not 0, for ever
574
+ settings.emitTime = emitter.emitElapsed;
542
575
  settings.emitRect = !emitter.emitCircle;
543
576
  settings.emitSize = emitter.emitSize.x;
544
577
  settings.emitHeight = emitter.emitSize.y;
@@ -629,6 +662,7 @@ function particleEffectSet3D(emitter, effect)
629
662
  * @memberof ParticleEffects */
630
663
  function particleEffect3D(nameOrEffect, pos3D=vec3(), options={})
631
664
  {
665
+ options ||= {}; // null is no options too
632
666
  const effect = particleEffectResolve(nameOrEffect, options);
633
667
  if (!effect) return;
634
668
  const e = new ParticleEmitter3D(pos3D.copy());
@@ -64,10 +64,10 @@ class PathFinderNode
64
64
  this.isOpen = this.isClosed = false;
65
65
  }
66
66
 
67
- /** Reset per-search state and walkability (called by buildNodeData). */
67
+ /** Reset per-search state and walkability (called by buildNodeData) */
68
68
  reset() { this.walkable = false; this.cost = 0; this.resetSearch(); }
69
69
 
70
- /** True if walkable and not blocked by cost.
70
+ /** True if walkable and not blocked by cost
71
71
  * @return {boolean} */
72
72
  isClear()
73
73
  {
@@ -77,7 +77,7 @@ class PathFinderNode
77
77
 
78
78
  ///////////////////////////////////////////////////////////////////////////////
79
79
 
80
- /** Grid pathfinder using A* with two optional smoothing passes.
80
+ /** Grid pathfinder using A* with two optional smoothing passes
81
81
  * @memberof PathFinding
82
82
  * @example
83
83
  * // Tile-layer driven (most common):
@@ -177,7 +177,7 @@ class PathFinder
177
177
  return 0;
178
178
  }
179
179
 
180
- /** Get the node at tile coords, or null if out of bounds.
180
+ /** Get the node at tile coords, or null if out of bounds
181
181
  * @param {number} x
182
182
  * @param {number} y
183
183
  * @return {PathFinderNode|null} */
@@ -187,7 +187,7 @@ class PathFinder
187
187
  return this.nodes[x + y * this.size.x];
188
188
  }
189
189
 
190
- /** Convert a world-space position to integer tile coords (no clamping).
190
+ /** Convert a world-space position to integer tile coords (no clamping)
191
191
  * @param {Vector2} worldPos
192
192
  * @return {Vector2} */
193
193
  worldToTile(worldPos)
@@ -197,7 +197,7 @@ class PathFinder
197
197
  return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy));
198
198
  }
199
199
 
200
- /** Convert integer tile coords to the world-space center of that tile.
200
+ /** Convert integer tile coords to the world-space center of that tile
201
201
  * @param {number} x
202
202
  * @param {number} y
203
203
  * @return {Vector2} */
@@ -822,13 +822,11 @@ function pathFinderNearestNode(finder, worldPos, searchRange, test)
822
822
  const bound = max(0, offset - .5);
823
823
  if (nearest && bound * bound >= nearestDistSq) break;
824
824
 
825
+ // only the ring itself, the inside was searched already: its top and bottom rows whole, its two ends between,
826
+ // in the same row by row order a full walk of the square visits them in, so ties pick the same node
825
827
  for (let dy = -offset; dy <= offset; ++dy)
826
- for (let dx = -offset; dx <= offset; ++dx)
828
+ for (let dx = -offset, step = abs(dy) === offset ? 1 : 2*offset; dx <= offset; dx += step)
827
829
  {
828
- // only the ring itself, the inside was searched already
829
- if (offset > 0 && abs(dx) !== offset && abs(dy) !== offset)
830
- continue;
831
-
832
830
  const node = finder.getNode(centerX + dx, centerY + dy);
833
831
  if (!node || !test(node)) continue;
834
832
 
@@ -24,6 +24,11 @@ export
24
24
  NewgroundsPlugin,
25
25
  NewgroundsMedal,
26
26
 
27
+ // Wavedash
28
+ wavedash,
29
+ WavedashPlugin,
30
+ WavedashMedal,
31
+
27
32
  // Post Process
28
33
  postProcess,
29
34
  PostProcessPlugin,
@@ -45,6 +50,7 @@ export
45
50
  lightSystem,
46
51
  LightSystemPlugin,
47
52
  Light,
53
+ DirectionalLight,
48
54
 
49
55
  // Audio Effects
50
56
  AudioEffect,
@@ -151,6 +157,9 @@ export
151
157
  // Render3D
152
158
  render3D,
153
159
  Render3DPlugin,
160
+ CubeMap,
161
+ makeCubeMap,
162
+ loadCubeMap,
154
163
  Camera3D,
155
164
  EngineObject3D,
156
165
  Mesh,
@@ -310,11 +310,13 @@ function postProcessFragmentSource(shaderCode, values={})
310
310
  'return iDepthRange.z>0.?(d*(f-n)+f+n)/2.:f>0.?2.*n*f/(f+n-d*(f-n)):2.*n/max(1.-d,1e-7);}'+
311
311
  // whether there is depth to read: a range of all 0 is none, an orthographic near plane may be behind
312
312
  '\n#define LJS_HAS_DEPTH (iDepthRange != vec3(0))\n'+ // a define needs a line of its own
313
- '\n' + shaderCode + '\n'+ // insert custom shader code
313
+ // main first, the code's mainImage declared for it, so a define in the code can not reach main
314
+ 'void mainImage(out vec4,vec2);'+
314
315
  'void main(){'+ // shader entry point
315
316
  'mainImage(c,gl_FragCoord.xy);'+ // call post process function
316
317
  'c.a=1.;'+ // always use full alpha
317
- '}'; // end of shader
318
+ '}'+ // end of shader
319
+ '\n' + shaderCode + '\n'; // insert custom shader code
318
320
  }
319
321
 
320
322
  /**