littlejsengine 1.18.29 → 1.20.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/FAQ.md +841 -815
  2. package/README.md +211 -199
  3. package/dist/littlejs.d.ts +4383 -812
  4. package/dist/littlejs.esm.js +31848 -16680
  5. package/dist/littlejs.esm.min.js +14 -1
  6. package/dist/littlejs.js +31230 -16166
  7. package/dist/littlejs.min.js +13 -1
  8. package/dist/littlejs.release.js +27419 -15457
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +375 -0
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +740 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -330
  16. package/plugins/math3d.js +911 -0
  17. package/plugins/medalSystem.js +118 -72
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +196 -115
  21. package/plugins/postProcess.js +284 -166
  22. package/plugins/render3d.js +4072 -0
  23. package/plugins/render3dDebug.js +109 -0
  24. package/plugins/render3dExtras.js +1754 -0
  25. package/plugins/render3dVoxels.js +502 -0
  26. package/plugins/textureSheet.js +86 -48
  27. package/plugins/threejs.js +183 -158
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +646 -526
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +8 -15
  32. package/src/engine.js +345 -194
  33. package/src/engineAudio.js +1022 -777
  34. package/src/engineBuild.mjs +289 -253
  35. package/src/engineDebug.js +912 -735
  36. package/src/engineDraw.js +1774 -1510
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +419 -396
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineLogo.js +4 -3
  41. package/src/engineMath.js +1274 -1183
  42. package/src/engineObject.js +674 -551
  43. package/src/engineParticles.js +583 -532
  44. package/src/engineRelease.js +67 -45
  45. package/src/engineSettings.js +808 -726
  46. package/src/engineTileLayer.js +995 -741
  47. package/src/engineUtilities.js +32 -13
  48. package/src/engineWebGL.js +1162 -941
  49. package/src/jsconfig.json +17 -13
  50. package/plugins/box2d.wasm.js +0 -630
  51. package/plugins/box2d.wasm.wasm +0 -0
@@ -48,10 +48,10 @@ class TextureSheet
48
48
 
49
49
  /** @property {number} - Width and height of the sheet in pixels */
50
50
  this.size = size;
51
- /** @property {OffscreenCanvas} - Canvas holding the packed images */
52
- this.canvas = headlessMode ? undefined : new OffscreenCanvas(size, size);
53
51
  /** @property {OffscreenCanvasRenderingContext2D} - 2d context for the canvas */
54
- this.context = this.canvas?.getContext('2d');
52
+ this.context = headlessMode ? undefined : createCanvasContext(size);
53
+ /** @property {OffscreenCanvas} - Canvas holding the packed images */
54
+ this.canvas = this.context?.canvas;
55
55
  /** @property {TextureInfo} - The texture info for this sheet */
56
56
  this.textureInfo = new TextureInfo(this.canvas);
57
57
  /** @property {Vector2} - Where the next image will be packed */
@@ -71,23 +71,25 @@ class TextureSheet
71
71
 
