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.
@@ -11,6 +11,110 @@
11
11
 
12
12
  'use strict';
13
13
 
14
+ // 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
15
+ // through; both are where they were before this frame's moves, so it does not matter which updated first
16
+ function engineObjectOneWayPass(solid, solidPos, from, fromSize)
17
+ {
18
+ const way = solid.oneWay, size = solid.size, epsilon = 1e-3;
19
+ return way.x > .5 ? from.x - fromSize.x/2 < solidPos.x + size.x/2 - epsilon :
20
+ way.x < -.5 ? from.x + fromSize.x/2 > solidPos.x - size.x/2 + epsilon :
21
+ way.y > .5 ? from.y - fromSize.y/2 < solidPos.y + size.y/2 - epsilon :
22
+ from.y + fromSize.y/2 > solidPos.y - size.y/2 + epsilon;
23
+ }
24
+
25
+ // 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
26
+ // whether it goes in through a side, x, or the top or bottom, the axis whose slab it enters last; one starting on a
27
+ // face is on that face, as its entry there is 0 and the other's is before it
28
+ function engineObjectRayEntry(start, end, cell)
29
+ {
30
+ const dx = end.x - start.x, dy = end.y - start.y;
31
+ const tx = dx ? min((cell.x - start.x) / dx, (cell.x + 1 - start.x) / dx) : -Infinity;
32
+ const ty = dy ? min((cell.y - start.y) / dy, (cell.y + 1 - start.y) / dy) : -Infinity;
33
+ const t = clamp(max(tx, ty));
34
+ return {pos: vec2(start.x + dx*t, start.y + dy*t), side: tx >= ty};
35
+ }
36
+
37
+ // a bullet's move from one point to another through the tiles: the first tile its collideWithTile takes, nearest
38
+ // first across the solid layers, with where the move goes into it, the face's normal and the layer; undefined for none
39
+ // - 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
40
+ // 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
41
+ // - collideWithTile is asked with the bullet where the move goes into the tile
42
+ // - only the stretches of the move near a solid layer are walked, nearest first and joined where they meet, so a long
43
+ // move through empty space, or between layers far apart, costs nothing; the layers are whole numbers, so the cells
44
+ // are the same as over the whole move
45
+ /** @param {EngineObject} object
46
+ * @param {Vector2} from
47
+ * @param {Vector2} to
48
+ * @return {{pos: Vector2, normal: Vector2, layer: TileCollisionLayer}|undefined} */
49
+ function engineObjectBulletSweep(object, from, to)
50
+ {
51
+ const move = to.subtract(from), length = move.length();
52
+ if (!length) return;
53
+ const back = from.subtract(move.scale(1e-6 / length)), startX = floor(back.x), startY = floor(back.y);
54
+
55
+ /** @type {{pos: Vector2, normal: Vector2, layer: TileCollisionLayer}|undefined} */
56
+ let hit;
57
+ const testCell = (cell)=>
58
+ {
59
+ // a bullet its collideWithTile destroyed is asked about nothing more
60
+ if (cell.x === startX && cell.y === startY || object.destroyed) return false;
61
+ for (const layer of tileCollisionLayers)
62
+ {
63
+ if (!layer.isSolid) continue;
64
+ const x = cell.x - layer.pos.x, y = cell.y - layer.pos.y;
65
+ if (x < 0 || y < 0 || x >= layer.size.x || y >= layer.size.y) continue;
66
+ const data = layer.collisionData[y*layer.size.x + x];
67
+ if (!data || tileCollisionOneWayPass(layer, x, y, from.x, from.y)) continue;
68
+ const tilePos = vec2(cell.x, cell.y), entry = engineObjectRayEntry(from, to, tilePos);
69
+ object.pos = entry.pos;
70
+ if (!object.collideWithTile(data, tilePos))
71
+ {
72
+ if (object.destroyed) return false; // nor the other layers' tiles in this cell
73
+ continue;
74
+ }
75
+ const normal = entry.side ? vec2(-sign(move.x), 0) : vec2(0, -sign(move.y));
76
+ hit = {pos: entry.pos, normal, layer};
77
+ return true;
78
+ }
79
+ return false;
80
+ };
81
+
82
+ const stretches = [];
83
+ for (const layer of tileCollisionLayers)
84
+ {
85
+ if (!layer.isSolid) continue;
86
+ tileCollisionAssertWhole(layer);
87
+ let t0 = 0, t1 = 1;
88
+ for (const [p, d, a, b] of [[from.x, move.x, layer.pos.x - 1, layer.pos.x + layer.size.x + 1],
89
+ [from.y, move.y, layer.pos.y - 1, layer.pos.y + layer.size.y + 1]])
90
+ {
91
+ if (!d)
92
+ {
93
+ if (p < a || p > b) t0 = 2;
94
+ continue;
95
+ }
96
+ const u = (a - p) / d, v = (b - p) / d;
97
+ t0 = max(t0, min(u, v)), t1 = min(t1, max(u, v));
98
+ }
99
+ t0 < t1 && stretches.push([t0, t1]);
100
+ }
101
+ stretches.sort((a, b)=> a[0] - b[0]);
102
+ const at = (t)=> t <= 0 ? from : t >= 1 ? to : from.add(move.scale(t));
103
+ for (let i = 0; i < stretches.length && !hit;)
104
+ {
105
+ const [t0] = stretches[i];
106
+ let t1 = stretches[i][1];
107
+ while (++i < stretches.length && stretches[i][0] <= t1)
108
+ t1 = max(t1, stretches[i][1]);
109
+ lineTest(at(t0), at(t1), testCell);
110
+ }
111
+ return hit;
112
+ }
113
+
114
+ // 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
115
+ // it, or where it is when updatePhysics is called on its own
116
+ function engineObjectStartPos(o) { return engineObjectsOneWayStart?.get(o) || o.pos; }
117
+
14
118
  /**
15
119
  * LittleJS Object Base Object Class
16
120
  * - Top level object class used by the engine
@@ -122,9 +226,27 @@ class EngineObject
122
226
  this.children = [];
123
227
  /** @property {boolean} - Limit object speed along x and y axis */
