littlejsengine 1.26.0 → 1.27.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.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.26.0';
38
+ const engineVersion = '1.27.0';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -69,6 +69,8 @@ let engineObjectsCollideStaticLast = [];
69
69
  let frame = 0;
70
70
 
71
71
  /** Current engine time since start in seconds
72
+ * - Below 20 FPS it falls behind the clock, since a frame catches up at most 50 ms of updates, so a timer in
73
+ * real seconds, one that must keep up on a slow device, goes by timeReal
72
74
  * @type {number}
73
75
  * @memberof Engine */
74
76
  let time = 0;
@@ -94,8 +96,13 @@ function getPaused() { return paused; }
94
96
  * @memberof Engine */
95
97
  function setPaused(isPaused=true) { paused = isPaused; }
96
98
 
99
+ /** Frames drawn per second, smoothed over the last few seconds, in release builds too
100
+ * @type {number}
101
+ * @memberof Engine */
102
+ let averageFPS = 0;
103
+
97
104
  // Engine internal variables
98
- let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
105
+ let frameTimeLastMS = 0, frameTimeBufferMS = 0;
99
106
 
100
107
  // delta smoothing, after Time Delta Smoothing by Frank Force (2013): a frame is on screen for whole display frames,
101
108
  // so each delta is rounded to them and the rest is carried to the next, keeping the total real time; the frame
@@ -193,6 +200,10 @@ let engineCollideGridMin = 64;
193
200
  // everything
194
201
  let engineCollideGrid;
195
202
 
203
+ // where each object that collides with solids was before this frame's moves, kept only when a one way solid is among
204
+ // them, so a one way test goes by where both were whichever of the two updates first
205
+ let engineObjectsOneWayStart;
206
+
196
207
  // the grid of a list of solids, its cells about twice a typical solid so most are in one to four
197
208
  function engineCollideGridBuild(list)
