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();
@@ -1237,6 +1241,7 @@ function debugVideoCaptureStart()
1237
1241
  {
1238
1242
  LOG('Video capture not supported in this browser!');
1239
1243
  silentAudioSource?.stop();
1244
+ audioStreamDestination && audioMasterGain.disconnect(audioStreamDestination);
1240
1245
  return;
1241
1246
  }
1242
1247
 
@@ -1265,6 +1270,8 @@ function debugVideoCaptureStop()
1265
1270
  debugVideoCapture.silentAudioSource?.stop();
1266
1271
  debugVideoCapture.mediaRecorder?.stop();
1267
1272
  debugVideoCapture.videoTrack?.stop();
1273
+ if (debugVideoCapture.audioStreamDestination)
1274
+ audioMasterGain.disconnect(debugVideoCapture.audioStreamDestination);
1268
1275
  debugVideoCapture = undefined;
1269
1276
  }
1270
1277
 
@@ -1503,7 +1510,7 @@ function smoothStep(percent) { return percent * percent * (3 - 2 * percent); }
1503
1510
  * @param {number} value
1504
1511
  * @return {boolean}
1505
1512
  * @memberof Math */
1506
- function isPowerOfTwo(value) { return !(value & (value - 1)); }
1513
+ function isPowerOfTwo(value) { return value > 0 && !(value & (value - 1)); }
1507
1514
 
1508
1515
  /** Returns the nearest power of two not less than the value
1509
1516
  * @param {number} value
@@ -1580,7 +1587,7 @@ function isIntersecting(start, end, pos, size)
1580
1587
  * @memberof Math */
1581
1588
  function oscillate(frequency=1, amplitude=1, t=time, offset=0, type=0)
1582
1589
  {
1583
- const phase = (offset + t*frequency) % 1;
1590
+ const phase = mod(offset + t*frequency, 1);
1584
1591
  let value;
1585
1592
 
1586
1593
  if (type === 1) // triangle
@@ -2516,7 +2523,7 @@ const MAGENTA = debugProtectConstant(rgb(1,0,1));
2516
2523
  class Timer
2517
2524
  {
2518
2525
  /** Create a timer object set time passed in
2519
- * @param {number} [timeLeft] - How much time left before the timer
2526
+ * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds (undefined = unset)
2520
2527
  * @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */
2521
2528
  constructor(timeLeft, useRealTime=false)
2522
2529
  {
@@ -3115,6 +3122,7 @@ let vibrateEnable = true;
3115
3122
  let soundEnable = true;
3116
3123
 
3117
3124
  /** Volume scale to apply to all sound, music and speech
3125
+ * Use setSoundVolume to also update the audio master gain immediately
3118
3126
  * @type {number}
3119
3127
  * @default
3120
3128
  * @memberof Settings */
@@ -4159,8 +4167,9 @@ class TileInfo
4159
4167
  * @param {TextureInfo} [textureInfo] - Texture info to use
4160
4168
  * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size)
4161
4169
  * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs)
4170
+ * @param {number} [columns] - How many frames per row for frame(), 0 to keep frames on a single row
4162
4171
  */
4163
- constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed)
4172
+ constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed, columns=0)
4164
4173
  {
4165
4174
  /** @property {Vector2} - Top left corner of tile in pixels */
4166
4175
  this.pos = pos.copy();
@@ -4172,6 +4181,8 @@ class TileInfo
4172
4181
  this.textureInfo = textureInfo;
4173
4182
  /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
4174
4183
  this.bleed = bleed;
4184
+ /** @property {number} - How many frames per row for frame(), 0 to keep frames on a single row */
4185
+ this.columns = columns;
4175
4186
  }
4176
4187
 
4177
4188
  /** Returns a copy of this tile offset by a vector
@@ -4179,9 +4190,10 @@ class TileInfo
4179
4190
  * @return {TileInfo}
4180
4191
  */
4181
4192
  offset(offset)
4182
- { return new TileInfo(this.pos.add(offset), this.size, this.textureInfo, this.padding, this.bleed); }
4193
+ { return new TileInfo(this.pos.add(offset), this.size, this.textureInfo, this.padding, this.bleed, this.columns); }
4183
4194
 
4184
4195
  /** Returns a copy of this tile offset by a number of animation frames
4196
+ * Frames wrap down to the next row if columns is set
4185
4197
  * @param {number} frame - Offset to apply in animation frames
4186
4198
  * @return {TileInfo}
4187
4199
  */
