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
@@ -4519,6 +4551,110 @@ function setDebugTweakables(show=true) { debugTweakables = show; }
4519
4551
  * - Objects sorted by renderOrder for layered rendering
4520
4552
  */
4521
4553
 
4554
+ // 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
4555
+ // through; both are where they were before this frame's moves, so it does not matter which updated first
4556
+ function engineObjectOneWayPass(solid, solidPos, from, fromSize)
4557
+ {
4558
+ const way = solid.oneWay, size = solid.size, epsilon = 1e-3;
4559
+ return way.x > .5 ? from.x - fromSize.x/2 < solidPos.x + size.x/2 - epsilon :
4560
+ way.x < -.5 ? from.x + fromSize.x/2 > solidPos.x - size.x/2 + epsilon :
4561
+ way.y > .5 ? from.y - fromSize.y/2 < solidPos.y + size.y/2 - epsilon :
4562
+ from.y + fromSize.y/2 > solidPos.y - size.y/2 + epsilon;
4563
+ }
4564
+
4565
+ // 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
4566
+ // whether it goes in through a side, x, or the top or bottom, the axis whose slab it enters last; one starting on a
4567
+ // face is on that face, as its entry there is 0 and the other's is before it
4568
+ function engineObjectRayEntry(start, end, cell)
4569
+ {
4570
+ const dx = end.x - start.x, dy = end.y - start.y;
4571
+ const tx = dx ? min((cell.x - start.x) / dx, (cell.x + 1 - start.x) / dx) : -Infinity;
4572
+ const ty = dy ? min((cell.y - start.y) / dy, (cell.y + 1 - start.y) / dy) : -Infinity;
4573
+ const t = clamp(max(tx, ty));
4574
+ return {pos: vec2(start.x + dx*t, start.y + dy*t), side: tx >= ty};
4575
+ }
4576
+
4577
+ // a bullet's move from one point to another through the tiles: the first tile its collideWithTile takes, nearest
4578
+ // first across the solid layers, with where the move goes into it, the face's normal and the layer; undefined for none
4579
+ // - 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
4580
+ // 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
4581
+ // - collideWithTile is asked with the bullet where the move goes into the tile
4582
+ // - only the stretches of the move near a solid layer are walked, nearest first and joined where they meet, so a long
4583
+ // move through empty space, or between layers far apart, costs nothing; the layers are whole numbers, so the cells
4584
+ // are the same as over the whole move
4585
+ /** @param {EngineObject} object
4586
+ * @param {Vector2} from
4587
+ * @param {Vector2} to
4588
+ * @return {{pos: Vector2, normal: Vector2, layer: TileCollisionLayer}|undefined} */
4589
+ function engineObjectBulletSweep(object, from, to)
4590
+ {
4591
+ const move = to.subtract(from), length = move.length();
4592
+ if (!length) return;
4593
+ const back = from.subtract(move.scale(1e-6 / length)), startX = floor(back.x), startY = floor(back.y);
4594
+
4595
+ /** @type {{pos: Vector2, normal: Vector2, layer: TileCollisionLayer}|undefined} */
4596
+ let hit;
4597
+ const testCell = (cell)=>
4598
+ {
4599
+ // a bullet its collideWithTile destroyed is asked about nothing more
4600
+ if (cell.x === startX && cell.y === startY || object.destroyed) return false;
4601
+ for (const layer of tileCollisionLayers)
4602
+ {
4603
+ if (!layer.isSolid) continue;
4604
+ const x = cell.x - layer.pos.x, y = cell.y - layer.pos.y;
4605
+ if (x < 0 || y < 0 || x >= layer.size.x || y >= layer.size.y) continue;
4606
+ const data = layer.collisionData[y*layer.size.x + x];
4607
+ if (!data || tileCollisionOneWayPass(layer, x, y, from.x, from.y)) continue;
4608
+ const tilePos = vec2(cell.x, cell.y), entry = engineObjectRayEntry(from, to, tilePos);
4609
+ object.pos = entry.pos;
4610
+ if (!object.collideWithTile(data, tilePos))
4611
+ {
4612
+ if (object.destroyed) return false; // nor the other layers' tiles in this cell
4613
+ continue;
4614
+ }
4615
+ const normal = entry.side ? vec2(-sign(move.x), 0) : vec2(0, -sign(move.y));
4616
+ hit = {pos: entry.pos, normal, layer};
4617
+ return true;
4618
+ }
4619
+ return false;
4620
+ };
4621
+
4622
+ const stretches = [];
4623
+ for (const layer of tileCollisionLayers)
4624
+ {
4625
+ if (!layer.isSolid) continue;
4626
+ tileCollisionAssertWhole(layer);
4627
+ let t0 = 0, t1 = 1;
4628
+ for (const [p, d, a, b] of [[from.x, move.x, layer.pos.x - 1, layer.pos.x + layer.size.x + 1],
4629
+ [from.y, move.y, layer.pos.y - 1, layer.pos.y + layer.size.y + 1]])
4630
+ {
4631
+ if (!d)
4632
+ {
4633
+ if (p < a || p > b) t0 = 2;
4634
+ continue;
4635
+ }
4636
+ const u = (a - p) / d, v = (b - p) / d;
4637
+ t0 = max(t0, min(u, v)), t1 = min(t1, max(u, v));
4638
+ }
4639
+ t0 < t1 && stretches.push([t0, t1]);
4640
+ }
4641
+ stretches.sort((a, b)=> a[0] - b[0]);
4642
+ const at = (t)=> t <= 0 ? from : t >= 1 ? to : from.add(move.scale(t));
4643
+ for (let i = 0; i < stretches.length && !hit;)
4644
+ {
4645
+ const [t0] = stretches[i];
4646
+ let t1 = stretches[i][1];
4647
+ while (++i < stretches.length && stretches[i][0] <= t1)
4648
+ t1 = max(t1, stretches[i][1]);
4649
+ lineTest(at(t0), at(t1), testCell);
4650
+ }
4651
+ return hit;
4652
+ }
4653
+
4654
+ // 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
4655
+ // it, or where it is when updatePhysics is called on its own
4656
+ function engineObjectStartPos(o) { return engineObjectsOneWayStart?.get(o) || o.pos; }
4657
+
4522
4658
  /**
4523
4659
  * LittleJS Object Base Object Class
4524
4660
  * - Top level object class used by the engine
@@ -4630,9 +4766,27 @@ class EngineObject
4630
4766
  this.children = [];
4631
4767
  /** @property {boolean} - Limit object speed along x and y axis */
4632
4768
  this.clampSpeed = true;
4769
+ /** @property {boolean} - Move through the tiles as a point along a ray, so it can not pass through one at
4770
+ * any speed, for small fast things like bullets: its speed is not held to objectMaxSpeed for the tiles,
4771
+ * collideWithTile is asked with this.pos where the ray goes into the tile, and on a hit it stops at the
4772
+ * surface, bounces off it by restitution and goes on with the rest of its move, so it slides along a floor
4773
+ * - Its collision with solid objects is the usual one, so one with a size that collides with them is still held
4774
+ * to objectMaxSpeed; one of no size is not, it never collides with them
4775
+ * - this.pos is set for each tile asked about and put where the move ends after, so a change collideWithTile
4776
+ * makes to it is not kept; move it after the physics, in update
4777
+ * - Once destroyed, as by its own collideWithTile, it is asked about no more tiles that frame, where an object
4778
+ * that is not a bullet still is */
4779
+ this.isBullet = false;
4633
4780
  /** @property {EngineObject|undefined} - Object we are standing on, if any
4634
4781
  * @type {EngineObject|undefined} */
4635
4782
  this.groundObject = undefined;
4783
+ /** @property {Vector2|undefined} - Makes a solid object one way, like a platform jumped up through: up, down,
4784
+ * left or right, the way others pass through it; it blocks only what was wholly on its far side before it
4785
+ * moved, or stood on it, and the rest are not stopped or asked through collideWithObject; with no mass it
4786
+ * moves before the other objects each frame, so a lift works the same at any render order; tiles are made one
4787
+ * way by the layer's setOneWay
4788
+ * @type {Vector2|undefined} */
4789
+ this.oneWay = undefined;
4636
4790
 
4637
4791
  // parent child system
