littlejsengine 1.24.2 → 1.25.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 (42) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/EDITOR.md +2 -2
  3. package/FAQ.md +6 -0
  4. package/REFERENCE.md +86 -41
  5. package/dist/littlejs.d.ts +600 -323
  6. package/dist/littlejs.esm.js +2412 -1797
  7. package/dist/littlejs.esm.min.js +38 -24
  8. package/dist/littlejs.js +2410 -1797
  9. package/dist/littlejs.min.js +37 -23
  10. package/dist/littlejs.release.js +2393 -1826
  11. package/package.json +1 -1
  12. package/plugins/box2d.js +41 -28
  13. package/plugins/drawUtilities.js +374 -380
  14. package/plugins/gltf.js +331 -55
  15. package/plugins/lightSystem.js +2 -2
  16. package/plugins/math3d.js +1013 -1014
  17. package/plugins/newgrounds.js +1 -4
  18. package/plugins/particleEffects.js +3 -4
  19. package/plugins/pathFinder.js +52 -28
  20. package/plugins/pluginExport.js +2 -0
  21. package/plugins/postProcess.js +196 -65
  22. package/plugins/render3d.js +8 -6
  23. package/plugins/render3dEditor.js +3 -3
  24. package/plugins/render3dExtras.js +31 -10
  25. package/plugins/render3dLevel.js +15 -4
  26. package/plugins/textureSheet.js +15 -7
  27. package/plugins/tweenSystem.js +43 -38
  28. package/plugins/uiSystem.js +25 -20
  29. package/src/engine.js +39 -21
  30. package/src/engineAudio.js +13 -9
  31. package/src/engineBuild.mjs +49 -2
  32. package/src/engineDebug.js +3 -2
  33. package/src/engineDraw.js +20 -18
  34. package/src/engineEditor.js +75 -23
  35. package/src/engineInput.js +10 -10
  36. package/src/engineMath.js +3 -6
  37. package/src/engineObject.js +8 -9
  38. package/src/engineParticles.js +8 -7
  39. package/src/engineRelease.js +17 -10
  40. package/src/engineTileLayer.js +33 -14
  41. package/src/engineUtilities.js +15 -2
  42. package/src/engineWebGL.js +34 -12
@@ -20,6 +20,9 @@
20
20
  * @memberof Audio */
21
21
  let audioContext = typeof AudioContext == 'undefined' ? undefined : new AudioContext;
22
22
 
23
+ // resume the audio, a refusal caught so it is not an uncaught error in the console, the next gesture tries again
24
+ function audioResume() { audioContext?.resume()?.catch?.(()=> {}); }
25
+
23
26
  /** Master gain node for all audio to pass through, made at load so effects can connect to it any time
24
27
  * @type {GainNode}
25
28
  * @memberof Audio */
@@ -76,6 +79,7 @@ function audioSetSession()
76
79
  let audioSuspendedWhenHidden = false;
77
80
  function audioVisibilityChange()