124
228
  this.clampSpeed = true;
229
+ /** @property {boolean} - Move through the tiles as a point along a ray, so it can not pass through one at
230
+ * any speed, for small fast things like bullets: its speed is not held to objectMaxSpeed for the tiles,
231
+ * collideWithTile is asked with this.pos where the ray goes into the tile, and on a hit it stops at the
232
+ * surface, bounces off it by restitution and goes on with the rest of its move, so it slides along a floor
233
+ * - Its collision with solid objects is the usual one, so one with a size that collides with them is still held
234
+ * to objectMaxSpeed; one of no size is not, it never collides with them
235
+ * - this.pos is set for each tile asked about and put where the move ends after, so a change collideWithTile
236
+ * makes to it is not kept; move it after the physics, in update
237
+ * - Once destroyed, as by its own collideWithTile, it is asked about no more tiles that frame, where an object
238
+ * that is not a bullet still is */
239
+ this.isBullet = false;
125
240
  /** @property {EngineObject|undefined} - Object we are standing on, if any
126
241
  * @type {EngineObject|undefined} */
127
242
  this.groundObject = undefined;
243
+ /** @property {Vector2|undefined} - Makes a solid object one way, like a platform jumped up through: up, down,
244
+ * left or right, the way others pass through it; it blocks only what was wholly on its far side before it
245
+ * moved, or stood on it, and the rest are not stopped or asked through collideWithObject; with no mass it
246
+ * moves before the other objects each frame, so a lift works the same at any render order; tiles are made one
247
+ * way by the layer's setOneWay
248
+ * @type {Vector2|undefined} */
249
+ this.oneWay = undefined;
128
250
 
129
251
  // parent child system