4638
4792
  /** @property {EngineObject|undefined} - Parent of object if in local space
@@ -4720,7 +4874,8 @@ class EngineObject
4720
4874
  // limit max speed to prevent missing collisions, after gravity so no move is past it, only for what collides:
4721
4875
  // with solids, or with tiles while it has a mass, which tile collision needs; anything else moves as fast as
4722
4876
  // it is told
4723
- if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
4877
+ if (this.clampSpeed && enablePhysicsSolver &&
4878
+ (this.collideSolidObjects && this.size.x && this.size.y || this.collideLevel && this.mass && !this.isBullet))
4724
4879
  {
4725
4880
  this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
4726
4881
  this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
@@ -4735,6 +4890,7 @@ class EngineObject
4735
4890
  // which way is down for this object, a negative gravityScale falls up and lands on ceilings
4736
4891
  const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
4737
4892
  const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
4893
+ const wasOn = this.groundObject; // a one way solid it stood on still holds it
4738
4894
  if (this.groundObject)
4739
4895
  {
4740
4896
  // apply friction in local space of ground object
@@ -4761,6 +4917,11 @@ class EngineObject
4761
4917
  // check collision
4762
4918
  if (!this.isOverlappingObject(o)) continue;
4763
4919
 
4920
+ // a one way solid lets through what was not wholly on its far side before it moved, either way round
4921
+ if (o.oneWay && wasOn !== o && engineObjectOneWayPass(o, engineObjectStartPos(o), oldPos, this.size))
4922
+ continue;
4923
+ if (this.oneWay && engineObjectOneWayPass(this, oldPos, engineObjectStartPos(o), o.size)) continue;
4924
+
4764
4925
  // each moving object checks its own contacts, so a pair the other one already asked about this frame
4765
4926
  // is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
4766
4927
  // to resolve, pushed back together since, is resolved again
@@ -4895,71 +5056,121 @@ class EngineObject
4895
5056
  }
4896
5057
  if (this.collideLevel)
4897
5058
  {
4898
- // check collision against tiles
4899
- const hitLayer = tileCollisionTest(this.pos, this.size, this);
4900
- if (hitLayer)
5059
+ // check collision against tiles, one way tiles go by where it was; put back after, a callback may throw
5060
+ const fromObject = tileCollisionFromObject, fromPos = tileCollisionFromPos;
5061
+ tileCollisionFromObject = this, tileCollisionFromPos = oldPos;
5062
+ try
4901
5063
  {
4902
- // if already was stuck in collision, don't do anything
4903
- // this should not happen unless something starts in collision
4904
- if (!tileCollisionTest(oldPos, this.size, this))
4905
- {
4906
- // test which side we bounced off (or both if a corner)
4907
- const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
4908
- const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
4909
- const restitution = max(this.restitution, hitLayer.restitution);
4910
- if (isBlockedX)
4911
- {
4912
- // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
4913
- // down off a ceiling ledge when gravity points up; zero gravity counts as down
4914
- const epsilon = 1e-3;
4915
- const maxMove = .1;
4916
- const y = gravityY > 0 ?
4917
- ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
4918
- floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
4919
- if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
4920
- {
4921
- this.pos.y = y;
4922
- debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
4923
- return;
4924
- }
5064
+ this.isBullet ? this.updatePhysicsBullet(oldPos, gravityY, wasFalling) :
5065
+ this.updatePhysicsTiles(oldPos, gravityY, wasFalling);
5066
+ }
5067
+ finally { tileCollisionFromObject = fromObject, tileCollisionFromPos = fromPos; }
5068
+ }
5069
+ }
4925
5070
 
4926
- // move against the wall and bounce, its side on the tile edge it moved toward, rounded in the
4927
- // layer's space as its collision test is; back to its previous X when that spot is not clear
4928
- const snapEpsilon = .0001, layerX = hitLayer.pos.x, offsetX = this.size.x/2 + snapEpsilon;
4929
- // never back past where it was: a leading edge already on a grid line would step back by
4930
- // the epsilon and take its trailing edge into the tile behind, which nothing tests, since
4931
- // only X is blocked; between there and the wall it covers no column it did not cover before
4932
- const movingLeft = this.pos.x < oldPos.x;
4933
- const snap = layerX + (movingLeft ?
4934
- floor(oldPos.x - layerX - this.size.x/2) + offsetX :
4935
- ceil( oldPos.x - layerX + this.size.x/2) - offsetX);
4936
- const x = movingLeft ? min(snap, oldPos.x) : max(snap, oldPos.x);
4937
- this.pos.x = tileCollisionTest(vec2(x, oldPos.y), this.size, this) ? oldPos.x : x;
4938
- this.velocity.x *= -restitution;
4939
- }
4940
- if (isBlockedY || !isBlockedX)
5071
+ // a bullet's move from oldPos through the tiles, as a point along a ray, called by updatePhysics
5072
+ /** @private */
5073
+ updatePhysicsBullet(oldPos, gravityY, wasFalling)
5074
+ {
5075
+ // up to three legs: the move, then what is left of it after each hit, along the surface or bounced off it as
5076
+ // its velocity is, so one rolling along the floor under gravity keeps going as a box does
5077
+ let from = oldPos, to = this.pos.copy();
5078
+ for (let leg = 0; leg < 3; ++leg)
5079
+ {
5080
+ const hit = engineObjectBulletSweep(this, from, to);
5081
+ if (!hit)
5082
+ {
5083
+ this.pos = to;
5084
+ break;
5085
+ }
5086
+
5087
+ // stop at the surface, a hair off it, more than a one way tile's margin, so going on along a ceiling a
5088
+ // one way tile it rose through lets it on; bounce off it by the more bouncy of it and the layer
5089
+ const {normal, layer} = hit, restitution = max(this.restitution, layer.restitution);
5090
+ const stop = hit.pos.add(normal.scale(2e-3));
5091
+ const into = this.velocity.dot(normal);
5092
+ if (into < 0)
5093
+ this.velocity = this.velocity.subtract(normal.scale(into * (1 + restitution)));
5094
+ if (normal.y * gravityY < 0 && wasFalling) // landed, against its gravity
5095
+ this.groundObject = layer;
5096
+ this.pos = stop;
5097
+
5098
+ // the rest of the move from there, turned the same way; none once its callback destroyed it
5099
+ const rest = to.subtract(hit.pos), restInto = rest.dot(normal);
5100
+ const next = stop.add(restInto < 0 ? rest.subtract(normal.scale(restInto * (1 + restitution))) : rest);
5101
+ if (this.destroyed || next.distanceSquared(stop) < 1e-12) break;
5102
+ from = stop, to = next;
5103
+ }
5104
+ debugPhysics && debugPoint(this.pos, '#f00', undefined, undefined, false);
5105
+ }
5106
+
5107
+ // resolve the tile collision of a move from oldPos, called by updatePhysics
5108
+ /** @private */
5109
+ updatePhysicsTiles(oldPos, gravityY, wasFalling)
5110
+ {
5111
+ const hitLayer = tileCollisionTest(this.pos, this.size, this);
5112
+ if (hitLayer)
5113
+ {
5114
+ // if already was stuck in collision, don't do anything
5115
+ // this should not happen unless something starts in collision
5116
+ if (!tileCollisionTest(oldPos, this.size, this))
5117
+ {
5118
+ // test which side we bounced off (or both if a corner)
5119
+ const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
5120
+ const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
5121
+ const restitution = max(this.restitution, hitLayer.restitution);
5122
+ if (isBlockedX)
5123
+ {
5124
+ // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
5125
+ // down off a ceiling ledge when gravity points up; zero gravity counts as down
5126
+ const epsilon = 1e-3;
5127
+ const maxMove = .1;
5128
+ const y = gravityY > 0 ?
5129
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
5130
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
5131
+ if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
4941
5132
  {
4942
- // adjust position to slightly away from the nearest tile, the floor or the ceiling it moved
4943
- // toward, which prevents a gap between them; rounded in the layer's space as its collision
4944
- // test is, or a bottom a hair below a grid line would round to the row under it, inside the
4945
- // floor, and fall through; back to its previous Y when that spot is not clear
4946
- const epsilon = .0001;
4947
- const offset = this.size.y/2 + epsilon;
4948
- const layerY = hitLayer.pos.y;
4949
- const y = layerY + (this.pos.y < oldPos.y ?
4950
- floor(oldPos.y - layerY - this.size.y/2) + offset :
4951
- ceil( oldPos.y - layerY + this.size.y/2) - offset);
4952
- const isClear = this.pos.y !== oldPos.y && !tileCollisionTest(vec2(this.pos.x, y), this.size, this);
4953
- this.pos.y = isClear ? y : oldPos.y;
4954
-
4955
- // set ground object for tile collision
4956
- this.groundObject = wasFalling ? hitLayer : undefined;
4957
-
4958
- // bounce velocity
4959
- this.velocity.y *= -restitution;
5133
+ this.pos.y = y;
5134
+ debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
5135
+ return;
4960
5136
  }
4961
- debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
5137
+
5138
+ // move against the wall and bounce, its side on the tile edge it moved toward, rounded in the
5139
+ // layer's space as its collision test is; back to its previous X when that spot is not clear
5140
+ const snapEpsilon = .0001, layerX = hitLayer.pos.x, offsetX = this.size.x/2 + snapEpsilon;
5141
+ // never back past where it was: a leading edge already on a grid line would step back by
5142
+ // the epsilon and take its trailing edge into the tile behind, which nothing tests, since
5143
+ // only X is blocked; between there and the wall it covers no column it did not cover before
5144
+ const movingLeft = this.pos.x < oldPos.x;
5145
+ const snap = layerX + (movingLeft ?
5146
+ floor(oldPos.x - layerX - this.size.x/2) + offsetX :
5147
+ ceil( oldPos.x - layerX + this.size.x/2) - offsetX);
5148
+ const x = movingLeft ? min(snap, oldPos.x) : max(snap, oldPos.x);
5149
+ this.pos.x = tileCollisionTest(vec2(x, oldPos.y), this.size, this) ? oldPos.x : x;
5150
+ this.velocity.x *= -restitution;
4962
5151
  }
5152
+ if (isBlockedY || !isBlockedX)
5153
+ {
5154
+ // adjust position to slightly away from the nearest tile, the floor or the ceiling it moved
5155
+ // toward, which prevents a gap between them; rounded in the layer's space as its collision
5156
+ // test is, or a bottom a hair below a grid line would round to the row under it, inside the
5157
+ // floor, and fall through; back to its previous Y when that spot is not clear
5158
+ const epsilon = .0001;
5159
+ const offset = this.size.y/2 + epsilon;
5160
+ const layerY = hitLayer.pos.y;
5161
+ const y = layerY + (this.pos.y < oldPos.y ?
5162
+ floor(oldPos.y - layerY - this.size.y/2) + offset :
5163
+ ceil( oldPos.y - layerY + this.size.y/2) - offset);
5164
+ const isClear = this.pos.y !== oldPos.y && !tileCollisionTest(vec2(this.pos.x, y), this.size, this);
5165
+ this.pos.y = isClear ? y : oldPos.y;
5166
+
5167
+ // set ground object for tile collision
5168
+ this.groundObject = wasFalling ? hitLayer : undefined;
5169
+
5170
+ // bounce velocity
5171
+ this.velocity.y *= -restitution;
5172
+ }
5173
+ debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
4963
5174
  }
4964
5175
  }
4965
5176
  }
