littlejsengine 1.22.0 → 1.23.1

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);
@@ -112,7 +116,8 @@ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
112
116
 
113
117
  /** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
114
118
  * - A level's voxels block makes a VoxelMap when the level loads, with texture 0 and the default tile size unless
115
- * a sheet is given here; a block's type shows that tile of the sheet on every face
119
+ * a sheet is given here; a block's type shows that tile of the sheet on every face; a game that has loaded no
120
+ * image gets plain blocks, a color for each type
116
121
  * - setup is called with each map a level makes, to give block types their own faces or make them see-through
117
122
  * - Call it before level3DLoad; with no arguments the defaults are back
118
123
  * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
@@ -174,7 +179,7 @@ function level3DVoxelsMake(voxels)
174
179
  {
175
180
  const shape = level3DVoxelsShape(voxels);
176
181
  if (!shape || typeof VoxelMap == 'undefined') return;
177
- const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || tile());
182
+ const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || level3DVoxelPlainTiles());
178
183
  level3DVoxelsDecode(voxels.blocks, map.data);
179
184
  level3DVoxelSetupMap?.(map);
180
185
  map.rebuild();
@@ -201,14 +206,74 @@ function level3DTerrainShape(terrain)
201
206
  height: isNumber(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights};
202
207
  }
203
208
 
209
+ // a hex color of a level as a Color, undefined when it is not one
210
+ function level3DHexColor(hex)
211
+ { return /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex) ? new Color().setHex(hex) : undefined; }
212
+
213
+ // the paint of a level's terrain block, for a terrain of so many samples: its colors as they are written and a
214
+ // color for each sample, row after row, 0 for none and 1 the first of the colors; undefined when it has none or
215
+ // it does not fit the terrain
216
+ function level3DTerrainPaint(terrain, samples)
217
+ {
218
+ const paint = terrain?.paint, colors = paint?.colors, runs = paint?.cells;
219
+ if (!isArray(colors) || !isArray(runs) || colors.length > 255 || !colors.every(level3DHexColor)) return;
220
+ const cells = new Uint8Array(samples);
221
+ let at = 0;
222
+ for (let i = 0; i < runs.length; i += 2)
223
+ {
224
+ const count = runs[i], color = runs[i + 1];
225
+ if (!(count > 0 && count % 1 === 0 && color >= 0 && color % 1 === 0) || color > colors.length ||
226
+ at + count > samples) return;
227
+ cells.fill(color, at, at += count);
228
+ }
229
+ return at === samples ? {colors: colors.slice(), cells} : undefined;
230
+ }
231
+
232
+ // give a terrain's map the colors of its block: its own color over the whole of it, or with paint a color for
233
+ // each sample, the terrain's own where it is not painted; the caller builds the map again
234
+ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain, map.rows * map.columns))
235
+ {
236
+ const base = level3DHexColor(terrain.color) || WHITE;
237
+ map.color = base, map.colors = undefined;
238
+ if (!paint || !paint.cells.some((v)=> v)) return;
239
+ const palette = [base, ...paint.colors.map(level3DHexColor)];
240
+ map.color = WHITE;
241
+ map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
242
+ }
243
+
244
+ // the sheet a level's blocks show when the game gave none with level3DVoxelSetup: the game's first image, as a
245
+ // VoxelMap takes it, or, for a game that has loaded no image, plain tiles made here, a color for each type, so a
246
+ // block map in a game with no art can still be seen and painted
247
+ let level3DVoxelPlain;
248
+ function level3DVoxelPlainTiles()
249
+ {
250
+ if (textureInfos[0]?.size.x || headlessMode || typeof OffscreenCanvas == 'undefined')
251
+ return tile();
252
+ if (!level3DVoxelPlain)
253
+ {
254
+ // 16 by 16 tiles of 16 pixels, each a color of its own with a darker edge, so the blocks read as blocks
255
+ const context = createCanvasContext(256);
256
+ for (let i = 0; i < 256; ++i)
257
+ {
258
+ const x = i % 16 * 16, y = (i / 16 | 0) * 16, hue = i * .618 % 1;
259
+ context.fillStyle = hsl(hue, .45, .4).toString();
260
+ context.fillRect(x, y, 16, 16);
261
+ context.fillStyle = hsl(hue, .5, .55).toString();
262
+ context.fillRect(x + 1, y + 1, 14, 14);
263
+ }
264
+ level3DVoxelPlain = new TextureInfo(context.canvas);
265
+ }
266
+ return tile(0, 16, level3DVoxelPlain, 0);
267
+ }
268
+
204
269
  // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
205
270
  function level3DTerrainMake(terrain)
206
271
  {
207
272
  const shape = level3DTerrainShape(terrain);
208
273
  if (!shape || typeof HeightMap == 'undefined') return;
209
274
  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);
275
+ level3DTerrainSetColors(map, terrain);
276
+ map.colors && map.rebuild();
212
277
  return map;
213
278
  }
214
279
 
@@ -281,6 +346,14 @@ function level3DProperties(type, object)
281
346
  // make one object of a level from the type added for its name, undefined when there is no such type or it made
282
347
  // nothing
283
348
  function level3DMake(object)
349
+ {
350
+ return level3DMakeAt(object, level3DVector(object.pos, vec3()),
351
+ level3DVector(object.rotation, vec3()).scale(PI / 180), level3DVector(object.scale, vec3(1)));
352
+ }
353
+
354
+ // make a level object's game object at a place, a rotation in radians and a scale, its own as level3DMake gives
355
+ // them or the ones a prefab's instance puts it at
356
+ function level3DMakeAt(object, pos, rotation, scale)
284
357
  {
285
358
  const type = level3DTypes.get(object.type);
286
359
  if (!type)
@@ -288,7 +361,7 @@ function level3DMake(object)
288
361
  debug && console.warn(`level3DLoad: no type added for ${object.type}, skipped`);
289
362
  return;
290
363
  }
291
- const pos = level3DVector(object.pos, vec3()), properties = level3DProperties(type, object);
364
+ const properties = level3DProperties(type, object);
292
365
  const {make} = type, count = engineObjects.length;
293
366
  let result;
294
367
  try { result = make.prototype ? new make(pos, properties) : make(pos, properties); }
@@ -306,16 +379,263 @@ function level3DMake(object)
306
379
  if (result instanceof EngineObject3D)
307
380
  {
308
381
  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)));
382
+ result.rotation3D = rotation.copy();
383
+ result.scale3D = result.scale3D.multiply(scale);
311
384
  }
312
385
 
313
386
  // 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
387
  for (const key in type.defaults)
315
388
  result[key] = properties[key];
389
+ // a prefab's instance makes its parts now that it is where it goes
390
+ result instanceof Prefab3D && result.placeAt(pos);
316
391
  return result;
317
392
  }
318
393
 
394
+ /** Make one object of a type added with level3DAddType, level3DAddMesh or level3DAddPrefab, from code, with no
395
+ * level: a prefab's instance, or a plain type
396
+ * @param {string} type - The type's name
397
+ * @param {Vector3} [pos3D]
398
+ * @param {Vector3} [rotation3D] - In radians, as an object has it
399
+ * @param {Vector3} [scale3D] - Times the scale the type makes it with
400
+ * @param {Object} [properties] - Over the type's defaults
401
+ * @return {any} - What the type made, a Prefab3D for a prefab, undefined when there is no such type
402
+ * @memberof Level3D
403
+ * @example
404
+ * level3DAddPrefab('House', await fetchJSON('house.json'));
405
+ * level3DSpawn('House', vec3(10, 0, 0), vec3(0, PI/2, 0)); */
406
+ function level3DSpawn(type, pos3D=vec3(), rotation3D=vec3(), scale3D=vec3(1), properties)
407
+ {
408
+ ASSERT(isVector3(pos3D) && isVector3(rotation3D) && isVector3(scale3D), 'pos3D, rotation3D and scale3D are vec3');
409
+ return level3DMakeAt(properties ? {type, properties} : {type}, pos3D.copy(), rotation3D, scale3D);
410
+ }
411
+
412
+ ///////////////////////////////////////////////////////////////////////////////
413
+ // prefabs: a prefab is a small level, a list of objects about its own origin, placed many times
414
+
415
+ // the prefabs by name: how an instance is built, its objects, fromLevel, true for one a level's own prefabs
416
+ // block added, which one the game adds takes the place of, and source, the object it was added from, which the
417
+ // level editor knows a level that is a prefab by
418
+ /** @type {Map<string, {attached: boolean, objects: Array<Object>, fromLevel: boolean, source: Object}>} */
419
+ const level3DPrefabs = new Map;
420
+
421
+ // how many prefabs deep an instance being made is, a prefab that holds itself is stopped; and how many of those
422
+ // are attached: a prefab inside an attached one is attached too, or its parts would be left behind in the world
423
+ let level3DPrefabDepth = 0, level3DPrefabAttached = 0;
424
+
425
+ /** Add a prefab: a small level, its objects placed about its own origin, to place many times under one name
426
+ * - It is a type from then on: a level's object of that type, the level editor's Place list and level3DSpawn make
427
+ * an instance, a Prefab3D; an instance is made of the prefab as it is then, so the level editor, where a prefab
428
+ * changes, makes its instances again
429
+ * - The prefab is a level as the level editor saves it, {objects: [...]}, so the editor is the prefab editor too;
430
+ * only its objects are used, and they may be of other prefabs
431
+ * - With attached true in it the parts are children of the instance and move with it as one body, without
432
+ * collision of their own, a prefab inside it too; its handle is at the middle of the box around them and its
433
+ * 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
434
+ * by hand does
435
+ * - Adding a name again replaces it
436
+ * @param {string} name - The type its instances have in a level
437
+ * @param {Object} prefab - {objects, attached}
438
+ * @memberof Level3D
439
+ * @example
440
+ * level3DAddPrefab('Tower', {objects: [{type: 'Box', pos: [0, 1, 0], scale: [2, 2, 2]},
441
+ * {type: 'Cylinder', pos: [0, 3, 0]}]});
442
+ * level3DLoad({objects: [{type: 'Tower', pos: [5, 0, 5]}, {type: 'Tower', pos: [-5, 0, 5], rotation: [0, 45, 0]}]}); */
443
+ function level3DAddPrefab(name, prefab) { level3DPrefabSet(name, prefab, false); }
444
+
445
+ // add a prefab, the game's or a level's own
446
+ function level3DPrefabSet(name, prefab, fromLevel)
447
+ {
448
+ ASSERT(!!prefab && typeof prefab === 'object', 'a prefab is an object, {objects: [...]}');
449
+ name = String(name);
450
+ const objects = isArray(prefab?.objects) ? prefab.objects.filter((o)=> o && typeof o === 'object') : [];
451
+ // a copy, what the game or the editor does to its own afterwards does not change the instances made later
452
+ level3DPrefabs.set(name, {attached: !!prefab?.attached, objects: JSON.parse(JSON.stringify(objects)), fromLevel,
453
+ source: prefab});
454
+ // a class of its own, so the level editor makes its instances again as it does a class's
455
+ level3DTypes.set(name, {make: class extends Prefab3D { constructor(pos) { super(pos, name); } }, defaults: {},
456
+ tileInfo: undefined});
457
+ }
458
+
459
+ // add a level's own prefabs, each unless the game added one of that name or it is a plain type's name
460
+ function level3DPrefabsAdd(prefabs)
461
+ {
462
+ if (!prefabs || typeof prefabs !== 'object' || isArray(prefabs)) return;
463
+ for (const name in prefabs)
464
+ {
465
+ const known = level3DPrefabs.get(name);
466
+ if (known ? !known.fromLevel : level3DTypes.has(name)) continue;
467
+ prefabs[name] && typeof prefabs[name] === 'object' && level3DPrefabSet(name, prefabs[name], true);
468
+ }
469
+ }
470
+
471
+ /** Load a prefab from a file the level editor saved and add it
472
+ * @param {string} name - The type its instances have in a level
473
+ * @param {string} url
474
+ * @return {Promise<void>}
475
+ * @memberof Level3D */
476
+ async function level3DLoadPrefab(name, url) { level3DAddPrefab(name, await fetchJSON(url)); }
477
+
478
+ // where an instance puts one of its prefab's objects: the object's own place, turn and size inside the instance's
479
+ function level3DPrefabPartTransform(instance, object)
480
+ {
481
+ const pos = level3DVector(object.pos, vec3()), scale = level3DVector(object.scale, vec3(1));
482
+ const rotation = level3DVector(object.rotation, vec3()).scale(PI / 180);
483
+ // the two turns one after the other; the sizes multiply along each axis, which is exact for an instance sized
484
+ // evenly or a part that is not turned, and as near as a box can be otherwise
485
+ const turn = buildMatrix(vec3(), instance.rotation3D).multiply(buildMatrix(vec3(), rotation));
486
+ return {pos: buildMatrix(instance.pos3D, instance.rotation3D, instance.scale3D).transformPoint(pos),
487
+ rotation: turn.getRotation(), scale: scale.multiply(instance.scale3D)};
488
+ }
489
+
490
+ /**
491
+ * An instance of a prefab, what a prefab's type makes: a handle with no shape of its own, and its parts, what the
492
+ * prefab's objects made
493
+ * - Parts of a prefab that is not attached are objects of their own in the world: moving, turning or sizing the
494
+ * handle puts them where it now says with its next update, or call placeParts; destroying it destroys them
495
+ * - Parts of an attached prefab are its children and move with it as one body
496
+ * @extends EngineObject3D
497
+ * @memberof Level3D
498
+ */
499
+ class Prefab3D extends EngineObject3D
500
+ {
501
+ /** Create an instance of a prefab, made by its type: place one with a level or level3DSpawn
502
+ * @param {Vector3} [pos3D]
503
+ * @param {string} [prefabName] - A prefab added with level3DAddPrefab */
504
+ constructor(pos3D, prefabName='')
505
+ {
506
+ super(pos3D);
507
+ /** @property {string} - The prefab it is an instance of */
508
+ this.prefabName = prefabName;
509
+ /** @property {boolean} - Are its parts its children, moving with it as one body */
510
+ this.attached = !!level3DPrefabs.get(prefabName)?.attached || level3DPrefabAttached > 0;
511
+ /** @property {Array<any>} - What the prefab's objects made, in the prefab's order */
512
+ this.parts = [];
513
+ /** @property {Vector3} - From the prefab's origin to the handle, in the prefab's own space: nothing for
514
+ * separate parts, and for an attached prefab the middle of the box around its parts, where its handle
515
+ * is, as an object's place is the middle of its body */
516
+ this.originOffset = vec3();
517
+ this.partObjects = []; // the prefab's object of each part
518
+ this.partsPlaced = ''; // where the handle was when its parts were last placed, undefined parts not made
519
+ this.partsMade = false;
520
+ }
521
+
522
+ /** Put the instance with its prefab's origin at a place, turned and sized as the handle is: where a level's
523
+ * object or level3DSpawn says it goes; the handle of an attached prefab is then at the middle of its body
524
+ * @param {Vector3} pos3D */
525
+ placeAt(pos3D)
526
+ {
527
+ this.pos3D = pos3D.copy();
528
+ this.placeParts();
529
+ const o = this.originOffset;
530
+ if (o.x || o.y || o.z)
531
+ this.pos3D = buildMatrix(pos3D, this.rotation3D, this.scale3D).transformPoint(o);
532
+ }
533
+
534
+ /** Put the parts where the handle is now, making them the first time; called by the handle's update when it
535
+ * has moved, turned or changed size */
536
+ placeParts()
537
+ {
538
+ if (this.destroyed) return;
539
+ this.partsMade ||= (this.makeParts(), true);
540
+ this.partsPlaced = this.placeKey();
541
+ if (this.attached) return; // children follow by themselves
542
+ this.parts.forEach((part, i)=>
543
+ {
544
+ if (!(part instanceof EngineObject3D)) return; // what a function made is where it was made
545
+ const to = level3DPrefabPartTransform(this, this.partObjects[i]);
546
+ part.rotation3D = to.rotation;
547
+ part.scale3D = (level3DBaseScale.get(part) ?? vec3(1)).multiply(to.scale);
548
+ // a prefab inside this one goes by its origin, which is not where an attached one's handle is
549
+ part instanceof Prefab3D ? part.placeAt(to.pos) : part.pos3D = to.pos;
550
+ });
551
+ }
552
+
553
+ // the handle's place, turn and size as one text, to tell when it has changed
554
+ placeKey()
555
+ {
556
+ const p = this.pos3D, r = this.rotation3D, s = this.scale3D;
557
+ return [p.x, p.y, p.z, r.x, r.y, r.z, s.x, s.y, s.z].join();
558
+ }
559
+
560
+ // make the parts, each where the instance puts it, or attached at its own place inside the instance
561
+ makeParts()
562
+ {
563
+ const prefab = level3DPrefabs.get(this.prefabName);
564
+ if (!prefab) return;
565
+ if (level3DPrefabDepth >= 8)
566
+ {
567
+ debug && console.error(`level3DLoad: the prefab ${this.prefabName} holds itself, left out there`);
568
+ return;
569
+ }
570
+ ++level3DPrefabDepth;
571
+ this.attached && ++level3DPrefabAttached;
572
+ try
573
+ {
574
+ const low = vec3(Infinity), high = vec3(-Infinity), children = [];
575
+ for (const object of prefab.objects)
576
+ {
577
+ const local = {pos: level3DVector(object.pos, vec3()), scale: level3DVector(object.scale, vec3(1)),
578
+ rotation: level3DVector(object.rotation, vec3()).scale(PI / 180)};
579
+ const at = this.attached ? local : level3DPrefabPartTransform(this, object);
580
+ const part = level3DMakeAt(object, at.pos, at.rotation, at.scale);
581
+ if (!part) continue; // level3DMakeAt gives an object or nothing
582
+ this.parts.push(part);
583
+ this.partObjects.push(object);
584
+ if (!this.attached || !(part instanceof EngineObject3D)) continue;
585
+ // a child rides with its parent and has no collision of its own
586
+ part.setCollision(false, false, false);
587
+ children.push(part);
588
+ // the corners of its box as it is turned; its place is its middle, a prefab inside this one too
589
+ const box = buildMatrix(part.pos3D, part.rotation3D, part.size3D.multiply(part.scale3D));
590
+ for (let i = 8; i--;)
591
+ {
592
+ const p = box.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
593
+ for (const k of ['x', 'y', 'z'])
594
+ low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
595
+ }
596
+ }
597
+ // an attached instance is the box around its parts, its handle at the middle of it as an object's
598
+ // place is the middle of its body, so a game that makes it solid has it solid where it is seen
599
+ if (children.length)
600
+ {
601
+ this.size3D = high.subtract(low);
602
+ this.originOffset = low.add(high).scale(.5);
603
+ }
604
+ for (const part of children)
605
+ {
606
+ part.pos3D = part.pos3D.subtract(this.originOffset);
607
+ this.addChild(part);
608
+ }
609
+ }
610
+ finally
611
+ {
612
+ --level3DPrefabDepth;
613
+ this.attached && --level3DPrefabAttached;
614
+ }
615
+ }
616
+
617
+ /** Keep the parts with the handle, called automatically each frame */
618
+ update()
619
+ {
620
+ super.update();
621
+ if (!this.attached && this.partsPlaced !== this.placeKey())
622
+ this.placeParts();
623
+ }
624
+
625
+ /** Destroy the instance and its parts
626
+ * @param {boolean} [immediate] */
627
+ destroy(immediate)
628
+ {
629
+ if (this.destroyed) return;
630
+ for (const part of this.parts)
631
+ part.parent === this || part.destroy?.(immediate); // children go with their parent
632
+ super.destroy(immediate);
633
+ }
634
+
635
+ /** A prefab's instance has nothing of its own to draw */
636
+ render3D() {}
637
+ }
638
+
319
639
  ///////////////////////////////////////////////////////////////////////////////
320
640
  // the built-in types
321
641
 
@@ -303,6 +303,7 @@ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
303
303
  for (const group of parseAtlas(data))
304
304
  {
305
305
  // reserve a block of full size cells, one per frame
306
+ if (!group.frames.length) continue; // a tag with no frames, the groups after it still load
306
307
  const sourceSize = group.frames[0].sourceSize;
307
308
  const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
308
309
  const added = textureSheetAdd(blockSize, sourceSize, padding);