130
252
  /** @property {EngineObject|undefined} - Parent of object if in local space
@@ -212,7 +334,8 @@ class EngineObject
212
334
  // limit max speed to prevent missing collisions, after gravity so no move is past it, only for what collides:
213
335
  // with solids, or with tiles while it has a mass, which tile collision needs; anything else moves as fast as
214
336
  // it is told
215
- if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
337
+ if (this.clampSpeed && enablePhysicsSolver &&
338
+ (this.collideSolidObjects && this.size.x && this.size.y || this.collideLevel && this.mass && !this.isBullet))
216
339
  {
217
340
  this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
218
341
  this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
@@ -227,6 +350,7 @@ class EngineObject
227
350
  // which way is down for this object, a negative gravityScale falls up and lands on ceilings
228
351
  const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
229
352
  const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
353
+ const wasOn = this.groundObject; // a one way solid it stood on still holds it
230
354
  if (this.groundObject)
231
355
  {
232
356
  // apply friction in local space of ground object
@@ -253,6 +377,11 @@ class EngineObject
253
377
  // check collision
254
378
  if (!this.isOverlappingObject(o)) continue;
255
379
 
380
+ // a one way solid lets through what was not wholly on its far side before it moved, either way round
381
+ if (o.oneWay && wasOn !== o && engineObjectOneWayPass(o, engineObjectStartPos(o), oldPos, this.size))
382
+ continue;
383
+ if (this.oneWay && engineObjectOneWayPass(this, oldPos, engineObjectStartPos(o), o.size)) continue;
384
+
256
385
  // each moving object checks its own contacts, so a pair the other one already asked about this frame
257
386
  // is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
258
387
  // to resolve, pushed back together since, is resolved again
@@ -387,71 +516,121 @@ class EngineObject
387
516
  }
388
517
  if (this.collideLevel)
389
518
  {
390
- // check collision against tiles
391
- const hitLayer = tileCollisionTest(this.pos, this.size, this);
392
- if (hitLayer)
519
+ // check collision against tiles, one way tiles go by where it was; put back after, a callback may throw
520
+ const fromObject = tileCollisionFromObject, fromPos = tileCollisionFromPos;
521
+ tileCollisionFromObject = this, tileCollisionFromPos = oldPos;
522
+ try
393
523
  {
394
- // if already was stuck in collision, don't do anything
395
- // this should not happen unless something starts in collision
396
- if (!tileCollisionTest(oldPos, this.size, this))
397
- {
398
- // test which side we bounced off (or both if a corner)
399
- const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
400
- const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
401
- const restitution = max(this.restitution, hitLayer.restitution);
402
- if (isBlockedX)
403
- {
404
- // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
405
- // down off a ceiling ledge when gravity points up; zero gravity counts as down
406
- const epsilon = 1e-3;
407
- const maxMove = .1;
408
- const y = gravityY > 0 ?
409
- ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
410
- floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
411
- if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
412
- {
413
- this.pos.y = y;
414
- debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
415
- return;
416
- }
524
+ this.isBullet ? this.updatePhysicsBullet(oldPos, gravityY, wasFalling) :
525
+ this.updatePhysicsTiles(oldPos, gravityY, wasFalling);
526
+ }
527
+ finally { tileCollisionFromObject = fromObject, tileCollisionFromPos = fromPos; }
528
+ }
529
+ }
417
530
 
418
- // move against the wall and bounce, its side on the tile edge it moved toward, rounded in the
419
- // layer's space as its collision test is; back to its previous X when that spot is not clear
420
- const snapEpsilon = .0001, layerX = hitLayer.pos.x, offsetX = this.size.x/2 + snapEpsilon;
421
- // never back past where it was: a leading edge already on a grid line would step back by
422
- // the epsilon and take its trailing edge into the tile behind, which nothing tests, since
423
- // only X is blocked; between there and the wall it covers no column it did not cover before
424
- const movingLeft = this.pos.x < oldPos.x;
425
- const snap = layerX + (movingLeft ?
426
- floor(oldPos.x - layerX - this.size.x/2) + offsetX :
427
- ceil( oldPos.x - layerX + this.size.x/2) - offsetX);
428
- const x = movingLeft ? min(snap, oldPos.x) : max(snap, oldPos.x);
429
- this.pos.x = tileCollisionTest(vec2(x, oldPos.y), this.size, this) ? oldPos.x : x;
430
- this.velocity.x *= -restitution;
431
- }
432
- if (isBlockedY || !isBlockedX)
531
+ // a bullet's move from oldPos through the tiles, as a point along a ray, called by updatePhysics
532
+ /** @private */
533
+ updatePhysicsBullet(oldPos, gravityY, wasFalling)
534
+ {
535
+ // up to three legs: the move, then what is left of it after each hit, along the surface or bounced off it as
536
+ // its velocity is, so one rolling along the floor under gravity keeps going as a box does
537
+ let from = oldPos, to = this.pos.copy();
538
+ for (let leg = 0; leg < 3; ++leg)
539
+ {
540
+ const hit = engineObjectBulletSweep(this, from, to);
541
+ if (!hit)
542
+ {
543
+ this.pos = to;
544
+ break;
545
+ }
546
+
547
+ // stop at the surface, a hair off it, more than a one way tile's margin, so going on along a ceiling a
548
+ // one way tile it rose through lets it on; bounce off it by the more bouncy of it and the layer
549
+ const {normal, layer} = hit, restitution = max(this.restitution, layer.restitution);
550
+ const stop = hit.pos.add(normal.scale(2e-3));
551
+ const into = this.velocity.dot(normal);
552
+ if (into < 0)
553
+ this.velocity = this.velocity.subtract(normal.scale(into * (1 + restitution)));
554
+ if (normal.y * gravityY < 0 && wasFalling) // landed, against its gravity
555
+ this.groundObject = layer;
556
+ this.pos = stop;
557
+
558
+ // the rest of the move from there, turned the same way; none once its callback destroyed it
559
+ const rest = to.subtract(hit.pos), restInto = rest.dot(normal);
560
+ const next = stop.add(restInto < 0 ? rest.subtract(normal.scale(restInto * (1 + restitution))) : rest);
561
+ if (this.destroyed || next.distanceSquared(stop) < 1e-12) break;
562
+ from = stop, to = next;
563
+ }
564
+ debugPhysics && debugPoint(this.pos, '#f00', undefined, undefined, false);
565
+ }
566
+
567
+ // resolve the tile collision of a move from oldPos, called by updatePhysics
568
+ /** @private */
569
+ updatePhysicsTiles(oldPos, gravityY, wasFalling)
570
+ {
571
+ const hitLayer = tileCollisionTest(this.pos, this.size, this);
572
+ if (hitLayer)
573
+ {
574
+ // if already was stuck in collision, don't do anything
575
+ // this should not happen unless something starts in collision
576
+ if (!tileCollisionTest(oldPos, this.size, this))
577
+ {
578
+ // test which side we bounced off (or both if a corner)
579
+ const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
580
+ const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
581
+ const restitution = max(this.restitution, hitLayer.restitution);
582
+ if (isBlockedX)
583
+ {
584
+ // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
585
+ // down off a ceiling ledge when gravity points up; zero gravity counts as down
586
+ const epsilon = 1e-3;
587
+ const maxMove = .1;
588
+ const y = gravityY > 0 ?
589
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
590
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
591
+ if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
433
592
  {
434
- // adjust position to slightly away from the nearest tile, the floor or the ceiling it moved
435
- // toward, which prevents a gap between them; rounded in the layer's space as its collision
436
- // test is, or a bottom a hair below a grid line would round to the row under it, inside the
437
- // floor, and fall through; back to its previous Y when that spot is not clear
438
- const epsilon = .0001;
439
- const offset = this.size.y/2 + epsilon;
440
- const layerY = hitLayer.pos.y;
441
- const y = layerY + (this.pos.y < oldPos.y ?
442
- floor(oldPos.y - layerY - this.size.y/2) + offset :
443
- ceil( oldPos.y - layerY + this.size.y/2) - offset);
444
- const isClear = this.pos.y !== oldPos.y && !tileCollisionTest(vec2(this.pos.x, y), this.size, this);
445
- this.pos.y = isClear ? y : oldPos.y;
446
-
447
- // set ground object for tile collision
448
- this.groundObject = wasFalling ? hitLayer : undefined;
449
-
450
- // bounce velocity
451
- this.velocity.y *= -restitution;
593
+ this.pos.y = y;
594
+ debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
595
+ return;
452
596
  }
453
- debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
597
+
598
+ // move against the wall and bounce, its side on the tile edge it moved toward, rounded in the
599
+ // layer's space as its collision test is; back to its previous X when that spot is not clear
600
+ const snapEpsilon = .0001, layerX = hitLayer.pos.x, offsetX = this.size.x/2 + snapEpsilon;
601
+ // never back past where it was: a leading edge already on a grid line would step back by
602
+ // the epsilon and take its trailing edge into the tile behind, which nothing tests, since
603
+ // only X is blocked; between there and the wall it covers no column it did not cover before
604
+ const movingLeft = this.pos.x < oldPos.x;
605
+ const snap = layerX + (movingLeft ?
606
+ floor(oldPos.x - layerX - this.size.x/2) + offsetX :
607
+ ceil( oldPos.x - layerX + this.size.x/2) - offsetX);
608
+ const x = movingLeft ? min(snap, oldPos.x) : max(snap, oldPos.x);
609
+ this.pos.x = tileCollisionTest(vec2(x, oldPos.y), this.size, this) ? oldPos.x : x;
610
+ this.velocity.x *= -restitution;
611
+ }
612
+ if (isBlockedY || !isBlockedX)
613
+ {
614
+ // adjust position to slightly away from the nearest tile, the floor or the ceiling it moved
615
+ // toward, which prevents a gap between them; rounded in the layer's space as its collision
616
+ // test is, or a bottom a hair below a grid line would round to the row under it, inside the
617
+ // floor, and fall through; back to its previous Y when that spot is not clear
618
+ const epsilon = .0001;
619
+ const offset = this.size.y/2 + epsilon;
620
+ const layerY = hitLayer.pos.y;
621
+ const y = layerY + (this.pos.y < oldPos.y ?
622
+ floor(oldPos.y - layerY - this.size.y/2) + offset :
623
+ ceil( oldPos.y - layerY + this.size.y/2) - offset);
624
+ const isClear = this.pos.y !== oldPos.y && !tileCollisionTest(vec2(this.pos.x, y), this.size, this);
625
+ this.pos.y = isClear ? y : oldPos.y;
626
+
627
+ // set ground object for tile collision
628
+ this.groundObject = wasFalling ? hitLayer : undefined;
629
+
630
+ // bounce velocity
631
+ this.velocity.y *= -restitution;
454
632
  }
633
+ debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
455
634
  }
456
635
  }
457
636
  }
