littlejsengine 1.18.19 → 1.18.22

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.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.18.19';
38
+ const engineVersion = '1.18.22';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -474,6 +474,11 @@ function engineObjectsUpdate()
474
474
  // get list of solid objects for physics optimization
475
475
  engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects);
476
476
 
477
+ // update physics before object update
478
+ for (const o of engineObjects)
479
+ if (!o.parent && !o.destroyed)
480
+ o.updatePhysics();
481
+
477
482
  // recursive object update
478
483
  function updateChildObject(o)
479
484
  {
@@ -489,7 +494,6 @@ function engineObjectsUpdate()
489
494
 
490
495
  // update top level objects
491
496
  o.update();
492
- o.updatePhysics();
493
497
  for (const child of o.children)
494
498
  updateChildObject(child);
495
499
  o.updateTransforms();
@@ -815,7 +819,7 @@ function smoothStep(percent) { return percent * percent * (3 - 2 * percent); }
815
819
  * @param {number} value
816
820
  * @return {boolean}
817
821
  * @memberof Math */
818
- function isPowerOfTwo(value) { return !(value & (value - 1)); }
822
+ function isPowerOfTwo(value) { return value > 0 && !(value & (value - 1)); }
819
823
 
820
824
  /** Returns the nearest power of two not less than the value
821
825
  * @param {number} value
@@ -892,7 +896,7 @@ function isIntersecting(start, end, pos, size)
892
896
  * @memberof Math */
893
897
  function oscillate(frequency=1, amplitude=1, t=time, offset=0, type=0)
894
898
  {
895
- const phase = (offset + t*frequency) % 1;
899
+ const phase = mod(offset + t*frequency, 1);
896
900
  let value;
897
901
 
898
902
  if (type === 1) // triangle
@@ -1828,7 +1832,7 @@ const MAGENTA = debugProtectConstant(rgb(1,0,1));
1828
1832
  class Timer
1829
1833
  {
1830
1834
  /** Create a timer object set time passed in
1831
- * @param {number} [timeLeft] - How much time left before the timer
1835
+ * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds (undefined = unset)
1832
1836
  * @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */
1833
1837
  constructor(timeLeft, useRealTime=false)
1834
1838
  {
@@ -2427,6 +2431,7 @@ let vibrateEnable = true;
2427
2431
  let soundEnable = true;
2428
2432
 
2429
2433
  /** Volume scale to apply to all sound, music and speech
2434
+ * Use setSoundVolume to also update the audio master gain immediately
2430
2435
  * @type {number}
2431
2436
  * @default
2432
2437
  * @memberof Settings */
@@ -3471,8 +3476,9 @@ class TileInfo
3471
3476
  * @param {TextureInfo} [textureInfo] - Texture info to use
3472
3477
  * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
3473
3478
  * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
3479
+ * @param {number} [columns] - How many frames per row for frame(), 0 to keep frames on a single row
3474
3480
  */
3475
- constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed)
3481
+ constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed, columns=0)
3476
3482
  {
3477
3483
  /** @property {Vector2} - Top left corner of tile in pixels */
3478
3484
  this.pos = pos.copy();
@@ -3484,6 +3490,8 @@ class TileInfo
3484
3490
  this.textureInfo = textureInfo;
3485
3491
  /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
3486
3492
  this.bleed = bleed;
3493
+ /** @property {number} - How many frames per row for frame(), 0 to keep frames on a single row */
3494
+ this.columns = columns;
3487
3495
  }
3488
3496
 
3489
3497
  /** Returns a copy of this tile offset by a vector
@@ -3491,9 +3499,10 @@ class TileInfo
3491
3499
  * @return {TileInfo}
3492
3500
  */
3493
3501
  offset(offset)
3494
- { return new TileInfo(this.pos.add(offset), this.size, this.textureInfo, this.padding, this.bleed); }
3502
+ { return new TileInfo(this.pos.add(offset), this.size, this.textureInfo, this.padding, this.bleed, this.columns); }
3495
3503
 
3496
3504
  /** Returns a copy of this tile offset by a number of animation frames
3505
+ * Frames wrap down to the next row if columns is set
3497
3506
  * @param {number} frame - Offset to apply in animation frames
3498
3507
  * @return {TileInfo}
3499
3508
  */
@@ -3501,11 +3510,33 @@ class TileInfo
3501
3510
  {
3502
3511
  ASSERT(typeof frame === 'number');
3503
3512
  const w = this.size.x + this.padding*2;
3504
- const x = frame*w;
3505
- ASSERT(x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
3506
- return this.offset(new Vector2(x));
3513
+ const h = this.size.y + this.padding*2;
3514
+ const x = (this.columns ? frame % this.columns : frame) * w;
3515
+ const y = (this.columns ? frame / this.columns | 0 : 0) * h;
3516
+ ASSERT(this.pos.x + x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
3517
+ ASSERT(this.pos.y + y + this.size.y <= this.textureInfo.size.y, 'frame extends beyond texture height!');
3518
+ return this.offset(new Vector2(x, y));
3519
+ }
3520
+
3521
+ /** Set how many frames per row this tile uses, so frame() can wrap
3522
+ * @param {number} [columns] - Frames per row, 0 to keep frames on a single row
3523
+ * @return {TileInfo}
3524
+ */
3525
+ setColumns(columns=0)
3526
+ {
3527
+ ASSERT(isNumber(columns) && columns >= 0, 'columns must be a number >= 0');
3528
+ this.columns = columns;
3529
+ return this;
3507
3530
  }
3508
3531
 
3532
+ /**
3533
+ * Returns a tile info for an index using this tile as reference
3534
+ * @param {Vector2|number} [index=0]
3535
+ * @return {TileInfo}
3536
+ */
3537
+ index(index)
3538
+ { return tile(index, this.size, this.textureInfo, this.padding, this.bleed).setColumns(this.columns); }
3539
+
3509
3540
  /**
3510
3541
  * Set this tile to use a full image in a texture info
3511
3542
  * @param {TextureInfo} [textureInfo]
@@ -3516,17 +3547,9 @@ class TileInfo
3516
3547
  this.textureInfo = textureInfo;
3517
3548
  this.pos = new Vector2;
3518
3549
  this.size = textureInfo.size.copy();
3519
- this.bleed = this.padding = 0;
3550
+ this.bleed = this.padding = this.columns = 0;
3520
3551
  return this;
3521
3552
  }
3522
-
3523
- /**
3524
- * Returns a tile info for an index using this tile as reference
3525
- * @param {Vector2|number} [index=0]
3526
- * @return {TileInfo}
3527
- */
3528
- tile(index)
3529
- { return tile(index, this.size, this.textureInfo, this.padding, this.bleed); }
3530
3553
  }
3531
3554
 
3532
3555
  /**
@@ -5224,7 +5247,10 @@ function inputInit()
5224
5247
  {
5225
5248
  inputData[0][e.code] = (inputData[0][e.code]&2) | 4;
5226
5249
  if (inputWASDEmulateDirection)
5227
- inputData[0][remapKey(e.code)] = 4;
5250
+ {
5251
+ const remap = remapKey(e.code);
5252
+ inputData[0][remap] = (inputData[0][remap]&2) | 4;
5253
+ }
5228
5254
  }
5229
5255
  function remapKey(k)
5230
5256
  {
@@ -5299,7 +5325,7 @@ function inputInit()
5299
5325
  document.addEventListener('touchend', (e)=> handleTouch(e), { passive: false });
5300
5326
 
5301
5327
  // handle all touch events the same way
5302
- let wasTouching;
5328
+ let wasTouching, touchIdentifier;
5303
5329
  function handleTouch(e)
5304
5330
  {
5305
5331
  if (!touchInputEnable) return;
@@ -5330,10 +5356,11 @@ function inputInit()
5330
5356
  const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
5331
5357
  const mousePosScreenLast = mousePosScreen;
5332
5358
  mousePosScreen = mouseEventToScreen(pos);
5333
- if (wasTouching)
5359
+ if (wasTouching && gameTouches[0].identifier === touchIdentifier)
5334
5360
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5335
- else
5361
+ else if (!wasTouching)
5336
5362
  inputData[0][button] = 3;
5363
+ touchIdentifier = gameTouches[0].identifier;
5337
5364
  }
5338
5365
  else if (wasTouching)
5339
5366
  inputData[0][button] = inputData[0][button] & 2 | 4;
@@ -6491,7 +6518,7 @@ class SoundInstance
6491
6518
  * @param {number} [rate] - How quickly to speak
6492
6519
  * @param {number} [pitch] - How much to change the pitch by
6493
6520
  * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
6494
- * @return {SpeechSynthesisUtterance} - The utterance that was spoken
6521
+ * @return {SpeechSynthesisUtterance|undefined} - The utterance that was spoken, or undefined if speech is unavailable
6495
6522
  * @memberof Audio */
6496
6523
  function speak(text, volume=1, rate=1, pitch=1, language='')
6497
6524
  {
@@ -6544,7 +6571,7 @@ function getNoteFrequency(semitoneOffset, rootFrequency=220)
6544
6571
  * @param {number} [pan] - How much to apply stereo panning
6545
6572
  * @param {boolean} [loop] - True if the sound should loop when it reaches the end
6546
6573
  * @param {number} [sampleRate=44100] - Sample rate for the sound
6547
- * @param {GainNode} [gainNode] - Optional gain node for volume control while playing
6574
+ * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
6548
6575
  * @param {number} [offset] - Offset in seconds to start playback from
6549
6576
  * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
6550
6577
  * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
@@ -6786,10 +6813,14 @@ function tileCollisionGetData(pos, solidOnly=true)
6786
6813
  // check all tile collision layers
6787
6814
  for (const layer of tileCollisionLayers)
6788
6815
  if (!solidOnly || layer.isSolid)
6789
- if (pos.arrayCheck(layer.size))
6790
6816
  {
6791
- const data = layer.getCollisionData(pos);
6792
- if (data) return data;
6817
+ // convert world pos to layer local space
6818
+ const layerPos = pos.subtract(layer.pos);
6819
+ if (layerPos.arrayCheck(layer.size))
6820
+ {
6821
+ const data = layer.getCollisionData(layerPos);
6822
+ if (data) return data;
6823
+ }
6793
6824
  }
6794
6825
  return 0;
6795
6826
  }
@@ -7064,7 +7095,7 @@ class TileLayer extends CanvasLayer
7064
7095
 
7065
7096
  /** @property {TileInfo} - Default tile info for layer */
7066
7097
  this.tileInfo = undefined;
7067
- /** @property {Array<TileLayerData>} - Default tile info for layer */
7098
+ /** @property {Array<TileLayerData>} - Array of tile data for the layer */
7068
7099
  this.data = [];
7069
7100
  /** @property {boolean} - Is this layer using a webgl texture? */
7070
7101
  this.isUsingWebGL = false;
@@ -7149,7 +7180,7 @@ class TileLayer extends CanvasLayer
7149
7180
 
7150
7181
  const size = this.drawSize || this.size;
7151
7182
  const pos = this.pos.add(size.scale(.5));
7152
- this.draw(pos, this.size, this.color, this.angle, this.mirror, this.additiveColor);
7183
+ this.draw(pos, size, this.color, this.angle, this.mirror, this.additiveColor);
7153
7184
  }
7154
7185
 
7155
7186
  /** Called after this layer is redrawn, does nothing by default */
@@ -7238,7 +7269,7 @@ class TileLayer extends CanvasLayer
7238
7269
  const d = this.getData(layerPos);
7239
7270
  if (!d || !d.tile) return;
7240
7271
 
7241
- const tileInfo = this.tileInfo && this.tileInfo.tile(d.tile);
7272
+ const tileInfo = this.tileInfo && this.tileInfo.index(d.tile);
7242
7273
  this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror);
7243
7274
  }
