littlejsengine 1.20.0 → 1.21.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.
@@ -38,6 +38,54 @@ function render3DSoftDot()
38
38
  return render3DSoftDotTexture = new TextureInfo(context.canvas);
39
39
  }
40
40
 
41
+ /** Make a normal map from a height at each pixel, for bumps and grooves with no image file: the slope at each pixel
42
+ * from its neighbors' heights, taken around the edges so the map tiles; set it as an object's normalMap
43
+ * @param {Vector2} size - In pixels
44
+ * @param {function(number, number): number} heightFunction - The height 0 to 1 at a pixel, x across and y down
45
+ * @param {number} [strength] - How steep the slopes are: a height change of 1 over one pixel leans the normal
46
+ * by strength
47
+ * @return {TextureInfo} - Wraps; headless it has no image
48
+ * @memberof Render3D */
49
+ function normalMapFromHeight(size, heightFunction, strength=1)
50
+ {
51
+ ASSERT(isVector2(size) && size.x >= 1 && size.y >= 1, 'normalMapFromHeight size must be a Vector2 of pixels');
52
+ const width = size.x | 0, height = size.y | 0;
53
+ if (typeof OffscreenCanvas == 'undefined')
54
+ {
55
+ // headless, nothing to draw into; the right size so what reads it still works
56
+ const textureInfo = new TextureInfo(undefined, false, true);
57
+ textureInfo.size = vec2(width, height), textureInfo.sizeInverse = vec2(1 / width, 1 / height);
58
+ return textureInfo;
59
+ }
60
+ const context = createCanvasContext(width, height);
61
+ const pixels = render3DNormalMapPixels(width, height, heightFunction, strength);
62
+ context.putImageData(new ImageData(pixels, width, height), 0, 0);
63
+ return new TextureInfo(context.canvas, true, true);
64
+ }
65
+
66
+ // the pixels of a normal map made from heights, rgba with the top row first: each normal leans away from the uphill
67
+ // side, green up the image as OpenGL has it, and a flat height is (128, 128, 255); rounded before they are stored,
68
+ // since the clamped array would round a flat 127.5 to even
69
+ function render3DNormalMapPixels(width, height, heightFunction, strength)
70
+ {
71
+ const heights = new Float32Array(width * height), pixels = new Uint8ClampedArray(width * height * 4);
72
+ for (let y = 0; y < height; ++y)
73
+ for (let x = 0; x < width; ++x)
74
+ heights[y * width + x] = heightFunction(x, y);
75
+ const h = (x, y)=> heights[mod(y, height) * width + mod(x, width)];
76
+ for (let y = 0; y < height; ++y)
77
+ for (let x = 0; x < width; ++x)
78
+ {
79
+ const dx = (h(x+1, y) - h(x-1, y)) / 2 * strength, dy = (h(x, y+1) - h(x, y-1)) / 2 * strength;
80
+ const k = 1 / hypot(dx, dy, 1), i = (y * width + x) * 4;
81
+ pixels[i] = round((-dx * k * .5 + .5) * 255);
82
+ pixels[i+1] = round((dy * k * .5 + .5) * 255);
83
+ pixels[i+2] = round((k * .5 + .5) * 255);
84
+ pixels[i+3] = 255;
85
+ }
86
+ return pixels;
87
+ }
88
+
41
89
  ///////////////////////////////////////////////////////////////////////////////
42
90
  // The rest of the shape builders: buildLathe, buildSphere, buildBox, buildGrid and buildSky live with the
43
91
  // renderer, since it hands those out itself
@@ -574,6 +622,22 @@ class HeightMap extends EngineObject3D
574
622
  * @ignore */
575
623
  levelRaycast3D(ray) { return this.raycast(ray); }
576
624
 