@@ -5027,10 +5238,10 @@ class EngineObject
5027
5238
  /** 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
5028
5239
  * - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
5029
5240
  * positions it only tries, so keep it free of side effects or guard them to once a frame
5030
- * - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
5031
- * saved in update, as the platformer example does
5032
- * - For the point of impact, like sparks where a bullet hit, raycast from where this frame's move started,
5033
- * this.pos minus this.velocity, to this.pos with tileCollisionRaycast, as the platformer's Bullet does
5241
+ * - this.pos has already moved, so a check on where it came from needs the position saved in update; for a one
5242
+ * way platform the layer's setOneWay does that, and this is not asked about a one way tile it passes through
5243
+ * - For the point of impact, like sparks where a bullet hit, set isBullet: this.pos is then where it meets the
5244
+ * tile, as the platformer's Bullet uses it
5034
5245
  * @param {number} tileData - the value of the tile at the position
5035
5246
  * @param {Vector2} pos - the tile's bottom left corner in world space
5036
5247
  * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
@@ -5748,6 +5959,9 @@ class SpriteAnimator
5748
5959
  * across the sprite or the mesh's own uv
5749
5960
  * - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, ambientGroundColor,
5750
5961
  * lightCount, lights[i], lightColors[i] and shadow()
5962
+ * - In 3D the snippet may also define void mainNormal(inout vec3 n), given the normal facing the camera after the
5963
+ * normal map, in world space, to bend it per pixel for waves or ripples; the lighting, specular, reflection and
5964
+ * fog then all use it, as they use a normal map
5751
5965
  * - In 3D the shadow map is drawn without the Shader, cut only by the texture's alpha, so a snippet that removes
5752
5966
  * parts of a surface still shadows with the whole of it
5753
5967
  * @example
@@ -7899,8 +8113,9 @@ function inputInit()
7899
8113
  }
7900
8114
  function onContextMenu(e)
7901
8115
  {
7902
- // prevent right click menu, but a text field keeps its copy and paste menu
7903
- if (inputPreventDefault && !isTextInput(e.target))
8116
+ // prevent right click menu, but a text field keeps its copy and paste menu and a link its open in new tab
8117
+ const target = /** @type {HTMLElement} */ (e.target);
8118
+ if (inputPreventDefault && !isTextInput(target) && !target?.closest?.('a[href]'))
7904
8119
  e.preventDefault();
7905
8120
  }
7906
8121
  function onBlur()
@@ -7921,6 +8136,10 @@ function inputInit()
7921
8136
  // handle all touch events the same way
7922
8137
  function handleTouch(e)
7923
8138
  {
8139
+ // fix stalled audio requiring user interaction, with touch input off too since the page's sound needs it
8140
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
8141
+ audioResume();
8142
+
7924
8143
  if (!touchInputEnable)
7925
8144
  {
7926
8145
  // turned off mid touch, the finger that drove the mouse lets go of it
@@ -7931,10 +8150,6 @@ function inputInit()
7931
8150
  }
7932
8151
  inputLastTouchTime = performance.now();
7933
8152
 
7934
- // fix stalled audio requiring user interaction
7935
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
7936
- audioResume();
7937
-
7938
8153
  // when the touch gamepad is enabled it owns touch input: suppress the
7939
8154
  // touch->mouse passthrough entirely unless touchGamepadPassthrough is set
7940
8155
  // (its own zones drive gameplay via pointer events)
@@ -9894,6 +10109,55 @@ const tileCollisionLayers = [];
9894
10109
  function tileCollisionAssertWhole(layer)
9895
10110
  { ASSERT(layer.pos.x % 1 === 0 && layer.pos.y % 1 === 0, 'a tile collision layer must sit at a whole number position', layer.pos); }
9896
10111
 
10112
+ // the object the physics is moving and where it was before this frame's move, for one way tiles
10113
+ let tileCollisionFromObject, tileCollisionFromPos;
10114
+
10115
+ // 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
10116
+ // wholly on its solid side, the side it can be passed through toward; the way is turned and mirrored as the tile's
10117
+ // art is, mirror first, then its quarter turns clockwise; a point is a box of no size
10118
+ function tileCollisionOneWayPass(layer, x, y, fromX, fromY, sizeX=0, sizeY=0)
10119
+ {
10120
+ const oneWayTiles = layer.oneWayTiles;
10121
+ if (!oneWayTiles.size) return false;
10122
+ const data = layer.data[y*layer.size.x + x], way = data && oneWayTiles.get(data.tile);
10123
+ if (!way) return false;
10124
+ let wayX = data.mirror ? -way.x : way.x, wayY = way.y;
10125
+ for (let turn = data.direction & 3; turn--;)
10126
+ [wayX, wayY] = [wayY, -wayX];
10127
+ const epsilon = 1e-3, cellX = layer.pos.x + x, cellY = layer.pos.y + y;
10128
+ return wayX > .5 ? fromX - sizeX/2 < cellX + 1 - epsilon :
10129
+ wayX < -.5 ? fromX + sizeX/2 > cellX + epsilon :
10130
+ wayY > .5 ? fromY - sizeY/2 < cellY + 1 - epsilon :
10131
+ fromY + sizeY/2 > cellY + epsilon;
10132
+ }
10133
+
10134
+ // the layer whose tile tileCollisionGetDataFrom last found solid, undefined for none, the one a particle bounces off
10135
+ let tileCollisionDataLayer;
10136
+
10137
+ // tileCollisionGetData of the solid layers for particles, which pass a one way tile when they were not on its far
10138
+ // side at from: a solid tile's data, else a negative marker, else 0
10139
+ function tileCollisionGetDataFrom(pos, fromX, fromY)
10140
+ {
10141
+ let found = 0;
10142
+ tileCollisionDataLayer = undefined;
10143
+ for (const layer of tileCollisionLayers)
10144
+ if (layer.isSolid)
10145
+ {
10146
+ const x = pos.x - layer.pos.x, y = pos.y - layer.pos.y, size = layer.size;
10147
+ if (x >= 0 && y >= 0 && x < size.x && y < size.y)
10148
+ {
10149
+ const data = layer.collisionData[(y|0)*size.x + (x|0)];
10150
+ if (data > 0)
10151
+ {
10152
+ if (!tileCollisionOneWayPass(layer, x|0, y|0, fromX, fromY))
10153
+ return tileCollisionDataLayer = layer, data;
10154
+ }
10155
+ else if (data && !found) found = data;
10156
+ }
10157
+ }
10158
+ return found;
10159
+ }
10160
+
9897
10161
  // the test a tile query applies to a cell's data: the callback, the object's collideWithTile, or solid data
9898
10162
  function tileCollisionTester(callbackObject)
9899
10163
  {
@@ -10668,13 +10932,17 @@ class TileLayer extends CanvasLayer
10668
10932
  * - This may be slow if not using webgl but only needs to be done once */
10669
10933
  redraw()
10670
10934
  {
10935
+ // the camera, canvas and target are the game's again even when a tile draw or onRedraw throws
10671
10936
  this.redrawStart(true);
10672
- for (let x = this.size.x; x--;)
10673
- for (let y = this.size.y; y--;)
10674
- this.drawTileData(vec2(x,y), false);
10675
- this.isUsingWebGL && glFlush();
10676
- this.onRedraw();
10677
- this.redrawEnd();
10937
+ try
10938
+ {
10939
+ for (let x = this.size.x; x--;)
10940
+ for (let y = this.size.y; y--;)
10941
+ this.drawTileData(vec2(x,y), false);
10942
+ this.isUsingWebGL && glFlush();
10943
+ this.onRedraw();
10944
+ }
10945
+ finally { this.redrawEnd(); }
10678
10946
  this.tilesInWebGL = this.isUsingWebGL;
10679
10947
  }
10680
10948
 
@@ -10787,8 +11055,8 @@ class TileLayer extends CanvasLayer
10787
11055
  ASSERT(drawContext !== this.context, 'redrawStart() should not be active when calling redrawTileData(), instead use drawTileData()');
10788
11056
 
10789
11057
  this.redrawStart();
10790
- this.drawTileData(layerPos, clear);
10791
- this.redrawEnd();
11058
+ try { this.drawTileData(layerPos, clear); }
11059
+ finally { this.redrawEnd(); }
10792
11060
  }
10793
11061
 
10794
11062
  /** Draw textured tile in layer space
@@ -10912,6 +11180,31 @@ class TileCollisionLayer extends TileLayer
10912
11180
  /** @property {boolean} - In the light system's shadow pass, cast only from the cells with collision, drawn
10913
11181
  * as the layer shows them, so a floor in the same layer stays lit; false casts every tile */
10914
11182
  this.shadowSolidOnly = true;
11183
+ /** @property {Map<number, Vector2>} - The tiles set one way, by tile index, and the way each can be passed
11184
+ * through, see setOneWay
11185
+ * @type {Map<number, Vector2>} */
11186
+ this.oneWayTiles = new Map;
11187
+ }
11188
+
11189
+ /** Make the cells that draw a tile one way, like a platform jumped up through and landed on: an object, a
11190
+ * particle or a ray passes through moving that way, and the tile blocks only what was wholly on its far side
11191
+ * before it moved, the side it is passed toward; turned and mirrored as each cell's tile is, so a platform turned
11192
+ * a quarter is one way to the side
11193
+ * - A moving object is not stopped or asked through collideWithTile until the tile would block it, so a
11194
+ * collideWithTile that returns false lets it drop through
11195
+ * - tileCollisionTest with an object goes by the object's pos, a raycast by its start, and a test with no object
11196
+ * or with a callback finds every one way tile solid
11197
+ * @param {number} tile - The tile index, as tile() and TileLayerData take it
11198
+ * @param {Vector2} [direction] - Up, down, left or right, the way it can be passed through: vec2(0, 1), the
11199
+ * default, is a platform landed on from above; layer.oneWayTiles.delete(tile) makes the tile solid again
11200
+ * @example
11201
+ * layer.setOneWay(5); // tile 5 is a platform, jumped up through and stood on */
11202
+ setOneWay(tile, direction=vec2(0, 1))
11203
+ {
11204
+ ASSERT(isNumber(tile), 'setOneWay: tile is a tile index, a number', tile);
11205
+ ASSERT(isVector2(direction) && abs(direction.x) + abs(direction.y) === 1 && !(direction.x && direction.y),
11206
+ 'setOneWay: direction is up, down, left or right, like vec2(0, 1)', direction);
11207
+ this.oneWayTiles.set(tile, direction.copy());
10915
11208
  }