7244
7275
 
@@ -7532,8 +7563,8 @@ class TileCollisionLayer extends TileLayer
7532
7563
  * rgb(1,1,1,1), rgb(0,0,0,1), // colorStartA, colorStartB
7533
7564
  * rgb(1,1,1,0), rgb(0,0,0,0), // colorEndA, colorEndB
7534
7565
  * 1, .2, .2, .1, .05, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed
7535
- * .99, 1, 1, PI, .05, // damping, angleDamping, gravityScale, particleCone, fadeRate,
7536
- * .5, 1 // randomness, collide, additive, randomColorLinear, renderOrder
7566
+ * .99, 1, 1, PI, .05, // damping, angleDamping, gravityScale, particleCone, fadeRate
7567
+ * .5, 1 // randomness, collide
7537
7568
  * );
7538
7569
  */
7539
7570
  class ParticleEmitter extends EngineObject
@@ -7925,13 +7956,12 @@ class Particle
7925
7956
  const hitLayer = tileCollisionTest(this.pos);
7926
7957
  if (!testCollision(oldPos))
7927
7958
  {
7928
- // testCollision already invoked collideCallback with the
7929
- // correct (this, data, pos) args; no need to re-check here.
7930
7959
  // test which side we bounced off (or both if a corner)
7931
7960
  const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
7932
7961
  const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
7933
- const hitRestitution = max(restitution, hitLayer.restitution);
7934
- const hitFriction = max(friction, hitLayer.friction);
7962
+ // collide callback may hit where the layer test does not, so hitLayer can be undefined
7963
+ const hitRestitution = hitLayer ? max(restitution, hitLayer.restitution) : restitution;
7964
+ const hitFriction = hitLayer ? max(friction, hitLayer.friction) : friction;
7935
7965
  if (isBlockedX)
7936
7966
  {
7937
7967
  // move to previous X position and bounce
@@ -9580,7 +9610,8 @@ class NewgroundsPlugin
9580
9610
  return;
9581
9611
  }
9582
9612
  debugMedals && LOG(xmlHttp.responseText);
9583
- return xmlHttp.responseText && JSON.parse(xmlHttp.responseText);
9613
+ try { return xmlHttp.responseText && JSON.parse(xmlHttp.responseText); }
9614
+ catch(e) { debugMedals && LOG('newgrounds response is not valid JSON', e); }
9584
9615
  }