72
72
  /** Find a spot for an image on this sheet without drawing it
73
73
  * @param {Vector2} imageSize - Size of the source image in pixels
74
- * @param {Vector2} [frameSize] - Size of each frame, or the whole image if not passed
74
+ * @param {Vector2} [frameSize] - Size of each frame, or the whole image less its source padding if not passed
75
75
  * @param {number} [padding] - How many pixels padding around each frame
76
76
  * @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image
77
- * @return {TileInfo} Tile for the packed image, or undefined if the sheet is full */
78
- tryAdd(imageSize, frameSize=imageSize, padding=textureSheetPadding, sourcePadding=0)
77
+ * @return {TileInfo|undefined} Tile for the packed image, or undefined if the sheet is full */
78
+ tryAdd(imageSize, frameSize, padding=textureSheetPadding, sourcePadding=0)
79
79
  {
80
- ASSERT(isVector2(imageSize) && isVector2(frameSize), 'sizes must be vec2');
81
- ASSERT(frameSize.x > 0 && frameSize.y > 0, 'frame size must be positive');
82
-
83
- if (isNumber(sourcePadding))
84
- sourcePadding = vec2(sourcePadding);
85
- ASSERT(isVector2(sourcePadding) && sourcePadding.x >= 0 && sourcePadding.y >= 0,
80
+ // a number pads both axes the same
81
+ const sourcePad = isNumber(sourcePadding) ?
82
+ vec2(/** @type {number} */ (sourcePadding)) : /** @type {Vector2} */ (sourcePadding);
83
+ ASSERT(isVector2(sourcePad) && sourcePad.x >= 0 && sourcePad.y >= 0,
86
84
  'sourcePadding must be a number or vec2 >= 0');
85
+ ASSERT(isVector2(imageSize), 'sizes must be vec2');
86
+ frameSize ||= imageSize.subtract(sourcePad.scale(2)); // the whole image, less the padding baked into it
87
+ ASSERT(isVector2(frameSize), 'sizes must be vec2');
88
+ ASSERT(frameSize.x > 0 && frameSize.y > 0, 'frame size must be positive');
87
89
 
88
90
  // the source may have its own padding baked in around each frame
89
- const sourceCellWidth = frameSize.x + sourcePadding.x*2;
90
- const sourceCellHeight = frameSize.y + sourcePadding.y*2;
91
+ const sourceCellWidth = frameSize.x + sourcePad.x*2;
92
+ const sourceCellHeight = frameSize.y + sourcePad.y*2;
91
93
  ASSERT(imageSize.x % sourceCellWidth === 0 && imageSize.y % sourceCellHeight === 0,
92
94
  'image size must be a multiple of the padded frame size');
93
95
 
@@ -98,8 +100,11 @@ class TextureSheet
98
100
 
99
101
  // keep the layout of the source image, but narrow it if a row is too wide
100
102
  // frames wrap down to the next row, which TileInfo.frame handles via columns
101
- const sourceColumns = imageSize.x / sourceCellWidth;
102
- const frameCount = sourceColumns * (imageSize.y / sourceCellHeight);
103
+ // whole frames only, a partial one left over at an edge is ignored
104
+ const sourceColumns = imageSize.x / sourceCellWidth | 0;
105
+ const frameCount = sourceColumns * (imageSize.y / sourceCellHeight | 0);
106
+ if (!frameCount)
107
+ return undefined; // smaller than a frame, there is nothing to pack
103
108
  const columns = min(sourceColumns, maxColumns);
104
109
  const blockWidth = columns * cellWidth;
105
110
  const blockHeight = ceil(frameCount / columns) * cellHeight;
@@ -126,7 +131,7 @@ class TextureSheet
126
131
  }
127
132
 
128
133
  /** Draw an image into this sheet at a tile returned by tryAdd
129
- * @param {HTMLImageElement} image - Source image to copy from
134
+ * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - Source image to copy from
130
135
  * @param {TileInfo} tileInfo - Where to put it, from tryAdd
131
136
  * @param {boolean} [update] - Upload to webgl now, pass false when batching
132
137
  * @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image */
@@ -134,23 +139,25 @@ class TextureSheet
134
139
  {
135
140
  ASSERT(!!this.context, 'texture sheet has no canvas');
136
141
 
137
- if (isNumber(sourcePadding))
138
- sourcePadding = vec2(sourcePadding);
142
+ // a number pads both axes the same
143
+ const sourcePad = isNumber(sourcePadding) ?
144
+ vec2(/** @type {number} */ (sourcePadding)) : /** @type {Vector2} */ (sourcePadding);
139
145
 
140
146
  // copy frames in order, reading the source left to right, top to bottom
141
147
  // the destination wraps at tileInfo.columns which may be narrower than the source
142
148
  const frameSize = tileInfo.size;
143
- const sourceCellWidth = frameSize.x + sourcePadding.x*2;
144
- const sourceCellHeight = frameSize.y + sourcePadding.y*2;
145
- const sourceColumns = image.width / sourceCellWidth;
146
- const frameCount = sourceColumns * (image.height / sourceCellHeight);
149
+ const sourceCellWidth = frameSize.x + sourcePad.x*2;
150
+ const sourceCellHeight = frameSize.y + sourcePad.y*2;
151
+ // whole frames only as tryAdd packed them, a fractional count would never end the loop
152
+ const sourceColumns = image.width / sourceCellWidth | 0;
153
+ const frameCount = sourceColumns * (image.height / sourceCellHeight | 0);
147
154
  const columns = tileInfo.columns || frameCount;
148
155
  const cellWidth = frameSize.x + tileInfo.padding*2;
149
156
  const cellHeight = frameSize.y + tileInfo.padding*2;
150
157
  for (let i = frameCount; i--;)
151
158
  {
152
- const sourceX = (i % sourceColumns) * sourceCellWidth + sourcePadding.x;
153
- const sourceY = (i / sourceColumns | 0) * sourceCellHeight + sourcePadding.y;
159
+ const sourceX = (i % sourceColumns) * sourceCellWidth + sourcePad.x;
160
+ const sourceY = (i / sourceColumns | 0) * sourceCellHeight + sourcePad.y;
154
161
  this.context.drawImage(image,
155
162
  sourceX, sourceY, frameSize.x, frameSize.y,
156
163
  tileInfo.pos.x + (i % columns) * cellWidth,
@@ -182,7 +189,8 @@ class TextureSheet
182
189
  * - Grid images keep their layout and frames wrap down to the next row
183
190
  * - Pass sourcePadding if the source image has padding baked in around frames
184
191
  * @param {string} src - Image source path
185
- * @param {Vector2|number} [frameSize] - Size of each animation frame in pixels
192
+ * @param {Vector2|number} [frameSize] - Size of each animation frame in pixels, or the whole image less its
193
+ * source padding if not passed
186
194
  * @param {number} [padding] - How many pixels padding around each frame
187
195
  * @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image
188
196
  * @return {TileInfo}
@@ -196,9 +204,10 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
196
204
  ASSERT(!frameSize || isVector2(frameSize) || isNumber(frameSize), 'frameSize must be a vec2 or number');
197
205
  ASSERT(isNumber(padding), 'padding must be a number');
198
206
  ASSERT(isNumber(sourcePadding) || isVector2(sourcePadding), 'sourcePadding must be a number or vec2');
207
+ ASSERT(engineInitialized || headlessMode, 'call loadSprite after engineInit, e.g. in gameInit');
199
208
 
200
209
  if (isNumber(frameSize))
201
- frameSize = vec2(frameSize);
210
+ frameSize = vec2(/** @type {number} */ (frameSize));
202
211
 
203
212
  // start with an empty tile that gets filled in when the image loads
204
213
  const tileInfo = new TileInfo(vec2(), vec2(), undefined, padding, 0);
@@ -217,8 +226,7 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
217
226
  });
218
227
 
219
228
  // pack through a queue so sheets fill in call order, not decode order
220
- ++textureSheetPendingCount;
221
- textureSheetQueue = textureSheetQueue.then(async ()=>
229
+ textureSheetQueueJob('loadSprite ' + src, async ()=>
222
230
  {
223
231
  await imagePromise;
224
232
  if (image.width)
@@ -226,19 +234,21 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
226
234
  // pack onto a sheet, then fill in the tile that was already handed out,
227
235
  // copying every field so nothing is missed if TileInfo gains more of them
228
236
  const imageSize = vec2(image.width, image.height);
229
- const {sheet, tile} = textureSheetAdd(imageSize, frameSize, padding, sourcePadding);
230
- Object.assign(tileInfo, tile);
231
- sheet.drawImage(image, tileInfo, false, sourcePadding); // upload once per batch below
237
+ const added = textureSheetAdd(imageSize, frameSize, padding, sourcePadding);
238
+ if (!added)
239
+ {
240
+ // leave the tile empty, no sheet can hold it
241
+ LOG('loadSprite image is too large to fit on a texture sheet:', src);
242
+ return;
243
+ }
244
+ Object.assign(tileInfo, added.tile);
245
+ added.sheet.drawImage(image, tileInfo, false, sourcePadding); // upload once per batch below
232
246
  }
233
247
  else
234
248
  {
235
249
  // leave the tile empty if the image failed to load
236
250
  LOG('loadSprite failed to load image:', src);
237
251
  }
238
-
239
- // upload to webgl once per batch, when the last pending load finishes
240
- if (!--textureSheetPendingCount)
241
- textureSheets.forEach(s=> s.updateTexture());
242
252
  });
243
253
 
244
254
  return tileInfo;
@@ -254,7 +264,7 @@ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0
254
264
  * @param {string} imageSrc - Atlas image path
255
265
  * @param {string|Object} jsonSrc - Atlas json path, or already parsed json data
256
266
  * @param {number} [padding] - How many pixels padding around each frame
257
- * @return {Object} Object mapping frame and animation names to TileInfos
267
+ * @return {Object<string, TileInfo>} Object mapping frame and animation names to TileInfos
258
268
  * @example
259
269
  * const atlas = loadAtlas('sprites.png', 'sprites.json');
260
270
  * await spritesReady();
@@ -266,7 +276,9 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
266
276
  ASSERT(isStringLike(imageSrc), 'atlas image src must be a string');
267
277
  ASSERT(isStringLike(jsonSrc) || typeof jsonSrc === 'object', 'atlas json must be a path or object');
268
278
  ASSERT(isNumber(padding), 'padding must be a number');
279
+ ASSERT(engineInitialized || headlessMode, 'call loadAtlas after engineInit, e.g. in gameInit');
269
280
 
281
+ /** @type {Object<string, TileInfo>} */
270
282
  const atlas = {};
271
283
  if (headlessMode) return atlas;
272
284
 
@@ -282,8 +294,7 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
282
294
  });