10916
11209
 
10917
11210
  /** Draw this layer's shadow shape: the cells with collision in the part the shadow map covers, each row of
@@ -11003,6 +11296,10 @@ class TileCollisionLayer extends TileLayer
11003
11296
  tileCollisionAssertWhole(this);
11004
11297
  const collisionTest = tileCollisionTester(callbackObject);
11005
11298
 
11299
+ // a one way tile goes by where an object was before the physics moved it, or where it is now
11300
+ const object = callbackObject instanceof EngineObject && this.oneWayTiles.size ? callbackObject : undefined;
11301
+ const from = object && (object === tileCollisionFromObject ? tileCollisionFromPos : object.pos);
11302
+
11006
11303
  // check any tiles in the area for collision
11007
11304
  const posX = pos.x - this.pos.x;
11008
11305
  const posY = pos.y - this.pos.y;
@@ -11021,7 +11318,8 @@ class TileCollisionLayer extends TileLayer
11021
11318
  {
11022
11319
  // check if the object should collide with this tile, the callback gets its own vector, one it can keep
11023
11320
  const tileData = this.collisionData[y*this.size.x+x];
11024
- if (tileData && collisionTest(tileData, vec2(x+this.pos.x, y+this.pos.y)))
11321
+ if (tileData && !(from && tileCollisionOneWayPass(this, x, y, from.x, from.y, size.x, size.y)) &&
11322
+ collisionTest(tileData, vec2(x+this.pos.x, y+this.pos.y)))
11025
11323
  return true;
11026
11324
  }
11027
11325
  return false;
@@ -11041,10 +11339,12 @@ class TileCollisionLayer extends TileLayer
11041
11339
  const collisionTest = tileCollisionTester(callbackObject);
11042
11340
  // the line is walked in the layer's own space, so its cells are the tiles wherever the layer sits
11043
11341
  const offset = this.pos;
11342
+ // a one way tile blocks a ray that starts on its far side
11044
11343
  const testFunction = (pos)=>
11045
11344
  {
11046
11345
  const tileData = this.getCollisionData(pos);
11047
- return tileData && collisionTest(tileData, vec2(pos.x + offset.x, pos.y + offset.y));
11346
+ return tileData && !tileCollisionOneWayPass(this, pos.x|0, pos.y|0, posStart.x, posStart.y) &&
11347
+ collisionTest(tileData, vec2(pos.x + offset.x, pos.y + offset.y));
11048
11348
  }
11049
11349
  // only the part of the line over the layer, and a cell around it, can meet a tile, so a ray toward a point
11050
11350
  // far beyond is walked across the layer and no farther; the cell around keeps the step into the layer as it was
@@ -11524,10 +11824,13 @@ function particleTileSet(tileInfo)
11524
11824
  }
11525
11825
  }
11526
11826
 
11827
+ // where the particle being tested was before its move, which one way tiles go by
11828
+ let particleCollideFromX = 0, particleCollideFromY = 0;
11829
+
11527
11830
  // tests if a particle collides with tiles at x, y, through its emitter's collide callback if it has one
11528
11831
  function particleCollideTest(particle, collideCallback, x, y)
11529
11832
  {
11530
- const data = tileCollisionGetData(particleCollidePos.set(x, y));
11833
+ const data = tileCollisionGetDataFrom(particleCollidePos.set(x, y), particleCollideFromX, particleCollideFromY);
11531
11834
  if (!collideCallback)
11532
11835
  return data > 0;
11533
11836
 
@@ -11659,10 +11962,12 @@ class Particle
11659
11962
 
11660
11963
  // check collision against tiles
11661
11964
  this.groundObject = undefined;
11965
+ particleCollideFromX = oldX, particleCollideFromY = oldY;
11662
11966
  if (particleCollideTest(this, collideCallback, this.pos.x, this.pos.y))
11663
11967
  {
11664
- // if already was stuck in collision, don't do anything
11665
- const hitLayer = tileCollisionTest(this.pos);
11968
+ // the layer that stopped it, as the test found it, a one way layer it passes is not; if already was stuck
11969
+ // in collision, don't do anything
11970
+ const hitLayer = tileCollisionDataLayer;
11666
11971
  if (!particleCollideTest(this, collideCallback, oldX, oldY))
11667
11972
  {
11668
11973
  // test which side we bounced off (or both if a corner)
@@ -12313,15 +12618,15 @@ function glShaderTrack(shader) { glShaderObjects.includes(shader) || glShaderObj
12313
12618
  // 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
12314
12619
  function glShaderDispose(shader)
12315
12620
  {
12621
+ // a batch drawing with it is drawn first, the batch holds the Shader; its first draw compiles it and lists it, so
12622
+ // it comes off the list after
12623
+ if (glContext && !headlessMode && glBatchShader === shader)
12624
+ glFlush();
12316
12625
  const i = glShaderObjects.indexOf(shader);
12317
12626
  i < 0 || glShaderObjects.splice(i, 1);
12318
12627
  if (glContext && !headlessMode)
12319
- {
12320
- if (glBatchShader === shader) // a batch drawing with it is drawn first, the batch holds the Shader
12321
- glFlush();
12322
12628
  for (const program of [shader.program, shader.program3D])
12323
12629
  program && glContext.deleteProgram(program);
12324
- }
12325
12630
  shader.program = shader.program3D = undefined;
12326
12631
  }
12327
12632
 
@@ -13681,6 +13986,8 @@ class NewgroundsPlugin
13681
13986
  /** @property {Array<Object>} - Scoreboards fetched from Newgrounds, empty until ready
13682
13987
  * @type {Array<Object>} */
13683
13988
  this.scoreboards = [];
13989
+ /** @type {Map<number, 'failed'|'notSave'>} */
13990
+ this.loadFailures = new Map; // why each slot's last load gave undefined, see loadFailure
13684
13991
  /** @property {{id: number, name: string, url: string, supporter: boolean}|null} - The logged in player once ready, null when not logged in
13685
13992
  * @type {{id: number, name: string, url: string, supporter: boolean}|null} */
13686
13993
  this.user = null;
@@ -13850,6 +14157,96 @@ class NewgroundsPlugin
13850
14157
  return this.call('ScoreBoard.getScores', parameters, session_id);
13851
14158
  }
13852
14159
 
14160
+ /** Save a value to one of the player's cloud save slots, which needs a logged in player
14161
+ * - Any value JSON can hold; the slots are numbered from 1, as many as the app's Newgrounds settings give it
14162
+ * @param {number} slot - The slot number
14163
+ * @param {*} data - The value to save
14164
+ * @return {Promise<boolean>} - Whether it saved, false when not logged in
14165
+ * @example
14166
+ * newgrounds.cloudSave(1, {level, coins}); */
14167
+ async cloudSave(slot, data)
14168
+ {
14169
+ ASSERT(isNumber(slot), 'Newgrounds cloudSave: slot must be a number', slot);
14170
+ if (!this.session_id) return false;
14171
+ let text;
14172
+ try { text = JSON.stringify(data); } catch (error) {}
14173
+ if (text === undefined)
14174
+ return console.warn('Newgrounds cloudSave: slot ' + slot + ' was given a value JSON can not hold'), false;
14175
+ const response = await this.call('CloudSave.setData', {'id':slot, 'data':text});
14176
+ newgroundsSessionLost(response) && this.dropSession();
14177
+ return !!response?.result?.data?.['success'];
14178
+ }
14179
+
14180
+ /** Load the value a cloud save slot holds: null when Newgrounds says it holds none, undefined when it could not be
14181
+ * loaded or the player is not logged in, said in the console when logged in
14182
+ * - Do not save over a slot that loaded as undefined, it may hold the player's save; loadFailure(slot) says why
14183
+ * it did: 'notSave' for a file there that is not a save, where a game may offer to start over, 'failed' otherwise
14184
+ * - A saved null loads as null, as an empty slot does
14185
+ * @param {number} slot - The slot number
14186
+ * @return {Promise<*>} - The value saved, null for none, undefined for a load that failed
14187
+ * @example
14188
+ * const save = await newgrounds.cloudLoad(1); */
14189
+ async cloudLoad(slot)
14190
+ {
14191
+ ASSERT(isNumber(slot), 'Newgrounds cloudLoad: slot must be a number', slot);
14192
+ const {value, failure} = await this.loadSlot(slot);
14193
+ failure ? this.loadFailures.set(+slot, failure) : this.loadFailures.delete(+slot);
14194
+ return value;
14195
+ }
14196
+
14197
+ /** Why a slot's last cloudLoad gave undefined: 'notSave' when the slot holds a file that is not a save, where a
14198
+ * game may offer to start over, 'failed' when it could not be loaded, as when not logged in, where a game must
14199
+ * not save over it; undefined when it loaded a save or found the slot empty, or was not loaded
14200
+ * @param {number} slot - The slot number
14201
+ * @return {'failed'|'notSave'|undefined} */
14202
+ loadFailure(slot) { return this.loadFailures.get(+slot); }
14203
+
14204
+ /** A slot's load, its value and why it failed, if it did
14205
+ * @param {number} slot
14206
+ * @return {Promise<{value: *, failure: 'failed'|'notSave'|undefined}>}
14207
+ * @ignore */
14208
+ async loadSlot(slot)
14209
+ {
14210
+ const failed = (message)=>
14211
+ {
14212
+ message && console.warn('Newgrounds could not load slot ' + slot + message);
14213
+ return {value: undefined, failure: /** @type {'failed'} */ ('failed')};
14214
+ };
14215
+ if (!this.session_id) return failed('');
14216
+ const response = await this.call('CloudSave.loadSlot', {'id':slot});
14217
+ newgroundsSessionLost(response) && this.dropSession();
14218
+ const slotData = response?.result?.data?.['slot'];
14219
+ if (!slotData) return failed('.');
14220
+ const url = slotData['url']; // where the saved text is, none for an empty slot
14221
+ if (!url) return {value: null, failure: undefined};
14222
+ let text;
14223
+ try
14224
+ {
14225
+ const signal = globalThis.AbortSignal?.timeout?.(newgroundsTimeoutMS);
14226
+ const saved = await fetch(url, {'cache':'no-store', 'signal':signal});
14227
+ if (!saved.ok) // an error page is not the save, even one that is JSON
14228
+ return failed(': ' + saved.status);
14229
+ text = await saved.text();
14230
+ }
14231
+ catch(e) { return failed(': ' + e); }
14232
+ if (!text) return failed(', it came back empty'); // a save is never empty
14233
+ try { return {value: JSON.parse(text), failure: undefined}; }
14234
+ catch(e)
14235
+ {
14236
+ console.warn('Newgrounds cloudLoad: slot ' + slot + ' holds a file that is not a save');
14237
+ return {value: undefined, failure: 'notSave'};
14238
+ }
14239
+ }
14240
+
14241
+ /** Count an event of the game's own on its Newgrounds stats page, like a level finished or a button pressed
14242
+ * @param {string} name - The event's name
14243
+ * @return {Promise<Object>} - The response JSON object, undefined when the call failed */
14244
+ logEvent(name)
14245
+ {
14246
+ ASSERT(typeof name === 'string' && name !== '', 'Newgrounds logEvent: name must be a string', name);
14247
+ return this.call('Event.logEvent', {'event_name':name, 'host':this.host});
14248
+ }
14249
+
13853
14250
  /** Encrypt text the way the Newgrounds gateway expects, AES-128 CBC with a random iv in front, as Base64
13854
14251
  * @param {string} text
13855
14252
  * @return {Promise<string>} */