198
209
  {
@@ -436,7 +447,9 @@ function engineLoadingScreenDraw(elapsed)
436
447
  * @param {GameCallback} [gameRender] - Called before objects are rendered, use for drawing backgrounds/world elements
437
448
  * @param {GameCallback} [gameRenderPost] - Called after objects are rendered, use for drawing UI/overlays
438
449
  * @param {Array<string>|string} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png']), or one path
439
- * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
450
+ * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body; it is
451
+ * styled for a game (no scroll bars or selection, touch-action none),
452
+ * where its own inline style does not say otherwise
440
453
  * It keeps its own inline styles and the canvas centers inside it, but the canvas is still sized from the window,
441
454
  * so set canvasFixedSize or canvasMaxSize to fit a smaller element
442
455
  * @example
@@ -499,8 +512,10 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
499
512
  // by ~page-load-time when RAF starts handing real timestamps
500
513
  if (!frameTimeLastMS) frameTimeDeltaMS = 0;
501
514
  frameTimeLastMS = frameTimeMS;
502
- if (debug || debugWatermark)
503
- averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
515
+ // the first frame's rate seeds the average, so it does not start out climbing from 0; a gap of over a
516
+ // second, a hidden tab coming back, is not a frame
517
+ if (frameTimeDeltaMS && frameTimeDeltaMS < 1e3)
518
+ averageFPS = averageFPS ? lerp(averageFPS, 1e3/frameTimeDeltaMS, .05) : 1e3/frameTimeDeltaMS;
504
519
  // the time the frame will be on screen, in whole display frames; engineStep's steps are exact already
505
520
  if (!manualStepAtStart)
506
521
  frameTimeDeltaMS = engineSmoothDelta(frameTimeDeltaMS);
@@ -949,19 +964,36 @@ function engineObjectsUpdate()
949
964
  // every frame makes this function deoptimize again and again in a game with no grid
950
965
  if (engineObjectsCollideStaticLast.length >= engineCollideGridMin)
951
966
  engineCollideGrid = {list: engineObjectsCollideStaticLast, built: undefined};
967
+ // written only when there is a one way solid, as the grid is
968
+ if (engineObjectsCollide.some((o)=> o.oneWay))
969
+ engineObjectsOneWayStart = new Map(engineObjectsCollide.map((o)=> [o, o.pos.copy()]));
952
970
 
953
971
  // update physics before object update, each solid put where it moved to in the grid, for the movers after it;
954
972
  // the grid is let go even when a callback throws, so an updatePhysics called before the next update checks all
973
+ // a one way solid with no mass moves first, nothing pushes it, so what lands on it or passes up through it sees it
974
+ // where it is now and where it was, whatever the render order
975
+ const oneWayFirst = (o)=> o.oneWay && !o.mass;
955
976
  try
956
977
  {
978
+ if (engineObjectsOneWayStart)
979
+ for (const o of engineObjects)
980
+ if (!o.parent && !o.destroyed && oneWayFirst(o))
981
+ {
982
+ o.updatePhysics();
983
+ engineCollideGrid?.built && engineCollideGridPlace(engineCollideGrid.built, o);
984
+ }
957
985
  for (const o of engineObjects)
958
- if (!o.parent && !o.destroyed)
986
+ if (!o.parent && !o.destroyed && !(engineObjectsOneWayStart && oneWayFirst(o)))
959
987
  {
960
988
  o.updatePhysics();
961
989
  engineCollideGrid?.built && engineCollideGridPlace(engineCollideGrid.built, o);
962
990
  }
963
991
  }
964
- finally { engineCollideGrid && (engineCollideGrid = undefined); }
992
+ finally
993
+ {
994
+ engineCollideGrid && (engineCollideGrid = undefined);
995
+ engineObjectsOneWayStart && (engineObjectsOneWayStart = undefined);
996
+ }
965
997
 
966
998
  // recursive object update: the children are walked from a copy on a shared stack, since a child that
967
999
  // destroys itself leaves its parent's list on the spot and the next child would slide past the loop
@@ -3785,6 +3817,110 @@ function setDebugTweakables(show=true) { debugTweakables = show; }
3785
3817
  * - Objects sorted by renderOrder for layered rendering
3786
3818
  */
3787
3819
 
3820
+ // a one way solid lets a box at from pass, when it was not wholly on the solid's far side, the way it is passed
3821
+ // through; both are where they were before this frame's moves, so it does not matter which updated first
3822
+ function engineObjectOneWayPass(solid, solidPos, from, fromSize)
3823
+ {
3824
+ const way = solid.oneWay, size = solid.size, epsilon = 1e-3;
3825
+ return way.x > .5 ? from.x - fromSize.x/2 < solidPos.x + size.x/2 - epsilon :
3826
+ way.x < -.5 ? from.x + fromSize.x/2 > solidPos.x - size.x/2 + epsilon :
3827
+ way.y > .5 ? from.y - fromSize.y/2 < solidPos.y + size.y/2 - epsilon :
3828
+ from.y + fromSize.y/2 > solidPos.y - size.y/2 + epsilon;
3829
+ }
3830
+
3831
+ // where a move from start to end goes into the cell at its corner, the first point of it inside the cell's box, and
3832
+ // whether it goes in through a side, x, or the top or bottom, the axis whose slab it enters last; one starting on a
3833
+ // face is on that face, as its entry there is 0 and the other's is before it
3834
+ function engineObjectRayEntry(start, end, cell)
3835
+ {
3836
+ const dx = end.x - start.x, dy = end.y - start.y;
3837
+ const tx = dx ? min((cell.x - start.x) / dx, (cell.x + 1 - start.x) / dx) : -Infinity;
3838
+ const ty = dy ? min((cell.y - start.y) / dy, (cell.y + 1 - start.y) / dy) : -Infinity;
3839
+ const t = clamp(max(tx, ty));
3840
+ return {pos: vec2(start.x + dx*t, start.y + dy*t), side: tx >= ty};
3841
+ }
3842
+
3843
+ // a bullet's move from one point to another through the tiles: the first tile its collideWithTile takes, nearest
3844
+ // first across the solid layers, with where the move goes into it, the face's normal and the layer; undefined for none
3845
+ // - the cell it starts in, a hair back along its move so one stopped on a tile's face going into it is outside, is not
3846
+ // tested, so it moves out of a tile it is inside, as a box that starts in one does, and a tile after it still stops it
3847
+ // - collideWithTile is asked with the bullet where the move goes into the tile
3848
+ // - only the stretches of the move near a solid layer are walked, nearest first and joined where they meet, so a long
3849
+ // move through empty space, or between layers far apart, costs nothing; the layers are whole numbers, so the cells
3850
+ // are the same as over the whole move
3851
+ /** @param {EngineObject} object
3852
+ * @param {Vector2} from
3853
+ * @param {Vector2} to
3854
+ * @return {{pos: Vector2, normal: Vector2, layer: TileCollisionLayer}|undefined} */
3855
+ function engineObjectBulletSweep(object, from, to)
3856
+ {
3857
+ const move = to.subtract(from), length = move.length();
3858
+ if (!length) return;
3859
+ const back = from.subtract(move.scale(1e-6 / length)), startX = floor(back.x), startY = floor(back.y);
3860
+
3861
+ /** @type {{pos: Vector2, normal: Vector2, layer: TileCollisionLayer}|undefined} */
3862
+ let hit;
3863
+ const testCell = (cell)=>
3864
+ {
3865
+ // a bullet its collideWithTile destroyed is asked about nothing more
3866
+ if (cell.x === startX && cell.y === startY || object.destroyed) return false;
3867
+ for (const layer of tileCollisionLayers)
3868
+ {
3869
+ if (!layer.isSolid) continue;
3870
+ const x = cell.x - layer.pos.x, y = cell.y - layer.pos.y;
3871
+ if (x < 0 || y < 0 || x >= layer.size.x || y >= layer.size.y) continue;
3872
+ const data = layer.collisionData[y*layer.size.x + x];
3873
+ if (!data || tileCollisionOneWayPass(layer, x, y, from.x, from.y)) continue;
3874
+ const tilePos = vec2(cell.x, cell.y), entry = engineObjectRayEntry(from, to, tilePos);
3875
+ object.pos = entry.pos;
3876
+ if (!object.collideWithTile(data, tilePos))
3877
+ {
3878
+ if (object.destroyed) return false; // nor the other layers' tiles in this cell
3879
+ continue;
3880
+ }
3881
+ const normal = entry.side ? vec2(-sign(move.x), 0) : vec2(0, -sign(move.y));
3882
+ hit = {pos: entry.pos, normal, layer};
3883
+ return true;
3884
+ }
3885
+ return false;
3886
+ };
3887
+
3888
+ const stretches = [];
3889
+ for (const layer of tileCollisionLayers)
3890
+ {
3891
+ if (!layer.isSolid) continue;
3892
+ tileCollisionAssertWhole(layer);
3893
+ let t0 = 0, t1 = 1;
3894
+ for (const [p, d, a, b] of [[from.x, move.x, layer.pos.x - 1, layer.pos.x + layer.size.x + 1],
3895
+ [from.y, move.y, layer.pos.y - 1, layer.pos.y + layer.size.y + 1]])
3896
+ {
3897
+ if (!d)
3898
+ {
3899
+ if (p < a || p > b) t0 = 2;
3900
+ continue;
3901
+ }
3902
+ const u = (a - p) / d, v = (b - p) / d;
3903
+ t0 = max(t0, min(u, v)), t1 = min(t1, max(u, v));
3904
+ }
3905
+ t0 < t1 && stretches.push([t0, t1]);
3906
+ }
3907
+ stretches.sort((a, b)=> a[0] - b[0]);
3908
+ const at = (t)=> t <= 0 ? from : t >= 1 ? to : from.add(move.scale(t));
3909
+ for (let i = 0; i < stretches.length && !hit;)
3910
+ {
3911
+ const [t0] = stretches[i];
3912
+ let t1 = stretches[i][1];
3913
+ while (++i < stretches.length && stretches[i][0] <= t1)
3914
+ t1 = max(t1, stretches[i][1]);
3915
+ lineTest(at(t0), at(t1), testCell);
3916
+ }
3917
+ return hit;
3918
+ }
3919
+
3920
+ // where an object was before this frame's moves, for a one way test: kept by the update when a one way solid is in
3921
+ // it, or where it is when updatePhysics is called on its own
3922
+ function engineObjectStartPos(o) { return engineObjectsOneWayStart?.get(o) || o.pos; }
3923
+
3788
3924
  /**
3789
3925
  * LittleJS Object Base Object Class
3790
3926
  * - Top level object class used by the engine
@@ -3896,9 +4032,27 @@ class EngineObject
3896
4032
  this.children = [];
3897
4033
  /** @property {boolean} - Limit object speed along x and y axis */
3898
4034
  this.clampSpeed = true;
4035
+ /** @property {boolean} - Move through the tiles as a point along a ray, so it can not pass through one at
4036
+ * any speed, for small fast things like bullets: its speed is not held to objectMaxSpeed for the tiles,
4037
+ * collideWithTile is asked with this.pos where the ray goes into the tile, and on a hit it stops at the
4038
+ * surface, bounces off it by restitution and goes on with the rest of its move, so it slides along a floor
4039
+ * - Its collision with solid objects is the usual one, so one with a size that collides with them is still held
4040
+ * to objectMaxSpeed; one of no size is not, it never collides with them
4041
+ * - this.pos is set for each tile asked about and put where the move ends after, so a change collideWithTile
4042
+ * makes to it is not kept; move it after the physics, in update
4043
+ * - Once destroyed, as by its own collideWithTile, it is asked about no more tiles that frame, where an object
4044
+ * that is not a bullet still is */
4045
+ this.isBullet = false;
3899
4046
  /** @property {EngineObject|undefined} - Object we are standing on, if any
3900
4047
  * @type {EngineObject|undefined} */
3901
4048
  this.groundObject = undefined;
4049
+ /** @property {Vector2|undefined} - Makes a solid object one way, like a platform jumped up through: up, down,
4050
+ * left or right, the way others pass through it; it blocks only what was wholly on its far side before it
4051
+ * moved, or stood on it, and the rest are not stopped or asked through collideWithObject; with no mass it
4052
+ * moves before the other objects each frame, so a lift works the same at any render order; tiles are made one
4053
+ * way by the layer's setOneWay
4054
+ * @type {Vector2|undefined} */
4055
+ this.oneWay = undefined;
3902
4056
 
3903
4057
  // parent child system
3904
4058
  /** @property {EngineObject|undefined} - Parent of object if in local space
@@ -3986,7 +4140,8 @@ class EngineObject
3986
4140
  // limit max speed to prevent missing collisions, after gravity so no move is past it, only for what collides:
3987
4141
  // with solids, or with tiles while it has a mass, which tile collision needs; anything else moves as fast as
3988
4142
  // it is told
3989
- if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
4143
+ if (this.clampSpeed && enablePhysicsSolver &&
4144
+ (this.collideSolidObjects && this.size.x && this.size.y || this.collideLevel && this.mass && !this.isBullet))
3990
4145
  {
3991
4146
  this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
3992
4147
  this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
@@ -4001,6 +4156,7 @@ class EngineObject
4001
4156
  // which way is down for this object, a negative gravityScale falls up and lands on ceilings
4002
4157
  const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
4003
4158
  const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
4159
+ const wasOn = this.groundObject; // a one way solid it stood on still holds it
4004
4160
  if (this.groundObject)
4005
4161
  {
4006
4162
  // apply friction in local space of ground object
@@ -4027,6 +4183,11 @@ class EngineObject
4027
4183
  // check collision
4028
4184
  if (!this.isOverlappingObject(o)) continue;
4029
4185
 
4186
+ // a one way solid lets through what was not wholly on its far side before it moved, either way round
4187
+ if (o.oneWay && wasOn !== o && engineObjectOneWayPass(o, engineObjectStartPos(o), oldPos, this.size))
4188
+ continue;
4189
+ if (this.oneWay && engineObjectOneWayPass(this, oldPos, engineObjectStartPos(o), o.size)) continue;
4190
+
4030
4191
  // each moving object checks its own contacts, so a pair the other one already asked about this frame
4031
4192
  // is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
4032
4193
  // to resolve, pushed back together since, is resolved again
@@ -4161,71 +4322,121 @@ class EngineObject
4161
4322
  }
4162
4323
  if (this.collideLevel)
4163
4324
  {
4164
- // check collision against tiles
4165
- const hitLayer = tileCollisionTest(this.pos, this.size, this);
4166
- if (hitLayer)
4325
+ // check collision against tiles, one way tiles go by where it was; put back after, a callback may throw
4326
+ const fromObject = tileCollisionFromObject, fromPos = tileCollisionFromPos;
4327
+ tileCollisionFromObject = this, tileCollisionFromPos = oldPos;
4328
+ try
4167
4329
  {
4168
- // if already was stuck in collision, don't do anything
4169
- // this should not happen unless something starts in collision
4170
- if (!tileCollisionTest(oldPos, this.size, this))
4171
- {
4172
- // test which side we bounced off (or both if a corner)
4173
- const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
4174
- const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
4175
- const restitution = max(this.restitution, hitLayer.restitution);
4176
- if (isBlockedX)
4177
- {
4178
- // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
4179
- // down off a ceiling ledge when gravity points up; zero gravity counts as down
4180
- const epsilon = 1e-3;
4181
- const maxMove = .1;
4182
- const y = gravityY > 0 ?
4183
- ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
4184
- floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
4185
- if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
4186
- {
4187
- this.pos.y = y;
4188
- debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
4189
- return;
4190
- }
4330
+ this.isBullet ? this.updatePhysicsBullet(oldPos, gravityY, wasFalling) :
4331
+ this.updatePhysicsTiles(oldPos, gravityY, wasFalling);
4332
+ }
4333
+ finally { tileCollisionFromObject = fromObject, tileCollisionFromPos = fromPos; }
4334
+ }
4335
+ }
4191
4336
 
4192
- // move against the wall and bounce, its side on the tile edge it moved toward, rounded in the
4193
- // layer's space as its collision test is; back to its previous X when that spot is not clear
4194
- const snapEpsilon = .0001, layerX = hitLayer.pos.x, offsetX = this.size.x/2 + snapEpsilon;
4195
- // never back past where it was: a leading edge already on a grid line would step back by
4196
- // the epsilon and take its trailing edge into the tile behind, which nothing tests, since
4197
- // only X is blocked; between there and the wall it covers no column it did not cover before
4198
- const movingLeft = this.pos.x < oldPos.x;
4199
- const snap = layerX + (movingLeft ?
4200
- floor(oldPos.x - layerX - this.size.x/2) + offsetX :
4201
- ceil( oldPos.x - layerX + this.size.x/2) - offsetX);
4202
- const x = movingLeft ? min(snap, oldPos.x) : max(snap, oldPos.x);
4203
- this.pos.x = tileCollisionTest(vec2(x, oldPos.y), this.size, this) ? oldPos.x : x;
4204
- this.velocity.x *= -restitution;
4205
- }
4206
- if (isBlockedY || !isBlockedX)
4337
+ // a bullet's move from oldPos through the tiles, as a point along a ray, called by updatePhysics
4338
+ /** @private */
4339
+ updatePhysicsBullet(oldPos, gravityY, wasFalling)
4340
+ {
4341
+ // up to three legs: the move, then what is left of it after each hit, along the surface or bounced off it as
4342
+ // its velocity is, so one rolling along the floor under gravity keeps going as a box does
4343
+ let from = oldPos, to = this.pos.copy();
4344
+ for (let leg = 0; leg < 3; ++leg)
4345
+ {
4346
+ const hit = engineObjectBulletSweep(this, from, to);
4347
+ if (!hit)
4348
+ {
4349
+ this.pos = to;
4350
+ break;
4351
+ }
4352
+
4353
+ // stop at the surface, a hair off it, more than a one way tile's margin, so going on along a ceiling a
4354
+ // one way tile it rose through lets it on; bounce off it by the more bouncy of it and the layer
4355
+ const {normal, layer} = hit, restitution = max(this.restitution, layer.restitution);
4356
+ const stop = hit.pos.add(normal.scale(2e-3));
4357
+ const into = this.velocity.dot(normal);
4358
+ if (into < 0)
4359
+ this.velocity = this.velocity.subtract(normal.scale(into * (1 + restitution)));
4360
+ if (normal.y * gravityY < 0 && wasFalling) // landed, against its gravity
4361
+ this.groundObject = layer;
4362
+ this.pos = stop;
4363
+
4364
+ // the rest of the move from there, turned the same way; none once its callback destroyed it
4365
+ const rest = to.subtract(hit.pos), restInto = rest.dot(normal);
4366
+ const next = stop.add(restInto < 0 ? rest.subtract(normal.scale(restInto * (1 + restitution))) : rest);
4367
+ if (this.destroyed || next.distanceSquared(stop) < 1e-12) break;
4368
+ from = stop, to = next;
4369
+ }
4370
+ debugPhysics && debugPoint(this.pos, '#f00', undefined, undefined, false);
4371
+ }
4372
+
4373
+ // resolve the tile collision of a move from oldPos, called by updatePhysics
4374
+ /** @private */
4375
+ updatePhysicsTiles(oldPos, gravityY, wasFalling)
4376
+ {
4377
+ const hitLayer = tileCollisionTest(this.pos, this.size, this);
4378
+ if (hitLayer)
4379
+ {
4380
+ // if already was stuck in collision, don't do anything
4381
+ // this should not happen unless something starts in collision
4382
+ if (!tileCollisionTest(oldPos, this.size, this))
4383
+ {
4384
+ // test which side we bounced off (or both if a corner)
4385
+ const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
4386
+ const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
4387
+ const restitution = max(this.restitution, hitLayer.restitution);
4388
+ if (isBlockedX)
4389
+ {
4390
+ // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
4391
+ // down off a ceiling ledge when gravity points up; zero gravity counts as down
4392
+ const epsilon = 1e-3;
4393
+ const maxMove = .1;
4394
+ const y = gravityY > 0 ?
4395
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
4396
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
4397
+ if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
4207
4398
  {
4208
- // adjust position to slightly away from the nearest tile, the floor or the ceiling it moved
4209
- // toward, which prevents a gap between them; rounded in the layer's space as its collision
4210
- // test is, or a bottom a hair below a grid line would round to the row under it, inside the
4211
- // floor, and fall through; back to its previous Y when that spot is not clear
4212
- const epsilon = .0001;
4213
- const offset = this.size.y/2 + epsilon;
4214
- const layerY = hitLayer.pos.y;
4215
- const y = layerY + (this.pos.y < oldPos.y ?
4216
- floor(oldPos.y - layerY - this.size.y/2) + offset :
4217
- ceil( oldPos.y - layerY + this.size.y/2) - offset);
4218
- const isClear = this.pos.y !== oldPos.y && !tileCollisionTest(vec2(this.pos.x, y), this.size, this);
4219
- this.pos.y = isClear ? y : oldPos.y;
4220
-
4221
- // set ground object for tile collision
4222
- this.groundObject = wasFalling ? hitLayer : undefined;
4223
-
4224
- // bounce velocity
4225
- this.velocity.y *= -restitution;
4399
+ this.pos.y = y;
4400
+ debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
4401
+ return;
4226
4402
  }
4227
- debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
4403
+
4404
+ // move against the wall and bounce, its side on the tile edge it moved toward, rounded in the
4405
+ // layer's space as its collision test is; back to its previous X when that spot is not clear
4406
+ const snapEpsilon = .0001, layerX = hitLayer.pos.x, offsetX = this.size.x/2 + snapEpsilon;
4407
+ // never back past where it was: a leading edge already on a grid line would step back by
4408
+ // the epsilon and take its trailing edge into the tile behind, which nothing tests, since
4409
+ // only X is blocked; between there and the wall it covers no column it did not cover before
4410
+ const movingLeft = this.pos.x < oldPos.x;
4411
+ const snap = layerX + (movingLeft ?
4412
+ floor(oldPos.x - layerX - this.size.x/2) + offsetX :
4413
+ ceil( oldPos.x - layerX + this.size.x/2) - offsetX);
4414
+ const x = movingLeft ? min(snap, oldPos.x) : max(snap, oldPos.x);
4415
+ this.pos.x = tileCollisionTest(vec2(x, oldPos.y), this.size, this) ? oldPos.x : x;
4416
+ this.velocity.x *= -restitution;
4417
+ }
4418
+ if (isBlockedY || !isBlockedX)
4419
+ {
4420
+ // adjust position to slightly away from the nearest tile, the floor or the ceiling it moved
4421
+ // toward, which prevents a gap between them; rounded in the layer's space as its collision
4422
+ // test is, or a bottom a hair below a grid line would round to the row under it, inside the
4423
+ // floor, and fall through; back to its previous Y when that spot is not clear
4424
+ const epsilon = .0001;
4425
+ const offset = this.size.y/2 + epsilon;
4426
+ const layerY = hitLayer.pos.y;
4427
+ const y = layerY + (this.pos.y < oldPos.y ?
4428
+ floor(oldPos.y - layerY - this.size.y/2) + offset :
4429
+ ceil( oldPos.y - layerY + this.size.y/2) - offset);
4430
+ const isClear = this.pos.y !== oldPos.y && !tileCollisionTest(vec2(this.pos.x, y), this.size, this);
4431
+ this.pos.y = isClear ? y : oldPos.y;
4432
+
4433
+ // set ground object for tile collision
4434
+ this.groundObject = wasFalling ? hitLayer : undefined;
4435
+
4436
+ // bounce velocity
4437
+ this.velocity.y *= -restitution;
4228
4438
  }
4439
+ debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
4229
4440
  }
4230
4441
  }
4231
4442
  }
@@ -4293,10 +4504,10 @@ class EngineObject
4293
4504
  /** Called to check if a tile collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually
4294
4505
  * - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
4295
4506
  * positions it only tries, so keep it free of side effects or guard them to once a frame
4296
- * - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
4297
- * saved in update, as the platformer example does
4298
- * - For the point of impact, like sparks where a bullet hit, raycast from where this frame's move started,
4299
- * this.pos minus this.velocity, to this.pos with tileCollisionRaycast, as the platformer's Bullet does
4507
+ * - this.pos has already moved, so a check on where it came from needs the position saved in update; for a one
4508
+ * way platform the layer's setOneWay does that, and this is not asked about a one way tile it passes through
4509
+ * - For the point of impact, like sparks where a bullet hit, set isBullet: this.pos is then where it meets the
4510
+ * tile, as the platformer's Bullet uses it
4300
4511
  * @param {number} tileData - the value of the tile at the position
4301
4512
  * @param {Vector2} pos - the tile's bottom left corner in world space
4302
4513
  * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
@@ -5014,6 +5225,9 @@ class SpriteAnimator
5014
5225
  * across the sprite or the mesh's own uv
5015
5226
  * - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, ambientGroundColor,
5016
5227
  * lightCount, lights[i], lightColors[i] and shadow()
5228
+ * - In 3D the snippet may also define void mainNormal(inout vec3 n), given the normal facing the camera after the
5229
+ * normal map, in world space, to bend it per pixel for waves or ripples; the lighting, specular, reflection and
5230
+ * fog then all use it, as they use a normal map
5017
5231
  * - In 3D the shadow map is drawn without the Shader, cut only by the texture's alpha, so a snippet that removes
5018
5232
  * parts of a surface still shadows with the whole of it
5019
5233
  * @example
@@ -7165,8 +7379,9 @@ function inputInit()
7165
7379
  }
7166
7380
  function onContextMenu(e)
7167
7381
  {
7168
- // prevent right click menu, but a text field keeps its copy and paste menu
7169
- if (inputPreventDefault && !isTextInput(e.target))
7382
+ // prevent right click menu, but a text field keeps its copy and paste menu and a link its open in new tab
7383
+ const target = /** @type {HTMLElement} */ (e.target);
7384
+ if (inputPreventDefault && !isTextInput(target) && !target?.closest?.('a[href]'))
7170
7385
  e.preventDefault();
7171
7386
  }