@@ -4189,11 +4201,33 @@ class TileInfo
4189
4201
  {
4190
4202
  ASSERT(typeof frame === 'number');
4191
4203
  const w = this.size.x + this.padding*2;
4192
- const x = frame*w;
4193
- ASSERT(x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
4194
- return this.offset(new Vector2(x));
4204
+ const h = this.size.y + this.padding*2;
4205
+ const x = (this.columns ? frame % this.columns : frame) * w;
4206
+ const y = (this.columns ? frame / this.columns | 0 : 0) * h;
4207
+ ASSERT(this.pos.x + x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!');
4208
+ ASSERT(this.pos.y + y + this.size.y <= this.textureInfo.size.y, 'frame extends beyond texture height!');
4209
+ return this.offset(new Vector2(x, y));
4210
+ }
4211
+
4212
+ /** Set how many frames per row this tile uses, so frame() can wrap
4213
+ * @param {number} [columns] - Frames per row, 0 to keep frames on a single row
4214
+ * @return {TileInfo}
4215
+ */
4216
+ setColumns(columns=0)
4217
+ {
4218
+ ASSERT(isNumber(columns) && columns >= 0, 'columns must be a number >= 0');
4219
+ this.columns = columns;
4220
+ return this;
4195
4221
  }
4196
4222
 
4223
+ /**
4224
+ * Returns a tile info for an index using this tile as reference
4225
+ * @param {Vector2|number} [index=0]
4226
+ * @return {TileInfo}
4227
+ */
4228
+ index(index)
4229
+ { return tile(index, this.size, this.textureInfo, this.padding, this.bleed).setColumns(this.columns); }
4230
+
4197
4231
  /**
4198
4232
  * Set this tile to use a full image in a texture info
4199
4233
  * @param {TextureInfo} [textureInfo]
@@ -4204,17 +4238,9 @@ class TileInfo
4204
4238
  this.textureInfo = textureInfo;
4205
4239
  this.pos = new Vector2;
4206
4240
  this.size = textureInfo.size.copy();
4207
- this.bleed = this.padding = 0;
4241
+ this.bleed = this.padding = this.columns = 0;
4208
4242
  return this;
4209
4243
  }
4210
-
4211
- /**
4212
- * Returns a tile info for an index using this tile as reference
4213
- * @param {Vector2|number} [index=0]
4214
- * @return {TileInfo}
4215
- */
4216
- tile(index)
4217
- { return tile(index, this.size, this.textureInfo, this.padding, this.bleed); }
4218
4244
  }
4219
4245
 
4220
4246
  /**
@@ -5912,7 +5938,10 @@ function inputInit()
5912
5938
  {
5913
5939
  inputData[0][e.code] = (inputData[0][e.code]&2) | 4;
5914
5940
  if (inputWASDEmulateDirection)
5915
- inputData[0][remapKey(e.code)] = 4;
5941
+ {
5942
+ const remap = remapKey(e.code);
5943
+ inputData[0][remap] = (inputData[0][remap]&2) | 4;
5944
+ }
5916
5945
  }
5917
5946
  function remapKey(k)
5918
5947
  {
@@ -5987,7 +6016,7 @@ function inputInit()
5987
6016
  document.addEventListener('touchend', (e)=> handleTouch(e), { passive: false });
5988
6017
 
5989
6018
  // handle all touch events the same way
5990
- let wasTouching;
6019
+ let wasTouching, touchIdentifier;
5991
6020
  function handleTouch(e)
5992
6021
  {
5993
6022
  if (!touchInputEnable) return;
@@ -6018,10 +6047,11 @@ function inputInit()
6018
6047
  const pos = vec2(gameTouches[0].clientX, gameTouches[0].clientY);
6019
6048
  const mousePosScreenLast = mousePosScreen;
6020
6049
  mousePosScreen = mouseEventToScreen(pos);
6021
- if (wasTouching)
6050
+ if (wasTouching && gameTouches[0].identifier === touchIdentifier)
6022
6051
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
6023
- else
6052
+ else if (!wasTouching)
6024
6053
  inputData[0][button] = 3;
6054
+ touchIdentifier = gameTouches[0].identifier;
6025
6055
  }
6026
6056
  else if (wasTouching)
6027
6057
  inputData[0][button] = inputData[0][button] & 2 | 4;
@@ -7179,7 +7209,7 @@ class SoundInstance
7179
7209
  * @param {number} [rate] - How quickly to speak
7180
7210
  * @param {number} [pitch] - How much to change the pitch by
7181
7211
  * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
7182
- * @return {SpeechSynthesisUtterance} - The utterance that was spoken
7212
+ * @return {SpeechSynthesisUtterance|undefined} - The utterance that was spoken, or undefined if speech is unavailable
7183
7213
  * @memberof Audio */
7184
7214
  function speak(text, volume=1, rate=1, pitch=1, language='')
7185
7215
  {
@@ -7232,7 +7262,7 @@ function getNoteFrequency(semitoneOffset, rootFrequency=220)
7232
7262
  * @param {number} [pan] - How much to apply stereo panning
7233
7263
  * @param {boolean} [loop] - True if the sound should loop when it reaches the end
7234
7264
  * @param {number} [sampleRate=44100] - Sample rate for the sound
7235
- * @param {GainNode} [gainNode] - Optional gain node for volume control while playing
7265
+ * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
7236
7266
  * @param {number} [offset] - Offset in seconds to start playback from
7237
7267
  * @param {AudioEndedCallback} [onended] - Callback for when the sound ends
7238
7268
  * @return {AudioBufferSourceNode} - The source node of the sound played, may be undefined if play fails
@@ -7474,10 +7504,14 @@ function tileCollisionGetData(pos, solidOnly=true)
7474
7504
  // check all tile collision layers
7475
7505
  for (const layer of tileCollisionLayers)
7476
7506
  if (!solidOnly || layer.isSolid)
7477
- if (pos.arrayCheck(layer.size))
7478
7507
  {
7479
- const data = layer.getCollisionData(pos);
7480
- if (data) return data;
7508
+ // convert world pos to layer local space
7509
+ const layerPos = pos.subtract(layer.pos);
7510
+ if (layerPos.arrayCheck(layer.size))
7511
+ {
7512
+ const data = layer.getCollisionData(layerPos);
7513
+ if (data) return data;
7514
+ }
7481
7515
  }
7482
7516
  return 0;
7483
7517
  }
@@ -7752,7 +7786,7 @@ class TileLayer extends CanvasLayer
7752
7786
 
7753
7787
  /** @property {TileInfo} - Default tile info for layer */
7754
7788
  this.tileInfo = undefined;
7755
- /** @property {Array<TileLayerData>} - Default tile info for layer */
7789
+ /** @property {Array<TileLayerData>} - Array of tile data for the layer */
7756
7790
  this.data = [];
7757
7791
  /** @property {boolean} - Is this layer using a webgl texture? */
7758
7792
  this.isUsingWebGL = false;
@@ -7837,7 +7871,7 @@ class TileLayer extends CanvasLayer
7837
7871
 
7838
7872
  const size = this.drawSize || this.size;
7839
7873
  const pos = this.pos.add(size.scale(.5));
7840
- this.draw(pos, this.size, this.color, this.angle, this.mirror, this.additiveColor);
7874
+ this.draw(pos, size, this.color, this.angle, this.mirror, this.additiveColor);
7841
7875
  }
7842
7876
 
7843
7877
  /** Called after this layer is redrawn, does nothing by default */
@@ -7926,7 +7960,7 @@ class TileLayer extends CanvasLayer
7926
7960
  const d = this.getData(layerPos);
7927
7961
  if (!d || !d.tile) return;
7928
7962
 
7929
- const tileInfo = this.tileInfo && this.tileInfo.tile(d.tile);
7963
+ const tileInfo = this.tileInfo && this.tileInfo.index(d.tile);
7930
7964
  this.drawLayerTile(drawPos, drawSize, tileInfo, d.color, d.direction*PI/2, d.mirror);
7931
7965
  }
7932
7966
 
@@ -8220,8 +8254,8 @@ class TileCollisionLayer extends TileLayer
8220
8254
  * rgb(1,1,1,1), rgb(0,0,0,1), // colorStartA, colorStartB
8221
8255
  * rgb(1,1,1,0), rgb(0,0,0,0), // colorEndA, colorEndB
8222
8256
  * 1, .2, .2, .1, .05, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed
8223
- * .99, 1, 1, PI, .05, // damping, angleDamping, gravityScale, particleCone, fadeRate,
8224
- * .5, 1 // randomness, collide, additive, randomColorLinear, renderOrder
8257
+ * .99, 1, 1, PI, .05, // damping, angleDamping, gravityScale, particleCone, fadeRate
8258
+ * .5, 1 // randomness, collide
8225
8259
  * );
8226
8260
  */
8227
8261
  class ParticleEmitter extends EngineObject
@@ -8613,13 +8647,12 @@ class Particle
8613
8647
  const hitLayer = tileCollisionTest(this.pos);
8614
8648
  if (!testCollision(oldPos))