@@ -13918,7 +14315,8 @@ class NewgroundsPlugin
13918
14315
 
13919
14316
  /**
13920
14317
  * LittleJS Wavedash Plugin
13921
- * - The Wavedash twin of the Newgrounds plugin: achievements and leaderboards, the same shape so a game can switch
14318
+ * - The Wavedash twin of the Newgrounds plugin: achievements, leaderboards and cloud saves, the same shape so a game
14319
+ * can switch, and stats and presence of Wavedash's own
13922
14320
  * - Wavedash serves the game's page and puts its SDK in window.Wavedash before the game runs, so nothing is bundled;
13923
14321
  * off Wavedash (local, itch, GitHub Pages) there is none, and every call does nothing
13924
14322
  * - Make the plugin when the game can draw, at the end of gameInit: Wavedash.init is called then, and until it is
@@ -13948,17 +14346,33 @@ const wavedashDisplayTypes = {number: 0, seconds: 1, milliseconds: 2, ticks: 3};
13948
14346
  // the SDK Wavedash put on the page, read at each call, undefined off Wavedash
13949
14347
  const wavedashSDK = ()=> globalThis['Wavedash'];
13950
14348
 
14349
+ // the file a cloud save slot is kept in
14350
+ const wavedashSavePath = (slot)=> 'saves/slot' + slot + '.json';
14351
+
13951
14352
  // 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
13952
14353
  // the answer is in, so nothing waits on it after
13953
- function wavedashWait(name, answer)
14354
+ function wavedashWait(name, answer, quiet=false)
13954
14355
  {
13955
14356
  let timer;
13956
- const limit = new Promise((resolve)=> timer = setTimeout(resolve, wavedashTimeoutMS));
14357
+ const timedOut = {}; // what the time limit gives, told apart from any answer
14358
+ const limit = new Promise((resolve)=> timer = setTimeout(()=> resolve(timedOut), wavedashTimeoutMS));
13957
14359
  return Promise.race([Promise.resolve(answer), limit])
13958
- .catch((error)=> { console.warn('Wavedash ' + name + ' failed: ' + error); })
14360
+ .then((value)=> value !== timedOut ? value : quiet ? undefined :
14361
+ void console.warn('Wavedash ' + name + ' took over ' + wavedashTimeoutMS / 1e3 + ' seconds'),
14362
+ (error)=> { console.warn('Wavedash ' + name + ' failed: ' + error); })
13959
14363
  .finally(()=> clearTimeout(timer));
13960
14364
  }
13961
14365
 
14366
+ // a value as the text a cloud save keeps, undefined with a warning for one JSON can not hold, as a circular object,
14367
+ // a BigInt, a function or undefined
14368
+ function wavedashSaveText(slot, data)
14369
+ {
14370
+ let text;
14371
+ try { text = JSON.stringify(data); } catch (error) {}
14372
+ text === undefined && console.warn('Wavedash cloudSave: slot ' + slot + ' was given a value JSON can not hold');
14373
+ return text;
14374
+ }
14375
+
13962
14376
  ///////////////////////////////////////////////////////////////////////////////
13963
14377
  /**
13964
14378
  * WavedashMedal: a medal that is also a Wavedash achievement, unlocked on Wavedash by its identifier
@@ -14037,6 +14451,14 @@ class WavedashPlugin
14037
14451
  this.achievementTries = Object.create(null); // how many times each one not taken yet was sent
14038
14452
  /** @type {number|undefined} */
14039
14453
  this.achievementRetry = undefined; // the timer that sends refused ones again
14454
+ /** @type {Promise<boolean>|undefined} */
14455
+ this.stats = undefined; // the player's stats loading, asked for the first time a stat is
14456
+ /** @type {Map<string, Promise<*>>} */
14457
+ this.slotQueues = new Map; // each slot's last save or load, the next waits for it, as they share a file
14458
+ /** @type {Map<string, Set<Promise<*>>>} */
14459
+ this.slotCalls = new Map; // each slot's file calls Wavedash has not answered, a timed out one may still change it
14460
+ /** @type {Map<string, 'failed'|'notSave'>} */
14461
+ this.loadFailures = new Map; // why each slot's last load gave undefined, by its file, see loadFailure
14040
14462
 
14041
14463
  // Wavedash keeps its loading screen until init, which is called once
14042
14464
  wavedashWait('init', this.call('init'));
@@ -14115,6 +14537,212 @@ class WavedashPlugin
14115
14537
  return result?.['success'] ? result['data'] : undefined;
14116
14538
  }
14117
14539
 