7172
7387
  function onBlur()
@@ -7187,6 +7402,10 @@ function inputInit()
7187
7402
  // handle all touch events the same way
7188
7403
  function handleTouch(e)
7189
7404
  {
7405
+ // fix stalled audio requiring user interaction, with touch input off too since the page's sound needs it
7406
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
7407
+ audioResume();
7408
+
7190
7409
  if (!touchInputEnable)
7191
7410
  {
7192
7411
  // turned off mid touch, the finger that drove the mouse lets go of it
@@ -7197,10 +7416,6 @@ function inputInit()
7197
7416
  }
7198
7417
  inputLastTouchTime = performance.now();
7199
7418
 
7200
- // fix stalled audio requiring user interaction
7201
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
7202
- audioResume();
7203
-
7204
7419
  // when the touch gamepad is enabled it owns touch input: suppress the
7205
7420
  // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
7206
7421
  // (its own zones drive gameplay via pointer events)
@@ -9160,6 +9375,55 @@ const tileCollisionLayers = [];
9160
9375
  function tileCollisionAssertWhole(layer)
9161
9376
  { false&&ASSERT(layer.pos.x % 1 === 0 && layer.pos.y % 1 === 0, 'a tile collision layer must sit at a whole number position', layer.pos); }
9162
9377
 
9378
+ // the object the physics is moving and where it was before this frame's move, for one way tiles
9379
+ let tileCollisionFromObject, tileCollisionFromPos;
9380
+
9381
+ // a one way tile at a layer's cell lets a box at from pass, when the cell draws a tile set one way and the box was not
9382
+ // wholly on its solid side, the side it can be passed through toward; the way is turned and mirrored as the tile's
9383
+ // art is, mirror first, then its quarter turns clockwise; a point is a box of no size
9384
+ function tileCollisionOneWayPass(layer, x, y, fromX, fromY, sizeX=0, sizeY=0)
9385
+ {
9386
+ const oneWayTiles = layer.oneWayTiles;
9387
+ if (!oneWayTiles.size) return false;
9388
+ const data = layer.data[y*layer.size.x + x], way = data && oneWayTiles.get(data.tile);
9389
+ if (!way) return false;
9390
+ let wayX = data.mirror ? -way.x : way.x, wayY = way.y;
9391
+ for (let turn = data.direction & 3; turn--;)
9392
+ [wayX, wayY] = [wayY, -wayX];
9393
+ const epsilon = 1e-3, cellX = layer.pos.x + x, cellY = layer.pos.y + y;
9394
+ return wayX > .5 ? fromX - sizeX/2 < cellX + 1 - epsilon :
9395
+ wayX < -.5 ? fromX + sizeX/2 > cellX + epsilon :
9396
+ wayY > .5 ? fromY - sizeY/2 < cellY + 1 - epsilon :
9397
+ fromY + sizeY/2 > cellY + epsilon;
9398
+ }
9399
+
9400
+ // the layer whose tile tileCollisionGetDataFrom last found solid, undefined for none, the one a particle bounces off
9401
+ let tileCollisionDataLayer;
9402
+
9403
+ // tileCollisionGetData of the solid layers for particles, which pass a one way tile when they were not on its far
9404
+ // side at from: a solid tile's data, else a negative marker, else 0
9405
+ function tileCollisionGetDataFrom(pos, fromX, fromY)
9406
+ {
9407
+ let found = 0;
9408
+ tileCollisionDataLayer = undefined;
9409
+ for (const layer of tileCollisionLayers)
9410
+ if (layer.isSolid)
9411
+ {
9412
+ const x = pos.x - layer.pos.x, y = pos.y - layer.pos.y, size = layer.size;
9413
+ if (x >= 0 && y >= 0 && x < size.x && y < size.y)
9414
+ {
9415
+ const data = layer.collisionData[(y|0)*size.x + (x|0)];
9416
+ if (data > 0)
9417
+ {
9418
+ if (!tileCollisionOneWayPass(layer, x|0, y|0, fromX, fromY))
9419
+ return tileCollisionDataLayer = layer, data;
9420
+ }
9421
+ else if (data && !found) found = data;
9422
+ }
9423
+ }
9424
+ return found;
9425
+ }
9426
+
9163
9427
  // the test a tile query applies to a cell's data: the callback, the object's collideWithTile, or solid data
9164
9428
  function tileCollisionTester(callbackObject)
9165
9429
  {
@@ -9934,13 +10198,17 @@ class TileLayer extends CanvasLayer
9934
10198
  * - This may be slow if not using webgl but only needs to be done once */
9935
10199
  redraw()
9936
10200
  {
10201
+ // the camera, canvas and target are the game's again even when a tile draw or onRedraw throws
9937
10202
  this.redrawStart(true);
9938
- for (let x = this.size.x; x--;)
9939
- for (let y = this.size.y; y--;)
9940
- this.drawTileData(vec2(x,y), false);
9941
- this.isUsingWebGL && glFlush();
9942
- this.onRedraw();
9943
- this.redrawEnd();
10203
+ try
10204
+ {
10205
+ for (let x = this.size.x; x--;)
10206
+ for (let y = this.size.y; y--;)
10207
+ this.drawTileData(vec2(x,y), false);
10208
+ this.isUsingWebGL && glFlush();
10209
+ this.onRedraw();
10210
+ }
10211
+ finally { this.redrawEnd(); }
9944
10212
  this.tilesInWebGL = this.isUsingWebGL;
9945
10213
  }
9946
10214
 
@@ -10053,8 +10321,8 @@ class TileLayer extends CanvasLayer
10053
10321
  false&&ASSERT(drawContext !== this.context, 'redrawStart() should not be active when calling redrawTileData(), instead use drawTileData()');
10054
10322
 
10055
10323
  this.redrawStart();
10056
- this.drawTileData(layerPos, clear);
10057
- this.redrawEnd();
10324
+ try { this.drawTileData(layerPos, clear); }
10325
+ finally { this.redrawEnd(); }
10058
10326
  }
10059
10327
 
10060
10328
  /** Draw textured tile in layer space
@@ -10178,6 +10446,31 @@ class TileCollisionLayer extends TileLayer
10178
10446
  /** @property {boolean} - In the light system's shadow pass, cast only from the cells with collision, drawn
10179
10447
  * as the layer shows them, so a floor in the same layer stays lit; false casts every tile */
10180
10448
  this.shadowSolidOnly = true;
10449
+ /** @property {Map<number, Vector2>} - The tiles set one way, by tile index, and the way each can be passed
10450
+ * through, see setOneWay
10451
+ * @type {Map<number, Vector2>} */
10452
+ this.oneWayTiles = new Map;
10453
+ }
10454
+
10455
+ /** Make the cells that draw a tile one way, like a platform jumped up through and landed on: an object, a
10456
+ * particle or a ray passes through moving that way, and the tile blocks only what was wholly on its far side
10457
+ * before it moved, the side it is passed toward; turned and mirrored as each cell's tile is, so a platform turned
10458
+ * a quarter is one way to the side
10459
+ * - A moving object is not stopped or asked through collideWithTile until the tile would block it, so a
10460
+ * collideWithTile that returns false lets it drop through
10461
+ * - tileCollisionTest with an object goes by the object's pos, a raycast by its start, and a test with no object
10462
+ * or with a callback finds every one way tile solid
10463
+ * @param {number} tile - The tile index, as tile() and TileLayerData take it
10464
+ * @param {Vector2} [direction] - Up, down, left or right, the way it can be passed through: vec2(0, 1), the
10465
+ * default, is a platform landed on from above; layer.oneWayTiles.delete(tile) makes the tile solid again
10466
+ * @example
10467
+ * layer.setOneWay(5); // tile 5 is a platform, jumped up through and stood on */
10468
+ setOneWay(tile, direction=vec2(0, 1))
10469
+ {
10470
+ false&&ASSERT(isNumber(tile), 'setOneWay: tile is a tile index, a number', tile);
10471
+ false&&ASSERT(isVector2(direction) && abs(direction.x) + abs(direction.y) === 1 && !(direction.x && direction.y),
10472
+ 'setOneWay: direction is up, down, left or right, like vec2(0, 1)', direction);
10473
+ this.oneWayTiles.set(tile, direction.copy());
10181
10474
  }