625
+ /** Where a short move goes under the surface, for a particle's move in one frame: the part of the move made
626
+ * before it, 0 to 1, and the surface normal there; undefined when it stays above, starts under, or is off the map
627
+ * @param {Vector3} from
628
+ * @param {Vector3} to
629
+ * @return {{distance: number, normal: Vector3}|undefined}
630
+ * @ignore */
631
+ levelSegment3D(from, to)
632
+ {
633
+ const m = this.pos3D, size = this.mapSize;
634
+ if (abs(to.x - m.x) > size.x / 2 || abs(to.z - m.z) > size.y / 2) return; // off the map
635
+ const a = from.y - this.getHeight(from.x, from.z), b = to.y - this.getHeight(to.x, to.z);
636
+ if (a < 0 || b >= 0) return; // above all the way, or under from the start
637
+ const distance = a / (a - b);
638
+ return {distance, normal: this.getNormal(from.x + (to.x - from.x) * distance, from.z + (to.z - from.z) * distance)};
639
+ }
640
+
577
641
  /** Keep an object above the ground, called by the engine for each object with collideLevel
578
642
  * @param {EngineObject3D} o
579
643
  * @param {Vector3} oldPos - Where it was before it moved
@@ -762,7 +826,7 @@ class FirstPersonCamera3D extends EngineObject3D
762
826
  /** @property {boolean} - Capture the mouse on a click, so looking needs no button held */
763
827
  this.lockPointer = true;
764
828
  /** @property {number} - Speed of a jump in world units a frame, 0 for none; Space or gamepad button 0 jumps
765
- * while it stands on something, a height map or voxel map it collides with, see collideLevel */
829
+ * while it stands on something, a height map, a voxel map or a solid, see groundObject */
766
830
  this.jumpSpeed = 0;
767
831
  /** @property {number} - How far above its position the eye is, in its own space, so with a size3D the eye can
768
832
  * sit toward the top of the body instead of its middle; keep it under half the body's height, or the eye is
@@ -833,6 +897,49 @@ class FirstPersonCamera3D extends EngineObject3D
833
897
  const RENDER3D_PARTICLE_FLOATS = 21;
834
898
  // the position, color and size of the particle being drawn, shared by every emitter so the draw loop makes no objects
835
899
  const render3DParticlePos = vec3(), render3DParticleColor = new Color, render3DParticleSize = vec2();
900
+ // where a particle was and is, for the level collision
901
+ const render3DParticleFrom = vec3(), render3DParticleTo = vec3();
902
+
903
+ // the emitters inside a particle callback now, whose nested calls each get a view of their own
904
+ const render3DParticlesCalling = new Set;
905
+
906
+ // a particle as a ParticleEmitter3D's callbacks see it, set from the particle for each call
907
+ /** @param {ParticleEmitter3D} emitter
908
+ * @return {Particle3D} */
909
+ function render3DParticleView(emitter)
910
+ {
911
+ return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, destroyed: false,
912
+ destroy() { this.destroyed = true; }};
913
+ }
914
+
915
+ /**
916
+ * A particle as a ParticleEmitter3D's callbacks see it: one object the emitter reuses, set from the particle for each
917
+ * call and written back after it, so copy what you keep
918
+ * @typedef {Object} Particle3D
919
+ * @property {ParticleEmitter3D} emitter - The emitter it is in
920
+ * @property {Vector3} pos - Where it is, change it to move it
921
+ * @property {Vector3} velocity - How far it moves each frame
922
+ * @property {number} age - Seconds it has lived
923
+ * @property {number} lifeTime - Seconds it lives
924
+ * @property {boolean} destroyed - Set by destroy
925
+ * @property {function(): void} destroy - End it this update, the destroy callback gets it
926
+ * @memberof Render3D
927
+ */
928
+
929
+ /**
930
+ * @callback Particle3DCallback - A function a ParticleEmitter3D calls with one of its particles
931
+ * @param {Particle3D} particle
932
+ * @memberof Render3D
933
+ */
934
+
935
+ /**
936
+ * @callback Particle3DCollideCallback - Decides whether a particle stops where it hits the level, a filter as in 2D
937
+ * @param {Particle3D} particle
938
+ * @param {EngineObject3D} level - The HeightMap or VoxelMap it hit
939
+ * @param {Vector3} pos - Where it hit
940
+ * @return {boolean|void} - true to stop it there; a callback that returns nothing lets it pass through
941
+ * @memberof Render3D
942
+ */
836
943
 