9585
9616
  }
9586
9617
  /**
@@ -9599,8 +9630,8 @@ class NewgroundsPlugin
9599
9630
  let postProcess;
9600
9631
 
9601
9632
  /////////////////////////////////////////////////////////////////////////
9602
- /**
9603
- * UI System Global Object
9633
+ /**
9634
+ * Post Process Plugin - Applies a full screen shader to the rendered output
9604
9635
  * @memberof PostProcess
9605
9636
  */
9606
9637
  class PostProcessPlugin
@@ -9714,8 +9745,9 @@ class PostProcessPlugin
9714
9745
  workCanvas.height = mainCanvasSize.y;
9715
9746
  glCopyToContext(workContext);
9716
9747
  workContext.drawImage(mainCanvas, 0, 0);
9717
- mainCanvas.width |= 0
9718
-
9748
+ mainCanvas.width |= 0; // setting size clears the main canvas
9749
+
9750
+
9719
9751
  // copy work canvas to texture
9720
9752
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, workCanvas);
9721
9753
  }
@@ -10820,7 +10852,7 @@ class UISystemPlugin
10820
10852
  }
10821
10853
 
10822
10854
  /** Get other axis navigation direction from gamepad or keyboard
10823
- * @return {Vector2} */
10855
+ * @return {number} */
10824
10856
  getNavigationOtherDirection()
10825
10857
  {
10826
10858
  if (uiSystem.navigationDirection === 2)
@@ -10909,6 +10941,7 @@ class UISystemPlugin
10909
10941
  uiSystem.navigationDirection = savedNavigationDirection;
10910
10942
  inputClear();
10911
10943
  }
10944
+ return confirmMenu;
10912
10945
  }
10913
10946
  }
10914
10947
 
@@ -11342,7 +11375,7 @@ class UITextInput extends UIObject
11342
11375
  this.onClick();
11343
11376
  }
11344
11377
 
11345
- /** Stop editing the text edited */
11378
+ /** Stop editing the text */
11346
11379
  stopEditing()
11347
11380
  {
11348
11381
  if (!this.isKeyInputObject())
@@ -11505,7 +11538,7 @@ class UICheckbox extends UIObject
11505
11538
  ASSERT(isStringLike(text), 'ui checkbox must be a string');
11506
11539
  ASSERT(isColor(color), 'ui checkbox color must be a color');
11507
11540
 
11508
- /** @property {boolean} - Current percentage value of this slider 0-1 */
11541
+ /** @property {boolean} - Is the checkbox currently checked? */
11509
11542
  this.checked = checked;
11510
11543
  // set properties
11511
11544
  this.text = text;
@@ -12270,7 +12303,7 @@ class Box2dObject extends EngineObject
12270
12303
  shape.set_m_vertex3(box2d.vec2dTo(getPoint(i+2)));
12271
12304
  const f = this.addShape(shape, density, friction, restitution, isSensor);
12272
12305
  fixtures.push(f);
12273
- i < points.length && edgePoints.push(points[i].copy());
12306
+ edgePoints.push(points[i].copy());
12274
12307
  }
12275
12308
  this.edgeLoops.push(edgePoints);
12276
12309
  return fixtures;
@@ -12336,7 +12369,7 @@ class Box2dObject extends EngineObject
12336
12369
  /** Sets the position
12337
12370
  * @param {Vector2} pos */
12338
12371
  setPosition(pos)
12339
- { this.setTransform(pos, this.body.GetAngle()); }
12372
+ { this.setTransform(pos, -this.body.GetAngle()); }
12340
12373
 
12341
12374
  /** Sets the angle
12342
12375
  * @param {number} angle */
@@ -12433,6 +12466,7 @@ class Box2dObject extends EngineObject
12433
12466
  filter.set_categoryBits(categoryBits);
12434
12467
  filter.set_maskBits(0xffff & ~ignoreCategoryBits);
12435
12468
  filter.set_groupIndex(groupIndex);
12469
+ fixture.SetFilterData(filter); // applies and refilters contacts
12436
12470
  });
12437
12471
  }
12438
12472
 
@@ -12968,7 +13002,7 @@ class Box2dRevoluteJoint extends Box2dJoint
12968
13002
  jointDef.set_bodyB(objectB.body);
12969
13003
  jointDef.set_localAnchorA(box2d.vec2dTo(localAnchorA));
12970
13004
  jointDef.set_localAnchorB(box2d.vec2dTo(localAnchorB));
12971
- jointDef.set_referenceAngle(objectA.body.GetAngle() - objectB.body.GetAngle());
13005
+ jointDef.set_referenceAngle(objectB.body.GetAngle() - objectA.body.GetAngle());
12972
13006
  jointDef.set_collideConnected(collide);
12973
13007
  super(jointDef);
12974
13008
  }
@@ -13115,14 +13149,14 @@ class Box2dPrismaticJoint extends Box2dJoint
13115
13149
  anchor ||= box2d.vec2From(objectB.body.GetPosition());
13116
13150
  const localAnchorA = objectA.worldToLocal(anchor);
13117
13151
  const localAnchorB = objectB.worldToLocal(anchor);
13118
- const localAxisA = objectB.worldToLocalVector(worldAxis);
13152
+ const localAxisA = objectA.worldToLocalVector(worldAxis);
13119
13153
  const jointDef = new box2d.instance.b2PrismaticJointDef();
13120
13154
  jointDef.set_bodyA(objectA.body);
13121
13155
  jointDef.set_bodyB(objectB.body);
13122
13156
  jointDef.set_localAnchorA(box2d.vec2dTo(localAnchorA));
13123
13157
  jointDef.set_localAnchorB(box2d.vec2dTo(localAnchorB));
13124
13158
  jointDef.set_localAxisA(box2d.vec2dTo(localAxisA));
13125
- jointDef.set_referenceAngle(objectA.body.GetAngle() - objectB.body.GetAngle());
13159
+ jointDef.set_referenceAngle(objectB.body.GetAngle() - objectA.body.GetAngle());
13126
13160
  jointDef.set_collideConnected(collide);