10182
10475
 
10183
10476
  /** Draw this layer's shadow shape: the cells with collision in the part the shadow map covers, each row of
@@ -10269,6 +10562,10 @@ class TileCollisionLayer extends TileLayer
10269
10562
  tileCollisionAssertWhole(this);
10270
10563
  const collisionTest = tileCollisionTester(callbackObject);
10271
10564
 
10565
+ // a one way tile goes by where an object was before the physics moved it, or where it is now
10566
+ const object = callbackObject instanceof EngineObject && this.oneWayTiles.size ? callbackObject : undefined;
10567
+ const from = object && (object === tileCollisionFromObject ? tileCollisionFromPos : object.pos);
10568
+
10272
10569
  // check any tiles in the area for collision
10273
10570
  const posX = pos.x - this.pos.x;
10274
10571
  const posY = pos.y - this.pos.y;
@@ -10287,7 +10584,8 @@ class TileCollisionLayer extends TileLayer
10287
10584
  {
10288
10585
  // check if the object should collide with this tile, the callback gets its own vector, one it can keep
10289
10586
  const tileData = this.collisionData[y*this.size.x+x];
10290
- if (tileData && collisionTest(tileData, vec2(x+this.pos.x, y+this.pos.y)))
10587
+ if (tileData && !(from && tileCollisionOneWayPass(this, x, y, from.x, from.y, size.x, size.y)) &&
10588
+ collisionTest(tileData, vec2(x+this.pos.x, y+this.pos.y)))
10291
10589
  return true;
10292
10590
  }
10293
10591
  return false;
@@ -10307,10 +10605,12 @@ class TileCollisionLayer extends TileLayer
10307
10605
  const collisionTest = tileCollisionTester(callbackObject);
10308
10606
  // the line is walked in the layer's own space, so its cells are the tiles wherever the layer sits
10309
10607
  const offset = this.pos;
10608
+ // a one way tile blocks a ray that starts on its far side
10310
10609
  const testFunction = (pos)=>
10311
10610
  {
10312
10611
  const tileData = this.getCollisionData(pos);
10313
- return tileData && collisionTest(tileData, vec2(pos.x + offset.x, pos.y + offset.y));
10612
+ return tileData && !tileCollisionOneWayPass(this, pos.x|0, pos.y|0, posStart.x, posStart.y) &&
10613
+ collisionTest(tileData, vec2(pos.x + offset.x, pos.y + offset.y));
10314
10614
  }
10315
10615
  // only the part of the line over the layer, and a cell around it, can meet a tile, so a ray toward a point
10316
10616
  // far beyond is walked across the layer and no farther; the cell around keeps the step into the layer as it was
@@ -10790,10 +11090,13 @@ function particleTileSet(tileInfo)
10790
11090
  }
10791
11091
  }
10792
11092
 
11093
+ // where the particle being tested was before its move, which one way tiles go by
11094
+ let particleCollideFromX = 0, particleCollideFromY = 0;
11095
+
10793
11096
  // tests if a particle collides with tiles at x, y, through its emitter's collide callback if it has one
10794
11097
  function particleCollideTest(particle, collideCallback, x, y)
10795
11098
  {
10796
- const data = tileCollisionGetData(particleCollidePos.set(x, y));
11099
+ const data = tileCollisionGetDataFrom(particleCollidePos.set(x, y), particleCollideFromX, particleCollideFromY);
10797
11100
  if (!collideCallback)
10798
11101
  return data > 0;
10799
11102
 
@@ -10925,10 +11228,12 @@ class Particle
10925
11228
 
10926
11229
  // check collision against tiles
10927
11230
  this.groundObject = undefined;
11231
+ particleCollideFromX = oldX, particleCollideFromY = oldY;
10928
11232
  if (particleCollideTest(this, collideCallback, this.pos.x, this.pos.y))
10929
11233
  {
10930
- // if already was stuck in collision, don't do anything
10931
- const hitLayer = tileCollisionTest(this.pos);
11234
+ // the layer that stopped it, as the test found it, a one way layer it passes is not; if already was stuck
11235
+ // in collision, don't do anything
11236
+ const hitLayer = tileCollisionDataLayer;
10932
11237
  if (!particleCollideTest(this, collideCallback, oldX, oldY))
10933
11238
  {
10934
11239
  // test which side we bounced off (or both if a corner)
@@ -11579,15 +11884,15 @@ function glShaderTrack(shader) { glShaderObjects.includes(shader) || glShaderObj
11579
11884
  // let go of a Shader's programs, 2D and 3D, and of its place in the list; a batch drawing with it is drawn first
11580
11885
  function glShaderDispose(shader)
11581
11886
  {
11887
+ // a batch drawing with it is drawn first, the batch holds the Shader; its first draw compiles it and lists it, so
11888
+ // it comes off the list after
11889
+ if (glContext && !headlessMode && glBatchShader === shader)
11890
+ glFlush();
11582
11891
  const i = glShaderObjects.indexOf(shader);
11583
11892
  i < 0 || glShaderObjects.splice(i, 1);
11584
11893
  if (glContext && !headlessMode)
11585
- {
11586
- if (glBatchShader === shader) // a batch drawing with it is drawn first, the batch holds the Shader
11587
- glFlush();
11588
11894
  for (const program of [shader.program, shader.program3D])
11589
11895
  program && glContext.deleteProgram(program);
11590
- }
11591
11896
  shader.program = shader.program3D = undefined;
11592
11897
  }
11593
11898
 
@@ -12947,6 +13252,8 @@ class NewgroundsPlugin
12947
13252
  /** @property {Array<Object>} - Scoreboards fetched from Newgrounds, empty until ready
12948
13253
  * @type {Array<Object>} */