@@ -519,10 +698,10 @@ class EngineObject
519
698
  /** 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
520
699
  * - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
521
700
  * positions it only tries, so keep it free of side effects or guard them to once a frame
522
- * - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
523
- * saved in update, as the platformer example does
524
- * - For the point of impact, like sparks where a bullet hit, raycast from where this frame's move started,
525
- * this.pos minus this.velocity, to this.pos with tileCollisionRaycast, as the platformer's Bullet does
701
+ * - this.pos has already moved, so a check on where it came from needs the position saved in update; for a one
702
+ * way platform the layer's setOneWay does that, and this is not asked about a one way tile it passes through
703
+ * - For the point of impact, like sparks where a bullet hit, set isBullet: this.pos is then where it meets the
704
+ * tile, as the platformer's Bullet uses it
526
705
  * @param {number} tileData - the value of the tile at the position
527
706
  * @param {Vector2} pos - the tile's bottom left corner in world space
528
707
  * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
@@ -440,10 +440,13 @@ function particleTileSet(tileInfo)
440
440
  }
441
441
  }
442
442
 
443
+ // where the particle being tested was before its move, which one way tiles go by
444
+ let particleCollideFromX = 0, particleCollideFromY = 0;
445
+
443
446
  // tests if a particle collides with tiles at x, y, through its emitter's collide callback if it has one
444
447
  function particleCollideTest(particle, collideCallback, x, y)
445
448
  {
446
- const data = tileCollisionGetData(particleCollidePos.set(x, y));
449
+ const data = tileCollisionGetDataFrom(particleCollidePos.set(x, y), particleCollideFromX, particleCollideFromY);
447
450
  if (!collideCallback)
448
451
  return data > 0;
449
452
 
@@ -575,10 +578,12 @@ class Particle
575
578
 
576
579
  // check collision against tiles
577
580
  this.groundObject = undefined;
581
+ particleCollideFromX = oldX, particleCollideFromY = oldY;
578
582
  if (particleCollideTest(this, collideCallback, this.pos.x, this.pos.y))
579
583
  {
580
- // if already was stuck in collision, don't do anything
581
- const hitLayer = tileCollisionTest(this.pos);
584
+ // the layer that stopped it, as the test found it, a one way layer it passes is not; if already was stuck
585
+ // in collision, don't do anything
586
+ const hitLayer = tileCollisionDataLayer;
582
587
  if (!particleCollideTest(this, collideCallback, oldX, oldY))
583
588
  {
584
589
  // test which side we bounced off (or both if a corner)
@@ -47,6 +47,55 @@ const tileCollisionLayers = [];
47
47
  function tileCollisionAssertWhole(layer)
48
48
  { ASSERT(layer.pos.x % 1 === 0 && layer.pos.y % 1 === 0, 'a tile collision layer must sit at a whole number position', layer.pos); }
49
49
 
50
+ // the object the physics is moving and where it was before this frame's move, for one way tiles
51
+ let tileCollisionFromObject, tileCollisionFromPos;
52
+
53
+ // 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
54
+ // wholly on its solid side, the side it can be passed through toward; the way is turned and mirrored as the tile's
55
+ // art is, mirror first, then its quarter turns clockwise; a point is a box of no size
56
+ function tileCollisionOneWayPass(layer, x, y, fromX, fromY, sizeX=0, sizeY=0)
57
+ {
58
+ const oneWayTiles = layer.oneWayTiles;
59
+ if (!oneWayTiles.size) return false;
60
+ const data = layer.data[y*layer.size.x + x], way = data && oneWayTiles.get(data.tile);
61
+ if (!way) return false;
62
+ let wayX = data.mirror ? -way.x : way.x, wayY = way.y;
63
+ for (let turn = data.direction & 3; turn--;)
64
+ [wayX, wayY] = [wayY, -wayX];
65
+ const epsilon = 1e-3, cellX = layer.pos.x + x, cellY = layer.pos.y + y;
66
+ return wayX > .5 ? fromX - sizeX/2 < cellX + 1 - epsilon :
67
+ wayX < -.5 ? fromX + sizeX/2 > cellX + epsilon :
68
+ wayY > .5 ? fromY - sizeY/2 < cellY + 1 - epsilon :
69
+ fromY + sizeY/2 > cellY + epsilon;
70
+ }
71
+
72
+ // the layer whose tile tileCollisionGetDataFrom last found solid, undefined for none, the one a particle bounces off
73
+ let tileCollisionDataLayer;
74
+
75
+ // tileCollisionGetData of the solid layers for particles, which pass a one way tile when they were not on its far
76
+ // side at from: a solid tile's data, else a negative marker, else 0
77
+ function tileCollisionGetDataFrom(pos, fromX, fromY)
78
+ {
79
+ let found = 0;
80
+ tileCollisionDataLayer = undefined;
81
+ for (const layer of tileCollisionLayers)
82
+ if (layer.isSolid)
83
+ {
84
+ const x = pos.x - layer.pos.x, y = pos.y - layer.pos.y, size = layer.size;
85
+ if (x >= 0 && y >= 0 && x < size.x && y < size.y)
86
+ {
87
+ const data = layer.collisionData[(y|0)*size.x + (x|0)];
88
+ if (data > 0)
89
+ {
90
+ if (!tileCollisionOneWayPass(layer, x|0, y|0, fromX, fromY))
91
+ return tileCollisionDataLayer = layer, data;
92
+ }
93
+ else if (data && !found) found = data;
94
+ }
95
+ }
96
+ return found;
97
+ }
98
+
50
99
  // the test a tile query applies to a cell's data: the callback, the object's collideWithTile, or solid data
51
100
  function tileCollisionTester(callbackObject)
52
101
  {
@@ -821,13 +870,17 @@ class TileLayer extends CanvasLayer
821
870
  * - This may be slow if not using webgl but only needs to be done once */