13127
13161
  super(jointDef);
13128
13162
  }
@@ -13225,7 +13259,7 @@ class Box2dWheelJoint extends Box2dJoint
13225
13259
  anchor ||= box2d.vec2From(objectB.body.GetPosition());
13226
13260
  const localAnchorA = objectA.worldToLocal(anchor);
13227
13261
  const localAnchorB = objectB.worldToLocal(anchor);
13228
- const localAxisA = objectB.worldToLocalVector(worldAxis);
13262
+ const localAxisA = objectA.worldToLocalVector(worldAxis);
13229
13263
  const jointDef = new box2d.instance.b2WheelJointDef();
13230
13264
  jointDef.set_bodyA(objectA.body);
13231
13265
  jointDef.set_bodyB(objectB.body);
@@ -13325,7 +13359,7 @@ class Box2dWeldJoint extends Box2dJoint
13325
13359
  jointDef.set_bodyB(objectB.body);
13326
13360
  jointDef.set_localAnchorA(box2d.vec2dTo(localAnchorA));
13327
13361
  jointDef.set_localAnchorB(box2d.vec2dTo(localAnchorB));
13328
- jointDef.set_referenceAngle(objectA.body.GetAngle() - objectB.body.GetAngle());
13362
+ jointDef.set_referenceAngle(objectB.body.GetAngle() - objectA.body.GetAngle());
13329
13363
  jointDef.set_collideConnected(collide);
13330
13364
  super(jointDef);
13331
13365
  }
@@ -13772,7 +13806,7 @@ class Box2dPlugin
13772
13806
  * @param {number} [lineWidth]
13773
13807
  * @param {boolean} [useWebGL=glEnable]
13774
13808
  * @param {CanvasRenderingContext2D} [context] */
13775
- drawFixture(fixture, pos, angle, color=WHITE, lineColor=BLACK, lineWidth=.1, useWebgl, context)
13809
+ drawFixture(fixture, pos, angle, color=WHITE, lineColor=BLACK, lineWidth=.1, useWebGL, context)
13776
13810
  {
13777
13811
  const shape = box2d.castShapeObject(fixture.GetShape());
13778
13812
  switch (shape.GetType())
@@ -13782,20 +13816,20 @@ class Box2dPlugin
13782
13816
  let points = [];
13783
13817
  for (let i=shape.GetVertexCount(); i--;)
13784
13818
  points.push(box2d.vec2From(shape.GetVertex(i)));
13785
- drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebgl, false, context);
13819
+ drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, false, context);
13786
13820
  break;
13787
13821
  }
13788
13822
  case box2d.instance.b2Shape.e_circle:
13789
13823
  {
13790
13824
  const radius = shape.get_m_radius();
13791
- drawCircle(pos, radius*2, color, lineWidth, lineColor, useWebgl, false, context);
13825
+ drawCircle(pos, radius*2, color, lineWidth, lineColor, useWebGL, false, context);
13792
13826
  break;
13793
13827
  }
13794
13828
  case box2d.instance.b2Shape.e_edge:
13795
13829
  {
13796
13830
  const v1 = box2d.vec2From(shape.get_m_vertex1());
13797
13831
  const v2 = box2d.vec2From(shape.get_m_vertex2());
13798
- drawLine(v1, v2, lineWidth, lineColor, pos, angle, useWebgl, false, context);
13832
+ drawLine(v1, v2, lineWidth, lineColor, pos, angle, useWebGL, false, context);
13799
13833
  break;
13800
13834
  }
13801
13835
  }
@@ -14200,6 +14234,444 @@ function getCrescentPoints(pos, size=1, percent=0, angle=0, invert=false, sides=
14200
14234
  }
14201
14235
  return points;
14202
14236
  }