283
295
 
284
296
  // pack through a queue so sheets fill in call order, not decode order
285
- ++textureSheetPendingCount;
286
- textureSheetQueue = textureSheetQueue.then(async ()=>
297
+ textureSheetQueueJob('loadAtlas ' + imageSrc, async ()=>
287
298
  {
288
299
  const data = await jsonPromise;
289
300
  await imagePromise;
@@ -294,7 +305,13 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
294
305
  // reserve a block of full size cells, one per frame
295
306
  const sourceSize = group.frames[0].sourceSize;
296
307
  const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
297
- const {sheet, tile} = textureSheetAdd(blockSize, sourceSize, padding);
308
+ const added = textureSheetAdd(blockSize, sourceSize, padding);
309
+ if (!added)
310
+ {
311
+ LOG('loadAtlas frames are too large to fit on a texture sheet:', group.name);
312
+ continue;
313
+ }
314
+ const {sheet, tile} = added;
298
315
 
299
316
  // draw each frame untrimmed into its cell
300
317
  const context = sheet.context;
@@ -327,15 +344,32 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
327
344
  // leave the atlas empty if either file failed to load
328
345
  LOG('loadAtlas failed to load:', imageSrc, jsonSrc);
329
346
  }
330
-
331
- // upload to webgl once per batch, when the last pending load finishes
332
- if (!--textureSheetPendingCount)
333
- textureSheets.forEach(s=> s.updateTexture());
334
347
  });
