2026-07-21 19:36:02 -04:00
|
|
|
"""The player character and all of its physics.
|
|
|
|
|
|
|
|
|
|
Movement: A / D to run, Space (or W) to jump with variable height, S to drop
|
|
|
|
|
through one-way platforms. Collision is swept-axis AABB against the level's
|
|
|
|
|
solid rects, with separate handling for one-way platforms and moving platforms
|
|
|
|
|
the player can ride.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import pygame
|
|
|
|
|
|
|
|
|
|
from . import settings as S
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InputState:
|
|
|
|
|
__slots__ = ("left", "right", "down", "jump_pressed", "jump_held")
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.left = self.right = self.down = False
|
2026-08-01 12:19:46 -04:00
|
|
|
self.jump_pressed = False # edge: pressed this frame
|
|
|
|
|
self.jump_held = False # level: currently down
|
2026-07-21 19:36:02 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Player:
|
|
|
|
|
def __init__(self, level):
|
|
|
|
|
self.level = level
|
|
|
|
|
self.w = int(level.tile * 0.72)
|
|
|
|
|
self.h = int(level.tile * 0.92)
|
|
|
|
|
self.rect = pygame.Rect(0, 0, self.w, self.h)
|
|
|
|
|
self.respawn()
|
|
|
|
|
|
|
|
|
|
def respawn(self):
|
|
|
|
|
sx, sy = self.level.spawn
|
|
|
|
|
# center the player horizontally in its spawn tile, feet at tile bottom
|
|
|
|
|
self.fx = float(sx + (self.level.tile - self.w) / 2)
|
|
|
|
|
self.fy = float(sy + (self.level.tile - self.h))
|
|
|
|
|
self.vx = 0.0
|
|
|
|
|
self.vy = 0.0
|
|
|
|
|
self.on_ground = False
|
|
|
|
|
self.coyote = 0.0
|
|
|
|
|
self.jump_buffer = 0.0
|
|
|
|
|
self.facing = 1
|
|
|
|
|
self.drop_through_timer = 0.0
|
|
|
|
|
self.was_jump_held = False
|
|
|
|
|
self.crushed = False
|
|
|
|
|
self.carry = (0.0, 0.0)
|
|
|
|
|
self._sync_rect()
|
|
|
|
|
|
|
|
|
|
def _sync_rect(self):
|
|
|
|
|
self.rect.x = round(self.fx)
|
|
|
|
|
self.rect.y = round(self.fy)
|
|
|
|
|
|
|
|
|
|
# --- main update ---------------------------------------------------------
|
|
|
|
|
def update(self, dt, inp):
|
|
|
|
|
self._ride_platforms()
|
|
|
|
|
self._push_by_movers()
|
|
|
|
|
self._horizontal(dt, inp)
|
|
|
|
|
self._vertical(dt, inp)
|
|
|
|
|
self.crushed = self._check_crush()
|
|
|
|
|
|
|
|
|
|
def _push_by_movers(self):
|
|
|
|
|
"""A solid moving horizontally into our side shoves us along that axis.
|
|
|
|
|
|
|
|
|
|
Without this the player never resolves against a block that walks into
|
|
|
|
|
them (they aren't moving), and the vertical pass then mis-reads the side
|
|
|
|
|
overlap as a downward collision — burying them in the floor.
|
|
|
|
|
"""
|
|
|
|
|
p = self.rect
|
|
|
|
|
for rect, dx, dy in self.level.carriers():
|
|
|
|
|
if dx == 0 or not p.colliderect(rect):
|
|
|
|
|
continue
|
|
|
|
|
# Only treat it as a side hit when we share a real chunk of height —
|
|
|
|
|
# a shallow overlap just means we're standing on top of the block.
|
|
|
|
|
v_overlap = min(p.bottom, rect.bottom) - max(p.top, rect.top)
|
|
|
|
|
if v_overlap < self.h * 0.5:
|
|
|
|
|
continue
|
|
|
|
|
# Push out the *nearer* horizontal side, not blindly the way the
|
|
|
|
|
# block is travelling — otherwise hitting its trailing face (e.g.
|
|
|
|
|
# jumping into the left side of a right-moving block) teleports us
|
|
|
|
|
# clear across it. Minimal displacement keeps the shove-along and
|
|
|
|
|
# shove-into-wall behaviours intact.
|
2026-08-01 12:19:46 -04:00
|
|
|
pen_right = rect.right - p.left # displacement to exit rightward
|
|
|
|
|
pen_left = p.right - rect.left # displacement to exit leftward
|
2026-07-21 19:36:02 -04:00
|
|
|
if pen_right <= pen_left:
|
|
|
|
|
p.left = rect.right
|
|
|
|
|
else:
|
|
|
|
|
p.right = rect.left
|
|
|
|
|
self.fx = float(p.x)
|
|
|
|
|
|
|
|
|
|
def _check_crush(self):
|
|
|
|
|
"""A crush = a moving block pressing us against a solid on the opposite
|
|
|
|
|
side (squished into the floor/ceiling/wall, or pinned while riding).
|
|
|
|
|
|
|
|
|
|
We probe a thin strip on each side of the player: if a solid backs us on
|
|
|
|
|
one side and a *moving* block is closing in from the other side on the
|
|
|
|
|
same axis, we're pinched and die.
|
|
|
|
|
"""
|
|
|
|
|
movers = [(r, dx, dy) for (r, dx, dy) in self.level.carriers() if dx or dy]
|
|
|
|
|
if not movers:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
solids = self.level.solid_rects()
|
|
|
|
|
p = self.rect
|
|
|
|
|
e = 4 # probe depth (a touch larger than a fast block's per-frame step)
|
|
|
|
|
up = pygame.Rect(p.left + 2, p.top - e, max(1, p.width - 4), e)
|
|
|
|
|
down = pygame.Rect(p.left + 2, p.bottom, max(1, p.width - 4), e)
|
|
|
|
|
left = pygame.Rect(p.left - e, p.top + 2, e, max(1, p.height - 4))
|
|
|
|
|
right = pygame.Rect(p.right, p.top + 2, e, max(1, p.height - 4))
|
|
|
|
|
|
|
|
|
|
def backed(probe):
|
|
|
|
|
return any(probe.colliderect(s) for s in solids)
|
|
|
|
|
|
|
|
|
|
bu, bd = backed(up), backed(down)
|
|
|
|
|
bl, br = backed(left), backed(right)
|
|
|
|
|
|
|
|
|
|
# Are we actually compressed? After this frame's resolution we still
|
|
|
|
|
# overlap a solid because we couldn't be separated. If a descending
|
|
|
|
|
# block stops with us fitting underneath, there's no overlap — no crush.
|
|
|
|
|
pinned = any(p.colliderect(s) for s in solids)
|
|
|
|
|
|
|
|
|
|
for r, dx, dy in movers:
|
2026-08-01 12:19:46 -04:00
|
|
|
if (
|
|
|
|
|
dy > 0 and bd and pinned and r.colliderect(up)
|
|
|
|
|
): # squished down onto floor
|
2026-07-21 19:36:02 -04:00
|
|
|
return True
|
2026-08-01 12:19:46 -04:00
|
|
|
if (
|
|
|
|
|
dy < 0 and bu and pinned and r.colliderect(down)
|
|
|
|
|
): # squished up into ceiling
|
2026-07-21 19:36:02 -04:00
|
|
|
return True
|
2026-08-01 12:19:46 -04:00
|
|
|
if dx > 0 and br and r.colliderect(left): # pushed right into a wall
|
2026-07-21 19:36:02 -04:00
|
|
|
return True
|
|
|
|
|
if dx < 0 and bl and r.colliderect(right): # pushed left into a wall
|
|
|
|
|
return True
|
|
|
|
|
|
2026-07-25 13:13:48 -04:00
|
|
|
# A platform that carries us *sideways* into a wall is NOT a crush: the
|
|
|
|
|
# carrier is under our feet, perpendicular to the wall, so it can't pinch
|
|
|
|
|
# us against it. The X pass simply stops us at the wall edge while the
|
|
|
|
|
# platform keeps sliding underneath. (A genuine horizontal crush — a
|
|
|
|
|
# mover closing on our side against a backing wall — is the dx cases
|
|
|
|
|
# above. Vertical carry crushes are the dy cases above.)
|
2026-07-21 19:36:02 -04:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def _ride_platforms(self):
|
|
|
|
|
# If standing on a moving platform, inherit its motion this frame.
|
|
|
|
|
self.carry = (0.0, 0.0)
|
|
|
|
|
for rect, dx, dy in self.level.carriers():
|
2026-08-01 12:19:46 -04:00
|
|
|
if (
|
|
|
|
|
abs(self.rect.bottom - rect.top) <= 3
|
|
|
|
|
and self.rect.right > rect.left + 1
|
|
|
|
|
and self.rect.left < rect.right - 1
|
|
|
|
|
):
|
2026-07-21 19:36:02 -04:00
|
|
|
self.fx += dx
|
|
|
|
|
self.fy += dy
|
|
|
|
|
self._sync_rect()
|
|
|
|
|
self.carry = (dx, dy)
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
def _horizontal(self, dt, inp):
|
|
|
|
|
target = 0.0
|
|
|
|
|
if inp.left:
|
|
|
|
|
target -= S.MOVE_SPEED
|
|
|
|
|
self.facing = -1
|
|
|
|
|
if inp.right:
|
|
|
|
|
target += S.MOVE_SPEED
|
|
|
|
|
self.facing = 1
|
|
|
|
|
|
|
|
|
|
if target != 0.0:
|
|
|
|
|
accel = S.ACCEL if self.on_ground else S.AIR_ACCEL
|
|
|
|
|
if self.vx < target:
|
|
|
|
|
self.vx = min(self.vx + accel * dt, target)
|
|
|
|
|
else:
|
|
|
|
|
self.vx = max(self.vx - accel * dt, target)
|
|
|
|
|
else:
|
|
|
|
|
# friction toward zero (only meaningful decel on the ground)
|
|
|
|
|
fr = S.FRICTION if self.on_ground else S.AIR_ACCEL * 0.5
|
|
|
|
|
if self.vx > 0:
|
|
|
|
|
self.vx = max(0.0, self.vx - fr * dt)
|
|
|
|
|
elif self.vx < 0:
|
|
|
|
|
self.vx = min(0.0, self.vx + fr * dt)
|
|
|
|
|
|
|
|
|
|
self.fx += self.vx * dt
|
|
|
|
|
self._sync_rect()
|
|
|
|
|
self._resolve_axis(axis="x")
|
|
|
|
|
|
|
|
|
|
def _vertical(self, dt, inp):
|
|
|
|
|
# timers
|
|
|
|
|
self.coyote = self.coyote - dt if self.coyote > 0 else 0.0
|
|
|
|
|
if inp.jump_pressed:
|
|
|
|
|
self.jump_buffer = S.JUMP_BUFFER
|
|
|
|
|
else:
|
|
|
|
|
self.jump_buffer = max(0.0, self.jump_buffer - dt)
|
|
|
|
|
self.drop_through_timer = max(0.0, self.drop_through_timer - dt)
|
|
|
|
|
if inp.down and inp.jump_pressed:
|
|
|
|
|
# Space + S: drop through one-way platforms.
|
|
|
|
|
self.drop_through_timer = 0.12
|
|
|
|
|
|
|
|
|
|
# jump (buffered + coyote)
|
2026-08-01 12:19:46 -04:00
|
|
|
if (
|
|
|
|
|
self.jump_buffer > 0
|
|
|
|
|
and (self.on_ground or self.coyote > 0)
|
|
|
|
|
and self.drop_through_timer <= 0
|
|
|
|
|
):
|
2026-07-21 19:36:02 -04:00
|
|
|
self.vy = -S.JUMP_SPEED
|
|
|
|
|
self.on_ground = False
|
|
|
|
|
self.coyote = 0.0
|
|
|
|
|
self.jump_buffer = 0.0
|
|
|
|
|
|
|
|
|
|
# variable jump height: the frame Space is released mid-rise, cut the
|
|
|
|
|
# remaining upward velocity once (a quick tap = a short hop).
|
|
|
|
|
if self.was_jump_held and not inp.jump_held and self.vy < 0:
|
|
|
|
|
self.vy *= S.JUMP_CUT
|
|
|
|
|
self.was_jump_held = inp.jump_held
|
|
|
|
|
|
|
|
|
|
# gravity
|
|
|
|
|
self.vy = min(self.vy + S.GRAVITY * dt, S.MAX_FALL)
|
|
|
|
|
|
|
|
|
|
was_on_ground = self.on_ground
|
|
|
|
|
self.on_ground = False
|
|
|
|
|
self.fy += self.vy * dt
|
|
|
|
|
self._sync_rect()
|
|
|
|
|
self._resolve_axis(axis="y")
|
|
|
|
|
|
|
|
|
|
# start coyote window the frame we walk off a ledge
|
|
|
|
|
if was_on_ground and not self.on_ground and self.vy >= 0:
|
|
|
|
|
self.coyote = S.COYOTE_TIME
|
|
|
|
|
|
|
|
|
|
# --- collision -----------------------------------------------------------
|
|
|
|
|
def _resolve_axis(self, axis):
|
|
|
|
|
solids = self.level.solid_rects()
|
|
|
|
|
if axis == "x":
|
|
|
|
|
for s in solids:
|
|
|
|
|
if self.rect.colliderect(s):
|
|
|
|
|
# Resolve toward the nearer edge (not by velocity sign) so
|
|
|
|
|
# clipping a block's corner can't warp us across it. But only
|
|
|
|
|
# if the overlap is *more horizontal than vertical* — a block
|
|
|
|
|
# sitting on top of us is a vertical collision; pushing us
|
|
|
|
|
# sideways out from under it would dodge a crush.
|
2026-08-01 12:19:46 -04:00
|
|
|
pen_left = self.rect.right - s.left # sank in from the left
|
|
|
|
|
pen_right = s.right - self.rect.left # sank in from the right
|
2026-07-21 19:36:02 -04:00
|
|
|
pen_y = min(self.rect.bottom - s.top, s.bottom - self.rect.top)
|
|
|
|
|
if min(pen_left, pen_right) > pen_y:
|
2026-08-01 12:19:46 -04:00
|
|
|
continue # let the Y pass handle it
|
2026-07-21 19:36:02 -04:00
|
|
|
if pen_right <= pen_left:
|
|
|
|
|
self.rect.left = s.right
|
|
|
|
|
else:
|
|
|
|
|
self.rect.right = s.left
|
|
|
|
|
self.fx = float(self.rect.x)
|
|
|
|
|
self.vx = 0.0
|
|
|
|
|
else: # y
|
|
|
|
|
for s in solids:
|
|
|
|
|
if self.rect.colliderect(s):
|
|
|
|
|
# Resolve toward the nearer edge, not by velocity sign — so a
|
|
|
|
|
# block descending onto us can't pop us out its top.
|
2026-08-01 12:19:46 -04:00
|
|
|
overlap_top = self.rect.bottom - s.top # sank onto its top
|
|
|
|
|
overlap_bottom = s.bottom - self.rect.top # rose into its underside
|
2026-07-21 19:36:02 -04:00
|
|
|
if overlap_top <= overlap_bottom:
|
|
|
|
|
self.rect.bottom = s.top
|
|
|
|
|
self.on_ground = True
|
|
|
|
|
else:
|
|
|
|
|
self.rect.top = s.bottom
|
|
|
|
|
self.fy = float(self.rect.y)
|
|
|
|
|
self.vy = 0.0
|
|
|
|
|
# one-way platforms: land only when falling and the feet just
|
|
|
|
|
# crossed the platform's top edge this frame.
|
|
|
|
|
if self.vy >= 0 and self.drop_through_timer <= 0:
|
|
|
|
|
max_pen = self.vy / S.FPS + 8 # how far feet could have sunk this frame
|
|
|
|
|
for o in self.level.oneway_rects():
|
|
|
|
|
if self.rect.colliderect(o):
|
|
|
|
|
penetration = self.rect.bottom - o.top
|
|
|
|
|
if 0 <= penetration <= max_pen:
|
|
|
|
|
self.rect.bottom = o.top
|
|
|
|
|
self.fy = float(self.rect.y)
|
|
|
|
|
self.vy = 0.0
|
|
|
|
|
self.on_ground = True
|
|
|
|
|
|
|
|
|
|
# --- rendering -----------------------------------------------------------
|
|
|
|
|
def draw(self, surface, assets):
|
2026-07-22 11:56:21 -04:00
|
|
|
ox, oy = self.level.render_offset
|
2026-08-01 12:19:46 -04:00
|
|
|
surface.blit(
|
|
|
|
|
assets.get("player", self.rect.w, self.rect.h), self.rect.move(ox, oy)
|
|
|
|
|
)
|