14540
+ /** Save a value to one of the player's cloud save slots, kept by Wavedash across devices; off Wavedash it does
14541
+ * nothing
14542
+ * - Any value JSON can hold, stored as the file saves/slot#.json
14543
+ * - Wavedash takes at most 30 saves a minute and 300 an hour from a player, so save at checkpoints, the end of a
14544
+ * level or when asked, not every frame; one over the limit is refused, said in the console
14545
+ * @param {number} slot - The slot number, any whole number the game picks
14546
+ * @param {*} data - The value to save
14547
+ * @return {Promise<boolean>} - Whether it saved
14548
+ * @example
14549
+ * wavedash.cloudSave(1, {level, coins}); */
14550
+ async cloudSave(slot, data)
14551
+ {
14552
+ ASSERT(isNumber(slot), 'Wavedash cloudSave: slot must be a number', slot);
14553
+ if (!this.isActive()) return false;
14554
+ const text = wavedashSaveText(slot, data); // as it is now, the save waits its turn
14555
+ if (text === undefined) return false;
14556
+ return this.slotQueue(slot, false, async (track)=>
14557
+ {
14558
+ // written first, and only a write Wavedash says it made is uploaded, never an older file at that path
14559
+ const path = wavedashSavePath(slot);
14560
+ const written = await wavedashWait('cloudSave', track(this.call('writeLocalFile', path,
14561
+ new TextEncoder().encode(text))));
14562
+ if (written !== true && !written?.['success'])
14563
+ {
14564
+ written && console.warn('Wavedash refused writing slot ' + slot + ': ' +
14565
+ (written['message'] ?? written['error'] ?? 'no reason given'));
14566
+ return false;
14567
+ }
14568
+ const result = await wavedashWait('cloudSave', track(this.call('uploadRemoteFile', path)));
14569
+ result && !result['success'] && console.warn('Wavedash refused cloudSave of slot ' + slot + ': ' +
14570
+ (result['message'] ?? result['error'] ?? 'no reason given'));
14571
+ return !!result?.['success'];
14572
+ });
14573
+ }
14574
+
14575
+ /** Load the value a cloud save slot holds: null when Wavedash says it holds none, undefined when it could not be
14576
+ * loaded or off Wavedash, said in the console on Wavedash
14577
+ * - Do not save over a slot that loaded as undefined, it may hold the player's save; loadFailure(slot) says why
14578
+ * it did: 'notSave' for a file there that is not a save, where a game may offer to start over, 'failed' otherwise
14579
+ * - A saved null loads as null, as an empty slot does
14580
+ * @param {number} slot - The slot number
14581
+ * @return {Promise<*>} - The value saved, null for none, undefined for a load that failed
14582
+ * @example
14583
+ * const save = await wavedash.cloudLoad(1); */
14584
+ async cloudLoad(slot)
14585
+ {
14586
+ ASSERT(isNumber(slot), 'Wavedash cloudLoad: slot must be a number', slot);
14587
+ const failed = {value: undefined, failure: /** @type {'failed'} */ ('failed')};
14588
+ const {value, failure} = this.isActive() ?
14589
+ await this.slotQueue(slot, failed, (track)=> this.loadSlot(slot, track)) : failed;
14590
+ const key = wavedashSavePath(slot);
14591
+ failure ? this.loadFailures.set(key, failure) : this.loadFailures.delete(key);
14592
+ return value;
14593
+ }
14594
+
14595
+ /** Why a slot's last cloudLoad gave undefined: 'notSave' when the slot holds a file that is not a save, where a
14596
+ * game may offer to start over, 'failed' when it could not be loaded, as off Wavedash or on a timeout, where a
14597
+ * game must not save over it; undefined when it loaded a save or found the slot empty, or was not loaded
14598
+ * @param {number} slot - The slot number
14599
+ * @return {'failed'|'notSave'|undefined} */
14600
+ loadFailure(slot) { return this.loadFailures.get(wavedashSavePath(slot)); }
14601
+
14602
+ /** A slot's load, its value and why it failed, if it did
14603
+ * @param {number} slot
14604
+ * @param {function(*): *} track
14605
+ * @return {Promise<{value: *, failure: 'failed'|'notSave'|undefined}>}
14606
+ * @ignore */
14607
+ async loadSlot(slot, track)
14608
+ {
14609
+ const path = wavedashSavePath(slot);
14610
+ const failed = (message)=>
14611
+ {
14612
+ console.warn('Wavedash ' + message);
14613
+ return {value: undefined, failure: /** @type {'failed'} */ ('failed')};
14614
+ };
14615
+ const downloaded = await wavedashWait('cloudLoad', track(this.call('downloadRemoteFile', path)));
14616
+ if (downloaded !== true && !downloaded?.['success']) // an answer of true or of {success: true}
14617
+ {
14618
+ // a file Wavedash says is not there is an empty slot, anything else a load that failed; it may answer
14619
+ // false or {success: true, data: false}
14620
+ const exists = await wavedashWait('cloudLoad', track(this.call('remoteFileExists', path)));
14621
+ if (exists === false || exists?.['success'] && exists['data'] === false)
14622
+ return {value: null, failure: undefined};
14623
+ return failed('could not load slot ' + slot);
14624
+ }
14625
+
14626
+ // the file's text, its bytes given as they are or in a response's data; none is a read that failed, as a
14627
+ // save is never empty
14628
+ const answer = await wavedashWait('cloudLoad', track(this.call('readLocalFile', path)));
14629
+ const bytes = answer instanceof Uint8Array || answer instanceof ArrayBuffer || typeof answer == 'string' ?
14630
+ answer : answer?.['data'];
14631
+ const text = !bytes ? '' : typeof bytes == 'string' ? bytes : new TextDecoder().decode(bytes);
14632
+ if (!text) return failed('could not read slot ' + slot);
14633
+ try { return {value: JSON.parse(text), failure: undefined}; }
14634
+ catch (error)
14635
+ {
14636
+ console.warn('Wavedash cloudLoad: slot ' + slot + ' holds a file that is not a save');
14637
+ return {value: undefined, failure: 'notSave'};
14638
+ }
14639
+ }
14640
+
14641
+ /** Run a slot's save or load after the one before it, as they share the slot's local file; other slots go on
14642
+ * at once, and one that fails does not hold up the next
14643
+ * - A call that timed out may still change the file when Wavedash gets to it, so the next waits for it to be
14644
+ * answered too, and gives up with failed when it is not, leaving the file alone
14645
+ * @param {number} slot
14646
+ * @param {*} failed - What the task gives when it can not run
14647
+ * @param {function(function(*): *): Promise<*>} task - Given track, which keeps a call's answer until it is in
14648
+ * @return {Promise<*>}
14649
+ * @ignore */
14650
+ slotQueue(slot, failed, task)
14651
+ {
14652
+ const key = wavedashSavePath(slot); // by its file, so a slot given as '1' and as 1 share one
14653
+ const calls = this.slotCalls.get(key) || new Set;
14654
+ this.slotCalls.set(key, calls);
14655
+ const track = (answer)=>
14656
+ {
14657
+ if (typeof answer?.then == 'function') // any promise, one made elsewhere too
14658
+ {
14659
+ calls.add(answer);
14660
+ const done = ()=>
14661
+ {
14662
+ calls.delete(answer);
14663
+ // the last of them, answered after the slot was let go of, lets go of its calls too
14664
+ calls.size || this.slotQueues.has(key) || this.slotCalls.get(key) !== calls ||
14665
+ this.slotCalls.delete(key);
14666
+ };
14667
+ answer.then(done, done);
14668
+ }
14669
+ return answer;
14670
+ };
14671
+ const start = async ()=>
14672
+ {
14673
+ if (calls.size && !await wavedashWait('slot ' + slot, Promise.allSettled([...calls]).then(()=> true), true))
14674
+ {
14675
+ console.warn('Wavedash slot ' + slot + ' is still busy with a call that timed out, not used');
14676
+ return failed;
14677
+ }
14678
+ return task(track);
14679
+ };
14680
+ const run = (this.slotQueues.get(key) || Promise.resolve()).then(start, start);
14681
+ const done = run.catch(()=> {});
14682
+ this.slotQueues.set(key, done);
14683
+ done.then(()=>
14684
+ {
14685
+ // the slot is let go of once nothing waits on it, and its calls with it once all are answered
14686
+ if (this.slotQueues.get(key) !== done) return;
14687
+ this.slotQueues.delete(key);
14688
+ calls.size || this.slotCalls.get(key) !== calls || this.slotCalls.delete(key);
14689
+ });
14690
+ return run;
14691
+ }
14692
+
14693
+ /** Set one of the player's stats, as made for the game in the Wavedash developer portal, which can unlock an
14694
+ * achievement set to follow it; Wavedash keeps it about a second later, or at once with storeNow; off Wavedash it
14695
+ * does nothing
14696
+ * @param {string} name - The stat's identifier
14697
+ * @param {number} value
14698
+ * @param {boolean} [storeNow] - Keep it now, as at the end of a game, not a second later
14699
+ * @return {Promise<boolean>} - Whether Wavedash took it */
14700
+ async setStat(name, value, storeNow=false)
14701
+ {
14702
+ ASSERT(typeof name === 'string' && name !== '', 'Wavedash setStat: name must be a string', name);
14703
+ ASSERT(isNumber(value), 'Wavedash setStat: value must be a number', value);
14704
+ if (!this.isActive() || !await this.statsLoaded()) return false;
14705
+ return this.call('setStat', name, value, !!storeNow) === true;
14706
+ }
14707
+
14708
+ /** Read one of the player's stats, 0 for one never set or off Wavedash
14709
+ * @param {string} name - The stat's identifier
14710
+ * @return {Promise<number>} */
14711
+ async getStat(name)
14712
+ {
14713
+ if (!this.isActive() || !await this.statsLoaded()) return 0;
14714
+ const value = this.call('getStat', name);
14715
+ return isNumber(value) ? value : 0;
14716
+ }
14717
+
14718
+ /** The player's stats loaded from Wavedash, which they must be before they are read or set, asked for the first
14719
+ * time a stat is
14720
+ * @return {Promise<boolean>} - Whether they loaded
14721
+ * @ignore */
14722
+ statsLoaded()
14723
+ {
14724
+ return this.stats ||= wavedashWait('requestStats', this.call('requestStats')).then((result)=>
14725
+ {
14726
+ const loaded = !!result?.['success'];
14727
+ loaded || (this.stats = undefined); // asked for again next time
14728
+ return loaded;
14729
+ });
14730
+ }
14731
+
14732
+ /** Set what the player is doing, kept with their presence on Wavedash; off Wavedash it does nothing
14733
+ * @param {string} [status] - One line of what they are doing, like 'In a race'
14734
+ * @param {string} [details] - More about it, like 'Lap 2 of 3'; both left out clears it
14735
+ * @return {Promise<boolean>} - Whether it was set */
14736
+ async setPresence(status, details)
14737
+ {
14738
+ if (!this.isActive()) return false;
14739
+ const presence = {};
14740
+ status === undefined || (presence['status'] = String(status));
14741
+ details === undefined || (presence['details'] = String(details));
14742
+ const result = await wavedashWait('setPresence', this.call('updateUserPresence', presence));
14743
+ return !!result?.['success'];
14744
+ }
14745
+
14118
14746
  /** Send every unlocked WavedashMedal's achievement Wavedash has not taken yet, again every two seconds while it
14119
14747
  * refuses some, as it does until it has loaded the player's achievements; one refused for about a minute is
14120
14748
  * taken as an identifier Wavedash does not have, said once in the console, and not sent again this visit
@@ -14193,6 +14821,10 @@ class PostProcessPlugin
14193
14821
  /** @property {string} - The shadertoy style mainImage code it shades with, see setShaderCode */
14194
14822
  this.shaderCode = shaderCode || postProcessEffects(); // no code passes the frame through
14195
14823
 