12949
13254
  this.scoreboards = [];
13255
+ /** @type {Map<number, 'failed'|'notSave'>} */
13256
+ this.loadFailures = new Map; // why each slot's last load gave undefined, see loadFailure
12950
13257
  /** @property {{id: number, name: string, url: string, supporter: boolean}|null} - The logged in player once ready, null when not logged in
12951
13258
  * @type {{id: number, name: string, url: string, supporter: boolean}|null} */
12952
13259
  this.user = null;
@@ -13116,6 +13423,96 @@ class NewgroundsPlugin
13116
13423
  return this.call('ScoreBoard.getScores', parameters, session_id);
13117
13424
  }
13118
13425
 
13426
+ /** Save a value to one of the player's cloud save slots, which needs a logged in player
13427
+ * - Any value JSON can hold; the slots are numbered from 1, as many as the app's Newgrounds settings give it
13428
+ * @param {number} slot - The slot number
13429
+ * @param {*} data - The value to save
13430
+ * @return {Promise<boolean>} - Whether it saved, false when not logged in
13431
+ * @example
13432
+ * newgrounds.cloudSave(1, {level, coins}); */
13433
+ async cloudSave(slot, data)
13434
+ {
13435
+ false&&ASSERT(isNumber(slot), 'Newgrounds cloudSave: slot must be a number', slot);
13436
+ if (!this.session_id) return false;
13437
+ let text;
13438
+ try { text = JSON.stringify(data); } catch (error) {}
13439
+ if (text === undefined)
13440
+ return console.warn('Newgrounds cloudSave: slot ' + slot + ' was given a value JSON can not hold'), false;
13441
+ const response = await this.call('CloudSave.setData', {'id':slot, 'data':text});
13442
+ newgroundsSessionLost(response) && this.dropSession();
13443
+ return !!response?.result?.data?.['success'];
13444
+ }
13445
+
13446
+ /** Load the value a cloud save slot holds: null when Newgrounds says it holds none, undefined when it could not be
13447
+ * loaded or the player is not logged in, said in the console when logged in
13448
+ * - Do not save over a slot that loaded as undefined, it may hold the player's save; loadFailure(slot) says why
13449
+ * it did: 'notSave' for a file there that is not a save, where a game may offer to start over, 'failed' otherwise
13450
+ * - A saved null loads as null, as an empty slot does
13451
+ * @param {number} slot - The slot number
13452
+ * @return {Promise<*>} - The value saved, null for none, undefined for a load that failed
13453
+ * @example
13454
+ * const save = await newgrounds.cloudLoad(1); */
13455
+ async cloudLoad(slot)
13456
+ {
13457
+ false&&ASSERT(isNumber(slot), 'Newgrounds cloudLoad: slot must be a number', slot);
13458
+ const {value, failure} = await this.loadSlot(slot);
13459
+ failure ? this.loadFailures.set(+slot, failure) : this.loadFailures.delete(+slot);
13460
+ return value;
13461
+ }
13462
+
13463
+ /** Why a slot's last cloudLoad gave undefined: 'notSave' when the slot holds a file that is not a save, where a
13464
+ * game may offer to start over, 'failed' when it could not be loaded, as when not logged in, where a game must
13465
+ * not save over it; undefined when it loaded a save or found the slot empty, or was not loaded
13466
+ * @param {number} slot - The slot number
13467
+ * @return {'failed'|'notSave'|undefined} */
13468
+ loadFailure(slot) { return this.loadFailures.get(+slot); }
13469
+
13470
+ /** A slot's load, its value and why it failed, if it did
13471
+ * @param {number} slot
13472
+ * @return {Promise<{value: *, failure: 'failed'|'notSave'|undefined}>}
13473
+ * @ignore */
13474
+ async loadSlot(slot)
13475
+ {
13476
+ const failed = (message)=>
13477
+ {
13478
+ message && console.warn('Newgrounds could not load slot ' + slot + message);
13479
+ return {value: undefined, failure: /** @type {'failed'} */ ('failed')};
13480
+ };
13481
+ if (!this.session_id) return failed('');
13482
+ const response = await this.call('CloudSave.loadSlot', {'id':slot});
13483
+ newgroundsSessionLost(response) && this.dropSession();
13484
+ const slotData = response?.result?.data?.['slot'];
13485
+ if (!slotData) return failed('.');
13486
+ const url = slotData['url']; // where the saved text is, none for an empty slot
13487
+ if (!url) return {value: null, failure: undefined};
13488
+ let text;
13489
+ try
13490
+ {
13491
+ const signal = globalThis.AbortSignal?.timeout?.(newgroundsTimeoutMS);
13492
+ const saved = await fetch(url, {'cache':'no-store', 'signal':signal});
13493
+ if (!saved.ok) // an error page is not the save, even one that is JSON
13494
+ return failed(': ' + saved.status);
13495
+ text = await saved.text();
13496
+ }
13497
+ catch(e) { return failed(': ' + e); }
13498
+ if (!text) return failed(', it came back empty'); // a save is never empty
13499
+ try { return {value: JSON.parse(text), failure: undefined}; }
13500
+ catch(e)
13501
+ {
13502
+ console.warn('Newgrounds cloudLoad: slot ' + slot + ' holds a file that is not a save');
13503
+ return {value: undefined, failure: 'notSave'};
13504
+ }
13505
+ }
13506
+
13507
+ /** Count an event of the game's own on its Newgrounds stats page, like a level finished or a button pressed
13508
+ * @param {string} name - The event's name
13509
+ * @return {Promise<Object>} - The response JSON object, undefined when the call failed */
13510
+ logEvent(name)
13511
+ {
13512
+ false&&ASSERT(typeof name === 'string' && name !== '', 'Newgrounds logEvent: name must be a string', name);
13513
+ return this.call('Event.logEvent', {'event_name':name, 'host':this.host});
13514
+ }
13515
+
13119
13516
  /** Encrypt text the way the Newgrounds gateway expects, AES-128 CBC with a random iv in front, as Base64
13120
13517
  * @param {string} text
13121
13518
  * @return {Promise<string>} */
@@ -13184,7 +13581,8 @@ class NewgroundsPlugin
13184
13581
 
13185
13582
  /**
13186
13583
  * LittleJS Wavedash Plugin
13187
- * - The Wavedash twin of the Newgrounds plugin: achievements and leaderboards, the same shape so a game can switch
13584
+ * - The Wavedash twin of the Newgrounds plugin: achievements, leaderboards and cloud saves, the same shape so a game
13585
+ * can switch, and stats and presence of Wavedash's own
13188
13586
  * - Wavedash serves the game's page and puts its SDK in window.Wavedash before the game runs, so nothing is bundled;
13189
13587
  * off Wavedash (local, itch, GitHub Pages) there is none, and every call does nothing
13190
13588
  * - Make the plugin when the game can draw, at the end of gameInit: Wavedash.init is called then, and until it is
@@ -13214,17 +13612,33 @@ const wavedashDisplayTypes = {number: 0, seconds: 1, milliseconds: 2, ticks: 3};
13214
13612
  // the SDK Wavedash put on the page, read at each call, undefined off Wavedash
13215
13613
  const wavedashSDK = ()=> globalThis['Wavedash'];
13216
13614
 
13615
+ // the file a cloud save slot is kept in
13616
+ const wavedashSavePath = (slot)=> 'saves/slot' + slot + '.json';
13617
+
13217
13618
  // a call's answer, or undefined if it does not come in time or fails, with a warning; the time limit is let go of once
13218
13619
  // the answer is in, so nothing waits on it after
13219
- function wavedashWait(name, answer)
13620
+ function wavedashWait(name, answer, quiet=false)
13220
13621
  {
13221
13622
  let timer;
13222
- const limit = new Promise((resolve)=> timer = setTimeout(resolve, wavedashTimeoutMS));
13623
+ const timedOut = {}; // what the time limit gives, told apart from any answer
13624
+ const limit = new Promise((resolve)=> timer = setTimeout(()=> resolve(timedOut), wavedashTimeoutMS));
13223
13625
  return Promise.race([Promise.resolve(answer), limit])
13224
- .catch((error)=> { console.warn('Wavedash ' + name + ' failed: ' + error); })
13626
+ .then((value)=> value !== timedOut ? value : quiet ? undefined :
13627
+ void console.warn('Wavedash ' + name + ' took over ' + wavedashTimeoutMS / 1e3 + ' seconds'),
13628
+ (error)=> { console.warn('Wavedash ' + name + ' failed: ' + error); })
13225
13629
  .finally(()=> clearTimeout(timer));
13226
13630
  }
13227
13631
 
13632
+ // a value as the text a cloud save keeps, undefined with a warning for one JSON can not hold, as a circular object,
13633
+ // a BigInt, a function or undefined
13634
+ function wavedashSaveText(slot, data)
13635
+ {
13636
+ let text;
13637
+ try { text = JSON.stringify(data); } catch (error) {}
13638
+ text === undefined && console.warn('Wavedash cloudSave: slot ' + slot + ' was given a value JSON can not hold');
13639
+ return text;
13640
+ }
13641
+
13228
13642
  ///////////////////////////////////////////////////////////////////////////////
13229
13643
  /**
13230
13644
  * WavedashMedal: a medal that is also a Wavedash achievement, unlocked on Wavedash by its identifier
@@ -13303,6 +13717,14 @@ class WavedashPlugin
13303
13717
  this.achievementTries = Object.create(null); // how many times each one not taken yet was sent
13304
13718
  /** @type {number|undefined} */