822
871
  redraw()
823
872
  {
873
+ // the camera, canvas and target are the game's again even when a tile draw or onRedraw throws
824
874
  this.redrawStart(true);
825
- for (let x = this.size.x; x--;)
826
- for (let y = this.size.y; y--;)
827
- this.drawTileData(vec2(x,y), false);
828
- this.isUsingWebGL && glFlush();
829
- this.onRedraw();
830
- this.redrawEnd();
875
+ try
876
+ {
877
+ for (let x = this.size.x; x--;)
878
+ for (let y = this.size.y; y--;)
879
+ this.drawTileData(vec2(x,y), false);
880
+ this.isUsingWebGL && glFlush();
881
+ this.onRedraw();
882
+ }
883
+ finally { this.redrawEnd(); }
831
884
  this.tilesInWebGL = this.isUsingWebGL;
832
885
  }
833
886
 
@@ -940,8 +993,8 @@ class TileLayer extends CanvasLayer
940
993
  ASSERT(drawContext !== this.context, 'redrawStart() should not be active when calling redrawTileData(), instead use drawTileData()');
941
994
 
942
995
  this.redrawStart();
943
- this.drawTileData(layerPos, clear);
944
- this.redrawEnd();
996
+ try { this.drawTileData(layerPos, clear); }
997
+ finally { this.redrawEnd(); }
945
998
  }
