littlejsengine 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1058,6 +1058,9 @@ class ParticleEmitter3D extends EngineObject3D
1058
1058
  /** @property {boolean} - Particles hit the level, the height maps and voxel maps, bouncing by restitution and
1059
1059
  * sliding along by friction; off by default, it tests each particle's move against the level every frame */
1060
1060
  this.collideLevel = false;
1061
+ /** @property {number} - How much a particle grips where it lands, 0 to 1: its speed along the surface is
1062
+ * cut by this much on each hit, on top of the friction, 1 stops it there */
1063
+ this.stick = 0;
1061
1064
  /** @property {Particle3DCallback|undefined} - Called with each particle as it is made
1062
1065
  * @type {Particle3DCallback|undefined} */
1063
1066
  this.particleCreateCallback = undefined;
@@ -1077,7 +1080,7 @@ class ParticleEmitter3D extends EngineObject3D
1077
1080
  /** @property {Vector3|undefined} - Where the emitter was at its last update, for when its parent is destroyed
1078
1081
  * @type {Vector3|undefined} */
1079
1082
  this.worldPos3D = undefined;
1080
- this.emitTimeBuffer = 0;
1083
+ this.emitTimeBuffer = 1; // the first particle comes at once, as the 2D emitter's does
1081
1084
  }
1082
1085
 
1083
1086
  /** Spawn new particles, move the live ones, and go away when done */
@@ -1094,9 +1097,10 @@ class ParticleEmitter3D extends EngineObject3D
1094
1097
  {
1095
1098
  // a rate of zero is an emitter fed by hand, and the global scale only quiets it,
1096
1099
  // neither is a reason to stop counting down the emit time
1097
- if (this.emitRate && particleEmitRateScale)
1100
+ const rate = this.emitRate * particleEmitRateScale;
1101
+ if (rate > 0 && rate < Infinity)
1098
1102
  {
1099
- this.emitTimeBuffer += this.emitRate * particleEmitRateScale * timeDelta;
1103
+ this.emitTimeBuffer += rate * timeDelta;
1100
1104
  for (; this.emitTimeBuffer >= 1; --this.emitTimeBuffer)
1101
1105
  this.emitParticle();
1102
1106
  }
@@ -1136,7 +1140,9 @@ class ParticleEmitter3D extends EngineObject3D
1136
1140
  const vz = data[k+5] = data[k+5] * damping + gravityZ;
1137
1141
  data[k] += vx, data[k+1] += vy, data[k+2] += vz;
1138
1142
  data[k+18] += data[k+19] *= angleDamping;
1139
- collideLevel && (this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz), reread());
1143
+ // true when its collide callback destroyed it, it has had its last callback but the destroy one
1144
+ const gone = collideLevel && this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz);
1145
+ collideLevel && reread();
1140
1146
  const t = i * trailMax * 3;
1141
1147
  if (trailMax)
1142
1148
  {
@@ -1148,7 +1154,7 @@ class ParticleEmitter3D extends EngineObject3D
1148
1154
  trail[j] = data[k], trail[j+1] = data[k+1], trail[j+2] = data[k+2];
1149
1155
  data[k+20] = n + 1;
1150
1156
  }
1151
- updateCallback && (this.particleCall(updateCallback, k), reread());
1157
+ updateCallback && !gone && (this.particleCall(updateCallback, k), reread());
1152
1158
  if ((data[k+17] += timeDelta) >= data[k+16])
1153
1159
  {
1154
1160
  // dead: the last particle takes its slot, trail and all, order does not matter
@@ -1250,13 +1256,14 @@ class ParticleEmitter3D extends EngineObject3D
1250
1256
  out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
1251
1257
  out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
1252
1258
  if (view.destroyed)
1253
- out[k+17] = max(out[k+17], out[k+16] - timeDelta); // aged past its life this update
1259
+ out[k+17] = max(out[k+17], out[k+16]); // its life lived: a step short of it can round to just under
1254
1260
  return result;
1255
1261
  }
1256
1262
 
1257
1263
  // stop the particle at k where its last move went into the level, from where it was: back at the surface, a hair
1258
1264
  // off it, its speed into it turned around by restitution and its speed along it kept by friction, unless the
1259
- // collide callback lets it through; one that starts inside is let go, as a 2D one is
1265
+ // collide callback lets it through; one that starts inside is let go, as a 2D one is; true when the callback
1266
+ // destroyed it
1260
1267
  particleCollide(k, x, y, z)
