littlejsengine 1.26.1 → 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.
- package/CHANGELOG.md +16 -0
- package/FAQ.md +3 -2
- package/README.md +4 -3
- package/REFERENCE.md +38 -0
- package/dist/littlejs.d.ts +181 -16
- package/dist/littlejs.esm.js +810 -98
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +810 -98
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +790 -92
- package/package.json +1 -1
- package/plugins/lightSystem.js +84 -2
- package/plugins/newgrounds.js +92 -0
- package/plugins/parallax.js +3 -1
- package/plugins/render3dEditor.js +20 -6
- package/plugins/render3dExtras.js +1 -0
- package/plugins/wavedash.js +235 -4
- package/src/engine.js +24 -3
- package/src/engineObject.js +244 -65
- package/src/engineParticles.js +8 -3
- package/src/engineTileLayer.js +95 -10
- package/src/engineWebGL.js +4 -4
package/dist/littlejs.js
CHANGED
|
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
|
|
|
35
35
|
* @type {string}
|
|
36
36
|
* @default
|
|
37
37
|
* @memberof Engine */
|
|
38
|
-
const engineVersion = '1.
|
|
38
|
+
const engineVersion = '1.27.0';
|
|
39
39
|
|
|
40
40
|
/** Frames per second to update
|
|
41
41
|
* @type {number}
|
|
@@ -200,6 +200,10 @@ let engineCollideGridMin = 64;
|
|
|
200
200
|
// everything
|
|
201
201
|
let engineCollideGrid;
|
|
202
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
|
+
|
|
203
207
|
// the grid of a list of solids, its cells about twice a typical solid so most are in one to four
|
|
204
208
|
function engineCollideGridBuild(list)
|
|
205
209
|
{
|
|
@@ -960,19 +964,36 @@ function engineObjectsUpdate()
|
|
|
960
964
|
// every frame makes this function deoptimize again and again in a game with no grid
|
|
961
965
|
if (engineObjectsCollideStaticLast.length >= engineCollideGridMin)
|
|
962
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()]));
|
|
963
970
|
|
|
964
971
|
// update physics before object update, each solid put where it moved to in the grid, for the movers after it;
|
|
965
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;
|
|
966
976
|
try
|
|
967
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
|
+
}
|
|
968
985
|
for (const o of engineObjects)
|
|
969
|
-
if (!o.parent && !o.destroyed)
|
|
986
|
+
if (!o.parent && !o.destroyed && !(engineObjectsOneWayStart && oneWayFirst(o)))
|
|
970
987
|
{
|
|
971
988
|
o.updatePhysics();
|
|
972
989
|
engineCollideGrid?.built && engineCollideGridPlace(engineCollideGrid.built, o);
|
|
973
990
|
}
|
|
974
991
|
}
|
|
975
|
-
finally
|
|
992
|
+
finally
|
|
993
|
+
{
|
|
994
|
+
engineCollideGrid && (engineCollideGrid = undefined);
|
|
995
|
+
engineObjectsOneWayStart && (engineObjectsOneWayStart = undefined);
|
|
996
|
+
}
|
|
976
997
|
|
|
977
998
|
// recursive object update: the children are walked from a copy on a shared stack, since a child that
|
|
978
999
|
// destroys itself leaves its parent's list on the spot and the next child would slide past the loop
|
|
@@ -4530,6 +4551,110 @@ function setDebugTweakables(show=true) { debugTweakables = show; }
|
|
|
4530
4551
|
* - Objects sorted by renderOrder for layered rendering
|
|
4531
4552
|
*/
|
|
4532
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
|
+
|
|
4533
4658
|
/**
|
|
4534
4659
|
* LittleJS Object Base Object Class
|
|
4535
4660
|
* - Top level object class used by the engine
|
|
@@ -4641,9 +4766,27 @@ class EngineObject
|
|
|
4641
4766
|
this.children = [];
|
|
4642
4767
|
/** @property {boolean} - Limit object speed along x and y axis */
|
|
4643
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;
|
|
4644
4780
|
/** @property {EngineObject|undefined} - Object we are standing on, if any
|
|
4645
4781
|
* @type {EngineObject|undefined} */
|
|
4646
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;
|
|
4647
4790
|
|
|
4648
4791
|
// parent child system
|
|
4649
4792
|
/** @property {EngineObject|undefined} - Parent of object if in local space
|
|
@@ -4731,7 +4874,8 @@ class EngineObject
|
|
|
4731
4874
|
// limit max speed to prevent missing collisions, after gravity so no move is past it, only for what collides:
|
|
4732
4875
|
// with solids, or with tiles while it has a mass, which tile collision needs; anything else moves as fast as
|
|
4733
4876
|
// it is told
|
|
4734
|
-
if (this.clampSpeed && enablePhysicsSolver &&
|
|
4877
|
+
if (this.clampSpeed && enablePhysicsSolver &&
|
|
4878
|
+
(this.collideSolidObjects && this.size.x && this.size.y || this.collideLevel && this.mass && !this.isBullet))
|
|
4735
4879
|
{
|
|
4736
4880
|
this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
|
|
4737
4881
|
this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
|
|
@@ -4746,6 +4890,7 @@ class EngineObject
|
|
|
4746
4890
|
// which way is down for this object, a negative gravityScale falls up and lands on ceilings
|
|
4747
4891
|
const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
|
|
4748
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
|
|
4749
4894
|
if (this.groundObject)
|
|
4750
4895
|
{
|
|
4751
4896
|
// apply friction in local space of ground object
|
|
@@ -4772,6 +4917,11 @@ class EngineObject
|
|
|
4772
4917
|
// check collision
|
|
4773
4918
|
if (!this.isOverlappingObject(o)) continue;
|
|
4774
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
|
+
|
|
4775
4925
|
// each moving object checks its own contacts, so a pair the other one already asked about this frame
|
|
4776
4926
|
// is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
|
|
4777
4927
|
// to resolve, pushed back together since, is resolved again
|
|
@@ -4906,71 +5056,121 @@ class EngineObject
|
|
|
4906
5056
|
}
|
|
4907
5057
|
if (this.collideLevel)
|
|
4908
5058
|
{
|
|
4909
|
-
// check collision against tiles
|
|
4910
|
-
const
|
|
4911
|
-
|
|
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
|
|
4912
5063
|
{
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
|
|
4920
|
-
const restitution = max(this.restitution, hitLayer.restitution);
|
|
4921
|
-
if (isBlockedX)
|
|
4922
|
-
{
|
|
4923
|
-
// a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
|
|
4924
|
-
// down off a ceiling ledge when gravity points up; zero gravity counts as down
|
|
4925
|
-
const epsilon = 1e-3;
|
|
4926
|
-
const maxMove = .1;
|
|
4927
|
-
const y = gravityY > 0 ?
|
|
4928
|
-
ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
|
|
4929
|
-
floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
|
|
4930
|
-
if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
|
|
4931
|
-
{
|
|
4932
|
-
this.pos.y = y;
|
|
4933
|
-
debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
|
|
4934
|
-
return;
|
|
4935
|
-
}
|
|
5064
|
+
this.isBullet ? this.updatePhysicsBullet(oldPos, gravityY, wasFalling) :
|
|
5065
|
+
this.updatePhysicsTiles(oldPos, gravityY, wasFalling);
|
|
5066
|
+
}
|
|
5067
|
+
finally { tileCollisionFromObject = fromObject, tileCollisionFromPos = fromPos; }
|
|
5068
|
+
}
|
|
5069
|
+
}
|
|
4936
5070
|
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
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))
|
|
4952
5132
|
{
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
// floor, and fall through; back to its previous Y when that spot is not clear
|
|
4957
|
-
const epsilon = .0001;
|
|
4958
|
-
const offset = this.size.y/2 + epsilon;
|
|
4959
|
-
const layerY = hitLayer.pos.y;
|
|
4960
|
-
const y = layerY + (this.pos.y < oldPos.y ?
|
|
4961
|
-
floor(oldPos.y - layerY - this.size.y/2) + offset :
|
|
4962
|
-
ceil( oldPos.y - layerY + this.size.y/2) - offset);
|
|
4963
|
-
const isClear = this.pos.y !== oldPos.y && !tileCollisionTest(vec2(this.pos.x, y), this.size, this);
|
|
4964
|
-
this.pos.y = isClear ? y : oldPos.y;
|
|
4965
|
-
|
|
4966
|
-
// set ground object for tile collision
|
|
4967
|
-
this.groundObject = wasFalling ? hitLayer : undefined;
|
|
4968
|
-
|
|
4969
|
-
// bounce velocity
|
|
4970
|
-
this.velocity.y *= -restitution;
|
|
5133
|
+
this.pos.y = y;
|
|
5134
|
+
debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
|
|
5135
|
+
return;
|
|
4971
5136
|
}
|
|
4972
|
-
|
|
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;
|
|
4973
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);
|
|
4974
5174
|
}
|
|
4975
5175
|
}
|
|
4976
5176
|
}
|
|
@@ -5038,10 +5238,10 @@ class EngineObject
|
|
|
5038
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
|
|
5039
5239
|
* - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
|
|
5040
5240
|
* positions it only tries, so keep it free of side effects or guard them to once a frame
|
|
5041
|
-
* - this.pos has already moved, so a check on where it came from
|
|
5042
|
-
*
|
|
5043
|
-
* - For the point of impact, like sparks where a bullet hit,
|
|
5044
|
-
*
|
|
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
|
|
5045
5245
|
* @param {number} tileData - the value of the tile at the position
|
|
5046
5246
|
* @param {Vector2} pos - the tile's bottom left corner in world space
|
|
5047
5247
|
* @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
|
|
@@ -9909,6 +10109,55 @@ const tileCollisionLayers = [];
|
|
|
9909
10109
|
function tileCollisionAssertWhole(layer)
|
|
9910
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); }
|
|
9911
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
|
+
|
|
9912
10161
|
// the test a tile query applies to a cell's data: the callback, the object's collideWithTile, or solid data
|
|
9913
10162
|
function tileCollisionTester(callbackObject)
|
|
9914
10163
|
{
|
|
@@ -10683,13 +10932,17 @@ class TileLayer extends CanvasLayer
|
|
|
10683
10932
|
* - This may be slow if not using webgl but only needs to be done once */
|
|
10684
10933
|
redraw()
|
|
10685
10934
|
{
|
|
10935
|
+
// the camera, canvas and target are the game's again even when a tile draw or onRedraw throws
|
|
10686
10936
|
this.redrawStart(true);
|
|
10687
|
-
|
|
10688
|
-
|
|
10689
|
-
this.
|
|
10690
|
-
|
|
10691
|
-
|
|
10692
|
-
|
|
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(); }
|
|
10693
10946
|
this.tilesInWebGL = this.isUsingWebGL;
|
|
10694
10947
|
}
|
|
10695
10948
|
|
|
@@ -10802,8 +11055,8 @@ class TileLayer extends CanvasLayer
|
|
|
10802
11055
|
ASSERT(drawContext !== this.context, 'redrawStart() should not be active when calling redrawTileData(), instead use drawTileData()');
|
|
10803
11056
|
|
|
10804
11057
|
this.redrawStart();
|
|
10805
|
-
this.drawTileData(layerPos, clear);
|
|
10806
|
-
this.redrawEnd();
|
|
11058
|
+
try { this.drawTileData(layerPos, clear); }
|
|
11059
|
+
finally { this.redrawEnd(); }
|
|
10807
11060
|
}
|
|
10808
11061
|
|
|
10809
11062
|
/** Draw textured tile in layer space
|
|
@@ -10927,6 +11180,31 @@ class TileCollisionLayer extends TileLayer
|
|
|
10927
11180
|
/** @property {boolean} - In the light system's shadow pass, cast only from the cells with collision, drawn
|
|
10928
11181
|
* as the layer shows them, so a floor in the same layer stays lit; false casts every tile */
|
|
10929
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());
|
|
10930
11208
|
}
|
|
10931
11209
|
|
|
10932
11210
|
/** Draw this layer's shadow shape: the cells with collision in the part the shadow map covers, each row of
|
|
@@ -11018,6 +11296,10 @@ class TileCollisionLayer extends TileLayer
|
|
|
11018
11296
|
tileCollisionAssertWhole(this);
|
|
11019
11297
|
const collisionTest = tileCollisionTester(callbackObject);
|
|
11020
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
|
+
|
|
11021
11303
|
// check any tiles in the area for collision
|
|
11022
11304
|
const posX = pos.x - this.pos.x;
|
|
11023
11305
|
const posY = pos.y - this.pos.y;
|
|
@@ -11036,7 +11318,8 @@ class TileCollisionLayer extends TileLayer
|
|
|
11036
11318
|
{
|
|
11037
11319
|
// check if the object should collide with this tile, the callback gets its own vector, one it can keep
|
|
11038
11320
|
const tileData = this.collisionData[y*this.size.x+x];
|
|
11039
|
-
if (tileData &&
|
|
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)))
|
|
11040
11323
|
return true;
|
|
11041
11324
|
}
|
|
11042
11325
|
return false;
|
|
@@ -11056,10 +11339,12 @@ class TileCollisionLayer extends TileLayer
|
|
|
11056
11339
|
const collisionTest = tileCollisionTester(callbackObject);
|
|
11057
11340
|
// the line is walked in the layer's own space, so its cells are the tiles wherever the layer sits
|
|
11058
11341
|
const offset = this.pos;
|
|
11342
|
+
// a one way tile blocks a ray that starts on its far side
|
|
11059
11343
|
const testFunction = (pos)=>
|
|
11060
11344
|
{
|
|
11061
11345
|
const tileData = this.getCollisionData(pos);
|
|
11062
|
-
return tileData &&
|
|
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));
|
|
11063
11348
|
}
|
|
11064
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
|
|
11065
11350
|
// far beyond is walked across the layer and no farther; the cell around keeps the step into the layer as it was
|
|
@@ -11539,10 +11824,13 @@ function particleTileSet(tileInfo)
|
|
|
11539
11824
|
}
|
|
11540
11825
|
}
|
|
11541
11826
|
|
|
11827
|
+
// where the particle being tested was before its move, which one way tiles go by
|
|
11828
|
+
let particleCollideFromX = 0, particleCollideFromY = 0;
|
|
11829
|
+
|
|
11542
11830
|
// tests if a particle collides with tiles at x, y, through its emitter's collide callback if it has one
|
|
11543
11831
|
function particleCollideTest(particle, collideCallback, x, y)
|
|
11544
11832
|
{
|
|
11545
|
-
const data =
|
|
11833
|
+
const data = tileCollisionGetDataFrom(particleCollidePos.set(x, y), particleCollideFromX, particleCollideFromY);
|
|
11546
11834
|
if (!collideCallback)
|
|
11547
11835
|
return data > 0;
|
|
11548
11836
|
|
|
@@ -11674,10 +11962,12 @@ class Particle
|
|
|
11674
11962
|
|
|
11675
11963
|
// check collision against tiles
|
|
11676
11964
|
this.groundObject = undefined;
|
|
11965
|
+
particleCollideFromX = oldX, particleCollideFromY = oldY;
|
|
11677
11966
|
if (particleCollideTest(this, collideCallback, this.pos.x, this.pos.y))
|
|
11678
11967
|
{
|
|
11679
|
-
//
|
|
11680
|
-
|
|
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;
|
|
11681
11971
|
if (!particleCollideTest(this, collideCallback, oldX, oldY))
|
|
11682
11972
|
{
|
|
11683
11973
|
// test which side we bounced off (or both if a corner)
|
|
@@ -12328,15 +12618,15 @@ function glShaderTrack(shader) { glShaderObjects.includes(shader) || glShaderObj
|
|
|
12328
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
|
|
12329
12619
|
function glShaderDispose(shader)
|
|
12330
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();
|
|
12331
12625
|
const i = glShaderObjects.indexOf(shader);
|
|
12332
12626
|
i < 0 || glShaderObjects.splice(i, 1);
|
|
12333
12627
|
if (glContext && !headlessMode)
|
|
12334
|
-
{
|
|
12335
|
-
if (glBatchShader === shader) // a batch drawing with it is drawn first, the batch holds the Shader
|
|
12336
|
-
glFlush();
|
|
12337
12628
|
for (const program of [shader.program, shader.program3D])
|
|
12338
12629
|
program && glContext.deleteProgram(program);
|
|
12339
|
-
}
|
|
12340
12630
|
shader.program = shader.program3D = undefined;
|
|
12341
12631
|
}
|
|
12342
12632
|
|
|
@@ -13696,6 +13986,8 @@ class NewgroundsPlugin
|
|
|
13696
13986
|
/** @property {Array<Object>} - Scoreboards fetched from Newgrounds, empty until ready
|
|
13697
13987
|
* @type {Array<Object>} */
|
|
13698
13988
|
this.scoreboards = [];
|
|
13989
|
+
/** @type {Map<number, 'failed'|'notSave'>} */
|
|
13990
|
+
this.loadFailures = new Map; // why each slot's last load gave undefined, see loadFailure
|
|
13699
13991
|
/** @property {{id: number, name: string, url: string, supporter: boolean}|null} - The logged in player once ready, null when not logged in
|
|
13700
13992
|
* @type {{id: number, name: string, url: string, supporter: boolean}|null} */
|
|
13701
13993
|
this.user = null;
|
|
@@ -13865,6 +14157,96 @@ class NewgroundsPlugin
|
|
|
13865
14157
|
return this.call('ScoreBoard.getScores', parameters, session_id);
|
|
13866
14158
|
}
|
|
13867
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
|
+
|
|
13868
14250
|
/** Encrypt text the way the Newgrounds gateway expects, AES-128 CBC with a random iv in front, as Base64
|
|
13869
14251
|
* @param {string} text
|
|
13870
14252
|
* @return {Promise<string>} */
|
|
@@ -13933,7 +14315,8 @@ class NewgroundsPlugin
|
|
|
13933
14315
|
|
|
13934
14316
|
/**
|
|
13935
14317
|
* LittleJS Wavedash Plugin
|
|
13936
|
-
* - The Wavedash twin of the Newgrounds plugin: achievements and
|
|
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
|
|
13937
14320
|
* - Wavedash serves the game's page and puts its SDK in window.Wavedash before the game runs, so nothing is bundled;
|
|
13938
14321
|
* off Wavedash (local, itch, GitHub Pages) there is none, and every call does nothing
|
|
13939
14322
|
* - Make the plugin when the game can draw, at the end of gameInit: Wavedash.init is called then, and until it is
|
|
@@ -13963,17 +14346,33 @@ const wavedashDisplayTypes = {number: 0, seconds: 1, milliseconds: 2, ticks: 3};
|
|
|
13963
14346
|
// the SDK Wavedash put on the page, read at each call, undefined off Wavedash
|
|
13964
14347
|
const wavedashSDK = ()=> globalThis['Wavedash'];
|
|
13965
14348
|
|
|
14349
|
+
// the file a cloud save slot is kept in
|
|
14350
|
+
const wavedashSavePath = (slot)=> 'saves/slot' + slot + '.json';
|
|
14351
|
+
|
|
13966
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
|
|
13967
14353
|
// the answer is in, so nothing waits on it after
|
|
13968
|
-
function wavedashWait(name, answer)
|
|
14354
|
+
function wavedashWait(name, answer, quiet=false)
|
|
13969
14355
|
{
|
|
13970
14356
|
let timer;
|
|
13971
|
-
const
|
|
14357
|
+
const timedOut = {}; // what the time limit gives, told apart from any answer
|
|
14358
|
+
const limit = new Promise((resolve)=> timer = setTimeout(()=> resolve(timedOut), wavedashTimeoutMS));
|
|
13972
14359
|
return Promise.race([Promise.resolve(answer), limit])
|
|
13973
|
-
.
|
|
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); })
|
|
13974
14363
|
.finally(()=> clearTimeout(timer));
|
|
13975
14364
|
}
|
|
13976
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
|
+
|
|
13977
14376
|
///////////////////////////////////////////////////////////////////////////////
|
|
13978
14377
|
/**
|
|
13979
14378
|
* WavedashMedal: a medal that is also a Wavedash achievement, unlocked on Wavedash by its identifier
|
|
@@ -14052,6 +14451,14 @@ class WavedashPlugin
|
|
|
14052
14451
|
this.achievementTries = Object.create(null); // how many times each one not taken yet was sent
|
|
14053
14452
|
/** @type {number|undefined} */
|
|
14054
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
|
|
14055
14462
|
|
|
14056
14463
|
// Wavedash keeps its loading screen until init, which is called once
|
|
14057
14464
|
wavedashWait('init', this.call('init'));
|
|
@@ -14130,6 +14537,212 @@ class WavedashPlugin
|
|
|
14130
14537
|
return result?.['success'] ? result['data'] : undefined;
|
|
14131
14538
|
}
|
|
14132
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
|
+
|
|
14133
14746
|
/** Send every unlocked WavedashMedal's achievement Wavedash has not taken yet, again every two seconds while it
|
|
14134
14747
|
* refuses some, as it does until it has loaded the player's achievements; one refused for about a minute is
|
|
14135
14748
|
* taken as an identifier Wavedash does not have, said once in the console, and not sent again this visit
|
|
@@ -15017,20 +15630,34 @@ class LightSystemPlugin
|
|
|
15017
15630
|
'uniform vec4 color;'+
|
|
15018
15631
|
'uniform sampler2D shadowTexture;'+ // this light's shadow, white where its rays reach
|
|
15019
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;'+
|
|
15020
15638
|
'in vec2 vWorldPos;'+
|
|
15021
15639
|
'in vec2 vUV;'+
|
|
15022
15640
|
'out vec4 c;'+
|
|
15023
15641
|
'void main(){'+
|
|
15024
|
-
'
|
|
15642
|
+
'vec2 d=vWorldPos-lightPos;'+
|
|
15643
|
+
'float dist=length(d);'+
|
|
15025
15644
|
'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
|
|
15026
15645
|
'c=vec4(color.rgb*t*color.a,1.);'+
|
|
15027
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;}'+
|
|
15028
15654
|
'}'
|
|
15029
15655
|
);
|
|
15030
15656
|
if (lightSystemShadersFailed(lightSystem.lightShader)) return;
|
|
15031
|
-
// 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
|
|
15032
15658
|
glContext.useProgram(lightSystem.lightShader);
|
|
15033
15659
|
glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'shadowTexture'), 1);
|
|
15660
|
+
glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'gelTexture'), 2);
|
|
15034
15661
|
|
|
15035
15662
|
// composite shader: fullscreen quad, samples the lightmap
|
|
15036
15663
|
lightSystem.compositeShader = glCreateProgram(
|
|
@@ -15603,6 +16230,19 @@ class LightSystemPlugin
|
|
|
15603
16230
|
[drawContext, glCustomShader] = saved;
|
|
15604
16231
|
}
|
|
15605
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
|
+
}
|
|
15606
16246
|
finally
|
|
15607
16247
|
{
|
|
15608
16248
|
glRenderTargetBase = undefined;
|
|
@@ -15716,6 +16356,18 @@ class LightSystemPlugin
|
|
|
15716
16356
|
const c = light.color;
|
|
15717
16357
|
glContext.uniform4f(glUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
|
|
15718
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
|
+
|
|
15719
16371
|
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
|
|
15720
16372
|
|
|
15721
16373
|
// restore engine's instanced shader+VAO so subsequent renderLight()
|
|
@@ -15793,6 +16445,31 @@ class LightSystemPlugin
|
|
|
15793
16445
|
|
|
15794
16446
|
///////////////////////////////////////////////////////////////////////////////
|
|
15795
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
|
+
|
|
15796
16473
|
/**
|
|
15797
16474
|
* A Light is an EngineObject that contributes a soft additive blob of color
|
|
15798
16475
|
* to the LightSystem plugin's lightmap.
|
|
@@ -15801,11 +16478,16 @@ class LightSystemPlugin
|
|
|
15801
16478
|
* - A light inside a caster is blocked entirely, so the object that holds it, its lamp, a torch, the player
|
|
15802
16479
|
* carrying it, needs a shadowCore that reaches past it, castShadow = false, or a renderShadow that leaves
|
|
15803
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
|
|
15804
16483
|
* @extends EngineObject
|
|
15805
16484
|
* @memberof LightSystem
|
|
15806
16485
|
* @example
|
|
15807
16486
|
* new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
|
|
15808
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;
|
|
15809
16491
|
*/
|
|
15810
16492
|
class Light extends EngineObject
|
|
15811
16493
|
{
|
|
@@ -15838,6 +16520,19 @@ class Light extends EngineObject
|
|
|
15838
16520
|
this.glowFalloff = 1;
|
|
15839
16521
|
/** @type {TileInfo|undefined} */
|
|
15840
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;
|
|
15841
16536
|
}
|
|
15842
16537
|
|
|
15843
16538
|
/** Lights are invisible in the main render pass — they only contribute
|
|
@@ -22728,7 +23423,9 @@ class ParallaxLayer extends CanvasLayer
|
|
|
22728
23423
|
* and 1 with the screen, a Vector2 to follow x and y by different amounts
|
|
22729
23424
|
* @param {number} [renderOrder] - Low to draw behind the game, far layers lowest
|
|
22730
23425
|
* @param {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} [drawFunction] - Draws
|
|
22731
|
-
* 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
|
|
22732
23429
|
* @param {Vector2} [canvasSize] - Size of the image in pixels */
|
|
22733
23430
|
constructor(pos=vec2(), size=vec2(32, 16), parallax=.5, renderOrder=-1e3, drawFunction=parallaxMountains(),
|
|
22734
23431
|
canvasSize=vec2(512, 256))
|
|
@@ -31675,6 +32372,7 @@ class ParticleEmitter3D extends EngineObject3D
|
|
|
31675
32372
|
const out = this.particleData;
|
|
31676
32373
|
out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
|
|
31677
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;
|
|
31678
32376
|
if (view.destroyed)
|
|
31679
32377
|
out[k+17] = max(out[k+17], out[k+16]); // its life lived: a step short of it can round to just under
|
|
31680
32378
|
return result;
|
|
@@ -39876,8 +40574,9 @@ function editor3DCameraStart()
|
|
|
39876
40574
|
camera.pos = from.pos.copy();
|
|
39877
40575
|
camera.rotation = vec3(from.rotation.x, from.rotation.y, 0);
|
|
39878
40576
|
camera.fov = from.fov;
|
|
39879
|
-
camera.near = from.near;
|
|
39880
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);
|
|
39881
40580
|
}
|
|
39882
40581
|
|
|
39883
40582
|
// turn the free camera on or off, it needs a Render3DPlugin and takes the keyboard and mouse while it is on
|
|
@@ -39932,6 +40631,7 @@ function editor3DWithView(fn)
|
|
|
39932
40631
|
// called by the 3D pass before it works out its matrices: the frame is drawn with the editor's camera
|
|
39933
40632
|
function editor3DCameraBegin()
|
|
39934
40633
|
{
|
|
40634
|
+
editor3DCameraEnd(); // a frame that threw before its End gives the game its camera back first, not the editor's
|
|
39935
40635
|
if (!editor3DViewOn() || !editor3DCamera) return;
|
|
39936
40636
|
const r = render3D, camera = editor3DGameCamera = r.camera;
|
|
39937
40637
|
r.camera = editor3DCamera;
|
|
@@ -40264,8 +40964,17 @@ function editor3DSize(object)
|
|
|
40264
40964
|
const made = editor3DInstances.get(object.id), box = editor3DPrefabBox(made);
|
|
40265
40965
|
if (box) return box.size;
|
|
40266
40966
|
if (!(made instanceof EngineObject3D) || !(made.mesh || made.tileInfo)) return vec3(1);
|
|
40267
|
-
const s = made.size3D, k = made.scale3D;
|
|
40268
|
-
|
|
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));
|
|
40269
40978
|
}
|
|
40270
40979
|
|
|
40271
40980
|
// the box around a prefab's instance in the world, the boxes of its parts together, as its middle and its size;
|
|
@@ -41477,6 +42186,7 @@ function editor3DSetOpen(open)
|
|
|
41477
42186
|
if (open)
|
|
41478
42187
|
{
|
|
41479
42188
|
editor3DSetFreeCamera(false);
|
|
42189
|
+
pointerLockIsActive() && pointerLockExit(); // the game's captured mouse is let go, the editor needs it
|
|
41480
42190
|
editor3DLevel || editor3DLevelLoaded({}); // a new level, with nothing loaded
|
|
41481
42191
|
editor3DCamera || editor3DCameraStart();
|
|
41482
42192
|
editor3DGamePaused = paused;
|
|
@@ -42174,10 +42884,11 @@ function editor3DDragTo(drag, mouse, ray, snap)
|
|
|
42174
42884
|
}
|
|
42175
42885
|
if (factor)
|
|
42176
42886
|
{
|
|
42887
|
+
// snapped and kept from nothing by its size, its sign kept, so a mirrored axis stays mirrored
|
|
42177
42888
|
const size = snap ? editor3DScaleStep : 0, least = size || .01;
|
|
42889
|
+
const fit = (v)=> (v < 0 ? -1 : 1) * max(editor3DSnap(abs(v), size), least);
|
|
42178
42890
|
const scale = s.scale.multiply(factor);
|
|
42179
|
-
editor3DSetTransform(object, undefined, undefined, vec3(
|
|
42180
|
-
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)));
|
|
42181
42892
|
}
|
|
42182
42893
|
}
|
|
42183
42894
|
});
|
|
@@ -42698,8 +43409,9 @@ function editor3DDrawLabels()
|
|
|
42698
43409
|
const drag = editor3DDrag;
|
|
42699
43410
|
if (drag?.kind === 'box')
|
|
42700
43411
|
{
|
|
43412
|
+
// on the main canvas, over the WebGL one, so a 3D pass drawn after the 2D scene does not cover it
|
|
42701
43413
|
const {from: a, to: b} = drag, corners = [a, vec2(b.x, a.y), b, vec2(a.x, b.y)];
|
|
42702
|
-
corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0,
|
|
43414
|
+
corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0, false, true));
|
|
42703
43415
|
}
|
|
42704
43416
|
}
|
|
42705
43417
|
|