13305
13719
  this.achievementRetry = undefined; // the timer that sends refused ones again
13720
+ /** @type {Promise<boolean>|undefined} */
13721
+ this.stats = undefined; // the player's stats loading, asked for the first time a stat is
13722
+ /** @type {Map<string, Promise<*>>} */
13723
+ this.slotQueues = new Map; // each slot's last save or load, the next waits for it, as they share a file
13724
+ /** @type {Map<string, Set<Promise<*>>>} */
13725
+ this.slotCalls = new Map; // each slot's file calls Wavedash has not answered, a timed out one may still change it
13726
+ /** @type {Map<string, 'failed'|'notSave'>} */
13727
+ this.loadFailures = new Map; // why each slot's last load gave undefined, by its file, see loadFailure
13306
13728
 
13307
13729
  // Wavedash keeps its loading screen until init, which is called once
13308
13730
  wavedashWait('init', this.call('init'));
@@ -13381,6 +13803,212 @@ class WavedashPlugin
13381
13803
  return result?.['success'] ? result['data'] : undefined;
13382
13804
  }
13383
13805
 
13806
+ /** Save a value to one of the player's cloud save slots, kept by Wavedash across devices; off Wavedash it does
13807
+ * nothing
13808
+ * - Any value JSON can hold, stored as the file saves/slot#.json
13809
+ * - Wavedash takes at most 30 saves a minute and 300 an hour from a player, so save at checkpoints, the end of a
13810
+ * level or when asked, not every frame; one over the limit is refused, said in the console
13811
+ * @param {number} slot - The slot number, any whole number the game picks
13812
+ * @param {*} data - The value to save
13813
+ * @return {Promise<boolean>} - Whether it saved
13814
+ * @example
13815
+ * wavedash.cloudSave(1, {level, coins}); */
13816
+ async cloudSave(slot, data)
13817
+ {
13818
+ false&&ASSERT(isNumber(slot), 'Wavedash cloudSave: slot must be a number', slot);
13819
+ if (!this.isActive()) return false;
13820
+ const text = wavedashSaveText(slot, data); // as it is now, the save waits its turn
13821
+ if (text === undefined) return false;
13822
+ return this.slotQueue(slot, false, async (track)=>
13823
+ {
13824
+ // written first, and only a write Wavedash says it made is uploaded, never an older file at that path
13825
+ const path = wavedashSavePath(slot);
13826
+ const written = await wavedashWait('cloudSave', track(this.call('writeLocalFile', path,
13827
+ new TextEncoder().encode(text))));
13828
+ if (written !== true && !written?.['success'])
13829
+ {
13830
+ written && console.warn('Wavedash refused writing slot ' + slot + ': ' +
13831
+ (written['message'] ?? written['error'] ?? 'no reason given'));
13832
+ return false;
13833
+ }
13834
+ const result = await wavedashWait('cloudSave', track(this.call('uploadRemoteFile', path)));
13835
+ result && !result['success'] && console.warn('Wavedash refused cloudSave of slot ' + slot + ': ' +
13836
+ (result['message'] ?? result['error'] ?? 'no reason given'));
13837
+ return !!result?.['success'];
13838
+ });
13839
+ }
13840
+
13841
+ /** Load the value a cloud save slot holds: null when Wavedash says it holds none, undefined when it could not be
13842
+ * loaded or off Wavedash, said in the console on Wavedash
13843
+ * - Do not save over a slot that loaded as undefined, it may hold the player's save; loadFailure(slot) says why
13844
+ * it did: 'notSave' for a file there that is not a save, where a game may offer to start over, 'failed' otherwise
13845
+ * - A saved null loads as null, as an empty slot does
13846
+ * @param {number} slot - The slot number
13847
+ * @return {Promise<*>} - The value saved, null for none, undefined for a load that failed
13848
+ * @example
13849
+ * const save = await wavedash.cloudLoad(1); */
13850
+ async cloudLoad(slot)
13851
+ {
13852
+ false&&ASSERT(isNumber(slot), 'Wavedash cloudLoad: slot must be a number', slot);
13853
+ const failed = {value: undefined, failure: /** @type {'failed'} */ ('failed')};
13854
+ const {value, failure} = this.isActive() ?
13855
+ await this.slotQueue(slot, failed, (track)=> this.loadSlot(slot, track)) : failed;
13856
+ const key = wavedashSavePath(slot);
13857
+ failure ? this.loadFailures.set(key, failure) : this.loadFailures.delete(key);
13858
+ return value;
13859
+ }
13860
+
13861
+ /** Why a slot's last cloudLoad gave undefined: 'notSave' when the slot holds a file that is not a save, where a
13862
+ * game may offer to start over, 'failed' when it could not be loaded, as off Wavedash or on a timeout, where a
13863
+ * game must not save over it; undefined when it loaded a save or found the slot empty, or was not loaded
13864
+ * @param {number} slot - The slot number
13865
+ * @return {'failed'|'notSave'|undefined} */
13866
+ loadFailure(slot) { return this.loadFailures.get(wavedashSavePath(slot)); }
13867
+
13868
+ /** A slot's load, its value and why it failed, if it did
13869
+ * @param {number} slot
13870
+ * @param {function(*): *} track
13871
+ * @return {Promise<{value: *, failure: 'failed'|'notSave'|undefined}>}
13872
+ * @ignore */
13873
+ async loadSlot(slot, track)
13874
+ {
13875
+ const path = wavedashSavePath(slot);
13876
+ const failed = (message)=>
13877
+ {
13878
+ console.warn('Wavedash ' + message);
13879
+ return {value: undefined, failure: /** @type {'failed'} */ ('failed')};
13880
+ };
13881
+ const downloaded = await wavedashWait('cloudLoad', track(this.call('downloadRemoteFile', path)));
13882
+ if (downloaded !== true && !downloaded?.['success']) // an answer of true or of {success: true}
13883
+ {
13884
+ // a file Wavedash says is not there is an empty slot, anything else a load that failed; it may answer
13885
+ // false or {success: true, data: false}
13886
+ const exists = await wavedashWait('cloudLoad', track(this.call('remoteFileExists', path)));
13887
+ if (exists === false || exists?.['success'] && exists['data'] === false)
13888
+ return {value: null, failure: undefined};
13889
+ return failed('could not load slot ' + slot);
13890
+ }
13891
+
13892
+ // the file's text, its bytes given as they are or in a response's data; none is a read that failed, as a
13893
+ // save is never empty
13894
+ const answer = await wavedashWait('cloudLoad', track(this.call('readLocalFile', path)));
13895
+ const bytes = answer instanceof Uint8Array || answer instanceof ArrayBuffer || typeof answer == 'string' ?
13896
+ answer : answer?.['data'];
13897
+ const text = !bytes ? '' : typeof bytes == 'string' ? bytes : new TextDecoder().decode(bytes);
13898
+ if (!text) return failed('could not read slot ' + slot);
13899
+ try { return {value: JSON.parse(text), failure: undefined}; }
13900
+ catch (error)
13901
+ {
13902
+ console.warn('Wavedash cloudLoad: slot ' + slot + ' holds a file that is not a save');
13903
+ return {value: undefined, failure: 'notSave'};
13904
+ }
13905
+ }
13906
+
13907
+ /** Run a slot's save or load after the one before it, as they share the slot's local file; other slots go on
13908
+ * at once, and one that fails does not hold up the next
13909
+ * - A call that timed out may still change the file when Wavedash gets to it, so the next waits for it to be
13910
+ * answered too, and gives up with failed when it is not, leaving the file alone
13911
+ * @param {number} slot
13912
+ * @param {*} failed - What the task gives when it can not run
13913
+ * @param {function(function(*): *): Promise<*>} task - Given track, which keeps a call's answer until it is in
13914
+ * @return {Promise<*>}
13915
+ * @ignore */
13916
+ slotQueue(slot, failed, task)
13917
+ {
13918
+ const key = wavedashSavePath(slot); // by its file, so a slot given as '1' and as 1 share one
13919
+ const calls = this.slotCalls.get(key) || new Set;
13920
+ this.slotCalls.set(key, calls);
13921
+ const track = (answer)=>
13922
+ {
13923
+ if (typeof answer?.then == 'function') // any promise, one made elsewhere too
13924
+ {
13925
+ calls.add(answer);
13926
+ const done = ()=>
13927
+ {
13928
+ calls.delete(answer);
13929
+ // the last of them, answered after the slot was let go of, lets go of its calls too
13930
+ calls.size || this.slotQueues.has(key) || this.slotCalls.get(key) !== calls ||
13931
+ this.slotCalls.delete(key);
13932
+ };
13933
+ answer.then(done, done);
13934
+ }
13935
+ return answer;
13936
+ };
13937
+ const start = async ()=>
13938
+ {
13939
+ if (calls.size && !await wavedashWait('slot ' + slot, Promise.allSettled([...calls]).then(()=> true), true))
13940
+ {
13941
+ console.warn('Wavedash slot ' + slot + ' is still busy with a call that timed out, not used');
13942
+ return failed;
13943
+ }
13944
+ return task(track);
13945
+ };
13946
+ const run = (this.slotQueues.get(key) || Promise.resolve()).then(start, start);
13947
+ const done = run.catch(()=> {});
13948
+ this.slotQueues.set(key, done);
13949
+ done.then(()=>
13950
+ {
13951
+ // the slot is let go of once nothing waits on it, and its calls with it once all are answered
13952
+ if (this.slotQueues.get(key) !== done) return;
13953
+ this.slotQueues.delete(key);
13954
+ calls.size || this.slotCalls.get(key) !== calls || this.slotCalls.delete(key);
13955
+ });
13956
+ return run;
13957
+ }
13958
+
13959
+ /** Set one of the player's stats, as made for the game in the Wavedash developer portal, which can unlock an
13960
+ * achievement set to follow it; Wavedash keeps it about a second later, or at once with storeNow; off Wavedash it
13961
+ * does nothing
13962
+ * @param {string} name - The stat's identifier
13963
+ * @param {number} value
13964
+ * @param {boolean} [storeNow] - Keep it now, as at the end of a game, not a second later
13965
+ * @return {Promise<boolean>} - Whether Wavedash took it */
13966
+ async setStat(name, value, storeNow=false)
13967
+ {
13968
+ false&&ASSERT(typeof name === 'string' && name !== '', 'Wavedash setStat: name must be a string', name);
13969
+ false&&ASSERT(isNumber(value), 'Wavedash setStat: value must be a number', value);
13970
+ if (!this.isActive() || !await this.statsLoaded()) return false;
13971
+ return this.call('setStat', name, value, !!storeNow) === true;
13972
+ }
13973
+
13974
+ /** Read one of the player's stats, 0 for one never set or off Wavedash
13975
+ * @param {string} name - The stat's identifier
13976
+ * @return {Promise<number>} */
13977
+ async getStat(name)
13978
+ {
13979
+ if (!this.isActive() || !await this.statsLoaded()) return 0;
13980
+ const value = this.call('getStat', name);
13981
+ return isNumber(value) ? value : 0;
13982
+ }
13983
+
13984
+ /** The player's stats loaded from Wavedash, which they must be before they are read or set, asked for the first
13985
+ * time a stat is
13986
+ * @return {Promise<boolean>} - Whether they loaded
13987
+ * @ignore */
13988
+ statsLoaded()
13989
+ {
13990
+ return this.stats ||= wavedashWait('requestStats', this.call('requestStats')).then((result)=>
13991
+ {
13992
+ const loaded = !!result?.['success'];
13993
+ loaded || (this.stats = undefined); // asked for again next time
13994
+ return loaded;
13995
+ });
13996
+ }
13997
+
13998
+ /** Set what the player is doing, kept with their presence on Wavedash; off Wavedash it does nothing
13999
+ * @param {string} [status] - One line of what they are doing, like 'In a race'
14000
+ * @param {string} [details] - More about it, like 'Lap 2 of 3'; both left out clears it
14001
+ * @return {Promise<boolean>} - Whether it was set */
14002
+ async setPresence(status, details)
14003
+ {
14004
+ if (!this.isActive()) return false;
14005
+ const presence = {};
14006
+ status === undefined || (presence['status'] = String(status));
14007
+ details === undefined || (presence['details'] = String(details));
14008
+ const result = await wavedashWait('setPresence', this.call('updateUserPresence', presence));
14009
+ return !!result?.['success'];
14010
+ }
14011
+
13384
14012
  /** Send every unlocked WavedashMedal's achievement Wavedash has not taken yet, again every two seconds while it
13385
14013
  * refuses some, as it does until it has loaded the player's achievements; one refused for about a minute is
13386
14014
  * taken as an identifier Wavedash does not have, said once in the console, and not sent again this visit
@@ -13459,6 +14087,10 @@ class PostProcessPlugin
13459
14087
  /** @property {string} - The shadertoy style mainImage code it shades with, see setShaderCode */