1261
1268
  {
1262
1269
  let data = this.particleData;
@@ -1269,18 +1276,21 @@ class ParticleEmitter3D extends EngineObject3D
1269
1276
  if (h && (!hit || h.distance < hit.distance))
1270
1277
  hit = h, level = l;
1271
1278
  }
1272
- if (!hit) return;
1279
+ if (!hit) return false;
1273
1280
  const point = from.lerp(to, hit.distance);
1274
1281
  const callback = this.particleCollideCallback;
1275
1282
  if (callback && (!this.particleCall(callback, k, level, point.copy()) || this.particleView.destroyed))
1276
- return; // let through, or destroyed by the callback
1283
+ return this.particleView.destroyed; // let through, or destroyed by the callback
1277
1284
  data = this.particleData; // as the callback left it, an emit may have grown it
1278
1285
 
1279
1286
  const n = hit.normal, v = vec3(data[k+3], data[k+4], data[k+5]), into = n.scale(v.dot(n));
1280
- const restitution = max(this.restitution, level.restitution), friction = max(this.friction, level.friction);
1287
+ // the larger friction of the two, as objects take it, then the emitter's own grip, which a level can not undo
1288
+ const restitution = max(this.restitution, level.restitution);
1289
+ const friction = max(this.friction, level.friction) * (1 - clamp(this.stick));
1281
1290
  const out = v.subtract(into).scale(friction).subtract(into.scale(restitution)), p = point.add(n.scale(1e-3));
1282
1291
  data[k] = p.x, data[k+1] = p.y, data[k+2] = p.z;
1283
1292
  data[k+3] = out.x, data[k+4] = out.y, data[k+5] = out.z;
1293
+ return false;
1284
1294
  }
1285
1295
 
1286
1296
  /** Draw the particles, as flat squares or as streaks when trailTime is set
@@ -1544,6 +1554,11 @@ class LensFlare3D extends EngineObject3D
1544
1554
  * height; shape is glow, disc or ring
1545
1555
  * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
1546
1556
  this.elements = undefined;
1557
+ /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
1558
+ * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
1559
+ * in front of the light, and a spotlight's shows from inside its beam only
1560
+ * @type {Light3D|undefined} */
1561
+ this.light = undefined;
1547
1562
  /** @property {boolean} - Fade out when something is between the camera and the sun */
1548
1563
  this.occlusion = true;
1549
1564
  /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
@@ -1582,13 +1597,30 @@ class LensFlare3D extends EngineObject3D
1582
1597
  return this.made;
1583
1598
  }
1584
1599
 
1585
- /** Where the sun is on the screen, in pixels like mousePosScreen, undefined when it is behind the camera
1600
+ // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun, and a point
1601
+ // to find it on the screen by; undefined with no sun direction, or a light that is gone or at the camera
1602
+ flareSource()
1603
+ {
1604
+ const camera = render3D.camera.pos, light = this.light;
1605
+ if (!light)
1606
+ {
1607
+ const sun = render3D.sunDirection;
1608
+ if (!sun.lengthSquared()) return;
1609
+ const direction = sun.normalize();
1610
+ return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
1611
+ }
1612
+ if (light.destroyed) return;
1613
+ const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
1614
+ return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
1615
+ }
1616
+
1617
+ /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
1618
+ * behind the camera
1586
1619
  * @return {Vector2|undefined} */
1587
1620
  getSunScreenPos()
1588
1621
  {
1589
- const direction = render3D.sunDirection;
1590
- if (!direction.lengthSquared()) return;
1591
- return render3D.worldToScreen(render3D.camera.pos.add(direction.normalize(100)));
1622
+ const source = this.flareSource();
1623
+ return source && render3D.worldToScreen(source.pos);
1592
1624
  }
1593
1625
 
1594
1626
  /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
@@ -1602,7 +1634,19 @@ class LensFlare3D extends EngineObject3D
1602
1634
  const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
1603
1635
  const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
1604
1636
  if (!(strength > 0)) return [];