946
999
 
947
1000
  /** Draw textured tile in layer space
@@ -1065,6 +1118,31 @@ class TileCollisionLayer extends TileLayer
1065
1118
  /** @property {boolean} - In the light system's shadow pass, cast only from the cells with collision, drawn
1066
1119
  * as the layer shows them, so a floor in the same layer stays lit; false casts every tile */
1067
1120
  this.shadowSolidOnly = true;
1121
+ /** @property {Map<number, Vector2>} - The tiles set one way, by tile index, and the way each can be passed
1122
+ * through, see setOneWay
1123
+ * @type {Map<number, Vector2>} */
1124
+ this.oneWayTiles = new Map;
1125
+ }
1126
+
1127
+ /** Make the cells that draw a tile one way, like a platform jumped up through and landed on: an object, a
1128
+ * particle or a ray passes through moving that way, and the tile blocks only what was wholly on its far side
1129
+ * before it moved, the side it is passed toward; turned and mirrored as each cell's tile is, so a platform turned
1130
+ * a quarter is one way to the side
1131
+ * - A moving object is not stopped or asked through collideWithTile until the tile would block it, so a
1132
+ * collideWithTile that returns false lets it drop through
1133
+ * - tileCollisionTest with an object goes by the object's pos, a raycast by its start, and a test with no object
1134
+ * or with a callback finds every one way tile solid
1135
+ * @param {number} tile - The tile index, as tile() and TileLayerData take it
1136
+ * @param {Vector2} [direction] - Up, down, left or right, the way it can be passed through: vec2(0, 1), the
1137
+ * default, is a platform landed on from above; layer.oneWayTiles.delete(tile) makes the tile solid again
1138
+ * @example
1139
+ * layer.setOneWay(5); // tile 5 is a platform, jumped up through and stood on */
1140
+ setOneWay(tile, direction=vec2(0, 1))
1141
+ {
1142
+ ASSERT(isNumber(tile), 'setOneWay: tile is a tile index, a number', tile);
1143
+ ASSERT(isVector2(direction) && abs(direction.x) + abs(direction.y) === 1 && !(direction.x && direction.y),
1144
+ 'setOneWay: direction is up, down, left or right, like vec2(0, 1)', direction);
1145
+ this.oneWayTiles.set(tile, direction.copy());
1068
1146
  }