8615
8649
  {
8616
- // testCollision already invoked collideCallback with the
8617
- // correct (this, data, pos) args; no need to re-check here.
8618
8650
  // test which side we bounced off (or both if a corner)
8619
8651
  const isBlockedX = testCollision(vec2(this.pos.x, oldPos.y));
8620
8652
  const isBlockedY = testCollision(vec2(oldPos.x, this.pos.y));
8621
- const hitRestitution = max(restitution, hitLayer.restitution);
8622
- const hitFriction = max(friction, hitLayer.friction);
8653
+ // collide callback may hit where the layer test does not, so hitLayer can be undefined
8654
+ const hitRestitution = hitLayer ? max(restitution, hitLayer.restitution) : restitution;
8655
+ const hitFriction = hitLayer ? max(friction, hitLayer.friction) : friction;
8623
8656
  if (isBlockedX)
8624
8657
  {
8625
8658
  // move to previous X position and bounce
@@ -10268,7 +10301,8 @@ class NewgroundsPlugin
10268
10301
  return;
10269
10302
  }
10270
10303
  debugMedals && LOG(xmlHttp.responseText);
10271
- return xmlHttp.responseText && JSON.parse(xmlHttp.responseText);
10304
+ try { return xmlHttp.responseText && JSON.parse(xmlHttp.responseText); }
10305
+ catch(e) { debugMedals && LOG('newgrounds response is not valid JSON', e); }
10272
10306
  }
10273
10307
  }
10274
10308
  /**
@@ -10287,8 +10321,8 @@ class NewgroundsPlugin
10287
10321
  let postProcess;
10288
10322
 
10289
10323
  /////////////////////////////////////////////////////////////////////////
10290
- /**
10291
- * UI System Global Object
10324
+ /**
10325
+ * Post Process Plugin - Applies a full screen shader to the rendered output
10292
10326
  * @memberof PostProcess
10293
10327
  */
10294
10328
  class PostProcessPlugin
@@ -10402,8 +10436,9 @@ class PostProcessPlugin
10402
10436
  workCanvas.height = mainCanvasSize.y;
10403
10437
  glCopyToContext(workContext);
10404
10438
  workContext.drawImage(mainCanvas, 0, 0);
10405
- mainCanvas.width |= 0
10406
-
10439
+ mainCanvas.width |= 0; // setting size clears the main canvas
10440
+
10441
+
10407
10442
  // copy work canvas to texture
10408
10443
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, workCanvas);
10409
10444
  }
@@ -11508,7 +11543,7 @@ class UISystemPlugin
11508
11543
  }
11509
11544
 
11510
11545
  /** Get other axis navigation direction from gamepad or keyboard
11511
- * @return {Vector2} */
11546
+ * @return {number} */
11512
11547
  getNavigationOtherDirection()
11513
11548
  {
11514
11549
  if (uiSystem.navigationDirection === 2)
@@ -11597,6 +11632,7 @@ class UISystemPlugin
11597
11632
  uiSystem.navigationDirection = savedNavigationDirection;
11598
11633
  inputClear();
11599
11634
  }
11635
+ return confirmMenu;
11600
11636
  }
11601
11637
  }
11602
11638
 
@@ -12030,7 +12066,7 @@ class UITextInput extends UIObject
12030
12066
  this.onClick();
12031
12067
  }
12032
12068
 
12033
- /** Stop editing the text edited */
12069
+ /** Stop editing the text */
12034
12070
  stopEditing()
12035
12071
  {
12036
12072
  if (!this.isKeyInputObject())
@@ -12193,7 +12229,7 @@ class UICheckbox extends UIObject
12193
12229
  ASSERT(isStringLike(text), 'ui checkbox must be a string');
12194
12230
  ASSERT(isColor(color), 'ui checkbox color must be a color');
12195
12231
 
12196
- /** @property {boolean} - Current percentage value of this slider 0-1 */
12232
+ /** @property {boolean} - Is the checkbox currently checked? */
12197
12233
  this.checked = checked;
12198
12234
  // set properties
12199
12235
  this.text = text;
@@ -12958,7 +12994,7 @@ class Box2dObject extends EngineObject
12958
12994
  shape.set_m_vertex3(box2d.vec2dTo(getPoint(i+2)));
12959
12995
  const f = this.addShape(shape, density, friction, restitution, isSensor);
12960
12996
  fixtures.push(f);
12961
- i < points.length && edgePoints.push(points[i].copy());
12997
+ edgePoints.push(points[i].copy());
12962
12998
  }
12963
12999
  this.edgeLoops.push(edgePoints);
12964
13000
  return fixtures;
@@ -13024,7 +13060,7 @@ class Box2dObject extends EngineObject
13024
13060
  /** Sets the position
13025
13061
  * @param {Vector2} pos */
13026
13062
  setPosition(pos)
13027
- { this.setTransform(pos, this.body.GetAngle()); }
13063
+ { this.setTransform(pos, -this.body.GetAngle()); }
13028
13064
 
13029
13065
  /** Sets the angle
13030
13066
  * @param {number} angle */
@@ -13121,6 +13157,7 @@ class Box2dObject extends EngineObject
13121
13157
  filter.set_categoryBits(categoryBits);
13122
13158
  filter.set_maskBits(0xffff & ~ignoreCategoryBits);
13123
13159
  filter.set_groupIndex(groupIndex);
13160
+ fixture.SetFilterData(filter); // applies and refilters contacts
13124
13161
  });
13125
13162
  }
13126
13163
 
@@ -13656,7 +13693,7 @@ class Box2dRevoluteJoint extends Box2dJoint
13656
13693
  jointDef.set_bodyB(objectB.body);
13657
13694
  jointDef.set_localAnchorA(box2d.vec2dTo(localAnchorA));
13658
13695
  jointDef.set_localAnchorB(box2d.vec2dTo(localAnchorB));
13659
- jointDef.set_referenceAngle(objectA.body.GetAngle() - objectB.body.GetAngle());
13696
+ jointDef.set_referenceAngle(objectB.body.GetAngle() - objectA.body.GetAngle());
13660
13697
  jointDef.set_collideConnected(collide);
13661
13698
  super(jointDef);
13662
13699
  }
@@ -13803,14 +13840,14 @@ class Box2dPrismaticJoint extends Box2dJoint
13803
13840
  anchor ||= box2d.vec2From(objectB.body.GetPosition());
13804
13841
  const localAnchorA = objectA.worldToLocal(anchor);
13805
13842
  const localAnchorB = objectB.worldToLocal(anchor);
13806
- const localAxisA = objectB.worldToLocalVector(worldAxis);
13843
+ const localAxisA = objectA.worldToLocalVector(worldAxis);
13807
13844
  const jointDef = new box2d.instance.b2PrismaticJointDef();
13808
13845
  jointDef.set_bodyA(objectA.body);
13809
13846
  jointDef.set_bodyB(objectB.body);
13810
13847
  jointDef.set_localAnchorA(box2d.vec2dTo(localAnchorA));
13811
13848
  jointDef.set_localAnchorB(box2d.vec2dTo(localAnchorB));
13812
13849
  jointDef.set_localAxisA(box2d.vec2dTo(localAxisA));
13813
- jointDef.set_referenceAngle(objectA.body.GetAngle() - objectB.body.GetAngle());
13850
+ jointDef.set_referenceAngle(objectB.body.GetAngle() - objectA.body.GetAngle());
13814
13851
  jointDef.set_collideConnected(collide);