1605
- const tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
1637
+ let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
1638
+ const light = this.light, source = light && this.flareSource();
1639
+ if (light)
1640
+ {
1641
+ if (!source) return [];
1642
+ // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
1643
+ const cone = render3DLightCone(light), d = source.direction;
1644
+ const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
1645
+ const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
1646
+ if (!(bright > 0)) return [];
1647
+ tint = this.color.multiply(rgb(c.r, c.g, c.b, bright));
1648
+ height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
1649
+ }
1606
1650
  return this.getElements().map((e)=>
1607
1651
  {
1608
1652
  const c = e.color.multiply(tint);
@@ -1612,15 +1656,32 @@ class LensFlare3D extends EngineObject3D
1612
1656
  });
1613
1657
  }
1614
1658
 
1615
- /** Is something between the camera and the sun: the level, or an object that is not see through
1659
+ /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
1660
+ * through
1616
1661
  * @return {boolean} */
1617
1662
  isSunHidden()
1618
1663
  {
1619
- const direction = render3D.sunDirection;
1620
- if (!direction.lengthSquared()) return true;
1621
- const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.transparent &&
1622
- !o.additive);
1623
- return !!render3D.pick(new Ray3D(render3D.camera.pos, direction.normalize()), blockers);
1664
+ const source = this.flareSource();
1665
+ if (!source) return true;
1666
+ const ray = new Ray3D(render3D.camera.pos, source.direction), reach = source.distance;
1667
+ const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.destroyed &&
1668
+ !o.transparent && !o.additive);
1669
+ // a voxel map says block by block what is see through: glass, water and leaves let the sun by, and the
1670
+ // ray goes on to the blocks behind them
1671
+ const maps = /** @type {Array<VoxelMap>} */ (typeof VoxelMap == 'undefined' ? [] :
1672
+ blockers.filter((o)=> o instanceof VoxelMap));
1673
+ for (const map of maps)
1674
+ if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
1675
+ return true;
1676
+ // an object is hit by its box, and a box the camera is inside, a room, a wide floor or the player's own
1677
+ // body, says nothing of what is in the way: the ray the other way hits it too, and it is left out; so is
1678
+ // one the light is inside, its lamp
1679
+ const backward = ray.direction.scale(-1), back = new Ray3D(ray.origin, backward);
1680
+ const lamp = this.light && [new Ray3D(source.pos, ray.direction), new Ray3D(source.pos, backward)];
1681
+ const hits = (r, o)=> render3DRaycastObject(r, o) !== undefined;
1682
+ const around = (o)=> !(o instanceof HeightMap) && (hits(back, o) || lamp && hits(lamp[0], o) && hits(lamp[1], o));
1683
+ const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !around(o)));
1684
+ return !!hit && hit.distance < reach;
1624
1685
  }
1625
1686
 
1626
1687
  /** Ease visible toward whether the sun shows, called automatically each frame */
@@ -78,7 +78,10 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
78
78
  * returned, see level3DVoxelSetup for its sheet
79
79
  * - A level may hold a terrain, in a terrain block: pos, its center, size, its size in the world along x and z,
80
80
  * height, how tall a full height is, color, and heights, rows of 0 to 1 from -z to +z, each from -x to +x; it
81
- * is made a HeightMap, returned with what else was made
81
+ * is made a HeightMap, returned with what else was made; paint, when it has it, colors its samples: colors, a
82
+ * list, and cells, runs of a count and a color along the rows, 0 for the terrain's own and 1 the list's first
83
+ * - A level may hold prefabs of its own, in a prefabs block, each by its name as level3DAddPrefab takes it; they
84
+ * are added before its objects are made, one the game added itself keeps its place
82
85
  * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
83
86
  * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
84
87
  * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
@@ -91,6 +94,7 @@ function level3DLoad(level)
91
94
  ASSERT(!!level && typeof level === 'object', 'a level is an object, {} for a new one');
92
95
  ASSERT(!(level.littlejs3D > LEVEL3D_VERSION), 'the level was made by a newer LittleJS');
93
96
  editor3DLevelLoaded(level); // debug builds: the 3D editor takes the level, its autosaved edits go in first
97
+ level3DPrefabsAdd(level.prefabs);
94
98
  level3DSceneApply(level.scene);
95
99
  const made = [], map = level3DVoxelMap = level3DVoxelsMake(level.voxels);
96
100
  map && made.push(map);
@@ -201,14 +205,49 @@ function level3DTerrainShape(terrain)
201
205
  height: isNumber(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights};
202
206
  }
203
207
 
