Allow player to be pushed off moving blocks

This commit is contained in:
James Campbell
2026-07-25 13:13:48 -04:00
parent 3ad36d2911
commit 8e077ff09d
6 changed files with 75 additions and 13 deletions

View File

@@ -166,6 +166,7 @@ position (so a moving trap's sensors follow it):
| `{ dir: left\|right\|above\|below }` | player is on that side | | `{ dir: left\|right\|above\|below }` | player is on that side |
| `{ dir: …, range: N }` | …and within N tiles that way | | `{ dir: …, range: N }` | …and within N tiles that way |
| `{ dir: …, aligned: true }` | …and overlapping on the perpendicular axis (i.e. *directly* left / *directly* above) | | `{ dir: …, aligned: true }` | …and overlapping on the perpendicular axis (i.e. *directly* left / *directly* above) |
| `{ dir: …, inclusive: true }` | …also count the trap's own tile as being on that side (default `false`: the player must be strictly past the near edge) |
| `{ timer: { interval: A, up_time: B } }` | cyclic: off A seconds, on B seconds | | `{ timer: { interval: A, up_time: B } }` | cyclic: off A seconds, on B seconds |
| `{ all: [ … ] }` | every listed condition (AND) | | `{ all: [ … ] }` | every listed condition (AND) |
| `{ any: [ … ] }` | any listed condition (OR) | | `{ any: [ … ] }` | any listed condition (OR) |
@@ -186,8 +187,11 @@ countdown. Handy for "linger and it strikes" spikes.
You also die if a moving (non-deadly) `block` **pinches** you: presses you You also die if a moving (non-deadly) `block` **pinches** you: presses you
against a solid surface — squished into the ground from above, carried up into a against a solid surface — squished into the ground from above, carried up into a
ceiling, or ridden sideways into a wall. During the death pause the whole scene ceiling, or driven sideways into a wall by a block hitting you from the side.
freezes, so the offending block stops on the spot until you respawn. Riding a platform horizontally into a wall is *not* a crush, though: you simply
stop at the wall while the platform slides on under your feet. During the death
pause the whole scene freezes, so the offending block stops on the spot until
you respawn.
### Mounting traps on other traps ### Mounting traps on other traps

View File

@@ -152,9 +152,13 @@ resolution), and whether a moving carrier is closing in on the appropriate side:
- Moving down onto a floored player (backed below + pinned) → crush. - Moving down onto a floored player (backed below + pinned) → crush.
- Moving up into a player under a ceiling (backed above + pinned) → crush. - Moving up into a player under a ceiling (backed above + pinned) → crush.
- Moving horizontally into a player backed by a wall → crush. - Moving horizontally into a player backed by a wall → crush.
- Being carried sideways into a wall (via the `carry` direction) → crush. - Being carried *up* into a ceiling while riding a platform → crush (the rising
carrier under the feet is caught by the "moving up" case above).
A block that stops with the player fitting underneath does **not** crush A block that stops with the player fitting underneath does **not** crush
(no residual overlap → not pinned). (no residual overlap → not pinned). Riding a platform *horizontally* into a wall
is **not** a crush: the carrier is under the feet, perpendicular to the wall, so
it can't pin the player against it — the X pass just stops them at the wall edge
while the platform slides on underneath.
### 5.7 Death conditions (in `playing`) ### 5.7 Death conditions (in `playing`)
- Battery ≤ 0. - Battery ≤ 0.
@@ -326,6 +330,7 @@ evaluated against the player each frame relative to the trap's `sensor_rect()`.
| `{ dir: left\|right\|above\|below }` | player is on that side | | `{ dir: left\|right\|above\|below }` | player is on that side |
| `{ dir: …, range: N }` | …and within N tiles in that direction | | `{ dir: …, range: N }` | …and within N tiles in that direction |
| `{ dir: …, aligned: true }` | …and overlapping on the perpendicular axis (directly left / directly above, etc.) | | `{ dir: …, aligned: true }` | …and overlapping on the perpendicular axis (directly left / directly above, etc.) |
| `{ dir: …, inclusive: true }` | …count the trap's own tile as being on that side (default false: the player must be strictly past the near edge) |
| `{ timer: { interval: A, up_time: B } }` | cyclic: off for A s, then on for B s, repeating | | `{ timer: { interval: A, up_time: B } }` | cyclic: off for A s, then on for B s, repeating |
| `{ all: [ … ] }` | AND of sub-conditions | | `{ all: [ … ] }` | AND of sub-conditions |
| `{ any: [ … ] }` | OR of sub-conditions | | `{ any: [ … ] }` | OR of sub-conditions |