13460
14088
  this.shaderCode = shaderCode || postProcessEffects(); // no code passes the frame through
13461
14089
 
14090
+ /** @property {boolean} - Is the pass on? Off, the frame shows as the engine drew it, the shader is kept,
14091
+ * and the feedback texture holds the last frame drawn with it on */
14092
+ this.enabled = true;
14093
+
13462
14094
  /** @property {Object<string, number|Array<number>>} - The game's own values for the shader, a uniform each
13463
14095
  * by its name, a number a float and a list of 2 to 4 numbers a vector, set every frame as they are; an
13464
14096
  * effect setting can be one of these names, so it changes every frame without making the shader again, all
@@ -13544,7 +14176,7 @@ class PostProcessPlugin
13544
14176
  }
13545
14177
  function postProcessRender()
13546
14178
  {
13547
- if (headlessMode || !glEnable) return;
14179
+ if (headlessMode || !glEnable || !postProcess.enabled) return;
13548
14180
 
13549
14181
  // clear out the buffer, before anything here binds its own
13550
14182
  glFlush();
@@ -14264,20 +14896,34 @@ class LightSystemPlugin
14264
14896
  'uniform vec4 color;'+
14265
14897
  'uniform sampler2D shadowTexture;'+ // this light's shadow, white where its rays reach
14266
14898
  'uniform bool useShadow;'+
14899
+ 'uniform vec2 forward;'+ // the light's up, the way its cone and gel look
14900
+ 'uniform vec2 cone;'+ // the cone's factor and its edge times it, (0, -1) for none
14901
+ 'uniform sampler2D gelTexture;'+
14902
+ 'uniform vec4 gelRect;'+ // the gel's tile in its texture, corner and size
14903
+ 'uniform bool useGel;'+
14267
14904
  'in vec2 vWorldPos;'+
14268
14905
  'in vec2 vUV;'+
14269
14906
  'out vec4 c;'+
14270
14907
  'void main(){'+
14271
- 'float dist=distance(vWorldPos,lightPos);'+
14908
+ 'vec2 d=vWorldPos-lightPos;'+
14909
+ 'float dist=length(d);'+
14272
14910
  'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
14273
14911
  'c=vec4(color.rgb*t*color.a,1.);'+
14274
14912
  'if(useShadow)c.rgb*=texture(shadowTexture,vUV).rgb;'+
14913
+ // the cone: full where its fade starts, nothing at its edge, smooth between, as a 3D spotlight's
14914
+ 'float k=clamp(dot(forward,d/max(dist,1e-6))*cone.x-cone.y,0.,1.);'+
14915
+ 'c.rgb*=k*k*(3.-2.*k);'+
14916
+ // the gel across the light's square, turned with it, its top the way the light looks
14917
+ 'if(useGel){'+
14918
+ 'vec2 g=clamp(vec2(dot(d,vec2(forward.y,-forward.x)),dot(d,forward))/(radius*2.)+.5,0.,1.);'+
14919
+ 'c.rgb*=texture(gelTexture,gelRect.xy+vec2(g.x,1.-g.y)*gelRect.zw).rgb;}'+
14275
14920
  '}'
14276
14921
  );
14277
14922
  if (lightSystemShadersFailed(lightSystem.lightShader)) return;
14278
- // the shadow texture is on unit 1, the engine's tracked texture stays on unit 0
14923
+ // the shadow texture is on unit 1 and the gel on 2, the engine's tracked texture stays on unit 0
14279
14924
  glContext.useProgram(lightSystem.lightShader);
14280
14925
  glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'shadowTexture'), 1);
14926
+ glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'gelTexture'), 2);
14281
14927
 
14282
14928
  // composite shader: fullscreen quad, samples the lightmap
14283
14929
  lightSystem.compositeShader = glCreateProgram(
@@ -14850,6 +15496,19 @@ class LightSystemPlugin
14850
15496
  [drawContext, glCustomShader] = saved;
14851
15497
  }
14852
15498
  }
15499
+ catch (error)
15500
+ {
15501
+ // a renderLight or renderEmissive threw: what it queued goes to the lightmap, then the screen is the
15502
+ // target again with its viewport and blend, so a game that catches the error draws to the screen; a
15503
+ // flush that throws too is let go of, the first error is the one that counts
15504
+ try { glFlush(); } catch (e) {}
15505
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
15506
+ glContext.viewport(0, 0, glCanvas.width, glCanvas.height);
15507
+ glActiveTexture && glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
15508
+ glSetInstancedMode(true);
15509
+ setAdditiveBlendMode(prevAdditive);
15510
+ throw error;
15511
+ }
14853
15512
  finally
14854
15513
  {
14855
15514
  glRenderTargetBase = undefined;
@@ -14963,6 +15622,18 @@ class LightSystemPlugin
14963
15622
  const c = light.color;
14964
15623
  glContext.uniform4f(glUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
14965
15624
 
15625
+ // the cone and the gel look along the light's up
15626
+ const forward = light.getUp();
15627
+ glContext.uniform2f(glUniformLocation(ls, 'forward'), forward.x, forward.y);
15628
+ const [coneFactor, coneEdge] = lightSystemCone(light);
15629
+ glContext.uniform2f(glUniformLocation(ls, 'cone'), coneFactor, coneEdge);
15630
+ const gel = lightSystemGel(light);
15631
+ glContext.uniform1i(glUniformLocation(ls, 'useGel'), gel ? 1 : 0);
15632
+ glContext.activeTexture(glContext.TEXTURE2);
15633
+ glContext.bindTexture(glContext.TEXTURE_2D, gel ? gel.texture : null);
15634
+ glContext.activeTexture(glContext.TEXTURE0);
15635
+ gel && glContext.uniform4fv(glUniformLocation(ls, 'gelRect'), gel.rect);
15636
+
14966
15637
  glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
14967
15638
 
14968
15639
  // restore engine's instanced shader+VAO so subsequent renderLight()
@@ -15040,6 +15711,31 @@ class LightSystemPlugin
15040
15711
 
15041
15712
  ///////////////////////////////////////////////////////////////////////////////
15042
15713
 
15714
+ // a light's cone for its shader, the factor and its edge times it: the dot of its up and a direction times the factor,
15715
+ // less the edge, is 1 where the fade starts and 0 at the edge; (0, -1) for no cone, which leaves the light as it was
15716
+ function lightSystemCone(light)
15717
+ {
15718
+ const angle = min(light.coneAngle, PI);
15719
+ if (!(angle > 0)) return [0, -1];
15720
+ const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
15721
+ const k = 1 / max(inner - outer, 1e-4);
15722
+ return [k, outer * k];
15723
+ }
15724
+
15725
+ // a light's gel for its shader, its texture and its tile's corner and size in it, a half texel in so the tiles beside
15726
+ // it do not bleed in; undefined for none or one not loaded yet
15727
+ function lightSystemGel(light)
15728
+ {
15729
+ const gel = light.gel;
15730
+ if (!gel) return;
15731
+ const isTile = gel instanceof TileInfo, textureInfo = isTile ? gel.textureInfo : gel;
15732
+ const texture = textureInfo?.glTexture, size = textureInfo?.size;
15733
+ if (!texture || !(size?.x > 0)) return;
15734
+ const pos = isTile ? gel.pos : vec2(), tileSize = isTile ? gel.size : size;
15735
+ return {texture, rect: [(pos.x + .5) / size.x, (pos.y + .5) / size.y,
15736
+ (tileSize.x - 1) / size.x, (tileSize.y - 1) / size.y]};
15737
+ }
15738
+
15043
15739
  /**
15044
15740
  * A Light is an EngineObject that contributes a soft additive blob of color
15045
15741
  * to the LightSystem plugin's lightmap.
@@ -15048,11 +15744,16 @@ class LightSystemPlugin
15048
15744
  * - A light inside a caster is blocked entirely, so the object that holds it, its lamp, a torch, the player
15049
15745
  * carrying it, needs a shadowCore that reaches past it, castShadow = false, or a renderShadow that leaves
15050
15746
  * the light's spot out
15747
+ * - A coneAngle makes it a cone along its up, turned by its angle, and a gel is a picture it shines through; both
15748
+ * are applied over the round light with its shadows, so they cost next to nothing
15051
15749
  * @extends EngineObject
15052
15750
  * @memberof LightSystem
15053
15751
  * @example
15054
15752
  * new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
15055
15753
  * new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
15754
+ * const flashlight = new Light(vec2(), 10); // a flashlight looking right, its beam 60 degrees across
15755
+ * flashlight.coneAngle = PI/6;
15756
+ * flashlight.angle = PI/2;
15056
15757
  */