208
+ // a hex color of a level as a Color, undefined when it is not one
209
+ function level3DHexColor(hex)
210
+ { return /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex) ? new Color().setHex(hex) : undefined; }
211
+
212
+ // the paint of a level's terrain block, for a terrain of so many samples: its colors as they are written and a
213
+ // color for each sample, row after row, 0 for none and 1 the first of the colors; undefined when it has none or
214
+ // it does not fit the terrain
215
+ function level3DTerrainPaint(terrain, samples)
216
+ {
217
+ const paint = terrain?.paint, colors = paint?.colors, runs = paint?.cells;
218
+ if (!isArray(colors) || !isArray(runs) || colors.length > 255 || !colors.every(level3DHexColor)) return;
219
+ const cells = new Uint8Array(samples);
220
+ let at = 0;
221
+ for (let i = 0; i < runs.length; i += 2)
222
+ {
223
+ const count = runs[i], color = runs[i + 1];
224
+ if (!(count > 0 && count % 1 === 0 && color >= 0 && color % 1 === 0) || color > colors.length ||
225
+ at + count > samples) return;
226
+ cells.fill(color, at, at += count);
227
+ }
228
+ return at === samples ? {colors: colors.slice(), cells} : undefined;
229
+ }
230
+
231
+ // give a terrain's map the colors of its block: its own color over the whole of it, or with paint a color for
232
+ // each sample, the terrain's own where it is not painted; the caller builds the map again
233
+ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain, map.rows * map.columns))
234
+ {
235
+ const base = level3DHexColor(terrain.color) || WHITE;
236
+ map.color = base, map.colors = undefined;
237
+ if (!paint || !paint.cells.some((v)=> v)) return;
238
+ const palette = [base, ...paint.colors.map(level3DHexColor)];
239
+ map.color = WHITE;
240
+ map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
241
+ }
242
+
204
243
  // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
205
244
  function level3DTerrainMake(terrain)
206
245
  {
207
246
  const shape = level3DTerrainShape(terrain);
208
247
  if (!shape || typeof HeightMap == 'undefined') return;
209
248
  const map = new HeightMap(shape.heights, shape.size, shape.height, undefined, shape.pos);
210
- if (/^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(terrain.color))
211
- map.color = new Color().setHex(terrain.color);
249
+ level3DTerrainSetColors(map, terrain);
250
+ map.colors && map.rebuild();
212
251
  return map;
213
252
  }
214
253
 
@@ -281,6 +320,14 @@ function level3DProperties(type, object)
281
320
  // make one object of a level from the type added for its name, undefined when there is no such type or it made
282
321
  // nothing
283
322
  function level3DMake(object)
323
+ {
324
+ return level3DMakeAt(object, level3DVector(object.pos, vec3()),
325
+ level3DVector(object.rotation, vec3()).scale(PI / 180), level3DVector(object.scale, vec3(1)));
326
+ }
327
+
328
+ // make a level object's game object at a place, a rotation in radians and a scale, its own as level3DMake gives
329
+ // them or the ones a prefab's instance puts it at
330
+ function level3DMakeAt(object, pos, rotation, scale)
284
331
  {
285
332
  const type = level3DTypes.get(object.type);
286
333
  if (!type)
@@ -288,7 +335,7 @@ function level3DMake(object)
288
335
  debug && console.warn(`level3DLoad: no type added for ${object.type}, skipped`);
289
336
  return;
290
337
  }
291
- const pos = level3DVector(object.pos, vec3()), properties = level3DProperties(type, object);
338
+ const properties = level3DProperties(type, object);
292
339
  const {make} = type, count = engineObjects.length;
293
340
  let result;
294
341
  try { result = make.prototype ? new make(pos, properties) : make(pos, properties); }
@@ -306,16 +353,263 @@ function level3DMake(object)
306
353
  if (result instanceof EngineObject3D)
307
354
  {
308
355
  level3DBaseScale.set(result, result.scale3D.copy());
309
- result.rotation3D = level3DVector(object.rotation, vec3()).scale(PI / 180);
310
- result.scale3D = result.scale3D.multiply(level3DVector(object.scale, vec3(1)));
356
+ result.rotation3D = rotation.copy();
357
+ result.scale3D = result.scale3D.multiply(scale);
311
358
  }
312
359
 
313
360
  // the type's own properties are set on it, one the type has no default for could be a field of the engine's
314
361
  for (const key in type.defaults)
315
362
  result[key] = properties[key];
363
+ // a prefab's instance makes its parts now that it is where it goes
364
+ result instanceof Prefab3D && result.placeAt(pos);
316
365
  return result;