78
81
  {
82
+ if (!audioContext) return; // a browser with no audio has none to pause
79
83
  if (document.hidden)
80
84
  {
81
85
  if (!soundPauseWhenHidden || audioContext.state != 'running') return;
@@ -85,7 +89,7 @@ function audioVisibilityChange()
85
89
  else if (audioSuspendedWhenHidden)
86
90
  {
87
91
  audioSuspendedWhenHidden = false;
88
- audioContext.resume();
92
+ audioResume();
89
93
  }
90
94
  }
91
95
 
@@ -224,7 +228,7 @@ function audioParamRamp(param, value, fadeTime=0)
224
228
  class Sound
225
229
  {
226
230
  /** Create a sound object and cache the audio for later use
227
- * @param {string|URL|Array} [asset] - Filename or URL of an audio file, or a zzfx array
231
+ * @param {string|URL|Array<number|undefined>} [asset] - Filename or URL of an audio file, or a zzfx array
228
232
  * @param {number} [randomness] - How much to randomize frequency each time sound plays, for zzfx sounds it overrides the array's own randomness, which is used if undefined
229
233
  * @param {number} [range=soundDefaultRange] - World space max range of sound
230
234
  * @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering
@@ -232,7 +236,7 @@ class Sound
232
236
  */
233
237
  constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
234
238
  {
235
- if (!soundEnable || headlessMode)
239
+ if (!soundEnable || headlessMode || !audioContext) // a browser with no audio makes none either
236
240
  {
237
241
  // no sound is made: it counts as loaded, so a game that waits for its sounds goes on
238
242
  this.loadedPercent = 1;
@@ -302,8 +306,8 @@ class Sound
302
306
  // load the audio file, a URL object as bundlers give works like its string;
303
307
  // report failures rather than leaving an unhandled rejection, the sound just stays unloaded and silent
304
308
  const filename = asset + '';
305
- engineAddLoad(this.loadSound(filename).catch(e=>
306
- LOG('Sound load failed for', filename, '-', e.message))); // startup waits for it
309
+ engineAddLoad(this.loadSound(filename).catch(e=> // startup waits for it, and a release build says why too
310
+ console.warn('Sound load failed for', filename, '-', e?.message ?? e)));
307
311
  }
308
312
  }
309
313
 
@@ -795,7 +799,7 @@ function getNoteFrequency(semitoneOffset, rootFrequency=220)
795
799
  * @param {number} [rate] - The playback rate to use
796
800
  * @param {number} [pan] - How much to apply stereo panning
797
801
  * @param {boolean} [loop] - True if the sound should loop when it reaches the end
798
- * @param {number} [sampleRate=44100] - Sample rate for the sound
802
+ * @param {number} [sampleRate=audioDefaultSampleRate] - Sample rate for the sound
799
803
  * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
800
804
  * @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
801
805
  * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
@@ -812,7 +816,7 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
812
816
  // fix stalled audio, don't build a buffer that can't be played;
813
817
  // but a context suspended because the page is hidden stays suspended until it shows
814
818
  if (!audioSuspendedWhenHidden)
815
- audioContext.resume();
819
+ audioResume();
816
820
  return;
817
821
  }
818
822
 
@@ -822,7 +826,7 @@ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sample
822
826
 
823
827
  /** Copy arrays of samples into a new audio buffer
824
828
  * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
825
- * @param {number} [sampleRate=44100] - Sample rate for the sound
829
+ * @param {number} [sampleRate=audioDefaultSampleRate] - Sample rate for the sound
826
830
  * @return {AudioBuffer} - The audio buffer holding the samples
827
831
  * @memberof Audio */
828
832
  function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
@@ -857,7 +861,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
857
861
  // fix stalled audio, this sound won't be able to play;
858
862
  // but a context suspended because the page is hidden stays suspended until it shows
859
863
  if (!audioSuspendedWhenHidden)
860
- audioContext.resume();
864
+ audioResume();
861
865
  return;
862
866
  }
863
867
 
@@ -289,17 +289,64 @@ function typeScriptBuildStep(filename)
289
289
  try
290
290
  {
291
291
  const tsFilename = join(BUILD_FOLDER, `${ENGINE_NAME}.d.ts`);
292
- execSync(`npx -p typescript tsc "${filename}" --declaration --allowJs --emitDeclarationOnly --outFile "${tsFilename}"`);
292
+ // strictNullChecks keeps undefined in the types, where tsc would drop it from accessors and object types
293
+ // whatever the JSDoc says, so a game in strict mode can set render3D.shader = undefined and is told a
294
+ // flare may be missing; a game not in strict mode reads the same types as before
295
+ execSync(`npx -p typescript tsc "${filename}" --declaration --allowJs --emitDeclarationOnly --strictNullChecks --outFile "${tsFilename}"`);
293
296
 
294
297
  // Make declare module part use the package name littlejsengine
295
298
  let fileContent = fs.readFileSync(tsFilename, 'utf8');
296
299
  fileContent = fileContent.replace(`${ENGINE_NAME}\.esm`, 'littlejsengine')
297
- fs.writeFileSync(tsFilename, fileContent);
300
+ fs.writeFileSync(tsFilename, typeScriptOptionalTidy(fileContent));
298
301
 
299
302
  }
300
303
  catch (e) { handleError(e, 'Failed to run TypeScript build step!'); }
301
304
  };
302
305
 