13815
13852
  super(jointDef);
13816
13853
  }
@@ -13913,7 +13950,7 @@ class Box2dWheelJoint extends Box2dJoint
13913
13950
  anchor ||= box2d.vec2From(objectB.body.GetPosition());
13914
13951
  const localAnchorA = objectA.worldToLocal(anchor);
13915
13952
  const localAnchorB = objectB.worldToLocal(anchor);
13916
- const localAxisA = objectB.worldToLocalVector(worldAxis);
13953
+ const localAxisA = objectA.worldToLocalVector(worldAxis);
13917
13954
  const jointDef = new box2d.instance.b2WheelJointDef();
13918
13955
  jointDef.set_bodyA(objectA.body);
13919
13956
  jointDef.set_bodyB(objectB.body);
@@ -14013,7 +14050,7 @@ class Box2dWeldJoint extends Box2dJoint
14013
14050
  jointDef.set_bodyB(objectB.body);
14014
14051
  jointDef.set_localAnchorA(box2d.vec2dTo(localAnchorA));
14015
14052
  jointDef.set_localAnchorB(box2d.vec2dTo(localAnchorB));
14016
- jointDef.set_referenceAngle(objectA.body.GetAngle() - objectB.body.GetAngle());
14053
+ jointDef.set_referenceAngle(objectB.body.GetAngle() - objectA.body.GetAngle());
14017
14054
  jointDef.set_collideConnected(collide);
14018
14055
  super(jointDef);
14019
14056
  }
@@ -14460,7 +14497,7 @@ class Box2dPlugin
14460
14497
  * @param {number} [lineWidth]
14461
14498
  * @param {boolean} [useWebGL=glEnable]
14462
14499
  * @param {CanvasRenderingContext2D} [context] */
14463
- drawFixture(fixture, pos, angle, color=WHITE, lineColor=BLACK, lineWidth=.1, useWebgl, context)
14500
+ drawFixture(fixture, pos, angle, color=WHITE, lineColor=BLACK, lineWidth=.1, useWebGL, context)
14464
14501
  {
14465
14502
  const shape = box2d.castShapeObject(fixture.GetShape());
14466
14503
  switch (shape.GetType())
@@ -14470,20 +14507,20 @@ class Box2dPlugin
14470
14507
  let points = [];
14471
14508
  for (let i=shape.GetVertexCount(); i--;)
14472
14509
  points.push(box2d.vec2From(shape.GetVertex(i)));
14473
- drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebgl, false, context);
14510
+ drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, false, context);
14474
14511
  break;
14475
14512
  }
14476
14513
  case box2d.instance.b2Shape.e_circle:
14477
14514
  {
14478
14515
  const radius = shape.get_m_radius();
14479
- drawCircle(pos, radius*2, color, lineWidth, lineColor, useWebgl, false, context);
14516
+ drawCircle(pos, radius*2, color, lineWidth, lineColor, useWebGL, false, context);
14480
14517
  break;
14481
14518
  }
14482
14519
  case box2d.instance.b2Shape.e_edge:
14483
14520
  {
14484
14521
  const v1 = box2d.vec2From(shape.get_m_vertex1());
14485
14522
  const v2 = box2d.vec2From(shape.get_m_vertex2());
14486
- drawLine(v1, v2, lineWidth, lineColor, pos, angle, useWebgl, false, context);
14523
+ drawLine(v1, v2, lineWidth, lineColor, pos, angle, useWebGL, false, context);
14487
14524
  break;
14488
14525
  }
14489
14526
  }
@@ -14888,6 +14925,444 @@ function getCrescentPoints(pos, size=1, percent=0, angle=0, invert=false, sides=
14888
14925
  }
14889
14926
  return points;
14890
14927
  }
