Initial commit
This commit is contained in:
270
game/player.py
Normal file
270
game/player.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""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
|
||||
|
||||
# Riding a platform that carries us *sideways* into a wall: the pushing
|
||||
# block is under our feet, so the side-probes above miss it — use the
|
||||
# carry direction instead. (Vertical carry crushes are caught above.)
|
||||
cdx, _ = self.carry
|
||||
if cdx > 0 and br:
|
||||
return True
|
||||
if cdx < 0 and bl:
|
||||
return True
|
||||
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):
|
||||
surface.blit(assets.get("player", self.rect.w, self.rect.h), self.rect)
|
||||
Reference in New Issue
Block a user