837
944
  /**
838
945
  * ParticleEmitter3D - Spawns camera facing particles, the 3D twin of ParticleEmitter
@@ -846,6 +953,10 @@ const render3DParticlePos = vec3(), render3DParticleColor = new Color, render3DP
846
953
  * - gravity here is its own number added to velocity y each frame: it is neither the engine's 2D
847
954
  * gravity nor render3D.gravity, so an effect keeps its own fall wherever it is used
848
955
  * - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter
956
+ * - Callbacks as the 2D emitter's: particleCreateCallback, particleUpdateCallback, particleCollideCallback and
957
+ * particleDestroyCallback, each given a Particle3D, one object the emitter reuses for every particle and call
958
+ * - collideLevel, off by default, has particles hit the height maps and voxel maps, bouncing by restitution and
959
+ * sliding by friction
849
960
  * @extends EngineObject3D
850
961
  * @memberof Render3D
851
962
  * @example
@@ -918,7 +1029,8 @@ class ParticleEmitter3D extends EngineObject3D
918
1029
  this.randomness = randomness;
919
1030
  /** @property {boolean} - Additive blending */
920
1031
  this.additive = additive;
921
- /** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards */
1032
+ /** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards;
1033
+ * with engineVariableStep it keeps trailTime * frameRate updates of path */
922
1034
  this.trailTime = 0;
923
1035
  /** @property {number} - Radians per frame each particle turns in the camera plane, either way; 0 is no spin */
924
1036
  this.angleSpeed = 0;
@@ -934,6 +1046,25 @@ class ParticleEmitter3D extends EngineObject3D
934
1046
  this.trailData = undefined;
935
1047
  /** @property {number} - Trail points kept per particle, from trailTime */
936
1048
  this.trailMax = 0;
1049
+ /** @property {boolean} - Particles hit the level, the height maps and voxel maps, bouncing by restitution and
1050
+ * sliding along by friction; off by default, it tests each particle's move against the level every frame */
1051
+ this.collideLevel = false;
1052
+ /** @property {Particle3DCallback|undefined} - Called with each particle as it is made
1053
+ * @type {Particle3DCallback|undefined} */
1054
+ this.particleCreateCallback = undefined;
1055
+ /** @property {Particle3DCallback|undefined} - Called with each particle each update, after it moves
1056
+ * @type {Particle3DCallback|undefined} */
1057
+ this.particleUpdateCallback = undefined;
1058
+ /** @property {Particle3DCollideCallback|undefined} - Decides if a particle stops where it hits the level,
1059
+ * with collideLevel on; a callback that returns nothing lets it through
1060
+ * @type {Particle3DCollideCallback|undefined} */
1061
+ this.particleCollideCallback = undefined;
1062
+ /** @property {Particle3DCallback|undefined} - Called with each particle as it goes, its life over or destroyed
1063
+ * @type {Particle3DCallback|undefined} */
1064
+ this.particleDestroyCallback = undefined;
1065
+ /** @property {Particle3D} - The particle the callbacks get, one object for every particle and call
1066
+ * @type {Particle3D} */
1067
+ this.particleView = render3DParticleView(this);
937
1068
  /** @property {Vector3|undefined} - Where the emitter was at its last update, for when its parent is destroyed
938
1069
  * @type {Vector3|undefined} */
939
1070
  this.worldPos3D = undefined;
@@ -963,8 +1094,9 @@ class ParticleEmitter3D extends EngineObject3D
963
1094
  else if (!this.particleCount)
964
1095
  this.destroy();
965
1096
 
966
- // the trail storage follows trailTime, a change starts every trail over
967
- const trailMax = this.trailTime ? max(1, round(this.trailTime / timeDelta)) : 0;
1097
+ // the trail storage follows trailTime at the fixed rate, a change starts every trail over, so a varying
1098
+ // timeDelta can not; with engineVariableStep a trail keeps that many updates
1099
+ const trailMax = this.trailTime ? max(1, round(this.trailTime * frameRate)) : 0;
968
1100
  if (trailMax !== this.trailMax)
969
1101
  {
970
1102
  this.trailMax = trailMax;
@@ -974,8 +1106,13 @@ class ParticleEmitter3D extends EngineObject3D
974
1106
  }
975
1107
 
976
1108
  // move the particles and drop the dead ones, all in the typed array: this runs for every particle every frame