306
+ // strictNullChecks writes an optional parameter or field as x?: T | undefined, which is the same as x?: T, so the
307
+ // redundant | undefined is taken off: each ?: is followed to the end of its type, the first , ; ) or line end outside
308
+ // brackets, and only that type is changed; a required x: T | undefined keeps it
309
+ function typeScriptOptionalTidy(text)
310
+ {
311
+ let out = '', i = 0;
312
+ for (let at; (at = text.indexOf('?: ', i)) >= 0;)
313
+ {
314
+ const start = at + 3;
315
+ let end = start, depth = 0;
316
+ for (; end < text.length; ++end)
317
+ {
318
+ const c = text[end];
319
+ if (c === '=' && text[end + 1] === '>') { ++end; continue; } // an arrow is not a closing bracket
320
+ if ('([{<'.includes(c)) ++depth;
321
+ else if (')]}>'.includes(c) && depth) --depth;
322
+ else if (!depth && (',;)\n'.includes(c))) break;
323
+ }
324
+ let type = typeScriptOptionalTidy(text.slice(start, end)); // and the optional fields of a record type in it
325
+ if (type.endsWith(' | undefined'))
326
+ {
327
+ type = type.slice(0, -12);
328
+ // a function type was wrapped for the union, (() => void) | undefined, and needs it no more
329
+ if (type[0] === '(' && typeScriptCloseOf(type, 0) === type.length - 1)
330
+ type = type.slice(1, -1);
331
+ }
332
+ out += text.slice(i, start) + type;
333
+ i = end;
334
+ }
335
+ return out + text.slice(i);
336
+ }
337
+
338
+ // the index of the bracket that closes the one at start
339
+ function typeScriptCloseOf(text, start)
340
+ {
341
+ let depth = 0;
342
+ for (let k = start; k < text.length; ++k)
343
+ {
344
+ if (text[k] === '(') ++depth;
345
+ else if (text[k] === ')' && !--depth) return k;
346
+ }
347
+ return -1;
348
+ }
349
+
303
350
  // display the error and exit
304
351
  function handleError(e,message)