14824
+ /** @property {boolean} - Is the pass on? Off, the frame shows as the engine drew it, the shader is kept,
14825
+ * and the feedback texture holds the last frame drawn with it on */
14826
+ this.enabled = true;
14827
+
14196
14828
  /** @property {Object<string, number|Array<number>>} - The game's own values for the shader, a uniform each
14197
14829
  * by its name, a number a float and a list of 2 to 4 numbers a vector, set every frame as they are; an
14198
14830
  * effect setting can be one of these names, so it changes every frame without making the shader again, all
@@ -14278,7 +14910,7 @@ class PostProcessPlugin
14278
14910
  }
14279
14911
  function postProcessRender()
14280
14912
  {
14281
- if (headlessMode || !glEnable) return;
14913
+ if (headlessMode || !glEnable || !postProcess.enabled) return;
14282
14914
 
14283
14915
  // clear out the buffer, before anything here binds its own
14284
14916
  glFlush();
@@ -14998,20 +15630,34 @@ class LightSystemPlugin
14998
15630
  'uniform vec4 color;'+
14999
15631
  'uniform sampler2D shadowTexture;'+ // this light's shadow, white where its rays reach
15000
15632
  'uniform bool useShadow;'+
15633
+ 'uniform vec2 forward;'+ // the light's up, the way its cone and gel look
15634
+ 'uniform vec2 cone;'+ // the cone's factor and its edge times it, (0, -1) for none
15635
+ 'uniform sampler2D gelTexture;'+
15636
+ 'uniform vec4 gelRect;'+ // the gel's tile in its texture, corner and size
15637
+ 'uniform bool useGel;'+
15001
15638
  'in vec2 vWorldPos;'+
15002
15639
  'in vec2 vUV;'+
15003
15640
  'out vec4 c;'+
15004
15641
  'void main(){'+
15005
- 'float dist=distance(vWorldPos,lightPos);'+
15642
+ 'vec2 d=vWorldPos-lightPos;'+
15643
+ 'float dist=length(d);'+
15006
15644
  'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
15007
15645
  'c=vec4(color.rgb*t*color.a,1.);'+
15008
15646
  'if(useShadow)c.rgb*=texture(shadowTexture,vUV).rgb;'+
15647
+ // the cone: full where its fade starts, nothing at its edge, smooth between, as a 3D spotlight's
15648
+ 'float k=clamp(dot(forward,d/max(dist,1e-6))*cone.x-cone.y,0.,1.);'+
15649
+ 'c.rgb*=k*k*(3.-2.*k);'+
15650
+ // the gel across the light's square, turned with it, its top the way the light looks
15651
+ 'if(useGel){'+
15652
+ 'vec2 g=clamp(vec2(dot(d,vec2(forward.y,-forward.x)),dot(d,forward))/(radius*2.)+.5,0.,1.);'+
15653
+ 'c.rgb*=texture(gelTexture,gelRect.xy+vec2(g.x,1.-g.y)*gelRect.zw).rgb;}'+
15009
15654
  '}'
15010
15655
  );
15011
15656
  if (lightSystemShadersFailed(lightSystem.lightShader)) return;
15012
- // the shadow texture is on unit 1, the engine's tracked texture stays on unit 0
15657
+ // the shadow texture is on unit 1 and the gel on 2, the engine's tracked texture stays on unit 0
15013
15658
  glContext.useProgram(lightSystem.lightShader);
15014
15659
  glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'shadowTexture'), 1);
15660
+ glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'gelTexture'), 2);
15015
15661
 
15016
15662
  // composite shader: fullscreen quad, samples the lightmap
15017
15663
  lightSystem.compositeShader = glCreateProgram(
@@ -15584,6 +16230,19 @@ class LightSystemPlugin
15584
16230
  [drawContext, glCustomShader] = saved;
15585
16231
  }
15586
16232
  }
16233
+ catch (error)
16234
+ {
16235
+ // a renderLight or renderEmissive threw: what it queued goes to the lightmap, then the screen is the
16236
+ // target again with its viewport and blend, so a game that catches the error draws to the screen; a
16237
+ // flush that throws too is let go of, the first error is the one that counts
16238
+ try { glFlush(); } catch (e) {}
16239
+ glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
16240
+ glContext.viewport(0, 0, glCanvas.width, glCanvas.height);
16241
+ glActiveTexture && glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
16242
+ glSetInstancedMode(true);
16243
+ setAdditiveBlendMode(prevAdditive);
16244
+ throw error;
16245
+ }
15587
16246
  finally
15588
16247
  {
15589
16248
  glRenderTargetBase = undefined;
@@ -15697,6 +16356,18 @@ class LightSystemPlugin
15697
16356
  const c = light.color;
15698
16357
  glContext.uniform4f(glUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
15699
16358
 
16359
+ // the cone and the gel look along the light's up
16360
+ const forward = light.getUp();
16361
+ glContext.uniform2f(glUniformLocation(ls, 'forward'), forward.x, forward.y);
16362
+ const [coneFactor, coneEdge] = lightSystemCone(light);
16363
+ glContext.uniform2f(glUniformLocation(ls, 'cone'), coneFactor, coneEdge);
16364
+ const gel = lightSystemGel(light);
16365
+ glContext.uniform1i(glUniformLocation(ls, 'useGel'), gel ? 1 : 0);
16366
+ glContext.activeTexture(glContext.TEXTURE2);
16367
+ glContext.bindTexture(glContext.TEXTURE_2D, gel ? gel.texture : null);
16368
+ glContext.activeTexture(glContext.TEXTURE0);
16369
+ gel && glContext.uniform4fv(glUniformLocation(ls, 'gelRect'), gel.rect);
16370
+
15700
16371
  glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
15701
16372
 
15702
16373
  // restore engine's instanced shader+VAO so subsequent renderLight()
@@ -15774,6 +16445,31 @@ class LightSystemPlugin
15774
16445
 
15775
16446
  ///////////////////////////////////////////////////////////////////////////////
15776
16447
 
16448
+ // 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,
16449
+ // 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
16450
+ function lightSystemCone(light)
16451
+ {
16452
+ const angle = min(light.coneAngle, PI);
16453
+ if (!(angle > 0)) return [0, -1];
16454
+ const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
16455
+ const k = 1 / max(inner - outer, 1e-4);
16456
+ return [k, outer * k];
16457
+ }
16458
+
16459
+ // 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
16460
+ // it do not bleed in; undefined for none or one not loaded yet
16461
+ function lightSystemGel(light)
16462
+ {
16463
+ const gel = light.gel;
16464
+ if (!gel) return;
16465
+ const isTile = gel instanceof TileInfo, textureInfo = isTile ? gel.textureInfo : gel;
16466
+ const texture = textureInfo?.glTexture, size = textureInfo?.size;
16467
+ if (!texture || !(size?.x > 0)) return;
16468
+ const pos = isTile ? gel.pos : vec2(), tileSize = isTile ? gel.size : size;
16469
+ return {texture, rect: [(pos.x + .5) / size.x, (pos.y + .5) / size.y,
16470
+ (tileSize.x - 1) / size.x, (tileSize.y - 1) / size.y]};
16471
+ }
16472
+
15777
16473
  /**
15778
16474
  * A Light is an EngineObject that contributes a soft additive blob of color
15779
16475
  * to the LightSystem plugin's lightmap.
@@ -15782,11 +16478,16 @@ class LightSystemPlugin
15782
16478
  * - A light inside a caster is blocked entirely, so the object that holds it, its lamp, a torch, the player
15783
16479
  * carrying it, needs a shadowCore that reaches past it, castShadow = false, or a renderShadow that leaves
15784
16480
  * the light's spot out
16481
+ * - A coneAngle makes it a cone along its up, turned by its angle, and a gel is a picture it shines through; both
16482
+ * are applied over the round light with its shadows, so they cost next to nothing
15785
16483
  * @extends EngineObject
15786
16484
  * @memberof LightSystem
15787
16485
  * @example
15788
16486
  * new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
15789
16487
  * new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
16488
+ * const flashlight = new Light(vec2(), 10); // a flashlight looking right, its beam 60 degrees across
16489
+ * flashlight.coneAngle = PI/6;
16490
+ * flashlight.angle = PI/2;
15790
16491
  */
15791
16492
  class Light extends EngineObject
15792
16493
  {
@@ -15819,6 +16520,19 @@ class Light extends EngineObject
15819
16520
  this.glowFalloff = 1;
15820
16521
  /** @type {TileInfo|undefined} */
15821
16522
  this.glowTileInfo = undefined; // the whole glow texture, kept for the falloff it was made for
16523
+ /** @property {number} - Makes it a cone, like a flashlight or headlight: the angle in radians from its up out
16524
+ * to the edge of the cone, so the beam is twice this across; it looks along getUp(), turned by its angle and
16525
+ * by what it is attached to; 0 for a light that shines every way */
16526
+ this.coneAngle = 0;
16527
+ /** @property {number} - How much of the cone is its fading edge: 0 a hard edge, .2 by default, the outer
16528
+ * fifth, 1 fading all the way from the middle of the beam */
16529
+ this.coneSoftness = .2;
16530
+ /** @property {TileInfo|TextureInfo|undefined} - A gel, a picture the light shines through, called a cookie in
16531
+ * some engines: stretched across the light's square, twice its radius, its top the way the light looks,
16532
+ * turned with it, and multiplied into its color, so one light can be a car's headlights and tail lights;
16533
+ * a TileInfo's own tile, or a whole TextureInfo; shadows still fall through it
16534
+ * @type {TileInfo|TextureInfo|undefined} */
16535
+ this.gel = undefined;
15822
16536
  }
15823
16537
 
15824
16538
  /** Lights are invisible in the main render pass — they only contribute
@@ -22709,7 +23423,9 @@ class ParallaxLayer extends CanvasLayer
22709
23423
  * and 1 with the screen, a Vector2 to follow x and y by different amounts
22710
23424
  * @param {number} [renderOrder] - Low to draw behind the game, far layers lowest
22711
23425
  * @param {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} [drawFunction] - Draws
22712
- * the image, given the canvas context, its size in pixels and the layer; mountains when not given
23426
+ * the image, given the canvas context, its size in pixels and the layer; mountains when not given; called
23427
+ * once when the layer is made and on redraw(), not each frame, so it draws with the canvas 2D context, not
23428
+ * the engine's draws like drawTile
22713
23429
  * @param {Vector2} [canvasSize] - Size of the image in pixels */
22714
23430
  constructor(pos=vec2(), size=vec2(32, 16), parallax=.5, renderOrder=-1e3, drawFunction=parallaxMountains(),
22715
23431
  canvasSize=vec2(512, 256))
@@ -24708,7 +25424,8 @@ function raycastBox(ray, pos, size, rotation)
24708
25424
  * - EngineObject3D is an EngineObject with a 3D position, rotation and mesh
24709
25425
  * - The 3D scene draws under the 2D sprites, so HUD and text land on top
24710
25426
  * - Lighting is the sun plus ambient, with optional extra lights, fog and shadows
24711
- * - Any object or draw can bring its own Shader, a mainImage snippet the lighting then applies to
25427
+ * - Any object or draw can bring its own Shader, a mainImage snippet the lighting then applies to, and a
25428
+ * mainNormal in it bends the normal the lighting uses
24712
25429
  * - Meshes and the basic builders are in render3dMesh.js, EngineObject3D, instancing and lights in
24713
25430
  * render3dObject.js, both right after this one; the other builders, terrain, particles, camera controls and the
24714
25431
  * OBJ loader are in the Render3D Extras plugin, which goes after those
@@ -25286,7 +26003,9 @@ class Render3DPlugin
25286
26003
  /** @property {Vector3|undefined} - Center of the shadowed area, read each frame, undefined follows the camera
25287
26004
  * @type {Vector3|undefined} */
25288
26005
  this.shadowCenter = undefined;