977
- const F = RENDER3D_PARTICLE_FLOATS, data = this.particleData, trail = this.trailData;
1109
+ // a callback can emit, which grows the arrays when they are full, so they are read again after each one
1110
+ const F = RENDER3D_PARTICLE_FLOATS;
1111
+ let data = this.particleData, trail = this.trailData;
1112
+ const reread = ()=> { data = this.particleData, trail = this.trailData; };
978
1113
  const damping = this.damping, gravity = this.gravity * scale, angleDamping = this.angleDamping; // a bigger effect has to fall faster to keep the same arc
1114
+ const collideLevel = this.collideLevel && render3DLevel.length;
1115
+ const updateCallback = this.particleUpdateCallback, destroyCallback = this.particleDestroyCallback;
979
1116
  for (let i = this.particleCount; i--;)
980
1117
  {
981
1118
  // damped first and gravity added after, the order the 2D particle uses, so the same
@@ -984,6 +1121,7 @@ class ParticleEmitter3D extends EngineObject3D
984
1121
  const vx = data[k+3] *= damping, vy = data[k+4] = data[k+4] * damping + gravity, vz = data[k+5] *= damping;
985
1122
  data[k] += vx, data[k+1] += vy, data[k+2] += vz;
986
1123
  data[k+18] += data[k+19] *= angleDamping;
1124
+ collideLevel && (this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz), reread());
987
1125
  const t = i * trailMax * 3;
988
1126
  if (trailMax)
989
1127
  {
@@ -995,9 +1133,11 @@ class ParticleEmitter3D extends EngineObject3D
995
1133
  trail[j] = data[k], trail[j+1] = data[k+1], trail[j+2] = data[k+2];
996
1134
  data[k+20] = n + 1;
997
1135
  }
1136
+ updateCallback && (this.particleCall(updateCallback, k), reread());
998
1137
  if ((data[k+17] += timeDelta) >= data[k+16])