317
366
  }
318
367
 
368
+ /** Make one object of a type added with level3DAddType, level3DAddMesh or level3DAddPrefab, from code, with no
369
+ * level: a prefab's instance, or a plain type
370
+ * @param {string} type - The type's name
371
+ * @param {Vector3} [pos3D]
372
+ * @param {Vector3} [rotation3D] - In radians, as an object has it
373
+ * @param {Vector3} [scale3D] - Times the scale the type makes it with
374
+ * @param {Object} [properties] - Over the type's defaults
375
+ * @return {any} - What the type made, a Prefab3D for a prefab, undefined when there is no such type
376
+ * @memberof Level3D
377
+ * @example
378
+ * level3DAddPrefab('House', await fetchJSON('house.json'));
379
+ * level3DSpawn('House', vec3(10, 0, 0), vec3(0, PI/2, 0)); */
380
+ function level3DSpawn(type, pos3D=vec3(), rotation3D=vec3(), scale3D=vec3(1), properties)
381
+ {
382
+ ASSERT(isVector3(pos3D) && isVector3(rotation3D) && isVector3(scale3D), 'pos3D, rotation3D and scale3D are vec3');
383
+ return level3DMakeAt(properties ? {type, properties} : {type}, pos3D.copy(), rotation3D, scale3D);
384
+ }
385
+
386
+ ///////////////////////////////////////////////////////////////////////////////
387
+ // prefabs: a prefab is a small level, a list of objects about its own origin, placed many times
388
+
389
+ // the prefabs by name: how an instance is built, its objects, fromLevel, true for one a level's own prefabs
390
+ // block added, which one the game adds takes the place of, and source, the object it was added from, which the
391
+ // level editor knows a level that is a prefab by
392
+ /** @type {Map<string, {attached: boolean, objects: Array<Object>, fromLevel: boolean, source: Object}>} */
393
+ const level3DPrefabs = new Map;
394
+
395
+ // how many prefabs deep an instance being made is, a prefab that holds itself is stopped; and how many of those
396
+ // are attached: a prefab inside an attached one is attached too, or its parts would be left behind in the world
397
+ let level3DPrefabDepth = 0, level3DPrefabAttached = 0;
398
+
399
+ /** Add a prefab: a small level, its objects placed about its own origin, to place many times under one name
400
+ * - It is a type from then on: a level's object of that type, the level editor's Place list and level3DSpawn make
401
+ * an instance, a Prefab3D; an instance is made of the prefab as it is then, so the level editor, where a prefab
402
+ * changes, makes its instances again
403
+ * - The prefab is a level as the level editor saves it, {objects: [...]}, so the editor is the prefab editor too;
404
+ * only its objects are used, and they may be of other prefabs
405
+ * - With attached true in it the parts are children of the instance and move with it as one body, without
406
+ * collision of their own, a prefab inside it too; its handle is at the middle of the box around them and its
407
+ * size3D is that box, so setCollision makes the body solid where it is seen; otherwise each part is an object of its own in the world and collides as one placed
408
+ * by hand does
409
+ * - Adding a name again replaces it
410
+ * @param {string} name - The type its instances have in a level
411
+ * @param {Object} prefab - {objects, attached}
412
+ * @memberof Level3D
413
+ * @example
414
+ * level3DAddPrefab('Tower', {objects: [{type: 'Box', pos: [0, 1, 0], scale: [2, 2, 2]},
415
+ * {type: 'Cylinder', pos: [0, 3, 0]}]});
416
+ * level3DLoad({objects: [{type: 'Tower', pos: [5, 0, 5]}, {type: 'Tower', pos: [-5, 0, 5], rotation: [0, 45, 0]}]}); */
417
+ function level3DAddPrefab(name, prefab) { level3DPrefabSet(name, prefab, false); }
418
+
419
+ // add a prefab, the game's or a level's own
420
+ function level3DPrefabSet(name, prefab, fromLevel)
421
+ {
422
+ ASSERT(!!prefab && typeof prefab === 'object', 'a prefab is an object, {objects: [...]}');
423
+ name = String(name);
424
+ const objects = isArray(prefab?.objects) ? prefab.objects.filter((o)=> o && typeof o === 'object') : [];
425
+ // a copy, what the game or the editor does to its own afterwards does not change the instances made later
426
+ level3DPrefabs.set(name, {attached: !!prefab?.attached, objects: JSON.parse(JSON.stringify(objects)), fromLevel,
427
+ source: prefab});
428
+ // a class of its own, so the level editor makes its instances again as it does a class's
429
+ level3DTypes.set(name, {make: class extends Prefab3D { constructor(pos) { super(pos, name); } }, defaults: {},
430
+ tileInfo: undefined});
431
+ }
432
+
433
+ // add a level's own prefabs, each unless the game added one of that name or it is a plain type's name
434
+ function level3DPrefabsAdd(prefabs)
435
+ {
436
+ if (!prefabs || typeof prefabs !== 'object' || isArray(prefabs)) return;
437
+ for (const name in prefabs)
438
+ {
439
+ const known = level3DPrefabs.get(name);
440
+ if (known ? !known.fromLevel : level3DTypes.has(name)) continue;
441
+ prefabs[name] && typeof prefabs[name] === 'object' && level3DPrefabSet(name, prefabs[name], true);
442
+ }
443
+ }
444
+
445
+ /** Load a prefab from a file the level editor saved and add it
446
+ * @param {string} name - The type its instances have in a level
447
+ * @param {string} url
448
+ * @return {Promise<void>}
449
+ * @memberof Level3D */
450
+ async function level3DLoadPrefab(name, url) { level3DAddPrefab(name, await fetchJSON(url)); }
451
+
452
+ // where an instance puts one of its prefab's objects: the object's own place, turn and size inside the instance's
453
+ function level3DPrefabPartTransform(instance, object)
454
+ {
455
+ const pos = level3DVector(object.pos, vec3()), scale = level3DVector(object.scale, vec3(1));
456
+ const rotation = level3DVector(object.rotation, vec3()).scale(PI / 180);
457
+ // the two turns one after the other; the sizes multiply along each axis, which is exact for an instance sized
458
+ // evenly or a part that is not turned, and as near as a box can be otherwise
459
+ const turn = buildMatrix(vec3(), instance.rotation3D).multiply(buildMatrix(vec3(), rotation));
460
+ return {pos: buildMatrix(instance.pos3D, instance.rotation3D, instance.scale3D).transformPoint(pos),
461
+ rotation: turn.getRotation(), scale: scale.multiply(instance.scale3D)};
462
+ }
463
+
464
+ /**
465
+ * An instance of a prefab, what a prefab's type makes: a handle with no shape of its own, and its parts, what the
466
+ * prefab's objects made
467
+ * - Parts of a prefab that is not attached are objects of their own in the world: moving, turning or sizing the
468
+ * handle puts them where it now says with its next update, or call placeParts; destroying it destroys them
469
+ * - Parts of an attached prefab are its children and move with it as one body
470
+ * @extends EngineObject3D
471
+ * @memberof Level3D
472
+ */
473
+ class Prefab3D extends EngineObject3D
474
+ {
475
+ /** Create an instance of a prefab, made by its type: place one with a level or level3DSpawn
476
+ * @param {Vector3} [pos3D]
477
+ * @param {string} [prefabName] - A prefab added with level3DAddPrefab */
478
+ constructor(pos3D, prefabName='')
479
+ {
480
+ super(pos3D);
481
+ /** @property {string} - The prefab it is an instance of */
482
+ this.prefabName = prefabName;
483
+ /** @property {boolean} - Are its parts its children, moving with it as one body */
484
+ this.attached = !!level3DPrefabs.get(prefabName)?.attached || level3DPrefabAttached > 0;
485
+ /** @property {Array<any>} - What the prefab's objects made, in the prefab's order */
486
+ this.parts = [];
487
+ /** @property {Vector3} - From the prefab's origin to the handle, in the prefab's own space: nothing for
488
+ * separate parts, and for an attached prefab the middle of the box around its parts, where its handle
489
+ * is, as an object's place is the middle of its body */
490
+ this.originOffset = vec3();
491
+ this.partObjects = []; // the prefab's object of each part
492
+ this.partsPlaced = ''; // where the handle was when its parts were last placed, undefined parts not made
493
+ this.partsMade = false;
494
+ }
495
+
496
+ /** Put the instance with its prefab's origin at a place, turned and sized as the handle is: where a level's
497
+ * object or level3DSpawn says it goes; the handle of an attached prefab is then at the middle of its body
498
+ * @param {Vector3} pos3D */
499
+ placeAt(pos3D)
500
+ {
501
+ this.pos3D = pos3D.copy();
502
+ this.placeParts();
503
+ const o = this.originOffset;
504
+ if (o.x || o.y || o.z)
505
+ this.pos3D = buildMatrix(pos3D, this.rotation3D, this.scale3D).transformPoint(o);
506
+ }
507
+
508
+ /** Put the parts where the handle is now, making them the first time; called by the handle's update when it
509
+ * has moved, turned or changed size */
510
+ placeParts()
511
+ {
512
+ if (this.destroyed) return;
513
+ this.partsMade ||= (this.makeParts(), true);
514
+ this.partsPlaced = this.placeKey();
515
+ if (this.attached) return; // children follow by themselves
516
+ this.parts.forEach((part, i)=>
517
+ {
518
+ if (!(part instanceof EngineObject3D)) return; // what a function made is where it was made
519
+ const to = level3DPrefabPartTransform(this, this.partObjects[i]);
520
+ part.rotation3D = to.rotation;
521
+ part.scale3D = (level3DBaseScale.get(part) ?? vec3(1)).multiply(to.scale);
522
+ // a prefab inside this one goes by its origin, which is not where an attached one's handle is
523
+ part instanceof Prefab3D ? part.placeAt(to.pos) : part.pos3D = to.pos;
524
+ });
525
+ }
526
+
527
+ // the handle's place, turn and size as one text, to tell when it has changed
528
+ placeKey()
529
+ {
530
+ const p = this.pos3D, r = this.rotation3D, s = this.scale3D;
531
+ return [p.x, p.y, p.z, r.x, r.y, r.z, s.x, s.y, s.z].join();
532
+ }
533
+
534
+ // make the parts, each where the instance puts it, or attached at its own place inside the instance
535
+ makeParts()
536
+ {
537
+ const prefab = level3DPrefabs.get(this.prefabName);
538
+ if (!prefab) return;
539
+ if (level3DPrefabDepth >= 8)
540
+ {
541
+ debug && console.error(`level3DLoad: the prefab ${this.prefabName} holds itself, left out there`);
542
+ return;
543
+ }
544
+ ++level3DPrefabDepth;
545
+ this.attached && ++level3DPrefabAttached;
546
+ try
547
+ {
548
+ const low = vec3(Infinity), high = vec3(-Infinity), children = [];
549
+ for (const object of prefab.objects)
550
+ {
551
+ const local = {pos: level3DVector(object.pos, vec3()), scale: level3DVector(object.scale, vec3(1)),
552
+ rotation: level3DVector(object.rotation, vec3()).scale(PI / 180)};
553
+ const at = this.attached ? local : level3DPrefabPartTransform(this, object);
554
+ const part = level3DMakeAt(object, at.pos, at.rotation, at.scale);
555
+ if (!part) continue; // level3DMakeAt gives an object or nothing
556
+ this.parts.push(part);
557
+ this.partObjects.push(object);
558
+ if (!this.attached || !(part instanceof EngineObject3D)) continue;
559
+ // a child rides with its parent and has no collision of its own
560
+ part.setCollision(false, false, false);
561
+ children.push(part);
562
+ // the corners of its box as it is turned; its place is its middle, a prefab inside this one too
563
+ const box = buildMatrix(part.pos3D, part.rotation3D, part.size3D.multiply(part.scale3D));
564
+ for (let i = 8; i--;)
565
+ {
566
+ const p = box.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
567
+ for (const k of ['x', 'y', 'z'])
568
+ low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
569
+ }
570
+ }
571
+ // an attached instance is the box around its parts, its handle at the middle of it as an object's
572
+ // place is the middle of its body, so a game that makes it solid has it solid where it is seen
573
+ if (children.length)
574
+ {
575
+ this.size3D = high.subtract(low);
576
+ this.originOffset = low.add(high).scale(.5);
577
+ }
578
+ for (const part of children)
579
+ {
580
+ part.pos3D = part.pos3D.subtract(this.originOffset);
581
+ this.addChild(part);
582
+ }
583
+ }
584
+ finally
585
+ {
586
+ --level3DPrefabDepth;
587
+ this.attached && --level3DPrefabAttached;
588
+ }
589
+ }
590
+
591
+ /** Keep the parts with the handle, called automatically each frame */
592
+ update()
593
+ {
594
+ super.update();
595
+ if (!this.attached && this.partsPlaced !== this.placeKey())
596
+ this.placeParts();
597
+ }
598
+
599
+ /** Destroy the instance and its parts
600
+ * @param {boolean} [immediate] */
601
+ destroy(immediate)
602
+ {
603
+ if (this.destroyed) return;
604
+ for (const part of this.parts)
605
+ part.parent === this || part.destroy?.(immediate); // children go with their parent
606
+ super.destroy(immediate);
607
+ }
608
+
609
+ /** A prefab's instance has nothing of its own to draw */
610
+ render3D() {}
611
+ }
612
+
319
613
  ///////////////////////////////////////////////////////////////////////////////