25289
- /** @property {number} - Stops surfaces shadowing themselves, raise for speckles, lower if shadows drift off */
26006
+ /** @property {number} - Stops surfaces shadowing themselves, raise for speckles, lower if shadows drift off;
26007
+ * a share of the shadow map's depth, twice shadowRange for the sun and a spotlight's radius, so the gap behind
26008
+ * a caster grows with the range as the map's texels do, and the speckles they make stay away */
25290
26009
  this.shadowBias = .003;
25291
26010
  /** @property {number} - How much to blur the shadow edges */
25292
26011
  this.shadowSoftness = 1;
@@ -26320,6 +27039,9 @@ class Camera3D
26320
27039
  /** @property {boolean} - Line the 3D camera up with the 2D camera, so 3D things at z=0 sit on the 2D sprites;
26321
27040
  * it lines up with the 2D view of the main canvas, whatever canvas size a screenToRay is given */
26322
27041
  this.align2D = false;
27042
+ /** @property {number} - The z of the plane align2D lines up with the 2D view, the camera sitting its
27043
+ * distance in front of it */
27044
+ this.align2DZ = 0;
26323
27045
  }
26324
27046
 
26325
27047
  /** Returns the camera's world transform
@@ -26390,7 +27112,7 @@ class Camera3D
26390
27112
  ASSERT(!canvasHeight || distance < this.far,
26391
27113
  'align2D needs this camera distance to match the 2D view, raise camera.far past it', distance);
26392
27114
  this.orthographic &&= halfHeight * 2; // an orthographic camera stays orthographic and shows the same height
26393
- this.pos = vec3(cameraPos.x, cameraPos.y, distance);
27115
+ this.pos = vec3(cameraPos.x, cameraPos.y, this.align2DZ + distance);
26394
27116
  this.rotation = vec3(0, 0, -cameraAngle); // 2D angles turn the other way
26395
27117
  }
26396
27118
  }
@@ -26442,7 +27164,8 @@ const RENDER3D_SNIPPET_NAMES =
26442
27164
  '#define lights extraLights\n' +
26443
27165
  '#define lightColors extraLightColors\n';
26444
27166
 
26445
- // the fragment shader; given a Shader's snippet, its mainImage replaces the texture sample and all else is the same
27167
+ // the fragment shader; given a Shader's snippet, its mainImage replaces the texture sample and all else is the same,
27168
+ // and a mainNormal in it bends the normal after the normal map, before all the lighting reads it
26446
27169
  // uniforms: lightDir (xyz the way the sunlight travels, w = emissive, 1 or more skips the lighting),
26447
27170
  // lightColor (the sun's rgb, a = specular), ambientFog (rgb, a = fogEnd), fogColor (rgb, a = fogStart),
26448
27171
  // cameraPos, tex, shadowMap, shadowParams (x = shadows on, y = bias, z = blur step in texture space,
@@ -26451,6 +27174,7 @@ const RENDER3D_SNIPPET_NAMES =
26451
27174
  // emissive map is on), skyTop, skyHorizon, skyBottom (what a reflection shows), normalTex, emissiveTex
26452
27175
  function render3DFragmentSource(fragmentCode)
26453
27176
  {
27177
+ const hasNormal = /\bmainNormal\s*\(/.test(fragmentCode || '');
26454
27178
  return '#version 300 es\n' +
26455
27179
  'precision highp float;' +
26456
27180
  'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams;' +
@@ -26498,6 +27222,7 @@ function render3DFragmentSource(fragmentCode)
26498
27222
  'return normalize((u*m.x-v*m.y)*inversesqrt(k)+n*m.z);}' +
26499
27223
  // a snippet's mainImage is declared here and written after main, so a define in it can not reach main
26500
27224
  (fragmentCode ? 'void mainImage(out vec4,vec2);' : '') +
27225
+ (hasNormal ? 'void mainNormal(inout vec3);' : '') +
26501
27226
  'void main(){' +
26502
27227
  (fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') +
26503
27228
  'if(premultipliedTexture&&t.a>0.)t.rgb/=t.a;' + // back to straight color, what the lighting and blend expect
@@ -26507,6 +27232,7 @@ function render3DFragmentSource(fragmentCode)
26507
27232
  'vec3 n=dot(N,N)>0.?normalize(N):vec3(0,1,0);' +
26508
27233
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
26509
27234
  'if(materialParams.x!=0.)n=normalMapNormal(n);' +
27235
+ (hasNormal ? 'mainNormal(n);n=normalize(n);' : '') +
26510
27236
  'float nl=dot(n,-lightDir.xyz);' +
26511
27237
  // the shadow is the sun's, or one spotlight's: shadowParams.x is 1 for the sun, 2 and up for that Light3D
26512
27238
  'float sh=shadow(),s=shadowParams.x<1.5?sh:1.;' +
@@ -31646,6 +32372,7 @@ class ParticleEmitter3D extends EngineObject3D
31646
32372
  const out = this.particleData;
31647
32373
  out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
31648
32374
  out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
32375
+ out[k+16] = view.lifeTime, out[k+17] = view.age;
31649
32376
  if (view.destroyed)
31650
32377
  out[k+17] = max(out[k+17], out[k+16]); // its life lived: a step short of it can round to just under
31651
32378
  return result;
@@ -39847,8 +40574,9 @@ function editor3DCameraStart()
39847
40574
  camera.pos = from.pos.copy();
39848
40575
  camera.rotation = vec3(from.rotation.x, from.rotation.y, 0);
39849
40576
  camera.fov = from.fov;
39850
- camera.near = from.near;
39851
40577
  camera.far = from.far;
40578
+ // an orthographic camera's near may sit behind it, which a perspective view can not use, so it keeps its own
40579
+ from.orthographic || (camera.near = from.near);
39852
40580
  }
39853
40581
 
39854
40582
  // turn the free camera on or off, it needs a Render3DPlugin and takes the keyboard and mouse while it is on
@@ -39903,6 +40631,7 @@ function editor3DWithView(fn)
39903
40631
  // called by the 3D pass before it works out its matrices: the frame is drawn with the editor's camera
39904
40632
  function editor3DCameraBegin()
39905
40633
  {
40634
+ editor3DCameraEnd(); // a frame that threw before its End gives the game its camera back first, not the editor's
39906
40635
  if (!editor3DViewOn() || !editor3DCamera) return;
39907
40636
  const r = render3D, camera = editor3DGameCamera = r.camera;
39908
40637
  r.camera = editor3DCamera;
@@ -40235,8 +40964,17 @@ function editor3DSize(object)
40235
40964
  const made = editor3DInstances.get(object.id), box = editor3DPrefabBox(made);
40236
40965
  if (box) return box.size;
40237
40966
  if (!(made instanceof EngineObject3D) || !(made.mesh || made.tileInfo)) return vec3(1);
40238
- const s = made.size3D, k = made.scale3D;
40239
- return vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z));
40967
+ const s = made.size3D, k = made.scale3D, size = vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z));
40968
+ if (!editor3DTurned(object)) return size;
40969
+
40970
+ // a turned one's box is the box around it turned, so it lands, drops and pastes by what it covers
40971
+ const matrix = buildMatrix(vec3(), editor3DRotation(object).scale(PI / 180), size), high = vec3();
40972
+ for (let i = 8; i--;)
40973
+ {
40974
+ const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
40975
+ high.x = max(high.x, p.x), high.y = max(high.y, p.y), high.z = max(high.z, p.z);
40976
+ }
40977
+ return vec3(editor3DRound(high.x * 2), editor3DRound(high.y * 2), editor3DRound(high.z * 2));
40240
40978
  }
40241
40979
 
40242
40980
  // the box around a prefab's instance in the world, the boxes of its parts together, as its middle and its size;
@@ -41448,6 +42186,7 @@ function editor3DSetOpen(open)
41448
42186
  if (open)
41449
42187
  {
41450
42188
  editor3DSetFreeCamera(false);
42189
+ pointerLockIsActive() && pointerLockExit(); // the game's captured mouse is let go, the editor needs it
41451
42190
  editor3DLevel || editor3DLevelLoaded({}); // a new level, with nothing loaded
41452
42191
  editor3DCamera || editor3DCameraStart();
41453
42192
  editor3DGamePaused = paused;
@@ -42145,10 +42884,11 @@ function editor3DDragTo(drag, mouse, ray, snap)
42145
42884
  }
42146
42885
  if (factor)
42147
42886
  {
42887
+ // snapped and kept from nothing by its size, its sign kept, so a mirrored axis stays mirrored
42148
42888
  const size = snap ? editor3DScaleStep : 0, least = size || .01;
42889
+ const fit = (v)=> (v < 0 ? -1 : 1) * max(editor3DSnap(abs(v), size), least);
42149
42890
  const scale = s.scale.multiply(factor);
42150
- editor3DSetTransform(object, undefined, undefined, vec3(max(editor3DSnap(scale.x, size), least),
42151
- max(editor3DSnap(scale.y, size), least), max(editor3DSnap(scale.z, size), least)));
42891
+ editor3DSetTransform(object, undefined, undefined, vec3(fit(scale.x), fit(scale.y), fit(scale.z)));
42152
42892
  }
42153
42893
  }
42154
42894
  });
@@ -42669,8 +43409,9 @@ function editor3DDrawLabels()
42669
43409
  const drag = editor3DDrag;
42670
43410
  if (drag?.kind === 'box')
42671
43411
  {
43412
+ // on the main canvas, over the WebGL one, so a 3D pass drawn after the 2D scene does not cover it
42672
43413
  const {from: a, to: b} = drag, corners = [a, vec2(b.x, a.y), b, vec2(a.x, b.y)];
42673
- corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0, glEnable, true));
43414
+ corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0, false, true));
42674
43415
  }
42675
43416
  }
42676
43417
 
@@ -43439,6 +44180,7 @@ export
43439
44180
  frame,
43440
44181
  time,
43441
44182
  timeReal,
44183
+ averageFPS,
43442
44184
  paused,
43443
44185
  getPaused,
43444
44186
  setPaused,