999
1138
  {
1000
1139
  // dead: the last particle takes its slot, trail and all, order does not matter
1140
+ destroyCallback && (this.particleCall(destroyCallback, k), reread());
1001
1141
  const last = --this.particleCount, kl = last * F;
1002
1142
  if (i !== last)
1003
1143
  {
@@ -1064,6 +1204,60 @@ class ParticleEmitter3D extends EngineObject3D
1064
1204
  data[k+18] = this.angleSpeed ? rand(2*PI) : 0;
1065
1205
  data[k+19] = this.angleSpeed ? this.angleSpeed * random() * randSign() : 0;
1066
1206
  data[k+20] = 0; // trail points
1207
+ this.particleCreateCallback && this.particleCall(this.particleCreateCallback, k);
1208
+ }
1209
+
1210
+ // call a particle callback with the particle at k: the emitter's one Particle3D is set from its numbers, and
1211
+ // what the callback changes is written back; a particle it destroys has lived its life, the update removes it
1212
+ particleCall(callback, k, level, pos)
1213
+ {
1214
+ // a callback that emits runs the create callback inside itself, which gets a view of its own so this one
1215
+ // keeps its particle; what the callback changes is written to the arrays as they are after it, since an
1216
+ // emit into a full emitter grows them
1217
+ const nested = render3DParticlesCalling.has(this);
1218
+ const view = nested ? render3DParticleView(this) : this.particleView, data = this.particleData;
1219
+ view.pos.set(data[k], data[k+1], data[k+2]);
1220
+ view.velocity.set(data[k+3], data[k+4], data[k+5]);
1221
+ view.age = data[k+17], view.lifeTime = data[k+16], view.destroyed = false;
1222
+ render3DParticlesCalling.add(this);
1223
+ let result;
1224
+ try { result = callback(view, level, pos); }
1225
+ finally { nested || render3DParticlesCalling.delete(this); }
1226
+ const out = this.particleData;
1227
+ out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
1228
+ out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
1229
+ if (view.destroyed)
1230
+ out[k+17] = max(out[k+17], out[k+16] - timeDelta); // aged past its life this update
1231
+ return result;
1232
+ }
1233
+
1234
+ // stop the particle at k where its last move went into the level, from where it was: back at the surface, a hair
1235
+ // off it, its speed into it turned around by restitution and its speed along it kept by friction, unless the
1236
+ // collide callback lets it through; one that starts inside is let go, as a 2D one is
1237
+ particleCollide(k, x, y, z)
1238
+ {
1239
+ let data = this.particleData;
1240
+ const from = render3DParticleFrom.set(x, y, z);
1241
+ const to = render3DParticleTo.set(data[k], data[k+1], data[k+2]);
1242
+ let hit, level;
1243
+ for (const l of render3DLevel)
1244
+ {
1245
+ const h = l.destroyed ? undefined : l.levelSegment3D(from, to);
1246
+ if (h && (!hit || h.distance < hit.distance))
1247
+ hit = h, level = l;
1248
+ }
1249
+ if (!hit) return;
1250
+ const point = from.lerp(to, hit.distance);
1251
+ const callback = this.particleCollideCallback;
1252
+ if (callback && (!this.particleCall(callback, k, level, point.copy()) || this.particleView.destroyed))
1253
+ return; // let through, or destroyed by the callback
1254
+ data = this.particleData; // as the callback left it, an emit may have grown it
1255
+
1256
+ const n = hit.normal, v = vec3(data[k+3], data[k+4], data[k+5]), into = n.scale(v.dot(n));
1257
+ const restitution = max(this.restitution, level.restitution), friction = max(this.friction, level.friction);
1258
+ const out = v.subtract(into).scale(friction).subtract(into.scale(restitution)), p = point.add(n.scale(1e-3));
1259
+ data[k] = p.x, data[k+1] = p.y, data[k+2] = p.z;
1260
+ data[k+3] = out.x, data[k+4] = out.y, data[k+5] = out.z;
1067
1261
  }
1068
1262
 
1069
1263
  /** Draw the particles, as flat squares or as streaks when trailTime is set
@@ -0,0 +1,189 @@
1
+ /**
2
+ * LittleJS 3D Level Plugin
3
+ * - A 3D level is a list of objects, each a type name, a position, and a rotation, scale and properties where
4
+ * they are not the default, kept as plain JSON that can be written by hand
5
+ * - level3DAddType names the types a game makes them from, as objectLayersAddType does in 2D
6
+ * - level3DLoad makes every object of a level
7
+ * - Box, Sphere, Cylinder and Light are built in, to block out and light a level with no code
8
+ * - The 3D level editor, in debug builds, edits the level a game loaded
9
+ * @namespace Level3D
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ ///////////////////////////////////////////////////////////////////////////////
15
+
16
+ const LEVEL3D_VERSION = 1; // the format a level's littlejs3D names
17
+
18
+ // the types a level's objects are made from, by name
19
+ const level3DTypes = new Map;
20
+
21
+ // the scale each object had when it was made, the level's scale multiplies it
22
+ const level3DBaseScale = new WeakMap;
23
+
24
+ /** Add a type of object, so level3DLoad makes one wherever a level has an object of that type
25
+ * - The name is a string because minified builds rename classes
26
+ * - A class, or any function with a prototype, is made with new make(pos3D, properties); an arrow function is
27
+ * called as make(pos3D, properties), for what is not an object, like a player start
28
+ * - properties is the defaults with the object's own values over them, and each of the type's own is also set on
29
+ * what was made; a property the type has no default for is in properties and is not set
30
+ * - Give a class a constructor of its own that takes the position: one that hands every argument on to
31
+ * EngineObject3D would hand it the properties as its mesh
32
+ * - An EngineObject3D then gets the object's rotation, and its scale times the scale it was made with
33
+ * - Adding a name again replaces it, Box, Sphere, Cylinder and Light too
34
+ * @param {string} name - The type the objects have in the level
35
+ * @param {Function} make - A class made at each object's position, or a function called with it
36
+ * @param {Object} [defaults] - Properties of each one made, the level editor shows inputs for them
37
+ * @param {TileInfo} [tileInfo] - An icon for the level editor
38
+ * @memberof Level3D
39
+ * @example
40
+ * level3DAddType('Crate', Crate, {health: 3});
41
+ * level3DAddType('PlayerStart', (pos)=> playerStart = pos); */
42
+ function level3DAddType(name, make, defaults={}, tileInfo)
43
+ {
44
+ ASSERT(isStringLike(name), 'object type name must be a string');
45
+ ASSERT(typeof make === 'function', 'make must be a class or function');
46
+ ASSERT(!!defaults && typeof defaults === 'object', 'defaults must be an object');
47
+ level3DTypes.set(String(name), {make, defaults, tileInfo});
48
+ }
49
+
50
+ /** Add a type that is a mesh and nothing more, a static prop with no class to write, for a built mesh or a model
51
+ * - Each object has a color and a solid property, solid collides as the box around the mesh
52
+ * @param {string} name - The type the objects have in the level
53
+ * @param {Mesh} mesh - Shared by every object of the type
54
+ * @param {TileInfo} [tileInfo] - Its texture
55
+ * @param {Color} [color] - Its color, an object's own color property goes over it
56
+ * @memberof Level3D
57
+ * @example
58
+ * level3DAddMesh('Tree', treeMesh, tile(4)); */
59
+ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
60
+ {
61
+ ASSERT(mesh instanceof Mesh, 'mesh must be a Mesh');
62
+ ASSERT(isColor(color), 'color must be a color');
63
+ level3DAddType(name, function(pos, properties) { return level3DMakeShape(pos, properties, mesh, tileInfo); },
64
+ {color, solid: false}, tileInfo);
65
+ }
66
+
67
+ /** Make the objects of a level, each from the type added for its name
68
+ * - The level is an object: {littlejs3D: 1, objects: [{id, type, pos: [x, y, z]}, ...]}, and {} is a new one
69
+ * - rotation is pitch, yaw and roll in degrees, scale is a number for each axis, both left out when they are
70
+ * the default, and properties holds what differs from the type's defaults
71
+ * - A Color property is a #rrggbb or #rrggbbaa string, a Vector2 or Vector3 an array, as the default says
72
+ * - An object whose type was not added is skipped, with a warning in debug builds
73
+ * - What a file written by hand gets wrong uses the default: a value that is not of its default's type
74
+ * - An object its type can not make is skipped with an error in debug builds, where asserts throw, and the rest
75
+ * of the level is made
76
+ * @param {Object} level - The level, the level editor edits this same object
77
+ * @return {Array<any>} - What each object's type made, a function that made nothing is left out
78
+ * @memberof Level3D */
79
+ function level3DLoad(level)
80
+ {
81
+ ASSERT(!!level && typeof level === 'object', 'a level is an object, {} for a new one');
82
+ ASSERT(!(level.littlejs3D > LEVEL3D_VERSION), 'the level was made by a newer LittleJS');
83
+ editor3DLevelLoaded(level); // debug builds: the 3D editor takes the level, its autosaved edits go in first
84
+ const made = [];
85
+ for (const object of isArray(level.objects) ? level.objects : [])
86
+ {
87
+ if (!object || typeof object !== 'object') continue;
88
+ const result = level3DMake(object);
89
+ editor3DObjectMade(object, result); // debug builds link it for the 3D editor
90
+ result && made.push(result);
91
+ }
92
+ return made;
93
+ }
94
+
95
+ // a vec3 of an array of three numbers, as the file has them, or the fallback
96
+ function level3DVector(value, fallback)
97
+ {
98
+ return isArray(value) && value.length === 3 && value.every((v)=> isNumber(v)) ?
99
+ vec3(value[0], value[1], value[2]) : fallback;
100
+ }
101
+
102
+ // the defaults of an object's type, a Color or vector copied for each object, then its own properties over them,
103
+ // each read as its default is; one the file got wrong, a value not of its default's type, keeps the default, and
104
+ // one the type has no default for is kept as it is
105
+ function level3DProperties(type, object)
106
+ {
107
+ const properties = {}, own = object.properties;
108
+ for (const [key, value] of Object.entries(type.defaults))
109
+ properties[key] = value?.copy ? value.copy() : value;
110
+ for (const [key, value] of Object.entries(own && typeof own === 'object' ? own : {}))
111
+ {
112
+ const d = type.defaults[key];
113
+ if (isColor(d))
114
+ /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) && (properties[key] = new Color().setHex(value));
115
+ else if (isVector3(d))
116
+ properties[key] = level3DVector(value, properties[key]);
117
+ else if (isVector2(d))
118
+ isArray(value) && value.length === 2 && value.every((v)=> isNumber(v)) &&
119
+ (properties[key] = vec2(value[0], value[1]));
120
+ else if (d === undefined || typeof value === typeof d)
121
+ properties[key] = value;
122
+ }
123
+ return properties;
124
+ }
125
+
126
+ // make one object of a level from the type added for its name, undefined when there is no such type or it made
127
+ // nothing
128
+ function level3DMake(object)
129
+ {
130
+ const type = level3DTypes.get(object.type);
131
+ if (!type)
132
+ {
133
+ debug && console.warn(`level3DLoad: no type added for ${object.type}, skipped`);
134
+ return;
135
+ }
136
+ const pos = level3DVector(object.pos, vec3()), properties = level3DProperties(type, object);
137
+ const {make} = type, count = engineObjects.length;
138
+ let result;
139
+ try { result = make.prototype ? new make(pos, properties) : make(pos, properties); }
140
+ catch (error)
141
+ {
142
+ // a value the type can not be made with: in debug builds, where asserts throw, the object is skipped with
143
+ // what it made so far, so the rest of the level loads and the level editor can put the value right
144
+ if (!debug) throw error;
145
+ console.error(`level3DLoad: ${object.type} ${object.id} could not be made, skipped:`, error);
146
+ for (const o of engineObjects.slice(count))
147
+ o.destroy();
148
+ return;
149
+ }
150
+ if (!result || typeof result !== 'object') return;
151
+ if (result instanceof EngineObject3D)
152
+ {
153
+ level3DBaseScale.set(result, result.scale3D.copy());
154
+ result.rotation3D = level3DVector(object.rotation, vec3()).scale(PI / 180);
155
+ result.scale3D = result.scale3D.multiply(level3DVector(object.scale, vec3(1)));
156
+ }
157
+
158
+ // the type's own properties are set on it, one the type has no default for could be a field of the engine's
159
+ for (const key in type.defaults)
160
+ result[key] = properties[key];
161
+ return result;
162
+ }
163
+
164
+ ///////////////////////////////////////////////////////////////////////////////
165
+ // the built-in types
166
+
167
+ // a static object of a mesh, with the color, tile and solid properties the built-in types have
168
+ function level3DMakeShape(pos, properties, mesh, tileInfo, asSphere=false)
169
+ {
170
+ const o = new EngineObject3D(pos, mesh, properties.tile >= 0 ? tile(properties.tile) : tileInfo, properties.color);
171
+ o.collideAsSphere3D = asSphere;
172
+ properties.solid && o.setCollision();
173
+ return o;
174
+ }
175
+
176
+ // the cylinder all Cylinder objects share, built the first time one is made
177
+ let level3DCylinderMesh;
178
+
179
+ level3DAddType('Box', function(pos, properties) { return level3DMakeShape(pos, properties, render3D.boxMesh); },
180
+ {color: WHITE, tile: -1, solid: true});
181
+ level3DAddType('Sphere', function(pos, properties)
182
+ { return level3DMakeShape(pos, properties, render3D.sphereMesh, undefined, true); },
183
+ {color: WHITE, tile: -1, solid: true});
184
+ level3DAddType('Cylinder', function(pos, properties)
185
+ { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
186
+ {color: WHITE, tile: -1, solid: true});
187
+ level3DAddType('Light', function(pos, properties)
188
+ { return new Light3D(pos, properties.radius, properties.color, properties.intensity); },
189
+ {color: WHITE, radius: 5, intensity: 1});
@@ -465,6 +465,18 @@ class VoxelMap extends EngineObject3D
465
465
  * @ignore */
466
466
  levelRaycast3D(ray) { return this.raycast(ray)?.distance; }
467
467
 
468
+ /** Where a short move goes into a block, for a particle's move in one frame: the part of the move made before it,
469
+ * 0 to 1, and the normal of the face it comes in through; undefined when it hits none, or starts inside one
470
+ * @param {Vector3} from
471
+ * @param {Vector3} to
472
+ * @return {{distance: number, normal: Vector3}|undefined}
473
+ * @ignore */
474
+ levelSegment3D(from, to)
475
+ {
476
+ const hit = this.raycast(new Ray3D(from, to.subtract(from)), 1);
477
+ return hit && hit.distance > 0 ? {distance: hit.distance, normal: hit.normal} : undefined;
478
+ }
479
+
468
480
  /** Keeps an eye on its placement, called automatically each frame */
469
481
  update()
470
482
  {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * LittleJS Scene System Plugin
3
+ * - A game's states, like a title, the game and game over, as scene objects
4
+ * - setScene leaves the current scene, destroys the objects and enters the next
5
+ * - Objects with the persistent flag set outlive every switch
6
+ * - Pause is the engine's paused flag, a scene's update stops with gameUpdate
7
+ * @namespace SceneSystem
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ ///////////////////////////////////////////////////////////////////////////////
13
+
14
+ /** A scene, any object with the hooks it needs, each optional and called as a method so this is the scene
15
+ * @typedef {Object} Scene
16
+ * @property {function():void} [enter] - Called when the scene starts, after the old one left and the objects were destroyed
17
+ * @property {function():void} [leave] - Called before the next scene starts, while the scene is current and its objects are still there
18
+ * @property {function():void} [update] - Called each update after gameUpdate, not while paused or at time scale 0
19
+ * @property {function():void} [render] - Called before gameRender, to draw under the objects
20
+ * @property {function():void} [renderPost] - Called after gameRenderPost, to draw over the game, while paused too; plugins made after the first setScene draw over it
21
+ * @memberof SceneSystem */
22
+
23
+ let sceneCurrent, sceneLeaving = false, scenePluginAdded = false;
24
+
25
+ /** Leave the current scene, destroy every object that is not persistent, and enter the next scene
26
+ * - Setting the current scene again restarts it, and no scene leaves the game with none
27
+ * - The switch happens at once, it can be called from anywhere but a scene's leave
28
+ * @param {Scene} [scene] - The scene to enter
29
+ * @memberof SceneSystem */
30
+ function setScene(scene)
31
+ {
32
+ ASSERT(!sceneLeaving, 'setScene can not be called from a scene leave');
33
+ if (!scenePluginAdded)
34
+ {
35
+ scenePluginAdded = true;
36
+ engineAddPlugin(sceneUpdate, sceneRenderPost, undefined, undefined, sceneRender);
37
+ }
38
+
39
+ if (sceneCurrent)
40
+ {
41
+ sceneLeaving = true;
42
+ sceneCurrent.leave?.();
43
+ sceneLeaving = false;
44
+ }
45
+ engineObjectsDestroy();
46
+ sceneCurrent = scene;
47
+ scene?.enter?.();
48
+ }
49
+
50
+ /** Get the current scene
51
+ * @return {Scene|undefined}
52
+ * @memberof SceneSystem */
53
+ function getScene() { return sceneCurrent; }
54
+
55
+ // the plugin hooks, update skips the frozen ticks that skip gameUpdate
56
+ function sceneUpdate() { paused || !timeScale || sceneCurrent?.update?.(); }
57
+ function sceneRender() { sceneCurrent?.render?.(); }
58
+ function sceneRenderPost() { sceneCurrent?.renderPost?.(); }
@@ -596,7 +596,9 @@ function tweenUpdate(gameDelta, realDelta)
596
596
  for (let i = list.length; i--;)
597
597
  {
598
598
  const t = list[i];
599
- if (!t.active || t.activePass === pass) continue; // stopped, or started again by a callback this update
599
+ // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
600
+ // counts on from this one
601
+ if (!t.active || t.activePass >= pass) continue;
600
602
  let dt;
601
603
  if (enginePath)
602
604
  {
@@ -620,7 +622,7 @@ function tweenUpdate(gameDelta, realDelta)
620
622
  // Completion: fire end value, remove from active, start the next iteration
621
623
  // of a loop or pingPong, or when there is none it has completed, fire onComplete
622
624
  t.callback(t.interp(0));
623
- if (!t.active || t.activePass === pass)
625
+ if (!t.active || t.activePass >= pass)
624
626
  continue; // stopped or restarted by its own callback, the run it was on ends without completing
625
627
  tweenDeactivate(t);
626
628
  const next = t.thenCallback;