"""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.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. 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. self.carry = (0.0, 0.0) 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() 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) 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) )