1069
1147
 
1070
1148
  /** Draw this layer's shadow shape: the cells with collision in the part the shadow map covers, each row of
@@ -1156,6 +1234,10 @@ class TileCollisionLayer extends TileLayer
1156
1234
  tileCollisionAssertWhole(this);
1157
1235
  const collisionTest = tileCollisionTester(callbackObject);
1158
1236
 
1237
+ // a one way tile goes by where an object was before the physics moved it, or where it is now
1238
+ const object = callbackObject instanceof EngineObject && this.oneWayTiles.size ? callbackObject : undefined;
1239
+ const from = object && (object === tileCollisionFromObject ? tileCollisionFromPos : object.pos);
1240
+
1159
1241
  // check any tiles in the area for collision
1160
1242
  const posX = pos.x - this.pos.x;
1161
1243
  const posY = pos.y - this.pos.y;
@@ -1174,7 +1256,8 @@ class TileCollisionLayer extends TileLayer
1174
1256
  {
1175
1257
  // check if the object should collide with this tile, the callback gets its own vector, one it can keep
1176
1258
  const tileData = this.collisionData[y*this.size.x+x];
1177
- if (tileData && collisionTest(tileData, vec2(x+this.pos.x, y+this.pos.y)))
1259
+ if (tileData && !(from && tileCollisionOneWayPass(this, x, y, from.x, from.y, size.x, size.y)) &&
1260
+ collisionTest(tileData, vec2(x+this.pos.x, y+this.pos.y)))
1178
1261
  return true;
1179
1262
  }
1180
1263
  return false;
@@ -1194,10 +1277,12 @@ class TileCollisionLayer extends TileLayer
1194
1277
  const collisionTest = tileCollisionTester(callbackObject);
1195
1278
  // the line is walked in the layer's own space, so its cells are the tiles wherever the layer sits
1196
1279
  const offset = this.pos;
1280
+ // a one way tile blocks a ray that starts on its far side
1197
1281
  const testFunction = (pos)=>
1198
1282
  {
1199
1283
  const tileData = this.getCollisionData(pos);
1200
- return tileData && collisionTest(tileData, vec2(pos.x + offset.x, pos.y + offset.y));
1284
+ return tileData && !tileCollisionOneWayPass(this, pos.x|0, pos.y|0, posStart.x, posStart.y) &&
1285
+ collisionTest(tileData, vec2(pos.x + offset.x, pos.y + offset.y));
1201
1286
  }
1202
1287
  // only the part of the line over the layer, and a cell around it, can meet a tile, so a ray toward a point
1203
1288
  // far beyond is walked across the layer and no farther; the cell around keeps the step into the layer as it was
@@ -530,15 +530,15 @@ function glShaderTrack(shader) { glShaderObjects.includes(shader) || glShaderObj
530
530
  // 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
531
531
  function glShaderDispose(shader)
532
532
  {
533
+ // a batch drawing with it is drawn first, the batch holds the Shader; its first draw compiles it and lists it, so
534
+ // it comes off the list after
535
+ if (glContext && !headlessMode && glBatchShader === shader)
536
+ glFlush();
533
537
  const i = glShaderObjects.indexOf(shader);
534
538
  i < 0 || glShaderObjects.splice(i, 1);
535
539
  if (glContext && !headlessMode)
536
- {
537
- if (glBatchShader === shader) // a batch drawing with it is drawn first, the batch holds the Shader
538
- glFlush();
539
540
  for (const program of [shader.program, shader.program3D])
540
541
  program && glContext.deleteProgram(program);
541
- }
542
542
  shader.program = shader.program3D = undefined;
543
543
  }
544
544