Files
battery/game/player.py
James Campbell a17a68a07e Code cleanup
2026-08-01 12:34:46 -04:00

278 lines
11 KiB
Python

"""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
self.jump_pressed = False # edge: pressed this frame
self.jump_held = False # level: currently down
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._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.
pen_right = rect.right - p.left # displacement to exit rightward
pen_left = p.right - rect.left # displacement to exit leftward
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:
if (
dy > 0 and bd and pinned and r.colliderect(up)
): # squished down onto floor
return True
if (
dy < 0 and bu and pinned and r.colliderect(down)
): # squished up into ceiling
return True
if dx > 0 and br and r.colliderect(left): # pushed right into a wall
return True
if dx < 0 and bl and r.colliderect(right): # pushed left into a wall
return True
# 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.)
return False
def _ride_platforms(self):
# If standing on a moving platform, inherit its motion this frame.
for rect, dx, dy in self.level.carriers():
if (
abs(self.rect.bottom - rect.top) <= 3
and self.rect.right > rect.left + 1
and self.rect.left < rect.right - 1
):
self.fx += dx
self.fy += dy
self._sync_rect()
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)
if (
self.jump_buffer > 0
and (self.on_ground or self.coyote > 0)
and self.drop_through_timer <= 0
):
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.
pen_left = self.rect.right - s.left # sank in from the left
pen_right = s.right - self.rect.left # sank in from the right
pen_y = min(self.rect.bottom - s.top, s.bottom - self.rect.top)
if min(pen_left, pen_right) > pen_y:
continue # let the Y pass handle it
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.
overlap_top = self.rect.bottom - s.top # sank onto its top
overlap_bottom = s.bottom - self.rect.top # rose into its underside
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):
ox, oy = self.level.render_offset
surface.blit(
assets.get("player", self.rect.w, self.rect.h), self.rect.move(ox, oy)
)