14928
+ /**
14929
+ * LittleJS Texture Sheet Plugin
14930
+ * - Packs images into texture sheets as they are loaded
14931
+ * - Sprites are placed automatically, callers get a TileInfo
14932
+ * - Sheets are created and filled as needed
14933
+ * - Sheets fill in call order, images decode in parallel
14934
+ * - Animation frames keep layout and wrap across rows as needed
14935
+ * - WebGL textures upload once per batch of loads
14936
+ * - loadAtlas imports pre-packed atlases (TexturePacker and Aseprite json)
14937
+ * @namespace TextureSheets
14938
+ */
14939
+
14940
+ /** Width and height in pixels of texture sheets created by loadSprite
14941
+ * @type {number}
14942
+ * @default
14943
+ * @memberof Settings */
14944
+ let textureSheetSize = 2048;
14945
+
14946
+ /** Default padding pixels around each frame packed by loadSprite
14947
+ * @type {number}
14948
+ * @default
14949
+ * @memberof Settings */
14950
+ let textureSheetPadding = 1;
14951
+
14952
+ /** Array of texture sheets created by loadSprite
14953
+ * @type {Array<TextureSheet>}
14954
+ * @memberof TextureSheets */
14955
+ let textureSheets = [];
14956
+
14957
+ // pending loads pack through a queue so sheets fill in call order
14958
+ let textureSheetQueue = Promise.resolve();
14959
+ let textureSheetPendingCount = 0;
14960
+
14961
+ /**
14962
+ * Texture Sheet - A texture that images are packed into as they load
14963
+ * Uses shelf packing, images are placed left to right then wrap to a new row
14964
+ * @memberof TextureSheets
14965
+ */
14966
+ class TextureSheet
14967
+ {
14968
+ /** Create a texture sheet, called automatically by loadSprite
14969
+ * @param {number} [size] - Width and height of the sheet in pixels */
14970
+ constructor(size=textureSheetSize)
14971
+ {
14972
+ ASSERT(size > 0, 'texture sheet size must be positive');
14973
+
14974
+ /** @property {number} - Width and height of the sheet in pixels */
14975
+ this.size = size;
14976
+ /** @property {OffscreenCanvas} - Canvas holding the packed images */
14977
+ this.canvas = headlessMode ? undefined : new OffscreenCanvas(size, size);
14978
+ /** @property {OffscreenCanvasRenderingContext2D} - 2d context for the canvas */
14979
+ this.context = this.canvas?.getContext('2d');
14980
+ /** @property {TextureInfo} - The texture info for this sheet */
14981
+ this.textureInfo = new TextureInfo(this.canvas);
14982
+ /** @property {Vector2} - Where the next image will be packed */
14983
+ this.cursor = vec2();
14984
+ /** @property {number} - Height of the row being packed */
14985
+ this.rowHeight = 0;
14986
+ /** @property {boolean} - Has the canvas changed since the last webgl upload? */
14987
+ this.glDirty = false;
14988
+
14989
+ if (headlessMode)
14990
+ {
14991
+ // tiles still need bounds when there is no canvas to measure
14992
+ this.textureInfo.size = vec2(size);
14993
+ this.textureInfo.sizeInverse = vec2(1/size);
14994
+ }
14995
+ }
14996
+
14997
+ /** Find a spot for an image on this sheet without drawing it
14998
+ * @param {Vector2} imageSize - Size of the source image in pixels
14999
+ * @param {Vector2} [frameSize] - Size of each frame, or the whole image if not passed
15000
+ * @param {number} [padding] - How many pixels padding around each frame
15001
+ * @return {TileInfo} Tile for the packed image, or undefined if the sheet is full */
15002
+ tryAdd(imageSize, frameSize=imageSize, padding=textureSheetPadding)
15003
+ {
15004
+ ASSERT(isVector2(imageSize) && isVector2(frameSize), 'sizes must be vec2');
15005
+ ASSERT(frameSize.x > 0 && frameSize.y > 0, 'frame size must be positive');
15006
+ ASSERT(imageSize.x % frameSize.x === 0 && imageSize.y % frameSize.y === 0,
15007
+ 'image size must be a multiple of the frame size');
15008
+
15009
+ const cellWidth = frameSize.x + padding*2;
15010
+ const cellHeight = frameSize.y + padding*2;
15011
+ const maxColumns = this.size / cellWidth | 0;
15012
+ ASSERT(maxColumns > 0, 'frame is too wide to fit on a texture sheet');
15013
+
15014
+ // keep the layout of the source image, but narrow it if a row is too wide
15015
+ // frames wrap down to the next row, which TileInfo.frame handles via columns
15016
+ const sourceColumns = imageSize.x / frameSize.x;
15017
+ const frameCount = sourceColumns * (imageSize.y / frameSize.y);
15018
+ const columns = min(sourceColumns, maxColumns);
15019
+ const blockWidth = columns * cellWidth;
15020
+ const blockHeight = ceil(frameCount / columns) * cellHeight;
15021
+
15022
+ // probe the placement using locals so a failed try leaves the sheet unchanged
15023
+ let x = this.cursor.x, y = this.cursor.y, rowHeight = this.rowHeight;
15024
+ if (x + blockWidth > this.size)
15025
+ {
15026
+ // start a new row if this one does not have enough space left
15027
+ x = 0;
15028
+ y += rowHeight;
15029
+ rowHeight = 0;
15030
+ }
15031
+
15032
+ // out of space, the caller needs to use a different sheet
15033
+ if (y + blockHeight > this.size)
15034
+ return undefined;
15035
+
15036
+ // commit the placement, tile pos points inside the padding to match how tile() works
15037
+ this.cursor.x = x + blockWidth;
15038
+ this.cursor.y = y;
15039
+ this.rowHeight = max(rowHeight, blockHeight);
15040
+ return new TileInfo(vec2(x + padding, y + padding), frameSize, this.textureInfo, padding, 0, columns);
15041
+ }
15042
+
15043
+ /** Draw an image into this sheet at a tile returned by tryAdd
15044
+ * @param {HTMLImageElement} image - Source image to copy from
15045
+ * @param {TileInfo} tileInfo - Where to put it, from tryAdd
15046
+ * @param {boolean} [update] - Upload to webgl now, pass false when batching */
15047
+ drawImage(image, tileInfo, update=true)
15048
+ {
15049
+ ASSERT(!!this.context, 'texture sheet has no canvas');
15050
+
15051
+ // copy frames in order, reading the source left to right, top to bottom
15052
+ // the destination wraps at tileInfo.columns which may be narrower than the source
15053
+ const frameSize = tileInfo.size;
15054
+ const sourceColumns = image.width / frameSize.x;
15055
+ const frameCount = sourceColumns * (image.height / frameSize.y);
15056
+ const columns = tileInfo.columns || frameCount;
15057
+ const cellWidth = frameSize.x + tileInfo.padding*2;
15058
+ const cellHeight = frameSize.y + tileInfo.padding*2;
15059
+ for (let i = frameCount; i--;)
15060
+ {
15061
+ const sourceX = (i % sourceColumns) * frameSize.x;
15062
+ const sourceY = (i / sourceColumns | 0) * frameSize.y;
15063
+ this.context.drawImage(image,
15064
+ sourceX, sourceY, frameSize.x, frameSize.y,
15065
+ tileInfo.pos.x + (i % columns) * cellWidth,
15066
+ tileInfo.pos.y + (i / columns | 0) * cellHeight,
15067
+ frameSize.x, frameSize.y);
15068
+ }
15069
+
15070
+ // upload now unless the caller is batching more images
15071
+ this.glDirty = true;
15072
+ update && this.updateTexture();
15073
+ }
15074
+
15075
+ /** Upload the canvas to webgl if it has changed since the last upload
15076
+ * Only needed after batching, drawImage uploads automatically by default */
15077
+ updateTexture()
15078
+ {
15079
+ if (!this.glDirty) return;
15080
+ this.glDirty = false;
15081
+ this.textureInfo.createWebGLTexture();
15082
+ }
15083
+ }
15084
+
15085
+ ///////////////////////////////////////////////////////////////////////////////
15086
+
15087
+ /** Load an image and pack it into a texture sheet
15088
+ * - Returns a TileInfo immediately which is filled in when the image loads
15089
+ * - Nothing is visible until it loads, use spritesReady to wait for it
15090
+ * - Pass frameSize for animations, then step through them with TileInfo.frame
15091
+ * - Grid images keep their layout and frames wrap down to the next row
15092
+ * @param {string} src - Image source path
15093
+ * @param {Vector2|number} [frameSize] - Size of each animation frame in pixels
15094
+ * @param {number} [padding] - How many pixels padding around each frame
15095
+ * @return {TileInfo}
15096
+ * @example
15097
+ * const playerTile = loadSprite('player.png'); // a single sprite
15098
+ * const runTile = loadSprite('run.png', vec2(16)); // a 16x16 frame animation
15099
+ * @memberof TextureSheets */
15100
+ function loadSprite(src, frameSize, padding=textureSheetPadding)
15101
+ {
15102
+ ASSERT(isStringLike(src), 'image src must be a string');
15103
+ ASSERT(!frameSize || isVector2(frameSize) || isNumber(frameSize), 'frameSize must be a vec2 or number');
15104
+ ASSERT(isNumber(padding), 'padding must be a number');
15105
+
15106
+ if (isNumber(frameSize))
15107
+ frameSize = vec2(frameSize);
15108
+
15109
+ // start with an empty tile that gets filled in when the image loads
15110
+ const tileInfo = new TileInfo(vec2(), vec2(), undefined, padding, 0);
15111
+ if (headlessMode) return tileInfo;
15112
+
15113
+ // point at a sheet right away so drawing before it loads picks up empty pixels
15114
+ tileInfo.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;
15115
+
15116
+ // start decoding right away, images decode in parallel
15117
+ const image = new Image;
15118
+ const imagePromise = new Promise(resolve =>
15119
+ {
15120
+ image.onerror = image.onload = resolve;
15121
+ image.crossOrigin = 'anonymous';
15122
+ image.src = src;
15123
+ });
15124
+
15125
+ // pack through a queue so sheets fill in call order, not decode order
15126
+ ++textureSheetPendingCount;
15127
+ textureSheetQueue = textureSheetQueue.then(async ()=>
15128
+ {
15129
+ await imagePromise;
15130
+ if (image.width)
15131
+ {
15132
+ // pack onto a sheet, then fill in the tile that was already handed out,
15133
+ // copying every field so nothing is missed if TileInfo gains more of them
15134
+ const imageSize = vec2(image.width, image.height);
15135
+ const {sheet, tile} = textureSheetAdd(imageSize, frameSize, padding);
15136
+ Object.assign(tileInfo, tile);
15137
+ sheet.drawImage(image, tileInfo, false); // upload once per batch below
15138
+ }
15139
+ else
15140
+ {
15141
+ // leave the tile empty if the image failed to load
15142
+ LOG('loadSprite failed to load image:', src);
15143
+ }
15144
+
15145
+ // upload to webgl once per batch, when the last pending load finishes
15146
+ if (!--textureSheetPendingCount)
15147
+ textureSheets.forEach(s=> s.updateTexture());
15148
+ });
15149
+
15150
+ return tileInfo;
15151
+ }
15152
+
15153
+ /** Load a pre-packed texture atlas and repack it onto texture sheets
15154
+ * - Supports TexturePacker json (hash and array) and Aseprite json
15155
+ * - Returns an empty object which is filled with TileInfos when loaded
15156
+ * - Frames are named by the json, animations are grouped automatically
15157
+ * - Aseprite frame tags become animations, so do names like run_0, run_1
15158
+ * - Trimmed frames are restored to their full source size when packed
15159
+ * - Rotated frames are rotated back upright when packed
15160
+ * @param {string} imageSrc - Atlas image path
15161
+ * @param {string|Object} jsonSrc - Atlas json path, or already parsed json data
15162
+ * @param {number} [padding] - How many pixels padding around each frame
15163
+ * @return {Object} Object mapping frame and animation names to TileInfos
15164
+ * @example
15165
+ * const atlas = loadAtlas('sprites.png', 'sprites.json');
15166
+ * await spritesReady();
15167
+ * drawTile(pos, size, atlas.player); // a single frame
15168
+ * drawTile(pos, size, atlas.run.frame(2)); // frame 2 of the run animation
15169
+ * @memberof TextureSheets */
15170
+ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
15171
+ {
15172
+ ASSERT(isStringLike(imageSrc), 'atlas image src must be a string');
15173
+ ASSERT(isStringLike(jsonSrc) || typeof jsonSrc === 'object', 'atlas json must be a path or object');
15174
+ ASSERT(isNumber(padding), 'padding must be a number');
15175
+
15176
+ const atlas = {};
15177
+ if (headlessMode) return atlas;
15178
+
15179
+ // start fetching the json and decoding the image right away, in parallel
15180
+ const jsonPromise = typeof jsonSrc === 'object' ? Promise.resolve(jsonSrc) :
15181
+ fetch(jsonSrc).then(r=> r.ok && r.json()).catch(()=> undefined);
15182
+ const image = new Image;
15183
+ const imagePromise = new Promise(resolve =>
15184
+ {
15185
+ image.onerror = image.onload = resolve;
15186
+ image.crossOrigin = 'anonymous';
15187
+ image.src = imageSrc;
15188
+ });
15189
+
15190
+ // pack through a queue so sheets fill in call order, not decode order
15191
+ ++textureSheetPendingCount;
15192
+ textureSheetQueue = textureSheetQueue.then(async ()=>
15193
+ {
15194
+ const data = await jsonPromise;
15195
+ await imagePromise;
15196
+ if (image.width && data)
15197
+ {
15198
+ for (const group of parseAtlas(data))
15199
+ {
15200
+ // reserve a block of full size cells, one per frame
15201
+ const sourceSize = group.frames[0].sourceSize;
15202
+ const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
15203
+ const {sheet, tile} = textureSheetAdd(blockSize, sourceSize, padding);
15204
+
15205
+ // draw each frame untrimmed into its cell
15206
+ const context = sheet.context;
15207
+ const cellWidth = sourceSize.x + padding*2;
15208
+ const cellHeight = sourceSize.y + padding*2;
15209
+ group.frames.forEach((f, i)=>
15210
+ {
15211
+ const x = tile.pos.x + (i % tile.columns)*cellWidth + f.offset.x;
15212
+ const y = tile.pos.y + (i / tile.columns |0)*cellHeight + f.offset.y;
15213
+ if (f.rotated)
15214
+ {
15215
+ // stored rotated 90 degrees clockwise, draw it back upright
15216
+ context.save();
15217
+ context.translate(x, y);
15218
+ context.rotate(-PI/2);
15219
+ context.drawImage(image, f.pos.x, f.pos.y, f.size.y, f.size.x,
15220
+ -f.size.y, 0, f.size.y, f.size.x);
15221
+ context.restore();
15222
+ }
15223
+ else
15224
+ context.drawImage(image, f.pos.x, f.pos.y, f.size.x, f.size.y,
15225
+ x, y, f.size.x, f.size.y);
15226
+ });
15227
+ sheet.glDirty = true;
15228
+ atlas[group.name] = tile;
15229
+ }
15230
+ }
15231
+ else
15232
+ {
15233
+ // leave the atlas empty if either file failed to load
15234
+ LOG('loadAtlas failed to load:', imageSrc, jsonSrc);
15235
+ }
15236
+
15237
+ // upload to webgl once per batch, when the last pending load finishes
15238
+ if (!--textureSheetPendingCount)
15239
+ textureSheets.forEach(s=> s.updateTexture());
15240
+ });
15241
+
15242
+ return atlas;
15243
+ }
15244
+
15245
+ /** Parse atlas json into a list of named frame groups, used by loadAtlas
15246
+ * - Accepts TexturePacker json (hash and array) and Aseprite json
15247
+ * - Frames tagged in Aseprite or named like run_0, run_1 group into animations
15248
+ * @param {Object} data - Parsed atlas json data
15249
+ * @return {Array<Object>} List of {name, frames} groups in atlas order
15250
+ * @memberof TextureSheets */
15251
+ function parseAtlas(data)
15252
+ {
15253
+ ASSERT(!!data?.frames, 'unrecognized atlas format, expected TexturePacker or Aseprite json');
15254
+
15255
+ // normalize both hash and array frame layouts into a single list
15256
+ const frames = (isArray(data.frames) ?
15257
+ data.frames.map(f=> [f.filename, f]) : Object.entries(data.frames))
15258
+ .map(([name, f])=> ({
15259
+ name: name.replace(/\.[^.\\/]+$/, ''), // strip file extension
15260
+ pos: vec2(f.frame.x, f.frame.y),
15261
+ size: vec2(f.frame.w, f.frame.h),
15262
+ offset: vec2(f.spriteSourceSize?.x ?? 0, f.spriteSourceSize?.y ?? 0),
15263
+ sourceSize: vec2(f.sourceSize?.w ?? f.frame.w, f.sourceSize?.h ?? f.frame.h),
15264
+ rotated: !!f.rotated,
15265
+ }));
15266
+
15267
+ const groups = [];
15268
+ const tags = data.meta?.frameTags;
15269
+ if (tags?.length)
15270
+ {
15271
+ // aseprite tags are authoritative, untagged frames stay individual
15272
+ const tagged = new Set;
15273
+ for (const tag of tags)
15274
+ {
15275
+ groups.push({name: tag.name, frames: frames.slice(tag.from, tag.to + 1)});
15276
+ for (let i = tag.from; i <= tag.to; ++i)
15277
+ tagged.add(i);
15278
+ }
15279
+ frames.forEach((f, i)=> tagged.has(i) || groups.push({name: f.name, frames: [f]}));
15280
+ return groups;
15281
+ }
15282
+
15283
+ // group frames that share a name stem with contiguous trailing numbers
15284
+ // run_0.png and run_1.png become a 2 frame animation named run
15285
+ const stems = new Map;
15286
+ for (const f of frames)
15287
+ {
15288
+ let match = f.name.match(/^(.+?)([-_ ])?(\d+)$/);
15289
+ if (match && !match[2] && /\d$/.test(match[1]))
15290
+ match = undefined; // all digit tails like 10 are a name, not frame 0 of 1
15291
+ const stem = match ? match[1] : f.name;
15292
+ f.groupIndex = match ? Number(match[3]) : undefined;
15293
+ stems.has(stem) || stems.set(stem, []);
15294
+ stems.get(stem).push(f);
15295
+ }
15296
+ for (const [stem, list] of stems)
15297
+ {
15298
+ // only group 2 or more frames with contiguous indices and matching sizes
15299
+ list.sort((a, b)=> a.groupIndex - b.groupIndex);
15300
+ const grouped = list.length > 1 &&
15301
+ list.every((f, i)=> f.groupIndex === list[0].groupIndex + i) &&
15302
+ list.every(f=> f.sourceSize.x === list[0].sourceSize.x &&
15303
+ f.sourceSize.y === list[0].sourceSize.y);
15304
+ if (grouped)
15305
+ groups.push({name: stem, frames: list});
15306
+ else
15307
+ list.forEach(f=> groups.push({name: f.name, frames: [f]}));
15308
+ }
15309
+ return groups;
15310
+ }
15311
+
15312
+ /** Wait for everything started by loadSprite and loadAtlas to finish packing
15313
+ * @return {Promise}
15314
+ * @example
15315
+ * async function gameInit()
15316
+ * {
15317
+ * playerTile = loadSprite('player.png');
15318
+ * runTile = loadSprite('run.png', vec2(16));
15319
+ * await spritesReady();
15320
+ * }
15321
+ * @memberof TextureSheets */
15322
+ async function spritesReady()
15323
+ {
15324
+ // keep waiting until the queue drains, more sprites may load while waiting
15325
+ while (textureSheetPendingCount)
15326
+ await textureSheetQueue;
15327
+ }
15328
+
15329
+ // create a new texture sheet and add it to the list
15330
+ function textureSheetCreate()
15331
+ {
15332
+ const sheet = new TextureSheet;
15333
+ textureSheets.push(sheet);
15334
+ return sheet;
15335
+ }
15336
+
15337
+ // use the first sheet with enough space, or make a new one
15338
+ function textureSheetAdd(imageSize, frameSize, padding)
15339
+ {
15340
+ let sheet, tile;
15341
+ for (sheet of textureSheets)
15342
+ if (tile = sheet.tryAdd(imageSize, frameSize, padding))
15343
+ break;
15344
+ if (!tile)
15345
+ {
15346
+ sheet = textureSheetCreate();
15347
+ tile = sheet.tryAdd(imageSize, frameSize, padding);
15348
+ ASSERT(!!tile, 'image is too large to fit on a texture sheet');
15349
+ }
15350
+ return {sheet, tile};
15351
+ }
15352
+
15353
+ ///////////////////////////////////////////////////////////////////////////////
15354
+ // Texture sheet setting setters
15355
+
15356
+ /** Set width and height in pixels of texture sheets created by loadSprite
15357
+ * @param {number} size
15358
+ * @memberof Settings */
15359
+ function setTextureSheetSize(size) { textureSheetSize = size; }
15360
+
15361
+ /** Set default padding pixels around each frame packed by loadSprite
15362
+ * @param {number} padding
15363
+ * @memberof Settings */
15364
+ function setTextureSheetPadding(padding) { textureSheetPadding = padding; }
15365
+
14891
15366
  /**
14892
15367
  * LittleJS Tween System Plugin
14893
15368
  * - Lightweight tweens for numbers, Vector2, Color, or any .lerp-able type
@@ -15314,7 +15789,11 @@ function tweenProperty(target, propertyPath, start, end, duration = 1, options =
15314
15789
  const callback = (value) =>
15315
15790
  {
15316
15791
  let obj = target;
15317
- for (const k of parts) obj = obj[k];
15792
+ for (const k of parts)
15793
+ {
15794
+ obj = obj[k];
15795
+ ASSERT(obj != null, 'tweenProperty path does not resolve: ' + propertyPath);
15796
+ }
15318
15797
  obj[lastKey] = value;
15319
15798
  };
15320
15799
  return new Tween(callback, start, end, duration, options);
@@ -16197,6 +16676,160 @@ class PathFinder
16197
16676
  }
16198
16677
  }
16199
16678
 
16679
+ /**
16680
+ * LittleJS Three.js Plugin
16681
+ * - Renders a three.js scene on a canvas behind the LittleJS canvases
16682
+ * - The three.js module is passed in by the user, nothing is bundled
16683
+ * - Keep canvasClearColor transparent so the 3D scene shows through
16684
+ * - Aligned camera mode locks the 3D camera to the LittleJS 2D camera
16685
+ * - ThreeJSObject lets LittleJS physics drive a three.js mesh
16686
+ * - Call new ThreeJSPlugin(THREE) in gameInit to set up
16687
+ * @namespace ThreeJS
16688
+ */
16689
+
16690
+ ///////////////////////////////////////////////////////////////////////////////
16691
+
16692
+ /** Global ThreeJS plugin object
16693
+ * @type {ThreeJSPlugin}
16694
+ * @memberof ThreeJS */
16695
+ let threeJS;
16696
+
16697
+ ///////////////////////////////////////////////////////////////////////////////
16698
+ /**
16699
+ * ThreeJS Plugin - Renders a three.js scene behind the LittleJS canvas
16700
+ * @example
16701
+ * // in gameInit, with three.js loaded by the user
16702
+ * new ThreeJSPlugin(THREE);
16703
+ * threeJS.scene.add(new THREE.AmbientLight);
16704
+ * @memberof ThreeJS
16705
+ */
16706
+ class ThreeJSPlugin
16707
+ {
16708
+ /** Set up the three.js rendering layer, call in gameInit
16709
+ * @param {Object} THREE - The three.js module, supplied by the user
16710
+ * @param {number} [cameraFOV] - Vertical field of view in degrees */
16711
+ constructor(THREE, cameraFOV=60)
16712
+ {
16713
+ ASSERT(!threeJS, 'ThreeJS plugin already initialized');
16714
+ threeJS = this;
16715
+ if (headlessMode) return;
16716
+ ASSERT(mainCanvas, 'ThreeJS plugin must be created after engineInit, call in gameInit');
16717
+ ASSERT(THREE && THREE.WebGLRenderer, 'three.js module must be passed in');
16718
+
16719
+ /** @property {Object} - The three.js module passed into the constructor */
16720
+ this.THREE = THREE;
16721
+ /** @property {Object} - The three.js renderer */
16722
+ this.renderer = new THREE.WebGLRenderer({antialias: true});
16723
+ /** @property {Object} - The three.js scene, add lights and meshes here */
16724
+ this.scene = new THREE.Scene();
16725
+ /** @property {Object} - The three.js perspective camera */
16726
+ this.camera = new THREE.PerspectiveCamera(cameraFOV, 1, .1, 1e3);
16727
+ /** @property {boolean} - Lock the camera to the LittleJS 2D camera so the z=0 plane matches world space */
16728
+ this.cameraAlign2D = true;
16729
+
16730
+ // insert the canvas below the engine canvases and match the layout
16731
+ const threeCanvas = this.renderer.domElement;
16732
+ const rootElement = mainCanvas.parentElement;
16733
+ rootElement.insertBefore(threeCanvas, rootElement.firstChild);
16734
+ threeCanvas.style.cssText = mainCanvas.style.cssText;
16735
+
16736
+ // render automatically each frame after the engine renders
16737
+ engineAddPlugin(undefined, ()=> this.render());
16738
+ }
16739
+
16740
+ /** Position the camera so the z=0 plane exactly matches LittleJS world space,
16741
+ * called automatically when cameraAlign2D is set */
16742
+ alignCamera2D()
16743
+ {
16744
+ const halfHeight = mainCanvasSize.y / 2 / cameraScale; // half visible height in world units
16745
+ const distance = halfHeight / tan(this.camera.fov/2 * PI/180);
16746
+ this.camera.position.set(cameraPos.x, cameraPos.y, distance);
16747
+ // reset all axes in case a free camera was used, littlejs angles are clockwise
16748
+ this.camera.rotation.set(0, 0, -cameraAngle);
16749
+ }
16750
+
16751
+ /** Sync the canvas layout and render the scene, called automatically each frame */
16752
+ render()
16753
+ {
16754
+ if (!this.renderer) return; // headless mode
16755
+
16756
+ // keep renderer size and css in sync with the LittleJS canvas
16757
+ const threeCanvas = this.renderer.domElement;
16758
+ if (threeCanvas.width != mainCanvasSize.x || threeCanvas.height != mainCanvasSize.y)
16759
+ {
16760
+ this.renderer.setSize(mainCanvasSize.x, mainCanvasSize.y, false);
16761
+ this.camera.aspect = mainCanvasSize.x / mainCanvasSize.y;
16762
+ this.camera.updateProjectionMatrix();
16763
+ }
16764
+ if (threeCanvas.style.cssText != mainCanvas.style.cssText)
16765
+ threeCanvas.style.cssText = mainCanvas.style.cssText;
16766
+
16767
+ if (this.cameraAlign2D)
16768
+ this.alignCamera2D();
16769
+ this.renderer.render(this.scene, this.camera);
16770
+ }
16771
+ }
16772
+
16773
+ ///////////////////////////////////////////////////////////////////////////////
16774
+ /**
16775
+ * ThreeJS Object - EngineObject that drives a three.js mesh
16776
+ * - LittleJS physics moves the object and the mesh follows automatically
16777
+ * - Destroying the object removes the mesh from the scene
16778
+ * @extends EngineObject
16779
+ * @memberof ThreeJS
16780
+ */
16781
+ class ThreeJSObject extends EngineObject
16782
+ {
16783
+ /** Create an engine object that drives a three.js mesh
16784
+ * @param {Vector2} [pos] - World space position
16785
+ * @param {Vector2} [size] - World space size
16786
+ * @param {Object} [mesh] - The three.js object3d to drive
16787
+ * @param {number} [z] - Mesh height above the 2D plane */
16788
+ constructor(pos, size, mesh, z=0)
16789
+ {
16790
+ super(pos, size);
16791
+ ASSERT(threeJS, 'ThreeJS plugin must be initialized first');
16792
+
16793
+ /** @property {Object} - The three.js object3d this object drives */
16794
+ this.mesh = mesh;
16795
+ /** @property {number} - Mesh height above the 2D plane */
16796
+ this.z = z;
16797
+ if (mesh)
16798
+ {
16799
+ threeJS.scene.add(mesh);
16800
+ this.syncMesh();
16801
+ }
16802
+ }
16803
+
16804
+ /** Update the object and sync the mesh to its transform */
16805
+ update()
16806
+ {
16807
+ super.update();
16808
+ this.syncMesh();
16809
+ }
16810
+
16811
+ /** Copy this object's transform to the mesh */
16812
+ syncMesh()
16813
+ {
16814
+ if (!this.mesh) return;
16815
+ this.mesh.position.set(this.pos.x, this.pos.y, this.z);
16816
+ this.mesh.rotation.z = -this.angle; // littlejs angles are clockwise
16817
+ }
16818
+
16819
+ /** The mesh is this object's visual, the default 2D rendering is skipped */
16820
+ render() {}
16821
+
16822
+ /** Destroy this object and remove its mesh from the scene
16823
+ * @param {boolean} [immediate] */
16824
+ destroy(immediate)
16825
+ {
16826
+ if (this.destroyed) return;
16827
+ // note: frequently destroyed objects should also dispose geometry and material
16828
+ this.mesh && threeJS.scene.remove(this.mesh);
16829
+ super.destroy(immediate);
16830
+ }
16831
+ }
16832
+
16200
16833
 
16201
16834
  /**
16202
16835
  * LittleJS Module Export
@@ -16683,4 +17316,22 @@ export
16683
17316
  // Path Finding
16684
17317
  PathFinder,
16685
17318
  PathFinderNode,
16686
- }
17319
+
17320
+ // Three.js
17321
+ threeJS,
17322
+ ThreeJSPlugin,
17323
+ ThreeJSObject,
17324
+
17325
+ // Texture Sheets
17326
+ textureSheetSize,
17327
+ textureSheetPadding,
17328
+ setTextureSheetSize,
17329
+ setTextureSheetPadding,
17330
+ textureSheets,
17331
+ TextureSheet,
17332
+ loadSprite,
17333
+ loadAtlas,
17334
+ parseAtlas,
17335
+ spritesReady,
17336
+ }
17337
+