14237
+ /**
14238
+ * LittleJS Texture Sheet Plugin
14239
+ * - Packs images into texture sheets as they are loaded
14240
+ * - Sprites are placed automatically, callers get a TileInfo
14241
+ * - Sheets are created and filled as needed
14242
+ * - Sheets fill in call order, images decode in parallel
14243
+ * - Animation frames keep layout and wrap across rows as needed
14244
+ * - WebGL textures upload once per batch of loads
14245
+ * - loadAtlas imports pre-packed atlases (TexturePacker and Aseprite json)
14246
+ * @namespace TextureSheets
14247
+ */
14248
+
14249
+ /** Width and height in pixels of texture sheets created by loadSprite
14250
+ * @type {number}
14251
+ * @default
14252
+ * @memberof Settings */
14253
+ let textureSheetSize = 2048;
14254
+
14255
+ /** Default padding pixels around each frame packed by loadSprite
14256
+ * @type {number}
14257
+ * @default
14258
+ * @memberof Settings */
14259
+ let textureSheetPadding = 1;
14260
+
14261
+ /** Array of texture sheets created by loadSprite
14262
+ * @type {Array<TextureSheet>}
14263
+ * @memberof TextureSheets */
14264
+ let textureSheets = [];
14265
+
14266
+ // pending loads pack through a queue so sheets fill in call order
14267
+ let textureSheetQueue = Promise.resolve();
14268
+ let textureSheetPendingCount = 0;
14269
+
14270
+ /**
14271
+ * Texture Sheet - A texture that images are packed into as they load
14272
+ * Uses shelf packing, images are placed left to right then wrap to a new row
14273
+ * @memberof TextureSheets
14274
+ */
14275
+ class TextureSheet
14276
+ {
14277
+ /** Create a texture sheet, called automatically by loadSprite
14278
+ * @param {number} [size] - Width and height of the sheet in pixels */
14279
+ constructor(size=textureSheetSize)
14280
+ {
14281
+ ASSERT(size > 0, 'texture sheet size must be positive');
14282
+
14283
+ /** @property {number} - Width and height of the sheet in pixels */
14284
+ this.size = size;
14285
+ /** @property {OffscreenCanvas} - Canvas holding the packed images */
14286
+ this.canvas = headlessMode ? undefined : new OffscreenCanvas(size, size);
14287
+ /** @property {OffscreenCanvasRenderingContext2D} - 2d context for the canvas */
14288
+ this.context = this.canvas?.getContext('2d');
14289
+ /** @property {TextureInfo} - The texture info for this sheet */
14290
+ this.textureInfo = new TextureInfo(this.canvas);
14291
+ /** @property {Vector2} - Where the next image will be packed */
14292
+ this.cursor = vec2();
14293
+ /** @property {number} - Height of the row being packed */
14294
+ this.rowHeight = 0;
14295
+ /** @property {boolean} - Has the canvas changed since the last webgl upload? */
14296
+ this.glDirty = false;
14297
+
14298
+ if (headlessMode)
14299
+ {
14300
+ // tiles still need bounds when there is no canvas to measure
14301
+ this.textureInfo.size = vec2(size);
14302
+ this.textureInfo.sizeInverse = vec2(1/size);
14303
+ }
14304
+ }
14305
+
14306
+ /** Find a spot for an image on this sheet without drawing it
14307
+ * @param {Vector2} imageSize - Size of the source image in pixels
14308
+ * @param {Vector2} [frameSize] - Size of each frame, or the whole image if not passed
14309
+ * @param {number} [padding] - How many pixels padding around each frame
14310
+ * @return {TileInfo} Tile for the packed image, or undefined if the sheet is full */
14311
+ tryAdd(imageSize, frameSize=imageSize, padding=textureSheetPadding)
14312
+ {
14313
+ ASSERT(isVector2(imageSize) && isVector2(frameSize), 'sizes must be vec2');
14314
+ ASSERT(frameSize.x > 0 && frameSize.y > 0, 'frame size must be positive');
14315
+ ASSERT(imageSize.x % frameSize.x === 0 && imageSize.y % frameSize.y === 0,
14316
+ 'image size must be a multiple of the frame size');
14317
+
14318
+ const cellWidth = frameSize.x + padding*2;
14319
+ const cellHeight = frameSize.y + padding*2;
14320
+ const maxColumns = this.size / cellWidth | 0;
14321
+ ASSERT(maxColumns > 0, 'frame is too wide to fit on a texture sheet');
14322
+
14323
+ // keep the layout of the source image, but narrow it if a row is too wide
14324
+ // frames wrap down to the next row, which TileInfo.frame handles via columns
14325
+ const sourceColumns = imageSize.x / frameSize.x;
14326
+ const frameCount = sourceColumns * (imageSize.y / frameSize.y);
14327
+ const columns = min(sourceColumns, maxColumns);
14328
+ const blockWidth = columns * cellWidth;
14329
+ const blockHeight = ceil(frameCount / columns) * cellHeight;
14330
+
14331
+ // probe the placement using locals so a failed try leaves the sheet unchanged
14332
+ let x = this.cursor.x, y = this.cursor.y, rowHeight = this.rowHeight;
14333
+ if (x + blockWidth > this.size)
14334
+ {
14335
+ // start a new row if this one does not have enough space left
14336
+ x = 0;
14337
+ y += rowHeight;
14338
+ rowHeight = 0;
14339
+ }
14340
+
14341
+ // out of space, the caller needs to use a different sheet
14342
+ if (y + blockHeight > this.size)
14343
+ return undefined;
14344
+
14345
+ // commit the placement, tile pos points inside the padding to match how tile() works
14346
+ this.cursor.x = x + blockWidth;
14347
+ this.cursor.y = y;
14348
+ this.rowHeight = max(rowHeight, blockHeight);
14349
+ return new TileInfo(vec2(x + padding, y + padding), frameSize, this.textureInfo, padding, 0, columns);
14350
+ }
14351
+
14352
+ /** Draw an image into this sheet at a tile returned by tryAdd
14353
+ * @param {HTMLImageElement} image - Source image to copy from
14354
+ * @param {TileInfo} tileInfo - Where to put it, from tryAdd
14355
+ * @param {boolean} [update] - Upload to webgl now, pass false when batching */
14356
+ drawImage(image, tileInfo, update=true)
14357
+ {
14358
+ ASSERT(!!this.context, 'texture sheet has no canvas');
14359
+
14360
+ // copy frames in order, reading the source left to right, top to bottom
14361
+ // the destination wraps at tileInfo.columns which may be narrower than the source
14362
+ const frameSize = tileInfo.size;
14363
+ const sourceColumns = image.width / frameSize.x;
14364
+ const frameCount = sourceColumns * (image.height / frameSize.y);
14365
+ const columns = tileInfo.columns || frameCount;
14366
+ const cellWidth = frameSize.x + tileInfo.padding*2;
14367
+ const cellHeight = frameSize.y + tileInfo.padding*2;
14368
+ for (let i = frameCount; i--;)
14369
+ {
14370
+ const sourceX = (i % sourceColumns) * frameSize.x;
14371
+ const sourceY = (i / sourceColumns | 0) * frameSize.y;
14372
+ this.context.drawImage(image,
14373
+ sourceX, sourceY, frameSize.x, frameSize.y,
14374
+ tileInfo.pos.x + (i % columns) * cellWidth,
14375
+ tileInfo.pos.y + (i / columns | 0) * cellHeight,
14376
+ frameSize.x, frameSize.y);
14377
+ }
14378
+
14379
+ // upload now unless the caller is batching more images
14380
+ this.glDirty = true;
14381
+ update && this.updateTexture();
14382
+ }
14383
+
14384
+ /** Upload the canvas to webgl if it has changed since the last upload
14385
+ * Only needed after batching, drawImage uploads automatically by default */
14386
+ updateTexture()
14387
+ {
14388
+ if (!this.glDirty) return;
14389
+ this.glDirty = false;
14390
+ this.textureInfo.createWebGLTexture();
14391
+ }
14392
+ }
14393
+
14394
+ ///////////////////////////////////////////////////////////////////////////////
14395
+
14396
+ /** Load an image and pack it into a texture sheet
14397
+ * - Returns a TileInfo immediately which is filled in when the image loads
14398
+ * - Nothing is visible until it loads, use spritesReady to wait for it
14399
+ * - Pass frameSize for animations, then step through them with TileInfo.frame
14400
+ * - Grid images keep their layout and frames wrap down to the next row
14401
+ * @param {string} src - Image source path
14402
+ * @param {Vector2|number} [frameSize] - Size of each animation frame in pixels
14403
+ * @param {number} [padding] - How many pixels padding around each frame
14404
+ * @return {TileInfo}
14405
+ * @example
14406
+ * const playerTile = loadSprite('player.png'); // a single sprite
14407
+ * const runTile = loadSprite('run.png', vec2(16)); // a 16x16 frame animation
14408
+ * @memberof TextureSheets */
14409
+ function loadSprite(src, frameSize, padding=textureSheetPadding)
14410
+ {
14411
+ ASSERT(isStringLike(src), 'image src must be a string');
14412
+ ASSERT(!frameSize || isVector2(frameSize) || isNumber(frameSize), 'frameSize must be a vec2 or number');
14413
+ ASSERT(isNumber(padding), 'padding must be a number');
14414
+
14415
+ if (isNumber(frameSize))
14416
+ frameSize = vec2(frameSize);
14417
+
14418
+ // start with an empty tile that gets filled in when the image loads
14419
+ const tileInfo = new TileInfo(vec2(), vec2(), undefined, padding, 0);
14420
+ if (headlessMode) return tileInfo;
14421
+
14422
+ // point at a sheet right away so drawing before it loads picks up empty pixels
14423
+ tileInfo.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
14424
+
14425
+ // start decoding right away, images decode in parallel
14426
+ const image = new Image;
14427
+ const imagePromise = new Promise(resolve =>
14428
+ {
14429
+ image.onerror = image.onload = resolve;
14430
+ image.crossOrigin = 'anonymous';
14431
+ image.src = src;
14432
+ });
14433
+
14434
+ // pack through a queue so sheets fill in call order, not decode order
14435
+ ++textureSheetPendingCount;
14436
+ textureSheetQueue = textureSheetQueue.then(async ()=>
14437
+ {
14438
+ await imagePromise;
14439
+ if (image.width)
14440
+ {
14441
+ // pack onto a sheet, then fill in the tile that was already handed out,
14442
+ // copying every field so nothing is missed if TileInfo gains more of them
14443
+ const imageSize = vec2(image.width, image.height);
14444
+ const {sheet, tile} = textureSheetAdd(imageSize, frameSize, padding);
14445
+ Object.assign(tileInfo, tile);
14446
+ sheet.drawImage(image, tileInfo, false); // upload once per batch below
14447
+ }
14448
+ else
14449
+ {
14450
+ // leave the tile empty if the image failed to load
14451
+ LOG('loadSprite failed to load image:', src);
14452
+ }
14453
+
14454
+ // upload to webgl once per batch, when the last pending load finishes
14455
+ if (!--textureSheetPendingCount)
14456
+ textureSheets.forEach(s=> s.updateTexture());
14457
+ });
14458
+
14459
+ return tileInfo;
14460
+ }
14461
+
14462
+ /** Load a pre-packed texture atlas and repack it onto texture sheets
14463
+ * - Supports TexturePacker json (hash and array) and Aseprite json
14464
+ * - Returns an empty object which is filled with TileInfos when loaded
14465
+ * - Frames are named by the json, animations are grouped automatically
14466
+ * - Aseprite frame tags become animations, so do names like run_0, run_1
14467
+ * - Trimmed frames are restored to their full source size when packed
14468
+ * - Rotated frames are rotated back upright when packed
14469
+ * @param {string} imageSrc - Atlas image path
14470
+ * @param {string|Object} jsonSrc - Atlas json path, or already parsed json data
14471
+ * @param {number} [padding] - How many pixels padding around each frame
14472
+ * @return {Object} Object mapping frame and animation names to TileInfos
14473
+ * @example
14474
+ * const atlas = loadAtlas('sprites.png', 'sprites.json');
14475
+ * await spritesReady();
14476
+ * drawTile(pos, size, atlas.player); // a single frame
14477
+ * drawTile(pos, size, atlas.run.frame(2)); // frame 2 of the run animation
14478
+ * @memberof TextureSheets */
14479
+ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
14480
+ {
14481
+ ASSERT(isStringLike(imageSrc), 'atlas image src must be a string');
14482
+ ASSERT(isStringLike(jsonSrc) || typeof jsonSrc === 'object', 'atlas json must be a path or object');
14483
+ ASSERT(isNumber(padding), 'padding must be a number');
14484
+
14485
+ const atlas = {};
14486
+ if (headlessMode) return atlas;
14487
+
14488
+ // start fetching the json and decoding the image right away, in parallel
14489
+ const jsonPromise = typeof jsonSrc === 'object' ? Promise.resolve(jsonSrc) :
14490
+ fetch(jsonSrc).then(r=> r.ok && r.json()).catch(()=> undefined);
14491
+ const image = new Image;
14492
+ const imagePromise = new Promise(resolve =>
14493
+ {
14494
+ image.onerror = image.onload = resolve;
14495
+ image.crossOrigin = 'anonymous';
14496
+ image.src = imageSrc;
14497
+ });
14498
+
14499
+ // pack through a queue so sheets fill in call order, not decode order
14500
+ ++textureSheetPendingCount;
14501
+ textureSheetQueue = textureSheetQueue.then(async ()=>
14502
+ {
14503
+ const data = await jsonPromise;
14504
+ await imagePromise;
14505
+ if (image.width && data)
14506
+ {
14507
+ for (const group of parseAtlas(data))
14508
+ {
14509
+ // reserve a block of full size cells, one per frame
14510
+ const sourceSize = group.frames[0].sourceSize;
14511
+ const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
14512
+ const {sheet, tile} = textureSheetAdd(blockSize, sourceSize, padding);
14513
+
14514
+ // draw each frame untrimmed into its cell
14515
+ const context = sheet.context;
14516
+ const cellWidth = sourceSize.x + padding*2;
14517
+ const cellHeight = sourceSize.y + padding*2;
14518
+ group.frames.forEach((f, i)=>
14519
+ {
14520
+ const x = tile.pos.x + (i % tile.columns)*cellWidth + f.offset.x;
14521
+ const y = tile.pos.y + (i / tile.columns |0)*cellHeight + f.offset.y;
14522
+ if (f.rotated)
14523
+ {
14524
+ // stored rotated 90 degrees clockwise, draw it back upright
14525
+ context.save();
14526
+ context.translate(x, y);
14527
+ context.rotate(-PI/2);
14528
+ context.drawImage(image, f.pos.x, f.pos.y, f.size.y, f.size.x,
14529
+ -f.size.y, 0, f.size.y, f.size.x);
14530
+ context.restore();
14531
+ }
14532
+ else
14533
+ context.drawImage(image, f.pos.x, f.pos.y, f.size.x, f.size.y,
14534
+ x, y, f.size.x, f.size.y);
14535
+ });
14536
+ sheet.glDirty = true;
14537
+ atlas[group.name] = tile;
14538
+ }
14539
+ }
14540
+ else
14541
+ {
14542
+ // leave the atlas empty if either file failed to load
14543
+ LOG('loadAtlas failed to load:', imageSrc, jsonSrc);
14544
+ }
14545
+
14546
+ // upload to webgl once per batch, when the last pending load finishes
14547
+ if (!--textureSheetPendingCount)
14548
+ textureSheets.forEach(s=> s.updateTexture());
14549
+ });
14550
+
14551
+ return atlas;
14552
+ }
14553
+
14554
+ /** Parse atlas json into a list of named frame groups, used by loadAtlas
14555
+ * - Accepts TexturePacker json (hash and array) and Aseprite json
14556
+ * - Frames tagged in Aseprite or named like run_0, run_1 group into animations
14557
+ * @param {Object} data - Parsed atlas json data
14558
+ * @return {Array<Object>} List of {name, frames} groups in atlas order
14559
+ * @memberof TextureSheets */
14560
+ function parseAtlas(data)
14561
+ {
14562
+ ASSERT(!!data?.frames, 'unrecognized atlas format, expected TexturePacker or Aseprite json');
14563
+
14564
+ // normalize both hash and array frame layouts into a single list
14565
+ const frames = (isArray(data.frames) ?
14566
+ data.frames.map(f=> [f.filename, f]) : Object.entries(data.frames))
14567
+ .map(([name, f])=> ({
14568
+ name: name.replace(/\.[^.\\/]+$/, ''), // strip file extension
14569
+ pos: vec2(f.frame.x, f.frame.y),
14570
+ size: vec2(f.frame.w, f.frame.h),
14571
+ offset: vec2(f.spriteSourceSize?.x ?? 0, f.spriteSourceSize?.y ?? 0),
14572
+ sourceSize: vec2(f.sourceSize?.w ?? f.frame.w, f.sourceSize?.h ?? f.frame.h),
14573
+ rotated: !!f.rotated,
14574
+ }));
14575
+
14576
+ const groups = [];
14577
+ const tags = data.meta?.frameTags;
14578
+ if (tags?.length)
14579
+ {
14580
+ // aseprite tags are authoritative, untagged frames stay individual
14581
+ const tagged = new Set;
14582
+ for (const tag of tags)
14583
+ {
14584
+ groups.push({name: tag.name, frames: frames.slice(tag.from, tag.to + 1)});
14585
+ for (let i = tag.from; i <= tag.to; ++i)
14586
+ tagged.add(i);
14587
+ }
14588
+ frames.forEach((f, i)=> tagged.has(i) || groups.push({name: f.name, frames: [f]}));
14589
+ return groups;
14590
+ }
14591
+
14592
+ // group frames that share a name stem with contiguous trailing numbers
14593
+ // run_0.png and run_1.png become a 2 frame animation named run
14594
+ const stems = new Map;
14595
+ for (const f of frames)
14596
+ {
14597
+ let match = f.name.match(/^(.+?)([-_ ])?(\d+)$/);
14598
+ if (match && !match[2] && /\d$/.test(match[1]))
14599
+ match = undefined; // all digit tails like 10 are a name, not frame 0 of 1
14600
+ const stem = match ? match[1] : f.name;
14601
+ f.groupIndex = match ? Number(match[3]) : undefined;
14602
+ stems.has(stem) || stems.set(stem, []);
14603
+ stems.get(stem).push(f);
14604
+ }
14605
+ for (const [stem, list] of stems)
14606
+ {
14607
+ // only group 2 or more frames with contiguous indices and matching sizes
14608
+ list.sort((a, b)=> a.groupIndex - b.groupIndex);
14609
+ const grouped = list.length > 1 &&
14610
+ list.every((f, i)=> f.groupIndex === list[0].groupIndex + i) &&
14611
+ list.every(f=> f.sourceSize.x === list[0].sourceSize.x &&
14612
+ f.sourceSize.y === list[0].sourceSize.y);
14613
+ if (grouped)
14614
+ groups.push({name: stem, frames: list});
14615
+ else
14616
+ list.forEach(f=> groups.push({name: f.name, frames: [f]}));
14617
+ }
14618
+ return groups;
14619
+ }
14620
+
14621
+ /** Wait for everything started by loadSprite and loadAtlas to finish packing
14622
+ * @return {Promise}
14623
+ * @example
14624
+ * async function gameInit()
14625
+ * {
14626
+ * playerTile = loadSprite('player.png');
14627
+ * runTile = loadSprite('run.png', vec2(16));
14628
+ * await spritesReady();
14629
+ * }
14630
+ * @memberof TextureSheets */
14631
+ async function spritesReady()
14632
+ {
14633
+ // keep waiting until the queue drains, more sprites may load while waiting
14634
+ while (textureSheetPendingCount)
14635
+ await textureSheetQueue;
14636
+ }
14637
+
14638
+ // create a new texture sheet and add it to the list
14639
+ function textureSheetCreate()
14640
+ {
14641
+ const sheet = new TextureSheet;
14642
+ textureSheets.push(sheet);
14643
+ return sheet;
14644
+ }
14645
+
14646
+ // use the first sheet with enough space, or make a new one
14647
+ function textureSheetAdd(imageSize, frameSize, padding)
14648
+ {
14649
+ let sheet, tile;
14650
+ for (sheet of textureSheets)
14651
+ if (tile = sheet.tryAdd(imageSize, frameSize, padding))
14652
+ break;
14653
+ if (!tile)
14654
+ {
14655
+ sheet = textureSheetCreate();
14656
+ tile = sheet.tryAdd(imageSize, frameSize, padding);
14657
+ ASSERT(!!tile, 'image is too large to fit on a texture sheet');
14658
+ }
14659
+ return {sheet, tile};
14660
+ }
14661
+
14662
+ ///////////////////////////////////////////////////////////////////////////////
14663
+ // Texture sheet setting setters
14664
+
14665
+ /** Set width and height in pixels of texture sheets created by loadSprite
14666
+ * @param {number} size
14667
+ * @memberof Settings */
14668
+ function setTextureSheetSize(size) { textureSheetSize = size; }
14669
+
14670
+ /** Set default padding pixels around each frame packed by loadSprite
14671
+ * @param {number} padding
14672
+ * @memberof Settings */
14673
+ function setTextureSheetPadding(padding) { textureSheetPadding = padding; }
14674
+
14203
14675
  /**
14204
14676
  * LittleJS Tween System Plugin
14205
14677
  * - Lightweight tweens for numbers, Vector2, Color, or any .lerp-able type
@@ -14626,7 +15098,11 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
14626
15098
  const callback = (value) =>
14627
15099
  {
14628
15100
  let obj = target;
14629
- for (const k of parts) obj = obj[k];
15101
+ for (const k of parts)
15102
+ {
15103
+ obj = obj[k];
15104
+ ASSERT(obj != null, 'tweenProperty path does not resolve: ' + propertyPath);
15105
+ }
14630
15106
  obj[lastKey] = value;
14631
15107
  };
14632
15108
  return new Tween(callback, start, end, duration, options);
@@ -15509,3 +15985,157 @@ class PathFinder
15509
15985
  }
15510
15986
  }
15511
15987
 
15988
+ /**
15989
+ * LittleJS Three.js Plugin
15990
+ * - Renders a three.js scene on a canvas behind the LittleJS canvases
15991
+ * - The three.js module is passed in by the user, nothing is bundled
15992
+ * - Keep canvasClearColor transparent so the 3D scene shows through
15993
+ * - Aligned camera mode locks the 3D camera to the LittleJS 2D camera
15994
+ * - ThreeJSObject lets LittleJS physics drive a three.js mesh
15995
+ * - Call new ThreeJSPlugin(THREE) in gameInit to set up
15996
+ * @namespace ThreeJS
15997
+ */
15998
+
15999
+ ///////////////////////////////////////////////////////////////////////////////
16000
+
16001
+ /** Global ThreeJS plugin object
16002
+ * @type {ThreeJSPlugin}
16003
+ * @memberof ThreeJS */
16004
+ let threeJS;
16005
+
16006
+ ///////////////////////////////////////////////////////////////////////////////
16007
+ /**
16008
+ * ThreeJS Plugin - Renders a three.js scene behind the LittleJS canvas
16009
+ * @example
16010
+ * // in gameInit, with three.js loaded by the user
16011
+ * new ThreeJSPlugin(THREE);
16012
+ * threeJS.scene.add(new THREE.AmbientLight);
16013
+ * @memberof ThreeJS
16014
+ */
16015
+ class ThreeJSPlugin
16016
+ {
16017
+ /** Set up the three.js rendering layer, call in gameInit
16018
+ * @param {Object} THREE - The three.js module, supplied by the user
16019
+ * @param {number} [cameraFOV] - Vertical field of view in degrees */
16020
+ constructor(THREE, cameraFOV=60)
16021
+ {
16022
+ ASSERT(!threeJS, 'ThreeJS plugin already initialized');
16023
+ threeJS = this;
16024
+ if (headlessMode) return;
16025
+ ASSERT(mainCanvas, 'ThreeJS plugin must be created after engineInit, call in gameInit');
16026
+ ASSERT(THREE && THREE.WebGLRenderer, 'three.js module must be passed in');
16027
+
16028
+ /** @property {Object} - The three.js module passed into the constructor */
16029
+ this.THREE = THREE;
16030
+ /** @property {Object} - The three.js renderer */
16031
+ this.renderer = new THREE.WebGLRenderer({antialias: true});
16032
+ /** @property {Object} - The three.js scene, add lights and meshes here */
16033
+ this.scene = new THREE.Scene();
16034
+ /** @property {Object} - The three.js perspective camera */
16035
+ this.camera = new THREE.PerspectiveCamera(cameraFOV, 1, .1, 1e3);
16036
+ /** @property {boolean} - Lock the camera to the LittleJS 2D camera so the z=0 plane matches world space */
16037
+ this.cameraAlign2D = true;
16038
+
16039
+ // insert the canvas below the engine canvases and match the layout
16040
+ const threeCanvas = this.renderer.domElement;
16041
+ const rootElement = mainCanvas.parentElement;
16042
+ rootElement.insertBefore(threeCanvas, rootElement.firstChild);
16043
+ threeCanvas.style.cssText = mainCanvas.style.cssText;
16044
+
16045
+ // render automatically each frame after the engine renders
16046
+ engineAddPlugin(undefined, ()=> this.render());
16047
+ }
16048
+
16049
+ /** Position the camera so the z=0 plane exactly matches LittleJS world space,
16050
+ * called automatically when cameraAlign2D is set */
16051
+ alignCamera2D()
16052
+ {
16053
+ const halfHeight = mainCanvasSize.y / 2 / cameraScale; // half visible height in world units
16054
+ const distance = halfHeight / tan(this.camera.fov/2 * PI/180);
16055
+ this.camera.position.set(cameraPos.x, cameraPos.y, distance);
16056
+ // reset all axes in case a free camera was used, littlejs angles are clockwise
16057
+ this.camera.rotation.set(0, 0, -cameraAngle);
16058
+ }
16059
+
16060
+ /** Sync the canvas layout and render the scene, called automatically each frame */
16061
+ render()
16062
+ {
16063
+ if (!this.renderer) return; // headless mode
16064
+
16065
+ // keep renderer size and css in sync with the LittleJS canvas
16066
+ const threeCanvas = this.renderer.domElement;
16067
+ if (threeCanvas.width != mainCanvasSize.x || threeCanvas.height != mainCanvasSize.y)
16068
+ {
16069
+ this.renderer.setSize(mainCanvasSize.x, mainCanvasSize.y, false);
16070
+ this.camera.aspect = mainCanvasSize.x / mainCanvasSize.y;
16071
+ this.camera.updateProjectionMatrix();
16072
+ }
16073
+ if (threeCanvas.style.cssText != mainCanvas.style.cssText)
16074
+ threeCanvas.style.cssText = mainCanvas.style.cssText;
16075
+
16076
+ if (this.cameraAlign2D)
16077
+ this.alignCamera2D();
16078
+ this.renderer.render(this.scene, this.camera);
16079
+ }
16080
+ }
16081
+
16082
+ ///////////////////////////////////////////////////////////////////////////////
16083
+ /**
16084
+ * ThreeJS Object - EngineObject that drives a three.js mesh
16085
+ * - LittleJS physics moves the object and the mesh follows automatically
16086
+ * - Destroying the object removes the mesh from the scene
16087
+ * @extends EngineObject
16088
+ * @memberof ThreeJS
16089
+ */
16090
+ class ThreeJSObject extends EngineObject
16091
+ {
16092
+ /** Create an engine object that drives a three.js mesh
16093
+ * @param {Vector2} [pos] - World space position
16094
+ * @param {Vector2} [size] - World space size
16095
+ * @param {Object} [mesh] - The three.js object3d to drive
16096
+ * @param {number} [z] - Mesh height above the 2D plane */
16097
+ constructor(pos, size, mesh, z=0)
16098
+ {
16099
+ super(pos, size);
16100
+ ASSERT(threeJS, 'ThreeJS plugin must be initialized first');
16101
+
16102
+ /** @property {Object} - The three.js object3d this object drives */
16103
+ this.mesh = mesh;
16104
+ /** @property {number} - Mesh height above the 2D plane */
16105
+ this.z = z;
16106
+ if (mesh)
16107
+ {
16108
+ threeJS.scene.add(mesh);
16109
+ this.syncMesh();
16110
+ }
16111
+ }
16112
+
16113
+ /** Update the object and sync the mesh to its transform */
16114
+ update()
16115
+ {
16116
+ super.update();
16117
+ this.syncMesh();
16118
+ }
16119
+
16120
+ /** Copy this object's transform to the mesh */
16121
+ syncMesh()
16122
+ {
16123
+ if (!this.mesh) return;
16124
+ this.mesh.position.set(this.pos.x, this.pos.y, this.z);
16125
+ this.mesh.rotation.z = -this.angle; // littlejs angles are clockwise
16126
+ }
16127
+
16128
+ /** The mesh is this object's visual, the default 2D rendering is skipped */
16129
+ render() {}
16130
+
16131
+ /** Destroy this object and remove its mesh from the scene
16132
+ * @param {boolean} [immediate] */
16133
+ destroy(immediate)
16134
+ {
16135
+ if (this.destroyed) return;
16136
+ // note: frequently destroyed objects should also dispose geometry and material
16137
+ this.mesh && threeJS.scene.remove(this.mesh);
16138
+ super.destroy(immediate);
16139
+ }
16140
+ }
16141
+