320
614
  // the built-in types
321
615
 
package/src/engine.js CHANGED
@@ -32,7 +32,7 @@ const engineName = 'LittleJS';
32
32
  * @type {string}
33
33
  * @default
34
34
  * @memberof Engine */
35
- const engineVersion = '1.22.0';
35
+ const engineVersion = '1.23.0';
36
36
 
37
37
  /** Frames per second to update
38
38
  * @type {number}
@@ -233,10 +233,9 @@ class Sound
233
233
  ASSERT(isNumber(range), 'range must be a number');
234
234
  ASSERT(isNumber(taper), 'taper must be a number');
235
235
 
236
- /** @property {number} - World space max range of sound */
237
236
  this.range = range;
238
237
  /** @property {boolean} - No range was given, so it got soundDefaultRange; played in 3D such a sound is heard
239
- * to render3D.soundDefaultRange instead, which is further */
238
+ * to render3D.soundDefaultRange instead, which is further; setting range makes it false */
240
239
  this.rangeIsDefault = rangeIsDefault;
241
240
  /** @property {number} - At what percentage of range should it start tapering */
242
241
  this.taper = taper;
@@ -292,6 +291,15 @@ class Sound
292
291
  }
293
292
  }
294
293
 
294
+ /** World space max range of sound, 0 for no limit; a range that is set is the sound's own, in 3D too
295
+ * @type {number} */
296
+ get range() { return this.rangeValue; }
297
+ set range(range)
298
+ {
299
+ this.rangeValue = range;
300
+ this.rangeIsDefault = false;
301
+ }
302
+
295
303
  /** Sample data for each channel
296
304
  * Sounds keep their samples in an audio buffer, so reading this rebuilds
297
305
  * the arrays from it and caches them. The copies are safe to hold onto,
@@ -50,6 +50,7 @@ const enginePluginFiles =
50
50
  `${PLUGIN_FOLDER}/textureSheet.js`,
51
51
  `${PLUGIN_FOLDER}/tweenSystem.js`,
52
52
  `${PLUGIN_FOLDER}/sceneSystem.js`,
53
+ `${PLUGIN_FOLDER}/parallax.js`,
53
54
  `${PLUGIN_FOLDER}/pathFinder.js`,
54
55
  `${PLUGIN_FOLDER}/math3d.js`,
55
56
  `${PLUGIN_FOLDER}/render3d.js`,
package/src/engineDraw.js CHANGED
@@ -496,17 +496,29 @@ class SpriteAnimator
496
496
  * @return {SpriteAnimation} */