View File

@@ -127,14 +127,12 @@ class Player:
if dx < 0 and bl and r.colliderect(right): # pushed left into a wall if dx < 0 and bl and r.colliderect(right): # pushed left into a wall
return True return True
# Riding a platform that carries us *sideways* into a wall: the pushing # A platform that carries us *sideways* into a wall is NOT a crush: the
# block is under our feet, so the side-probes above miss it — use the # carrier is under our feet, perpendicular to the wall, so it can't pinch
# carry direction instead. (Vertical carry crushes are caught above.) # us against it. The X pass simply stops us at the wall edge while the
cdx, _ = self.carry # platform keeps sliding underneath. (A genuine horizontal crush — a
if cdx > 0 and br: # mover closing on our side against a backing wall — is the dx cases
return True # above. Vertical carry crushes are the dy cases above.)
if cdx < 0 and bl:
return True
return False return False
def _ride_platforms(self): def _ride_platforms(self):

View File

@@ -83,7 +83,7 @@ class _Directional:
if self.aligned and not (p.bottom > r.top and p.top < r.bottom): if self.aligned and not (p.bottom > r.top and p.top < r.bottom):
return False return False
if d == "left": if d == "left":
ref = r.right if self.inclusive else r.right ref = r.right if self.inclusive else r.left
if p.centerx >= ref: if p.centerx >= ref:
return False return False
dist = ref - p.centerx dist = ref - p.centerx

View File

@@ -113,6 +113,46 @@ traps:
assert g.player.rect.x < start_x # got shoved along assert g.player.rect.x < start_x # got shoved along
def test_ride_into_wall_stops_no_crush(make_game):
# Riding a horizontally-moving platform into a stationary wall must NOT
# crush the player: they stop at the wall while the block slides underneath.
# The wall sits one row *above* the platform's track (col 8, row 2), so it
# catches the player's body while the platform passes below it.
g = make_game("""
name: t
tile_size: 32
map: |
###########
#.........#
#.......#.#
#.........#
#........G#
###########
traps:
- type: block
at: [1, 3]
move: [8, 0]
speed: 200
trigger: always
""")
# stand the player on top of the platform at its left end (body in row 2)
place(g, 1, 2)
g.player.fy = float(3 * 32 - g.player.h) # feet on the block's top
g.player._sync_rect()
wall_left = 8 * 32
hit_wall = False
for _ in range(180):
step(g)
if g.state != "playing":
break
assert not g.player.crushed # never crushed
assert g.player.rect.right <= wall_left + 1 # stopped at the wall
if g.player.rect.right >= wall_left - 1:
hit_wall = True
assert g.state == "playing" # survived the whole time
assert hit_wall # actually reached the wall
def test_corner_clip_does_not_warp(make_game): def test_corner_clip_does_not_warp(make_game):
# Walking into a moving block near its corner must not fling the player # Walking into a moving block near its corner must not fling the player
# across it (the old velocity-sign resolver bug). # across it (the old velocity-sign resolver bug).

View File

@@ -41,6 +41,21 @@ def test_dir_aligned():
assert ev({"dir": "above"}, 400, 60) is True assert ev({"dir": "above"}, 400, 60) is True
def test_dir_inclusive():
# trap column spans x 100..132 (centre 116). A player standing *inside* the
# column (centre 116) is neither strictly left nor strictly right of it.
assert ev({"dir": "left"}, 106, 105) is False # centre 116, inside -> not left
assert ev({"dir": "right"}, 106, 105) is False # inside -> not right
# inclusive counts the trap's own tile as being on that side
assert ev({"dir": "left", "inclusive": True}, 106, 105) is True
assert ev({"dir": "right", "inclusive": True}, 106, 105) is True
# and vertically, too (column spans y 100..132)
assert ev({"dir": "above"}, 108, 106) is False
assert ev({"dir": "above", "inclusive": True}, 108, 106) is True
assert ev({"dir": "below"}, 108, 106) is False
assert ev({"dir": "below", "inclusive": True}, 108, 106) is True
def test_all_and_any(): def test_all_and_any():
c = {"all": [{"within": 3}, {"dir": "above", "aligned": True}]} c = {"all": [{"within": 3}, {"dir": "above", "aligned": True}]}
assert ev(c, 108, 80) is True assert ev(c, 108, 80) is True