15057
15758
  class Light extends EngineObject
15058
15759
  {
@@ -15085,6 +15786,19 @@ class Light extends EngineObject
15085
15786
  this.glowFalloff = 1;
15086
15787
  /** @type {TileInfo|undefined} */
15087
15788
  this.glowTileInfo = undefined; // the whole glow texture, kept for the falloff it was made for
15789
+ /** @property {number} - Makes it a cone, like a flashlight or headlight: the angle in radians from its up out
15790
+ * to the edge of the cone, so the beam is twice this across; it looks along getUp(), turned by its angle and
15791
+ * by what it is attached to; 0 for a light that shines every way */
15792
+ this.coneAngle = 0;
15793
+ /** @property {number} - How much of the cone is its fading edge: 0 a hard edge, .2 by default, the outer
15794
+ * fifth, 1 fading all the way from the middle of the beam */
15795
+ this.coneSoftness = .2;
15796
+ /** @property {TileInfo|TextureInfo|undefined} - A gel, a picture the light shines through, called a cookie in
15797
+ * some engines: stretched across the light's square, twice its radius, its top the way the light looks,
15798
+ * turned with it, and multiplied into its color, so one light can be a car's headlights and tail lights;
15799
+ * a TileInfo's own tile, or a whole TextureInfo; shadows still fall through it
15800
+ * @type {TileInfo|TextureInfo|undefined} */
15801
+ this.gel = undefined;
15088
15802
  }
15089
15803
 
15090
15804
  /** Lights are invisible in the main render pass — they only contribute
@@ -21975,7 +22689,9 @@ class ParallaxLayer extends CanvasLayer
21975
22689
  * and 1 with the screen, a Vector2 to follow x and y by different amounts
21976
22690
  * @param {number} [renderOrder] - Low to draw behind the game, far layers lowest
21977
22691
  * @param {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} [drawFunction] - Draws
21978
- * the image, given the canvas context, its size in pixels and the layer; mountains when not given
22692
+ * the image, given the canvas context, its size in pixels and the layer; mountains when not given; called
22693
+ * once when the layer is made and on redraw(), not each frame, so it draws with the canvas 2D context, not
22694
+ * the engine's draws like drawTile
21979
22695
  * @param {Vector2} [canvasSize] - Size of the image in pixels */
21980
22696
  constructor(pos=vec2(), size=vec2(32, 16), parallax=.5, renderOrder=-1e3, drawFunction=parallaxMountains(),
21981
22697
  canvasSize=vec2(512, 256))
@@ -23974,7 +24690,8 @@ function raycastBox(ray, pos, size, rotation)
23974
24690
  * - EngineObject3D is an EngineObject with a 3D position, rotation and mesh
23975
24691
  * - The 3D scene draws under the 2D sprites, so HUD and text land on top
23976
24692
  * - Lighting is the sun plus ambient, with optional extra lights, fog and shadows
23977
- * - Any object or draw can bring its own Shader, a mainImage snippet the lighting then applies to
24693
+ * - Any object or draw can bring its own Shader, a mainImage snippet the lighting then applies to, and a
24694
+ * mainNormal in it bends the normal the lighting uses
23978
24695
  * - Meshes and the basic builders are in render3dMesh.js, EngineObject3D, instancing and lights in
23979
24696
  * render3dObject.js, both right after this one; the other builders, terrain, particles, camera controls and the
23980
24697
  * OBJ loader are in the Render3D Extras plugin, which goes after those
@@ -24552,7 +25269,9 @@ class Render3DPlugin
24552
25269
  /** @property {Vector3|undefined} - Center of the shadowed area, read each frame, undefined follows the camera
24553
25270
  * @type {Vector3|undefined} */
24554
25271
  this.shadowCenter = undefined;
24555
- /** @property {number} - Stops surfaces shadowing themselves, raise for speckles, lower if shadows drift off */
25272
+ /** @property {number} - Stops surfaces shadowing themselves, raise for speckles, lower if shadows drift off;
25273
+ * a share of the shadow map's depth, twice shadowRange for the sun and a spotlight's radius, so the gap behind
25274
+ * a caster grows with the range as the map's texels do, and the speckles they make stay away */
24556
25275
  this.shadowBias = .003;
24557
25276
  /** @property {number} - How much to blur the shadow edges */
24558
25277
  this.shadowSoftness = 1;
@@ -25586,6 +26305,9 @@ class Camera3D
25586
26305
  /** @property {boolean} - Line the 3D camera up with the 2D camera, so 3D things at z=0 sit on the 2D sprites;
25587
26306
  * it lines up with the 2D view of the main canvas, whatever canvas size a screenToRay is given */
25588
26307
  this.align2D = false;
26308
+ /** @property {number} - The z of the plane align2D lines up with the 2D view, the camera sitting its
26309
+ * distance in front of it */
26310
+ this.align2DZ = 0;
25589
26311
  }
25590
26312
 
25591
26313
  /** Returns the camera's world transform
@@ -25656,7 +26378,7 @@ class Camera3D
25656
26378
  false&&ASSERT(!canvasHeight || distance < this.far,
25657
26379
  'align2D needs this camera distance to match the 2D view, raise camera.far past it', distance);
25658
26380
  this.orthographic &&= halfHeight * 2; // an orthographic camera stays orthographic and shows the same height
25659
- this.pos = vec3(cameraPos.x, cameraPos.y, distance);
26381
+ this.pos = vec3(cameraPos.x, cameraPos.y, this.align2DZ + distance);
25660
26382
  this.rotation = vec3(0, 0, -cameraAngle); // 2D angles turn the other way
25661
26383
  }
25662
26384
  }
@@ -25708,7 +26430,8 @@ const RENDER3D_SNIPPET_NAMES =
25708
26430
  '#define lights extraLights\n' +
25709
26431
  '#define lightColors extraLightColors\n';
25710
26432
 
25711
- // the fragment shader; given a Shader's snippet, its mainImage replaces the texture sample and all else is the same
26433
+ // the fragment shader; given a Shader's snippet, its mainImage replaces the texture sample and all else is the same,
26434
+ // and a mainNormal in it bends the normal after the normal map, before all the lighting reads it
25712
26435
  // uniforms: lightDir (xyz the way the sunlight travels, w = emissive, 1 or more skips the lighting),
25713
26436
  // lightColor (the sun's rgb, a = specular), ambientFog (rgb, a = fogEnd), fogColor (rgb, a = fogStart),
25714
26437
  // cameraPos, tex, shadowMap, shadowParams (x = shadows on, y = bias, z = blur step in texture space,
@@ -25717,6 +26440,7 @@ const RENDER3D_SNIPPET_NAMES =
25717
26440
  // emissive map is on), skyTop, skyHorizon, skyBottom (what a reflection shows), normalTex, emissiveTex
25718
26441
  function render3DFragmentSource(fragmentCode)
25719
26442
  {
26443
+ const hasNormal = /\bmainNormal\s*\(/.test(fragmentCode || '');
25720
26444
  return '#version 300 es\n' +
25721
26445
  'precision highp float;' +
25722
26446
  'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams;' +
@@ -25764,6 +26488,7 @@ function render3DFragmentSource(fragmentCode)
25764
26488
  'return normalize((u*m.x-v*m.y)*inversesqrt(k)+n*m.z);}' +
25765
26489
  // a snippet's mainImage is declared here and written after main, so a define in it can not reach main
25766
26490
  (fragmentCode ? 'void mainImage(out vec4,vec2);' : '') +
26491
+ (hasNormal ? 'void mainNormal(inout vec3);' : '') +
25767
26492
  'void main(){' +
25768
26493
  (fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') +
25769
26494
  'if(premultipliedTexture&&t.a>0.)t.rgb/=t.a;' + // back to straight color, what the lighting and blend expect
@@ -25773,6 +26498,7 @@ function render3DFragmentSource(fragmentCode)
25773
26498
  'vec3 n=dot(N,N)>0.?normalize(N):vec3(0,1,0);' +
25774
26499
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
25775
26500
  'if(materialParams.x!=0.)n=normalMapNormal(n);' +
26501
+ (hasNormal ? 'mainNormal(n);n=normalize(n);' : '') +
25776
26502
  'float nl=dot(n,-lightDir.xyz);' +
25777
26503
  // the shadow is the sun's, or one spotlight's: shadowParams.x is 1 for the sun, 2 and up for that Light3D
25778
26504
  'float sh=shadow(),s=shadowParams.x<1.5?sh:1.;' +
@@ -30912,6 +31638,7 @@ class ParticleEmitter3D extends EngineObject3D
30912
31638
  const out = this.particleData;
30913
31639
  out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
30914
31640
  out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
31641
+ out[k+16] = view.lifeTime, out[k+17] = view.age;
30915
31642
  if (view.destroyed)
30916
31643
  out[k+17] = max(out[k+17], out[k+16]); // its life lived: a step short of it can round to just under
30917
31644
  return result;