305
352
  {
@@ -94,7 +94,7 @@ function LOG(...output) { console.log(...output); }
94
94
 
95
95
  /** Draw a debug rectangle in world space, or on the screen with screenSpace
96
96
  * @param {Vector2} pos
97
- * @param {Vector2} [size=vec2(0)]
97
+ * @param {Vector2} [size=vec2()]
98
98
  * @param {Color|string} [color]
99
99
  * @param {number} [time]
100
100
  * @param {number} [angle]
@@ -766,7 +766,8 @@ function debugRenderPost() { debugVideoCaptureIsActive() && debugVideoCaptureUpd
766
766
  let debugVideoCapture, debugVideoCaptureIcon;
767
767
 
768
768
  /** Check if video capture is active
769
- * @memberof Debug */
769
+ * @memberof Debug
770
+ * @return {boolean} */
770
771
  function debugVideoCaptureIsActive() { return !!debugVideoCapture; }
771
772
 
772
773
  /** Start capturing video
package/src/engineDraw.js CHANGED
@@ -105,7 +105,7 @@ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; }
105
105
  * Create a tile info object using a grid based system
106
106
  * - This can take vecs or floats for easier use and conversion
107
107
  * - If an index is passed in, the tile size and index will determine the position
108
- * @param {Vector2|number} [index=0] - Index of the tile in 1d or 2d form
108
+ * @param {Vector2|number} [index] - Index of the tile in 1d or 2d form
109
109
  * @param {Vector2|number} [size] - Size of tile in pixels
110
110
  * @param {TextureInfo|number} [texture] - Texture index or info to use
111
111
  * @param {number} [padding] - How many pixels padding around tiles
@@ -134,10 +134,13 @@ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPaddi
134
134
  // create tile info object
135
135
  const textureInfo = typeof texture === 'number' ?
136
136
  textureInfos[texture] : texture;
137
- if (headlessMode && !textureInfo?.size.x)
138
- return new TileInfo(new Vector2, size.copy(), textureInfo, padding, bleed); // no image loaded, no place in it
139
- ASSERT(textureInfo instanceof TextureInfo, 'tile texture is not loaded');
140
- ASSERT(textureInfo.size.x > 0, 'tile texture is not loaded');
137
+ if (!textureInfo?.size.x)
138
+ {
139
+ // no image: headless loads none and keeps the size; an image that failed to load, whose warning named it, gives
140
+ // a tile of no size, which draws nothing; a slot never given an image is a mistake a debug build points out
141
+ ASSERT(headlessMode || textureInfo instanceof TextureInfo, 'tile texture is not loaded', texture);
142
+ return new TileInfo(new Vector2, headlessMode ? size.copy() : new Vector2, textureInfo, padding, bleed);
143
+ }
141
144
 
142
145
  // get the position of the tile
143
146
  const sizePaddedX = size.x + padding*2;
@@ -586,10 +589,10 @@ class Shader
586
589
  * @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined
587
590
  * @param {Color} [color=WHITE] - Color to modulate with
588
591
  * @param {number} [angle] - Angle to rotate by
589
- * @param {boolean} [mirror] - Is image flipped along the Y axis?
592
+ * @param {boolean} [mirror] - Is the image flipped left to right?
590
593
  * @param {Color} [additiveColor] - Additive color to be applied if any
591
594
  * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
592
- * @param {boolean} [screenSpace=drawScreenSpace] - Are the pos and size are in screen space?
595
+ * @param {boolean} [screenSpace=drawScreenSpace] - Are the pos and size in screen space?
593
596
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to
594
597
  * @memberof Draw */
595
598
  function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
@@ -605,8 +608,8 @@ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE,
605
608
  if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to
606
609
 
607
610
  const textureInfo = tileInfo?.textureInfo;
608
- if (textureInfo && !(tileInfo.size.x && tileInfo.size.y))
609
- return; // a tile with no area draws nothing, like a sprite still loading
611
+ if (textureInfo && !(tileInfo.size.x && tileInfo.size.y && textureInfo.size.x))
612
+ return; // a tile with no area draws nothing, like a sprite still loading, and nor does an image that failed
610
613
  const bleed = tileInfo?.bleed ?? 0;
611
614
  if (useWebGL && glEnable)
612
615
  {
@@ -700,7 +703,7 @@ function drawRect(pos, size, color, angle, useWebGL, screenSpace, context)
700
703
  * @param {boolean} [screenSpace=drawScreenSpace]
701
704
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
702
705
  * @memberof Draw */
703
- function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
706
+ function drawRectGradient(pos, size=vec2(1), colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
704
707
  {
705
708
  ASSERT(isVector2(pos), 'pos must be a vec2');
706
709
  ASSERT(isVector2(size), 'size must be a vec2');
@@ -759,9 +762,9 @@ function drawRectGradient(pos, size, colorTop=WHITE, colorBottom=CLEAR_WHITE, an
759
762
  * @param {Vector2} pos - Center of the rect in world space
760
763
  * @param {Vector2} size - Size of the rect in world space
761
764
  * @param {Vector2} wrapCount - How many times the texture repeats (x, y)
762
- * @param {TextureInfo|number} [texture=0] - TextureInfo or texture index into textureInfos
765
+ * @param {TextureInfo|number} [texture] - TextureInfo or texture index into textureInfos
763
766
  * @param {Color} [color=WHITE] - Color to modulate with
764
- * @param {number} [angle=0] - Angle to rotate by
767
+ * @param {number} [angle] - Angle to rotate by
765
768
  * @param {Color} [additiveColor] - Additive color to be applied if any
766
769
  * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering?
767
770
  * @param {boolean} [screenSpace=drawScreenSpace] - Are pos and size in screen space?
@@ -1103,9 +1106,9 @@ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineC
1103
1106
 
1104
1107
  /** Draw colored circle using passed in point
1105
1108
  * @param {Vector2} pos
1106
- * @param {number} [size=1] - Diameter
1109
+ * @param {number} [size] - Diameter
1107
1110
  * @param {Color} [color=WHITE]
1108
- * @param {number} [lineWidth=0]
1111
+ * @param {number} [lineWidth]
1109
1112
  * @param {Color} [lineColor=BLACK]
1110
1113
  * @param {boolean} [useWebGL=glEnable]
1111
1114
  * @param {boolean} [screenSpace=drawScreenSpace]
@@ -1200,7 +1203,7 @@ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLE
1200
1203
  * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead
1201
1204
  * - Stacking gradients at the exact same position may show a faint vertical artifact
1202
1205
  * @param {Vector2} pos
1203
- * @param {number} [size=1] - Diameter
1206
+ * @param {number} [size] - Diameter
1204
1207
  * @param {Color} [colorInner=WHITE]
1205
1208
  * @param {Color} [colorOuter=CLEAR_WHITE]
1206
1209
  * @param {boolean} [useWebGL=glEnable]
@@ -1265,7 +1268,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
1265
1268
  * @param {Color} [color=WHITE]
1266
1269
  * @param {number} [lineWidth]
1267
1270
  * @param {Color} [lineColor=BLACK]
1268
- * @param {'left'|'center'|'right'} [textAlign='center']
1271
+ * @param {'left'|'center'|'right'} [textAlign]
1269
1272
  * @param {string} [font=fontDefault]
1270
1273
  * @param {string} [fontStyle]
1271
1274
  * @param {number} [maxWidth]
@@ -1695,8 +1698,7 @@ function bakeTintedImage(image, color, additiveColor)
1695
1698
  * @param {number} dHeight
1696
1699
  * @param {Color} color
1697
1700
  * @param {Color} [additiveColor]
1698
- * @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding
1699
- * @memberof Draw */
1701
+ * @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding */
1700
1702
  function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0)
1701
1703
  {
1702
1704
  const sx2 = bleed;
@@ -14,7 +14,7 @@
14
14
 
15
15
  /**
16
16
  * @callback EditorPlayFromCallback - Puts the player at a world position, for the level editor's Play from mouse
17
- * @param {Vector2} pos - Where to start playing
17
+ * @param {any} pos - Where to start playing, a Vector2 from the 2D editor and a Vector3 from the 3D one
18
18
  * @memberof Editor
19
19
  */
20
20
 
@@ -32,25 +32,29 @@
32
32
  */
33
33
 
34
34
  /**
35
- * @typedef {Object} EditorToolAt - What the level editor gives the callbacks of a tool of the game's own
36
- * @property {any} pos - Where the mouse is in the level: a Vector2 in the 2D editor; in the 3D one a Vector3 on
35
+ * @template [P=Vector2]
36
+ * @typedef {Object} EditorToolAt - What the level editor gives the callbacks of a tool of the game's own; P is
37
+ * the type of pos, a Vector2 for a 2D tool, the default, and Vector3|undefined for a 3D one
38
+ * @property {P} pos - Where the mouse is in the level: a Vector2 in the 2D editor; in the 3D one a Vector3 on
37
39
  * the level or the ground, undefined when the mouse is over the panel
38
40
  * @property {Vector2|undefined} cell - 2D: the tile cell under the mouse on the selected tile layer
39
- * @property {any} ray - 3D: the mouse's Ray3D
41
+ * @property {Ray3D|undefined} ray - 3D: the mouse's Ray3D
40
42
  * @property {boolean} shift - Is Shift held
41
43
  * @property {boolean} ctrl - Is Ctrl held
42
44
  * @memberof Editor
43
45
  */
44
46
 
45
47
  /**
46
- * @typedef {Object} EditorTool - A tool of the game's own for the level editor, see LevelEditor.addTool
48
+ * @template [P=Vector2]
49
+ * @typedef {Object} EditorTool - A tool of the game's own for the level editor, see LevelEditor.addTool; P is the
50
+ * type of its pos, as EditorToolAt has it
47
51
  * @property {string} [key] - The key that picks it, as addKey spells one
48
52
  * @property {string} [hint] - The hint line while it is on
49
- * @property {function(EditorToolAt): any} [onPress] - The left button went down in the level; returning false
53
+ * @property {function(EditorToolAt<P>): any} [onPress] - The left button went down in the level; returning false
50
54
  * says the press was not the tool's
51
- * @property {function(EditorToolAt): any} [onDrag] - Each frame it is held
52
- * @property {function(EditorToolAt): any} [onRelease] - It was let go
53
- * @property {function(EditorToolAt): any} [onDraw] - Each frame the tool is on, to draw its cursor or preview
55
+ * @property {function(EditorToolAt<P>): any} [onDrag] - Each frame it is held
56
+ * @property {function(EditorToolAt<P>): any} [onRelease] - It was let go
57
+ * @property {function(EditorToolAt<P>): any} [onDraw] - Each frame the tool is on, to draw its cursor or preview
54
58
  * @memberof Editor
55
59
  */
56
60
 
@@ -143,7 +147,7 @@ class LevelEditor
143
147
  this.keys = {};
144
148
  /** @type {Array<{label: string, onClick: Function, title: string}>} */
145
149
  this.buttons = [];
146
- /** @type {Object<string, EditorTool>} */
150
+ /** @type {Object<string, EditorTool<any>>} */
147
151
  this.tools = {};
148
152
  }
149
153
 
@@ -161,7 +165,7 @@ class LevelEditor
161
165
 
162
166
  /** Put the player at a position, a Vector2 in the 2D editor and a Vector3 in the 3D one; set it or override it
163
167
  * and the editor has Play from mouse, which starts play there
164
- * @param {any} pos */
168
+ * @param {any} pos - A Vector2 from the 2D editor, a Vector3 from the 3D one */
165
169
  onPlayFrom(pos) {}
166
170
 
167
171
  /** Called when the editor opens, to set or override */
@@ -244,8 +248,9 @@ class LevelEditor
244
248
  * in 3D
245
249
  * - What the callbacks change through edit2D or edit3D is one undo: the editor ends the stroke at the release,
246
250
  * and takes it back when the right button or Escape ends the press
251
+ * @template [P=Vector2]
247
252
  * @param {string} name
248
- * @param {EditorTool} tool - {key, hint, onPress, onDrag, onRelease, onDraw}, each optional; onPress returning
253
+ * @param {EditorTool<P>} tool - {key, hint, onPress, onDrag, onRelease, onDraw}, each optional; onPress returning
249
254
  * false says the press was not the tool's */
250
255
  addTool(name, tool)
251
256
  {
@@ -260,7 +265,10 @@ class LevelEditor
260
265
  open()
261
266
  {
262
267
  if (editorOther?.wanted())
263
- return editorOther.open();
268
+ {
269
+ editorOther.open();
270
+ return;
271
+ }
264
272
  if (!editorSession)
265
273
  editorCameraScale = cameraScale; // a new session starts at the game's zoom, a return keeps the editor's
266
274
  editorSession = true;
@@ -272,7 +280,10 @@ class LevelEditor
272
280
  close()
273
281
  {
274
282
  if (editorOther?.active())
275
- return editorOther.close();
283
+ {
284
+ editorOther.close();
285
+ return;
286
+ }
276
287
  editorSession = false;
277
288
  editorSetOpen(false);
278
289
  }
@@ -1084,6 +1095,8 @@ let editorStroke;
1084
1095
 
1085
1096
  // during a big edit, the layers whose cell by cell redraws are held off, with the redraw each had of its own
1086
1097
  let editorHeldRedraws;
1098
+ // the live layers a change can only show by drawing them again whole, drawn when it is done
1099
+ const editorRedrawWhole = new Set;
1087
1100
 
1088
1101
  // make a big edit, a fill, clear, undo, apply or revert, with each layer's cell by cell redraws held off, a game's
1089
1102
  // tile callback's included; the stroke's end draws each layer it touched again whole
@@ -1110,6 +1123,11 @@ function editorSetCell(layer, pos, gid)
1110
1123
  source.data[index] = gid;
1111
1124
  if (live.destroyed) return before; // a layer the game let go of, the map still has the change
1112
1125
 
1126
+ // a plain cell draws itself here; held draws, a game's onTile, which may change the cells around, and its own
1127
+ // drawing over the layer show only when the whole layer draws again, once the change is done
1128
+ if (editorHeldRedraws || editorHas('onTile') || live.onRedraw !== TileLayer.prototype.onRedraw)
1129
+ editorRedrawWhole.add(live);
1130
+
1113
1131
  if (editorHeldRedraws && !editorHeldRedraws.has(live))
1114
1132
  {
1115
1133
  editorHeldRedraws.set(live, live.hasOwnProperty('redrawTileData') ? live.redrawTileData : undefined);
@@ -1187,8 +1205,7 @@ function editorUndo(redo=false)
1187
1205
  editorChanged(stroke);
1188
1206
  }
1189
1207
 
1190
- // after a change the tile layers it touched draw again whole, so a game's onRedraw decoration sees the new tiles,
1191
- // and the maps it touched are autosaved; a resized map is made again by the game, with the editor staying open
1208
+ // after a change the tile layers it touched that need it draw again whole, and the maps it touched are autosaved; a resized map is made again by the game, with the editor staying open
1192
1209
  function editorChanged(stroke)
1193
1210
  {
1194
1211
  editorRedraw(stroke);
@@ -1197,11 +1214,11 @@ function editorChanged(stroke)
1197
1214
  stroke.some((entry)=> entry.resize) && editorHas('onRestart') && levelEditor.onRestart();
1198
1215
  }
1199
1216
 
1200
- // the tile layers a stroke touched draw again whole
1217
+ // the tile layers a stroke touched that need it draw again whole
1201
1218
  function editorRedraw(stroke)
1202
1219
  {
1203
1220
  for (const layer of new Set(stroke.filter((entry)=> entry.layer).map((entry)=> entry.layer)))
1204
- layer.live.destroyed || layer.live.redraw();
1221
+ editorRedrawWhole.delete(layer.live) && !layer.live.destroyed && layer.live.redraw();
1205
1222
  }
1206
1223
 
1207
1224
  ///////////////////////////////////////////////////////////////////////////////
@@ -1708,6 +1725,40 @@ function editorGidMirror(gid)
1708
1725
  return t ? editorTileToGid(t.tile, (4 - t.direction) % 4, !t.mirror) : gid;
1709
1726
  }
1710
1727
 
1728
+ // a stamp's object turned a quarter clockwise with it: its place by the stamp, its body by its own rotation, which
1729
+ // Tiled turns clockwise about the object's place, so the place needs no other move
1730
+ function editorStampObjectTurn(entry, width)
1731
+ {
1732
+ const offset = vec2(entry.offset.y, width - entry.offset.x), object = entry.object;
1733
+ return {...entry, offset, ...(object && {object: {...object, rotation: ((object.rotation || 0) + 90) % 360}})};
1734
+ }
1735
+
1736
+ // a stamp's object mirrored left to right with it: its rotation the other way, a polygon's points mirrored about its
1737
+ // place, a tile object's image flipped, and a box, ellipse, text or tile, placed by a corner, placed by the corner
1738
+ // that becomes it, its width along its turn from the mirrored place; mirrored twice it is as it was
1739
+ function editorStampObjectMirror(entry, width)
1740
+ {
1741
+ const offset = vec2(width - entry.offset.x, entry.offset.y), object = entry.object;
1742
+ if (!object) return {...entry, offset};
1743
+ const mirrored = {...object, rotation: object.rotation ? -object.rotation : 0};
1744
+ const points = (list)=> list.map((p)=> ({...p, x: -p.x}));
1745
+ if (object.polygon || object.polyline)
1746
+ {
1747
+ object.polygon && (mirrored.polygon = points(object.polygon));
1748
+ object.polyline && (mirrored.polyline = points(object.polyline));
1749
+ }
1750
+ else
1751
+ {
1752
+ // Tiled's pixels run down, the stamp's cells up
1753
+ const angle = (object.rotation || 0) * PI / 180, w = object.width || 0, {x: tw, y: th} = entry.tileSize ?? vec2(1);
1754
+ offset.x -= w * cos(angle) / tw;
1755
+ offset.y -= w * sin(angle) / th;
1756
+ }
1757
+ if (object.gid)
1758
+ mirrored.gid = (object.gid ^ 0x80000000) >>> 0; // Tiled's flip across
1759
+ return {...entry, offset, object: mirrored};
1760
+ }
1761
+
1711
1762
  // a stamp turned a quarter turn clockwise on screen, or back, its cells, their tiles and its objects together; cell
1712
1763
  // (x, y) goes to (y, width - 1 - x), so the bottom row becomes the left column, its left end at the top
1713
1764
  function editorStampTurn(stamp, back=false)
@@ -1715,7 +1766,7 @@ function editorStampTurn(stamp, back=false)
1715
1766
  for (let turns = back ? 3 : 1; turns--;)
1716
1767
  {
1717
1768
  const {width, height} = stamp;
1718
- const objects = stamp.objects?.map((object)=> ({...object, offset: vec2(object.offset.y, width - object.offset.x)}));
1769
+ const objects = stamp.objects?.map((entry)=> editorStampObjectTurn(entry, width));
1719
1770
  stamp = {width: height, height: width, grids: stamp.grids.map((grid)=>
1720
1771
  {
1721
1772
  const turned = [];
@@ -1732,7 +1783,7 @@ function editorStampTurn(stamp, back=false)
1732
1783
  function editorStampMirror(stamp)
1733
1784
  {
1734
1785
  const {width, height} = stamp;
1735
- const objects = stamp.objects?.map((object)=> ({...object, offset: vec2(width - object.offset.x, object.offset.y)}));
1786
+ const objects = stamp.objects?.map((entry)=> editorStampObjectMirror(entry, width));
1736
1787
  return {width, height, grids: stamp.grids.map((grid)=>
1737
1788
  {
1738
1789
  const mirrored = [];
@@ -1864,8 +1915,9 @@ function editorClear()
1864
1915
  editorStrokeEnd();
1865
1916
  }
1866
1917
 
1867
- // the objects of a map's object layers inside a tile area, each {group, type, offset, object}: the index of its
1868
- // object layer, its offset from the area's bottom left corner, and a copy of it, as a stamp keeps them
1918
+ // the objects of a map's object layers inside a tile area, each {group, type, offset, object, tileSize}: the index of
1919
+ // its object layer, its offset from the area's bottom left corner, a copy of it, and the map's tile size in pixels,
1920
+ // which a mirror reads its width in cells by, as a stamp keeps them
1869
1921
  function editorAreaObjects(layer, area)
1870
1922
  {
1871
1923
  const corner = layer.live.pos.add(area.min), far = layer.live.pos.add(area.max).add(vec2(1));
@@ -1874,7 +1926,7 @@ function editorAreaObjects(layer, area)
1874
1926
  const ids = new Set(editorObjectsIn(objectLayer, corner, far));
1875
1927
  return (objectLayer.group?.objects ?? []).filter((object)=> ids.has(object.id)).map((object)=>
1876
1928
  ({group, type: object.type || object.class, offset: editorObjectPos(layer.record, object).subtract(corner),
1877
- object: editorObjectsCopy(object)}));
1929
+ object: editorObjectsCopy(object), tileSize: vec2(layer.record.map.tilewidth || 1, layer.record.map.tileheight || 1)}));
1878
1930
  });
1879
1931
  }
1880
1932
 
@@ -248,7 +248,6 @@ function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='A
248
248
  }
249
249
 
250
250
  /** Returns true if mouse button is down
251
- * @function
252
251
  * @param {number} button
253
252
  * @return {boolean}
254
253
  * @memberof Input */
@@ -259,7 +258,6 @@ function mouseIsDown(button)
259
258
  }
260
259
 
261
260
  /** Returns true if mouse button was pressed
262
- * @function
263
261
  * @param {number} button
264
262
  * @return {boolean}
265
263
  * @memberof Input */
@@ -270,7 +268,6 @@ function mouseWasPressed(button)
270
268
  }
271
269
 
272
270
  /** Returns true if mouse button was released
273
- * @function
274
271
  * @param {number} button
275
272
  * @return {boolean}
276
273
  * @memberof Input */
@@ -498,7 +495,7 @@ function inputInit()
498
495
  {
499
496
  // fix stalled audio requiring user interaction, a keyboard only game has no other gesture
500
497
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
501
- audioContext.resume();
498
+ audioResume();
502
499
 
503
500
  // keys typed into an html text field are the player's typing, not game input;
504
501
  // a key already down still releases on keyup, which only lets go of keys that are down
@@ -584,10 +581,11 @@ function inputInit()
584
581
 
585
582
  // fix stalled audio requiring user interaction
586
583
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
587
- audioContext.resume();
584
+ audioResume();
588
585
 
589
- // a press in the bars around a letterboxed canvas is not one on its edge; a drag out of it still moves
590
- if (!inCanvas(e.x, e.y))
586
+ // a press in the bars around a letterboxed canvas is not one on its edge; a drag out of it still moves; under
587
+ // pointer lock the mouse stays where the lock began, which may be in a bar, and every click is the game's
588
+ if (!pointerLockIsActive() && !inCanvas(e.x, e.y))
591
589
  return;
592
590
  inputData[0][e.button] = 3;
593
591
 
@@ -683,7 +681,7 @@ function inputInit()
683
681
 
684
682
  // fix stalled audio requiring user interaction
685
683
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
686
- audioContext.resume();
684
+ audioResume();
687
685
 
688
686
  // when the touch gamepad is enabled it owns touch input: suppress the
689
687
  // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
@@ -1004,9 +1002,11 @@ function inputUpdate()
1004
1002
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
1005
1003
  }
1006
1004
 
1007
- // copy dpad to left analog stick when pressed
1005
+ // copy dpad to left analog stick when pressed; a gamepad with no sticks has that one, at rest when not
1008
1006
  if (gamepadDirectionEmulateStick && (dpad.x || dpad.y))
1009
1007
  sticks[0] = dpad.clampLength();
1008
+ else if (gamepadDirectionEmulateStick && !sticks.length)
1009
+ sticks[0] = vec2();
1010
1010
  }
1011
1011
 
1012
1012
  // disable touch gamepad if using real gamepad
@@ -1446,7 +1446,7 @@ function touchGamepadPointerDown(e, zone)
1446
1446
 
1447
1447
  // resume audio on first interaction
1448
1448
  if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
1449
- audioContext.resume();
1449
+ audioResume();
1450
1450
 
1451
1451
  // while paused, any touch is the start button; a control belongs to the first finger on it until that
1452
1452
  // finger lifts, so a second finger landing on the same one neither takes it over nor lets it go
package/src/engineMath.js CHANGED
@@ -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;
@@ -687,11 +689,6 @@ function isVector2(v) { return v instanceof Vector2 && v.isValid(); }
687
689
  // vector2 asserts
688
690
  function ASSERT_VECTOR2_VALID(v) { ASSERT(isVector2(v), 'Vector2 is invalid.', v); }
689
691
  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
- }
695
692
 
696
693
  /**
697
694
  * 2D Vector object with vector math library
@@ -483,19 +483,23 @@ class EngineObject
483
483
  }
484
484
 
485
485
  /** Convert from local space to world space
486
- * @param {Vector2} pos - local space point */
486
+ * @param {Vector2} pos - local space point
487
+ * @return {Vector2} */
487
488
  localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); }
488
489
 
489
490
  /** Convert from world space to local space
490
- * @param {Vector2} pos - world space point */
491
+ * @param {Vector2} pos - world space point
492
+ * @return {Vector2} */
491
493
  worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); }
492
494
 
493
495
  /** Convert from local space to world space for a vector (rotation only)
494
- * @param {Vector2} vec - local space vector */
496
+ * @param {Vector2} vec - local space vector
497
+ * @return {Vector2} */
495
498
  localToWorldVector(vec) { return vec.rotate(this.angle); }
496
499
 
497
500
  /** Convert from world space to local space for a vector (rotation only)
498
- * @param {Vector2} vec - world space vector */
501
+ * @param {Vector2} vec - world space vector
502
+ * @return {Vector2} */
499
503
  worldToLocalVector(vec) { return vec.rotate(-this.angle); }
500
504
 
501
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.
@@ -634,11 +638,6 @@ class EngineObject
634
638
  this.collideRaycast = collideRaycast;
635
639
  }
636
640
 
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
641
  /** Returns string containing info about this object for debugging
643
642
  * @return {string} */
644
643
  toString()