497
497
  get clip() { return this.clips[this.name]; }
498
498
 
499
+ /** The clip showing now, once a clip that has ended has called its onEnd: the callback may switch clips, and a
500
+ * read gives the clip it switched to, not the one that ended
501
+ * @return {SpriteAnimation}
502
+ * @ignore */
503
+ get settledClip()
504
+ {
505
+ // each clip's own isDone calls its onEnd once; a few switches in a row at most
506
+ for (let i = 4, clip; i-- && clip !== this.clip;)
507
+ (clip = this.clip).isDone;
508
+ return this.clip;
509
+ }
510
+
499
511
  /** The tile of the frame showing now
500
512
  * @return {TileInfo} */
501
- get tileInfo() { return this.clip.tileInfo; }
513
+ get tileInfo() { return this.settledClip.tileInfo; }
502
514
 
503
515
  /** The frame of the clip showing now
504
516
  * @return {number} */
505
- get frame() { return this.clip.frame; }
517
+ get frame() { return this.settledClip.frame; }
506
518
 
507
519
  /** True once the clip showing now is a play that has ended
508
520
  * @return {boolean} */
509
- get isDone() { return this.clip.isDone; }
521
+ get isDone() { return this.settledClip.isDone; }
510
522
  }
511
523
 
512
524
  ///////////////////////////////////////////////////////////////////////////////