335
348
 
336
349
  return atlas;
337
350
  }
338
351
 
352
+ // run a load in the queue: a load that throws is reported and the loads after it carry on, the pending count
353
+ // always comes back down, and the sheets upload to webgl once per batch, when the last pending load finishes
354
+ function textureSheetQueueJob(name, job)
355
+ {
356
+ ++textureSheetPendingCount;
357
+ textureSheetQueue = textureSheetQueue.then(async ()=>
358
+ {
359
+ try { await job(); }
360
+ catch (e) { console.error(name + ' failed:', e); }
361
+ finally
362
+ {
363
+ if (!--textureSheetPendingCount)
364
+ for (const sheet of textureSheets)
365
+ {
366
+ try { sheet.updateTexture(); }
367
+ catch (e) { console.error('texture sheet upload failed:', e); }
368
+ }
369
+ }
370
+ });
371
+ }
372
+
339
373
  /** Parse atlas json into a list of named frame groups, used by loadAtlas
340
374
  * - Accepts TexturePacker json (hash and array) and Aseprite json
341
375
  * - Frames tagged in Aseprite or named like run_0, run_1 group into animations
@@ -375,11 +409,11 @@ function parseAtlas(data)
375
409
  }
376
410
 
377
411
  // group frames that share a name stem with contiguous trailing numbers
378
- // run_0.png and run_1.png become a 2 frame animation named run
412
+ // run_0.png and run_1.png become a 2 frame animation named run, and so do the folder style run/0.png and run/1.png
379
413
  const stems = new Map;
380
414
  for (const f of frames)
381
415
  {
382
- let match = f.name.match(/^(.+?)([-_ ])?(\d+)$/);
416
+ let match = f.name.match(/^(.+?)([-_ /])?(\d+)$/);
383
417
  if (match && !match[2] && /\d$/.test(match[1]))
384
418
  match = undefined; // all digit tails like 10 are a name, not frame 0 of 1
385
419
  const stem = match ? match[1] : f.name;
@@ -428,7 +462,8 @@ function textureSheetCreate()
428
462
  return sheet;
429
463
  }
430
464
 
431
- // use the first sheet with enough space, or make a new one
465
+ // use the first sheet with enough space, or make a new one,
466
+ // returns undefined if it would not fit even on an empty sheet
432
467
  function textureSheetAdd(imageSize, frameSize, padding, sourcePadding)
433
468
  {
434
469
  let sheet, tile;
@@ -437,9 +472,12 @@ function textureSheetAdd(imageSize, frameSize, padding, sourcePadding)
437
472
  break;
438
473
  if (!tile)
439
474
  {
475
+ // probe an empty sheet first, a new one is a canvas and a texture kept for good
476
+ const emptySheet = {size: textureSheetSize, cursor: vec2(), rowHeight: 0};
477
+ if (!TextureSheet.prototype.tryAdd.call(emptySheet, imageSize, frameSize, padding, sourcePadding))
478
+ return;
440
479
  sheet = textureSheetCreate();
441
480
  tile = sheet.tryAdd(imageSize, frameSize, padding, sourcePadding);
442
- ASSERT(!!tile, 'image is too large to fit on a texture sheet');
443
481
  }
444
482
  return {sheet, tile};
445
483
  }
@@ -1,158 +1,183 @@
1
- /**
2
- * LittleJS Three.js Plugin
3
- * - Renders a three.js scene on a canvas behind the LittleJS canvases
4
- * - The three.js module is passed in by the user, nothing is bundled
5
- * - Keep canvasClearColor transparent so the 3D scene shows through
6
- * - Aligned camera mode locks the 3D camera to the LittleJS 2D camera
7
- * - ThreeJSObject lets LittleJS physics drive a three.js mesh
8
- * - Call new ThreeJSPlugin(THREE) in gameInit to set up
9
- * @namespace ThreeJS
10
- */
11
-
12
- 'use strict';
13
-
14
- ///////////////////////////////////////////////////////////////////////////////
15
-
16
- /** Global ThreeJS plugin object
17
- * @type {ThreeJSPlugin}
18
- * @memberof ThreeJS */
19
- let threeJS;
20
-
21
- ///////////////////////////////////////////////////////////////////////////////
22
- /**
23
- * ThreeJS Plugin - Renders a three.js scene behind the LittleJS canvas
24
- * @example
25
- * // in gameInit, with three.js loaded by the user
26
- * new ThreeJSPlugin(THREE);
27
- * threeJS.scene.add(new THREE.AmbientLight);
28
- * @memberof ThreeJS
29
- */
30
- class ThreeJSPlugin
31
- {
32
- /** Set up the three.js rendering layer, call in gameInit
33
- * @param {Object} THREE - The three.js module, supplied by the user
34
- * @param {number} [cameraFOV] - Vertical field of view in degrees */
35
- constructor(THREE, cameraFOV=60)
36
- {
37
- ASSERT(!threeJS, 'ThreeJS plugin already initialized');
38
- threeJS = this;
39
- if (headlessMode) return;
40
- ASSERT(mainCanvas, 'ThreeJS plugin must be created after engineInit, call in gameInit');
41
- ASSERT(THREE && THREE.WebGLRenderer, 'three.js module must be passed in');
42
-
43
- /** @property {Object} - The three.js module passed into the constructor */
44
- this.THREE = THREE;
45
- /** @property {Object} - The three.js renderer */
46
- this.renderer = new THREE.WebGLRenderer({antialias: true});
47
- /** @property {Object} - The three.js scene, add lights and meshes here */
48
- this.scene = new THREE.Scene();
49
- /** @property {Object} - The three.js perspective camera */
50
- this.camera = new THREE.PerspectiveCamera(cameraFOV, 1, .1, 1e3);
51
- /** @property {boolean} - Lock the camera to the LittleJS 2D camera so the z=0 plane matches world space */
52
- this.cameraAlign2D = true;
53
-
54
- // insert the canvas below the engine canvases and match the layout
55
- const threeCanvas = this.renderer.domElement;
56
- const rootElement = mainCanvas.parentElement;
57
- rootElement.insertBefore(threeCanvas, rootElement.firstChild);
58
- threeCanvas.style.cssText = mainCanvas.style.cssText;
59
-
60
- // composite the 3D canvas into screenshots and video capture
61
- setBackgroundCanvas(threeCanvas);
62
-
63
- // render automatically each frame after the engine renders
64
- engineAddPlugin(undefined, ()=> this.render());
65
- }
66
-
67
- /** Position the camera so the z=0 plane exactly matches LittleJS world space,
68
- * called automatically when cameraAlign2D is set */
69
- alignCamera2D()
70
- {
71
- const halfHeight = mainCanvasSize.y / 2 / cameraScale; // half visible height in world units
72
- const distance = halfHeight / tan(this.camera.fov/2 * PI/180);
73
- this.camera.position.set(cameraPos.x, cameraPos.y, distance);
74
- // reset all axes in case a free camera was used, littlejs angles are clockwise
75
- this.camera.rotation.set(0, 0, -cameraAngle);
76
- }
77
-
78
- /** Sync the canvas layout and render the scene, called automatically each frame */
79
- render()
80
- {
81
- if (!this.renderer) return; // headless mode
82
-
83
- // keep renderer size and css in sync with the LittleJS canvas
84
- const threeCanvas = this.renderer.domElement;
85
- if (threeCanvas.width != mainCanvasSize.x || threeCanvas.height != mainCanvasSize.y)
86
- {
87
- this.renderer.setSize(mainCanvasSize.x, mainCanvasSize.y, false);
88
- this.camera.aspect = mainCanvasSize.x / mainCanvasSize.y;
89
- this.camera.updateProjectionMatrix();
90
- }
91
- if (threeCanvas.style.cssText != mainCanvas.style.cssText)
92
- threeCanvas.style.cssText = mainCanvas.style.cssText;
93
-
94
- if (this.cameraAlign2D)
95
- this.alignCamera2D();
96
- this.renderer.render(this.scene, this.camera);
97
- }
98
- }
99
-
100
- ///////////////////////////////////////////////////////////////////////////////
101
- /**
102
- * ThreeJS Object - EngineObject that drives a three.js mesh
103
- * - LittleJS physics moves the object and the mesh follows automatically
104
- * - Destroying the object removes the mesh from the scene
105
- * @extends EngineObject
106
- * @memberof ThreeJS
107
- */
108
- class ThreeJSObject extends EngineObject
109
- {
110
- /** Create an engine object that drives a three.js mesh
111
- * @param {Vector2} [pos] - World space position
112
- * @param {Vector2} [size] - World space size
113
- * @param {Object} [mesh] - The three.js object3d to drive
114
- * @param {number} [z] - Mesh height above the 2D plane */
115
- constructor(pos, size, mesh, z=0)
116
- {
117
- super(pos, size);
118
- ASSERT(threeJS, 'ThreeJS plugin must be initialized first');
119
-
120
- /** @property {Object} - The three.js object3d this object drives */
121
- this.mesh = mesh;
122
- /** @property {number} - Mesh height above the 2D plane */
123
- this.z = z;
124
- if (mesh)
125
- {
126
- threeJS.scene.add(mesh);
127
- this.syncMesh();
128
- }
129
- }
130
-
131
- /** Update the object and sync the mesh to its transform */
132
- update()
133
- {
134
- super.update();
135
- this.syncMesh();
136
- }
137
-
138
- /** Copy this object's transform to the mesh */
139
- syncMesh()
140
- {
141
- if (!this.mesh) return;
142
- this.mesh.position.set(this.pos.x, this.pos.y, this.z);
143
- this.mesh.rotation.z = -this.angle; // littlejs angles are clockwise
144
- }
145
-
146
- /** The mesh is this object's visual, the default 2D rendering is skipped */
147
- render() {}
148
-
149
- /** Destroy this object and remove its mesh from the scene
150
- * @param {boolean} [immediate] */
151
- destroy(immediate)
152
- {
153
- if (this.destroyed) return;
154
- // note: frequently destroyed objects should also dispose geometry and material
155
- this.mesh && threeJS.scene.remove(this.mesh);
156
- super.destroy(immediate);
157
- }
158
- }
1
+ /**
2
+ * LittleJS Three.js Plugin
3
+ * - Renders a three.js scene on a canvas behind the LittleJS canvases
4
+ * - The three.js module is passed in by the user, nothing is bundled
5
+ * - Keep canvasClearColor transparent so the 3D scene shows through
6
+ * - Aligned camera mode locks the 3D camera to the LittleJS 2D camera
7
+ * - ThreeJSObject lets LittleJS physics drive a three.js mesh
8
+ * - Call new ThreeJSPlugin(THREE) in gameInit to set up
9
+ * @namespace ThreeJS
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ ///////////////////////////////////////////////////////////////////////////////
15
+
16
+ /** Global ThreeJS plugin object
17
+ * @type {ThreeJSPlugin}
18
+ * @memberof ThreeJS */
19
+ let threeJS;
20
+
21
+ ///////////////////////////////////////////////////////////////////////////////
22
+ /**
23
+ * ThreeJS Plugin - Renders a three.js scene behind the LittleJS canvas
24
+ * @example
25
+ * // in gameInit, with three.js loaded by the user
26
+ * new ThreeJSPlugin(THREE);
27
+ * threeJS.scene.add(new THREE.AmbientLight);
28
+ * @memberof ThreeJS
29
+ */
30
+ class ThreeJSPlugin
31
+ {
32
+ /** Set up the three.js rendering layer, call in gameInit
33
+ * @param {Object} THREE - The three.js module, supplied by the user
34
+ * @param {number} [cameraFOV] - Vertical field of view in degrees */
35
+ constructor(THREE, cameraFOV=60)
36
+ {
37
+ ASSERT(!threeJS, 'ThreeJS plugin already initialized');
38
+ threeJS = this;
39
+ if (headlessMode) return;
40
+ ASSERT(mainCanvas, 'ThreeJS plugin must be created after engineInit, call in gameInit');
41
+ ASSERT(THREE && THREE.WebGLRenderer, 'three.js module must be passed in');
42
+
43
+ /** @property {Object} - The three.js module passed into the constructor */
44
+ this.THREE = THREE;
45
+ /** @property {Object} - The three.js renderer */
46
+ this.renderer = new THREE.WebGLRenderer({antialias: true});
47
+ /** @property {Object} - The three.js scene, add lights and meshes here */
48
+ this.scene = new THREE.Scene();
49
+ /** @property {Object} - The three.js perspective camera */
50
+ this.camera = new THREE.PerspectiveCamera(cameraFOV, 1, .1, 1e3);
51
+ /** @property {boolean} - Lock the camera to the LittleJS 2D camera so the z=0 plane matches world space */
52
+ this.cameraAlign2D = true;
53
+ /** @property {number|undefined} - Near plane of the aligned camera before any zoom out, from camera.near on the
54
+ * first aligned frame; while aligned, change this rather than camera.near
55
+ * @type {number|undefined} */
56
+ this.cameraNear = undefined;
57
+ /** @property {number|undefined} - Far plane of the aligned camera before any zoom out, from camera.far on the
58
+ * first aligned frame; while aligned, change this rather than camera.far
59
+ * @type {number|undefined} */
60
+ this.cameraFar = undefined;
61
+
62
+ // insert the canvas below the engine canvases and match the layout
63
+ const threeCanvas = this.renderer.domElement;
64
+ const rootElement = mainCanvas.parentElement;
65
+ rootElement.insertBefore(threeCanvas, rootElement.firstChild);
66
+ threeCanvas.style.cssText = mainCanvas.style.cssText;
67
+
68
+ // composite the 3D canvas into screenshots and video capture
69
+ setBackgroundCanvas(threeCanvas);
70
+
71
+ // render automatically each frame after the engine renders
72
+ engineAddPlugin(undefined, ()=> this.render());
73
+ }
74
+
75
+ /** Position the camera so the z=0 plane exactly matches LittleJS world space,
76
+ * called automatically when cameraAlign2D is set */
77
+ alignCamera2D()
78
+ {
79
+ const halfHeight = mainCanvasSize.y / 2 / cameraScale; // half visible height in world units
80
+ const distance = halfHeight / tan(this.camera.fov/2 * PI/180);
81
+
82
+ // zoomed out far enough that the z=0 plane would be past the far plane, push both planes out together
83
+ // so the depth precision stays what it was, and bring them back from where they began when zoomed in again
84
+ const baseNear = this.cameraNear ??= this.camera.near, baseFar = this.cameraFar ??= this.camera.far;
85
+ const scale = max(1, distance * 2 / baseFar), near = baseNear * scale, far = baseFar * scale;
86
+ if (near != this.camera.near || far != this.camera.far)
87
+ {
88
+ this.camera.near = near;
89
+ this.camera.far = far;
90
+ this.camera.updateProjectionMatrix();
91
+ }
92
+ this.camera.position.set(cameraPos.x, cameraPos.y, distance);
93
+ // reset all axes in case a free camera was used, littlejs angles are clockwise
94
+ this.camera.rotation.set(0, 0, -cameraAngle);
95
+ }
96
+
97
+ /** Sync the canvas layout and render the scene, called automatically each frame */
98
+ render()
99
+ {
100
+ if (!this.renderer) return; // headless mode
101
+
102
+ // keep renderer size and css in sync with the LittleJS canvas
103
+ // mainCanvasSize is css pixels, three scales it by the pixel ratio
104
+ const threeCanvas = this.renderer.domElement;
105
+ const dpr = getCanvasPixelRatio();
106
+ const bufferSizeX = mainCanvasSize.x * dpr | 0;
107
+ const bufferSizeY = mainCanvasSize.y * dpr | 0;
108
+ if (threeCanvas.width != bufferSizeX || threeCanvas.height != bufferSizeY)
109
+ {
110
+ this.renderer.setPixelRatio(dpr);
111
+ this.renderer.setSize(mainCanvasSize.x, mainCanvasSize.y, false);
112
+ this.camera.aspect = mainCanvasSize.x / mainCanvasSize.y;
113
+ this.camera.updateProjectionMatrix();
114
+ }
115
+ if (threeCanvas.style.cssText != mainCanvas.style.cssText)
116
+ threeCanvas.style.cssText = mainCanvas.style.cssText;
117
+
118
+ if (this.cameraAlign2D)
119
+ this.alignCamera2D();
120
+ this.renderer.render(this.scene, this.camera);
121
+ }
122
+ }
123
+
124
+ ///////////////////////////////////////////////////////////////////////////////
125
+ /**
126
+ * ThreeJS Object - EngineObject that drives a three.js mesh
127
+ * - LittleJS physics moves the object and the mesh follows automatically
128
+ * - Destroying the object removes the mesh from the scene
129
+ * @extends EngineObject
130
+ * @memberof ThreeJS
131
+ */
132
+ class ThreeJSObject extends EngineObject
133
+ {
134
+ /** Create an engine object that drives a three.js mesh
135
+ * @param {Vector2} [pos] - World space position
136
+ * @param {Vector2} [size] - World space size
137
+ * @param {Object} [mesh] - The three.js object3d to drive
138
+ * @param {number} [z] - Mesh height above the 2D plane */
139
+ constructor(pos, size, mesh, z=0)
140
+ {
141
+ super(pos, size);
142
+ ASSERT(threeJS, 'ThreeJS plugin must be initialized first');
143
+
144
+ /** @property {Object} - The three.js object3d this object drives */
145
+ this.mesh = mesh;
146
+ /** @property {number} - Mesh height above the 2D plane */
147
+ this.z = z;
148
+ if (mesh)
149
+ {
150
+ threeJS.scene?.add(mesh); // no scene in headless mode
151
+ this.syncMesh();
152
+ }
153
+ }
154
+
155
+ /** Update the transform and sync the mesh to it, after the parent has placed a child, and while paused too
156
+ * @param {boolean} [updateChildren] - Also update the children's transforms */
157
+ updateTransforms(updateChildren=true)
158
+ {
159
+ super.updateTransforms(updateChildren);
160
+ this.syncMesh();
161
+ }
162
+
163
+ /** Copy this object's transform to the mesh */
164
+ syncMesh()
165
+ {
166
+ if (!this.mesh) return;
167
+ this.mesh.position.set(this.pos.x, this.pos.y, this.z);
168
+ this.mesh.rotation.z = -this.angle; // littlejs angles are clockwise
169
+ }
170
+
171
+ /** The mesh is this object's visual, the default 2D rendering is skipped */
172
+ render() {}
173
+
174
+ /** Destroy this object and remove its mesh from the scene
175
+ * @param {boolean} [immediate] */
176
+ destroy(immediate)
177
+ {
178
+ if (this.destroyed) return;
179
+ // note: frequently destroyed objects should also dispose geometry and material
180
+ this.mesh && threeJS.scene?.remove(this.mesh);
181
+ super.destroy(immediate);
182
+ }
183
+ }