Format code with black
This commit is contained in:
@@ -15,8 +15,8 @@ from . import settings
|
||||
class AssetStore:
|
||||
def __init__(self, assets_dir):
|
||||
self.assets_dir = assets_dir
|
||||
self._raw = {} # name -> original loaded Surface (or None if missing)
|
||||
self._cache = {} # (name, w, h) -> scaled Surface
|
||||
self._raw = {} # name -> original loaded Surface (or None if missing)
|
||||
self._cache = {} # (name, w, h) -> scaled Surface
|
||||
self._font = None
|
||||
|
||||
def _font_for(self, h):
|
||||
|
||||
107
game/game.py
107
game/game.py
@@ -9,8 +9,8 @@ from .assets import AssetStore
|
||||
from .level import Level
|
||||
from .player import Player, InputState
|
||||
|
||||
HUD_H = 46 # height of the status bar above the play area
|
||||
MIN_W = 480 # keep the window wide enough for the HUD text
|
||||
HUD_H = 46 # height of the status bar above the play area
|
||||
MIN_W = 480 # keep the window wide enough for the HUD text
|
||||
|
||||
|
||||
class Game:
|
||||
@@ -18,7 +18,7 @@ class Game:
|
||||
pygame.init()
|
||||
pygame.display.set_caption(S.CAPTION)
|
||||
self.level_paths = level_paths
|
||||
self.force_debug = debug # --debug: turn debug view on for every level
|
||||
self.force_debug = debug # --debug: turn debug view on for every level
|
||||
self.assets = AssetStore(assets_dir)
|
||||
self.clock = pygame.time.Clock()
|
||||
self.hud_font = pygame.font.SysFont("consolas,menlo,monospace", 22, bold=True)
|
||||
@@ -26,12 +26,12 @@ class Game:
|
||||
|
||||
self.index = 0
|
||||
self.running = True
|
||||
self.state = "playing" # playing | dying | won_level | won_all
|
||||
self.death_timer = 0.0 # counts down during the "dying" pause
|
||||
self.death_rect = None # where to draw the corpse
|
||||
self.deaths = 0 # total deaths this session
|
||||
self.level_deaths = 0 # deaths on the current level (resets per level)
|
||||
self._pending_reload = False # F5: reload the level file on next respawn
|
||||
self.state = "playing" # playing | dying | won_level | won_all
|
||||
self.death_timer = 0.0 # counts down during the "dying" pause
|
||||
self.death_rect = None # where to draw the corpse
|
||||
self.deaths = 0 # total deaths this session
|
||||
self.level_deaths = 0 # deaths on the current level (resets per level)
|
||||
self._pending_reload = False # F5: reload the level file on next respawn
|
||||
|
||||
# Size the window ONCE to fit the largest level, then never resize it —
|
||||
# calling set_mode again mid-session recreates the window (it flickers /
|
||||
@@ -62,7 +62,7 @@ class Game:
|
||||
def _load_current(self):
|
||||
self.level = Level(self.level_paths[self.index])
|
||||
if self.force_debug:
|
||||
self.level.debug = True # CLI flag overrides the per-level setting
|
||||
self.level.debug = True # CLI flag overrides the per-level setting
|
||||
self.player = Player(self.level)
|
||||
self.battery = self.level.battery
|
||||
# In debug, grow the play surface by a margin and shift all drawing into
|
||||
@@ -78,7 +78,7 @@ class Game:
|
||||
self.state = "playing"
|
||||
self.death_timer = 0.0
|
||||
self.death_rect = None
|
||||
self.level_deaths = 0 # fresh count for the level we just loaded
|
||||
self.level_deaths = 0 # fresh count for the level we just loaded
|
||||
|
||||
def _start_death(self):
|
||||
# Begin the death pause: freeze everything, leave the corpse on screen.
|
||||
@@ -104,7 +104,7 @@ class Game:
|
||||
try:
|
||||
self._load_current()
|
||||
self.level_deaths = kept
|
||||
except Exception as ex: # bad edit -> don't crash
|
||||
except Exception as ex: # bad edit -> don't crash
|
||||
print(f"[reload] {self.level_paths[self.index]}: {ex}")
|
||||
self.player.respawn()
|
||||
self.level.reset()
|
||||
@@ -112,7 +112,7 @@ class Game:
|
||||
else:
|
||||
self.player.respawn()
|
||||
self.level.reset()
|
||||
self.battery = self.level.battery # phone gets plugged back in at start
|
||||
self.battery = self.level.battery # phone gets plugged back in at start
|
||||
self.state = "playing"
|
||||
|
||||
def _reload_level(self):
|
||||
@@ -129,7 +129,7 @@ class Game:
|
||||
self.state = "charging"
|
||||
self.charge_timer = 0.0
|
||||
tf = max(0.0, self.battery / self.level.battery) if self.level.battery else 0.0
|
||||
self.charge_from = tf * (self.level.battery_pct / 100.0) # near-empty start
|
||||
self.charge_from = tf * (self.level.battery_pct / 100.0) # near-empty start
|
||||
self._final = self.index + 1 >= len(self.level_paths)
|
||||
|
||||
def _advance(self):
|
||||
@@ -157,8 +157,8 @@ class Game:
|
||||
if self.fade_timer < S.FADE_TIME:
|
||||
return
|
||||
if self.fade_phase == "out":
|
||||
self.fade_action() # swap levels while the screen is black
|
||||
self.state = "fading" # _load_current() flips to "playing"; undo it
|
||||
self.fade_action() # swap levels while the screen is black
|
||||
self.state = "fading" # _load_current() flips to "playing"; undo it
|
||||
self.fade_phase = "in"
|
||||
self.fade_timer = 0.0
|
||||
else:
|
||||
@@ -195,7 +195,7 @@ class Game:
|
||||
def run(self):
|
||||
while self.running:
|
||||
dt = self.clock.tick(S.FPS) / 1000.0
|
||||
dt = min(dt, 1 / 30) # clamp to avoid tunneling on lag spikes
|
||||
dt = min(dt, 1 / 30) # clamp to avoid tunneling on lag spikes
|
||||
inp = self._poll_events()
|
||||
|
||||
if self.state == "playing":
|
||||
@@ -224,8 +224,8 @@ class Game:
|
||||
# death conditions
|
||||
pr = self.player.rect
|
||||
died = self.battery <= 0
|
||||
died = died or pr.top > self.level.height + 160 # fell out of the world
|
||||
died = died or self.player.crushed # pinched by a moving block
|
||||
died = died or pr.top > self.level.height + 160 # fell out of the world
|
||||
died = died or self.player.crushed # pinched by a moving block
|
||||
if not died:
|
||||
for hz in self.level.hazard_rects():
|
||||
if pr.colliderect(hz):
|
||||
@@ -257,7 +257,9 @@ class Game:
|
||||
if self.state == "dying" and self.death_rect is not None:
|
||||
# The traps stay drawn in their moment-of-death state; the player is
|
||||
# replaced by a corpse sprite where they fell.
|
||||
sprite = self.assets.get("player_dead", self.death_rect.w, self.death_rect.h)
|
||||
sprite = self.assets.get(
|
||||
"player_dead", self.death_rect.w, self.death_rect.h
|
||||
)
|
||||
self.world.blit(sprite, self.death_rect.move(self.level.render_offset))
|
||||
else:
|
||||
self.player.draw(self.world, self.assets)
|
||||
@@ -278,13 +280,17 @@ class Game:
|
||||
|
||||
if self.state == "won_level":
|
||||
plural = "death" if self.level_deaths == 1 else "deaths"
|
||||
self._draw_win_splash("LEVEL COMPLETE — phone charged!",
|
||||
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level")
|
||||
self._draw_win_splash(
|
||||
"LEVEL COMPLETE — phone charged!",
|
||||
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level",
|
||||
)
|
||||
elif self.state == "won_all":
|
||||
self._draw_win_splash("YOU MADE IT! Phone fully charged.",
|
||||
f"All levels cleared with {self.deaths} deaths. ENTER to replay")
|
||||
self._draw_win_splash(
|
||||
"YOU MADE IT! Phone fully charged.",
|
||||
f"All levels cleared with {self.deaths} deaths. ENTER to replay",
|
||||
)
|
||||
|
||||
if self.state == "fading": # fade-in only; fade-out returned early above
|
||||
if self.state == "fading": # fade-in only; fade-out returned early above
|
||||
p = min(1.0, self.fade_timer / S.FADE_TIME)
|
||||
overlay = pygame.Surface(self.screen.get_size())
|
||||
overlay.fill((0, 0, 0))
|
||||
@@ -300,16 +306,22 @@ class Game:
|
||||
r = max(2, rect.h // 6)
|
||||
inset = max(2, rect.h // 10)
|
||||
pygame.draw.rect(surf, (60, 60, 72), rect, border_radius=r)
|
||||
pygame.draw.rect(surf, (28, 30, 40),
|
||||
rect.inflate(-inset, -inset), border_radius=r)
|
||||
pygame.draw.rect(
|
||||
surf, (28, 30, 40), rect.inflate(-inset, -inset), border_radius=r
|
||||
)
|
||||
fw = int((rect.w - inset * 2) * max(0.0, min(1.0, fill_frac)))
|
||||
if fill_frac > 0:
|
||||
fw = max(inset, fw)
|
||||
pygame.draw.rect(surf, col, (rect.x + inset, rect.y + inset, fw, rect.h - inset * 2),
|
||||
border_radius=r)
|
||||
pygame.draw.rect(
|
||||
surf,
|
||||
col,
|
||||
(rect.x + inset, rect.y + inset, fw, rect.h - inset * 2),
|
||||
border_radius=r,
|
||||
)
|
||||
nub_w, nub_h = max(3, rect.h // 4), rect.h // 2
|
||||
pygame.draw.rect(surf, (60, 60, 72),
|
||||
(rect.right, rect.centery - nub_h // 2, nub_w, nub_h))
|
||||
pygame.draw.rect(
|
||||
surf, (60, 60, 72), (rect.right, rect.centery - nub_h // 2, nub_w, nub_h)
|
||||
)
|
||||
|
||||
def _draw_hud(self, hide_battery=False):
|
||||
w = self.screen.get_width()
|
||||
@@ -322,17 +334,24 @@ class Game:
|
||||
bar_w, bar_h = 180, 20
|
||||
bx, by = 12, (HUD_H - bar_h) // 2
|
||||
if not hide_battery:
|
||||
time_frac = max(0.0, self.battery / self.level.battery) if self.level.battery else 0.0
|
||||
time_frac = (
|
||||
max(0.0, self.battery / self.level.battery)
|
||||
if self.level.battery
|
||||
else 0.0
|
||||
)
|
||||
visual = time_frac * (self.level.battery_pct / 100.0)
|
||||
col = (240, 170, 60) if time_frac > 0.25 else (240, 80, 80)
|
||||
self._draw_battery(self.screen, pygame.Rect(bx, by, bar_w, bar_h), visual, col)
|
||||
self._draw_battery(
|
||||
self.screen, pygame.Rect(bx, by, bar_w, bar_h), visual, col
|
||||
)
|
||||
|
||||
secs = max(0.0, self.battery)
|
||||
label = self.hud_font.render(f"{secs:4.1f}s", True, S.COLOR_HUD)
|
||||
self.screen.blit(label, (bx + bar_w + 16, by - 1))
|
||||
|
||||
deaths = self.hud_font.render(
|
||||
f"deaths {self.level_deaths} / {self.deaths} total", True, S.COLOR_HUD)
|
||||
f"deaths {self.level_deaths} / {self.deaths} total", True, S.COLOR_HUD
|
||||
)
|
||||
self.screen.blit(deaths, (w - deaths.get_width() - 12, by - 1))
|
||||
|
||||
name = self.hud_font.render(self.level.name, True, S.COLOR_HUD)
|
||||
@@ -355,8 +374,8 @@ class Game:
|
||||
it fills to 100%."""
|
||||
W, H = self.screen.get_size()
|
||||
t = min(1.0, self.charge_timer / S.CHARGE_TIME)
|
||||
move_p = 1 - (1 - min(1.0, t / 0.4)) ** 3 # ease-out; centred by 40%
|
||||
fill_p = max(0.0, min(1.0, (t - 0.2) / 0.6)) # fill from 20%..80%
|
||||
move_p = 1 - (1 - min(1.0, t / 0.4)) ** 3 # ease-out; centred by 40%
|
||||
fill_p = max(0.0, min(1.0, (t - 0.2) / 0.6)) # fill from 20%..80%
|
||||
fill = self.charge_from + (1.0 - self.charge_from) * fill_p
|
||||
|
||||
dim = pygame.Surface((W, H), pygame.SRCALPHA)
|
||||
@@ -367,9 +386,12 @@ class Game:
|
||||
home = pygame.Vector2(12 + bar_w / 2, (HUD_H - bar_h) // 2 + bar_h / 2)
|
||||
target = self._hero_battery()
|
||||
pos = home.lerp(pygame.Vector2(target.center), move_p)
|
||||
rect = pygame.Rect(0, 0,
|
||||
round(bar_w + (target.w - bar_w) * move_p),
|
||||
round(bar_h + (target.h - bar_h) * move_p))
|
||||
rect = pygame.Rect(
|
||||
0,
|
||||
0,
|
||||
round(bar_w + (target.w - bar_w) * move_p),
|
||||
round(bar_h + (target.h - bar_h) * move_p),
|
||||
)
|
||||
rect.center = (round(pos.x), round(pos.y))
|
||||
self._draw_battery(self.screen, rect, fill, (90, 220, 120))
|
||||
|
||||
@@ -391,7 +413,10 @@ class Game:
|
||||
s = self.hud_font.render(subtitle, True, S.COLOR_HUD)
|
||||
self.screen.blit(s, s.get_rect(center=(W // 2, rect.bottom + 34)))
|
||||
|
||||
|
||||
def discover_levels(levels_dir):
|
||||
paths = sorted(glob.glob(os.path.join(levels_dir, "*.yaml")) +
|
||||
glob.glob(os.path.join(levels_dir, "*.yml")))
|
||||
paths = sorted(
|
||||
glob.glob(os.path.join(levels_dir, "*.yaml"))
|
||||
+ glob.glob(os.path.join(levels_dir, "*.yml"))
|
||||
)
|
||||
return paths
|
||||
|
||||
@@ -55,8 +55,8 @@ class Level:
|
||||
# by the Game). (0, 0) in normal play, so nothing moves.
|
||||
self.render_offset = (0, 0)
|
||||
|
||||
self.solids = [] # list[pygame.Rect] — full blocking
|
||||
self.oneways = [] # list[pygame.Rect] — blocking only from above
|
||||
self.solids = [] # list[pygame.Rect] — full blocking
|
||||
self.oneways = [] # list[pygame.Rect] — blocking only from above
|
||||
self.spawn = (self.tile, self.tile)
|
||||
self.goal_rect = pygame.Rect(self.width - self.tile, 0, self.tile, self.tile)
|
||||
|
||||
@@ -144,8 +144,10 @@ class Level:
|
||||
for rect in self.oneways:
|
||||
surface.blit(oneway_img, rect.move(ox, oy))
|
||||
|
||||
surface.blit(assets.get("goal", self.goal_rect.w, self.goal_rect.h),
|
||||
self.goal_rect.move(ox, oy))
|
||||
surface.blit(
|
||||
assets.get("goal", self.goal_rect.w, self.goal_rect.h),
|
||||
self.goal_rect.move(ox, oy),
|
||||
)
|
||||
for tr in self.traps:
|
||||
tr.render(surface, assets)
|
||||
|
||||
@@ -173,10 +175,12 @@ class Level:
|
||||
# Shade the off-screen overscan so the real play area reads clearly.
|
||||
view = pygame.Rect(ox, oy, self.width, self.height)
|
||||
shade = (10, 12, 20, 130)
|
||||
for band in (pygame.Rect(0, 0, sw, oy), # above
|
||||
pygame.Rect(0, view.bottom, sw, sh - view.bottom), # below
|
||||
pygame.Rect(0, oy, ox, self.height), # left
|
||||
pygame.Rect(view.right, oy, sw - view.right, self.height)): # right
|
||||
for band in (
|
||||
pygame.Rect(0, 0, sw, oy), # above
|
||||
pygame.Rect(0, view.bottom, sw, sh - view.bottom), # below
|
||||
pygame.Rect(0, oy, ox, self.height), # left
|
||||
pygame.Rect(view.right, oy, sw - view.right, self.height),
|
||||
): # right
|
||||
if band.w > 0 and band.h > 0:
|
||||
overlay.fill(shade, band)
|
||||
|
||||
@@ -190,14 +194,16 @@ class Level:
|
||||
pygame.draw.line(overlay, line, (0, k * t), (sw, k * t))
|
||||
label = (150, 162, 190)
|
||||
for k in range(sw // t):
|
||||
overlay.blit(Level._grid_font.render(str(k - mx), True, label),
|
||||
(k * t + 2, 1))
|
||||
overlay.blit(
|
||||
Level._grid_font.render(str(k - mx), True, label), (k * t + 2, 1)
|
||||
)
|
||||
for k in range(sh // t):
|
||||
overlay.blit(Level._grid_font.render(str(k - my), True, label),
|
||||
(1, k * t + 1))
|
||||
overlay.blit(
|
||||
Level._grid_font.render(str(k - my), True, label), (1, k * t + 1)
|
||||
)
|
||||
|
||||
# Outline the actual runtime viewport (the level's true bounds).
|
||||
if (mx or my):
|
||||
if mx or my:
|
||||
pygame.draw.rect(overlay, (95, 210, 235, 200), view, 2)
|
||||
|
||||
surface.blit(overlay, (0, 0))
|
||||
|
||||
@@ -16,8 +16,8 @@ class InputState:
|
||||
|
||||
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
|
||||
self.jump_pressed = False # edge: pressed this frame
|
||||
self.jump_held = False # level: currently down
|
||||
|
||||
|
||||
class Player:
|
||||
@@ -78,8 +78,8 @@ class Player:
|
||||
# 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
|
||||
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:
|
||||
@@ -118,11 +118,15 @@ class Player:
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -139,9 +143,11 @@ class Player:
|
||||
# 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):
|
||||
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()
|
||||
@@ -188,8 +194,11 @@ class Player:
|
||||
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:
|
||||
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
|
||||
@@ -225,11 +234,11 @@ class Player:
|
||||
# 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_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
|
||||
continue # let the Y pass handle it
|
||||
if pen_right <= pen_left:
|
||||
self.rect.left = s.right
|
||||
else:
|
||||
@@ -241,8 +250,8 @@ class Player:
|
||||
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
|
||||
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
|
||||
@@ -266,5 +275,6 @@ class Player:
|
||||
# --- 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))
|
||||
surface.blit(
|
||||
assets.get("player", self.rect.w, self.rect.h), self.rect.move(ox, oy)
|
||||
)
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
"""Global tunables. Anything a level does not override falls back to these."""
|
||||
|
||||
# --- Display -----------------------------------------------------------------
|
||||
TILE = 32 # default tile size in pixels (levels may override)
|
||||
TILE = 32 # default tile size in pixels (levels may override)
|
||||
FPS = 60
|
||||
CAPTION = "Dying Phone"
|
||||
# Debug view only: how many extra tiles to reveal beyond the level on every side,
|
||||
# so off-screen geometry (e.g. an invisible catch-wall a step off the map) is
|
||||
# visible while designing. The true play area is outlined within this margin.
|
||||
DEBUG_VIEW_MARGIN = 1 # tiles of overscan shown around the level in debug
|
||||
DEBUG_VIEW_MARGIN = 1 # tiles of overscan shown around the level in debug
|
||||
|
||||
# --- Physics (pixels / second, unless noted) ---------------------------------
|
||||
GRAVITY = 2200.0 # downward acceleration
|
||||
MAX_FALL = 1400.0 # terminal velocity
|
||||
MOVE_SPEED = 320.0 # horizontal run speed
|
||||
ACCEL = 3200.0 # ground acceleration toward target speed
|
||||
AIR_ACCEL = 2200.0 # weaker control in the air
|
||||
FRICTION = 3600.0 # deceleration when no input on ground
|
||||
JUMP_SPEED = 760.0 # initial upward velocity of a jump
|
||||
JUMP_CUT = 0.45 # velocity retained when jump released early (variable height)
|
||||
COYOTE_TIME = 0.10 # seconds after leaving a ledge you can still jump
|
||||
JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing
|
||||
GRAVITY = 2200.0 # downward acceleration
|
||||
MAX_FALL = 1400.0 # terminal velocity
|
||||
MOVE_SPEED = 320.0 # horizontal run speed
|
||||
ACCEL = 3200.0 # ground acceleration toward target speed
|
||||
AIR_ACCEL = 2200.0 # weaker control in the air
|
||||
FRICTION = 3600.0 # deceleration when no input on ground
|
||||
JUMP_SPEED = 760.0 # initial upward velocity of a jump
|
||||
JUMP_CUT = 0.45 # velocity retained when jump released early (variable height)
|
||||
COYOTE_TIME = 0.10 # seconds after leaving a ledge you can still jump
|
||||
JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing
|
||||
|
||||
# --- Gameplay ----------------------------------------------------------------
|
||||
DEFAULT_BATTERY = 45.0 # seconds of phone battery if a level doesn't set one
|
||||
DEFAULT_BATTERY = 45.0 # seconds of phone battery if a level doesn't set one
|
||||
DEFAULT_BATTERY_PCT = 12.0 # how full the battery *looks* at the start (visual only;
|
||||
# the phone is dying, so the bar reads near-empty)
|
||||
DEATH_PAUSE = 0.5 # seconds the corpse lingers (traps frozen) before respawn
|
||||
CHARGE_TIME = 1.0 # seconds the battery-fill animation plays on level clear
|
||||
FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition
|
||||
# the phone is dying, so the bar reads near-empty)
|
||||
DEATH_PAUSE = 0.5 # seconds the corpse lingers (traps frozen) before respawn
|
||||
CHARGE_TIME = 1.0 # seconds the battery-fill animation plays on level clear
|
||||
FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition
|
||||
|
||||
# --- Colors (placeholder rendering) ------------------------------------------
|
||||
COLOR_BG = (24, 26, 38)
|
||||
@@ -36,17 +36,17 @@ COLOR_HUD_WARN = (240, 90, 90)
|
||||
|
||||
# Fallback colors + labels for placeholder rectangles, keyed by sprite name.
|
||||
PLACEHOLDERS = {
|
||||
"player": ((90, 200, 255), "P"),
|
||||
"player_dead": ((120, 40, 40), "X"),
|
||||
"block": ((110, 120, 140), ""),
|
||||
"goal": ((90, 230, 130), "GOAL"),
|
||||
"fake_block": ((110, 120, 140), ""), # looks identical to a real block on purpose
|
||||
"spike": ((230, 80, 80), "^"),
|
||||
"moving_block": ((150, 130, 90), ""),
|
||||
"patrol_block": ((130, 100, 170), ""),
|
||||
"player": ((90, 200, 255), "P"),
|
||||
"player_dead": ((120, 40, 40), "X"),
|
||||
"block": ((110, 120, 140), ""),
|
||||
"goal": ((90, 230, 130), "GOAL"),
|
||||
"fake_block": ((110, 120, 140), ""), # looks identical to a real block on purpose
|
||||
"spike": ((230, 80, 80), "^"),
|
||||
"moving_block": ((150, 130, 90), ""),
|
||||
"patrol_block": ((130, 100, 170), ""),
|
||||
"crumble_block": ((150, 120, 100), ""),
|
||||
"arrow_shooter": ((80, 80, 95), ""),
|
||||
"arrow": ((250, 220, 90), ">"),
|
||||
"spike_block": ((150, 156, 172), "*"),
|
||||
"phase_block": ((90, 180, 210), ""),
|
||||
"arrow": ((250, 220, 90), ">"),
|
||||
"spike_block": ((150, 156, 172), "*"),
|
||||
"phase_block": ((90, 180, 210), ""),
|
||||
}
|
||||
|
||||
145
game/traps.py
145
game/traps.py
@@ -17,10 +17,12 @@ import pygame
|
||||
|
||||
from . import settings
|
||||
|
||||
|
||||
# --- helpers -----------------------------------------------------------------
|
||||
_DIRS = {
|
||||
"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0),
|
||||
"up": (0, -1),
|
||||
"down": (0, 1),
|
||||
"left": (-1, 0),
|
||||
"right": (1, 0),
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +38,7 @@ _DIRS = {
|
||||
# trigger: { timer: { interval: 1.2, up_time: 0.7 } }
|
||||
# trigger: { all: [ { within: 3 }, { dir: above, aligned: true } ] }
|
||||
|
||||
|
||||
class _Always:
|
||||
def evaluate(self, trap, game, dt):
|
||||
return True
|
||||
@@ -46,6 +49,7 @@ class _Always:
|
||||
|
||||
class _Within:
|
||||
"""Player within N tiles of the trap centre (Euclidean radius)."""
|
||||
|
||||
def __init__(self, n):
|
||||
self.n = float(n)
|
||||
|
||||
@@ -69,6 +73,7 @@ class _Directional:
|
||||
left/right (same rows) or *directly* above/below (same columns).
|
||||
inclusive: specify if the trap tile itself counts in the given direction.
|
||||
"""
|
||||
|
||||
def __init__(self, direction, rng, aligned, inclusive):
|
||||
self.dir = direction
|
||||
self.rng = None if rng is None else float(rng)
|
||||
@@ -113,6 +118,7 @@ class _Directional:
|
||||
|
||||
class _Timer:
|
||||
"""Cyclic: hidden for ``interval`` seconds, then active for ``up_time``."""
|
||||
|
||||
def __init__(self, interval, up_time):
|
||||
self.interval = float(interval)
|
||||
self.up_time = float(up_time)
|
||||
@@ -155,7 +161,9 @@ def make_condition(spec):
|
||||
if spec == "always":
|
||||
return _Always()
|
||||
if not isinstance(spec, dict):
|
||||
raise ValueError(f"trigger must be 'always' or a condition object, got {spec!r}")
|
||||
raise ValueError(
|
||||
f"trigger must be 'always' or a condition object, got {spec!r}"
|
||||
)
|
||||
if "all" in spec:
|
||||
return _All([make_condition(s) for s in spec["all"]])
|
||||
if "any" in spec:
|
||||
@@ -166,7 +174,12 @@ def make_condition(spec):
|
||||
if "within" in spec:
|
||||
return _Within(spec["within"])
|
||||
if "dir" in spec:
|
||||
return _Directional(spec["dir"], spec.get("range"), spec.get("aligned", False), spec.get("inclusive", False))
|
||||
return _Directional(
|
||||
spec["dir"],
|
||||
spec.get("range"),
|
||||
spec.get("aligned", False),
|
||||
spec.get("inclusive", False),
|
||||
)
|
||||
raise ValueError(f"unrecognized trigger condition: {spec!r}")
|
||||
|
||||
|
||||
@@ -178,7 +191,7 @@ class Trap:
|
||||
at = spec.get("at", [0, 0])
|
||||
self.col, self.row = int(at[0]), int(at[1])
|
||||
self.base_rect = level.cell_rect(self.col, self.row)
|
||||
self._mounted = False # set True on traps that ride another (a mount)
|
||||
self._mounted = False # set True on traps that ride another (a mount)
|
||||
# Any trap can be made invisible (still functional; revealed in debug).
|
||||
self.invisible = bool(spec.get("invisible", False))
|
||||
|
||||
@@ -274,13 +287,14 @@ class Trap:
|
||||
def _follow(self, parent):
|
||||
ox, oy = self._mount_off
|
||||
pr = parent.current_rect()
|
||||
self.base_rect = pygame.Rect(pr.x + ox, pr.y + oy,
|
||||
self.base_rect.w, self.base_rect.h)
|
||||
self.base_rect = pygame.Rect(
|
||||
pr.x + ox, pr.y + oy, self.base_rect.w, self.base_rect.h
|
||||
)
|
||||
|
||||
def tick(self, dt, game):
|
||||
self.update(dt, game)
|
||||
for c in self.mounts:
|
||||
c._follow(self) # reposition after we've moved this frame
|
||||
c._follow(self) # reposition after we've moved this frame
|
||||
c.tick(dt, game)
|
||||
|
||||
def render(self, surface, assets):
|
||||
@@ -354,6 +368,7 @@ class Spike(Trap):
|
||||
direction: which edge of the cell the spike sits on (up/down/left/right).
|
||||
See the trigger-condition docs at the top of this module.
|
||||
"""
|
||||
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
self.direction = spec.get("direction", "up")
|
||||
@@ -369,13 +384,13 @@ class Spike(Trap):
|
||||
t = self.tile
|
||||
r = self.base_rect
|
||||
dx, dy = _DIRS.get(self.direction, (0, -1))
|
||||
if dy == -1: # up: bottom half is ground, tip points up
|
||||
if dy == -1: # up: bottom half is ground, tip points up
|
||||
return pygame.Rect(r.x, r.y + t // 2, t, t // 2)
|
||||
if dy == 1: # down (ceiling spike)
|
||||
if dy == 1: # down (ceiling spike)
|
||||
return pygame.Rect(r.x, r.y, t, t // 2)
|
||||
if dx == -1: # left (from right wall pointing left)
|
||||
if dx == -1: # left (from right wall pointing left)
|
||||
return pygame.Rect(r.x + t // 2, r.y, t // 2, t)
|
||||
return pygame.Rect(r.x, r.y, t // 2, t) # right
|
||||
return pygame.Rect(r.x, r.y, t // 2, t) # right
|
||||
|
||||
# CCW rotation to point the (up-facing) sprite the right way.
|
||||
_ANGLE = {"up": 0, "left": 90, "down": 180, "right": -90}
|
||||
@@ -415,6 +430,7 @@ class Block(Trap):
|
||||
start extending and be clear for ``release`` seconds to start
|
||||
retracting — hysteresis that stops boundary jitter.
|
||||
"""
|
||||
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
t = self.tile
|
||||
@@ -429,15 +445,23 @@ class Block(Trap):
|
||||
self.speed = float(spec.get("speed", 140.0))
|
||||
self.mode = spec.get("mode", "once")
|
||||
self.deadly = bool(spec.get("deadly", False))
|
||||
self.fake = bool(spec.get("fake", False)) # looks solid, isn't
|
||||
self.fake = bool(spec.get("fake", False)) # looks solid, isn't
|
||||
self.crumble = bool(spec.get("crumble", False)) # gives way when stood on
|
||||
self.crumble_delay = float(spec.get("crumble_delay", 0.4))
|
||||
self.respawn = float(spec.get("respawn", 2.5))
|
||||
self.sprite = spec.get("sprite",
|
||||
"spike_block" if self.deadly else
|
||||
"fake_block" if self.fake else
|
||||
"crumble_block" if self.crumble else "moving_block")
|
||||
self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis
|
||||
self.sprite = spec.get(
|
||||
"sprite",
|
||||
(
|
||||
"spike_block"
|
||||
if self.deadly
|
||||
else (
|
||||
"fake_block"
|
||||
if self.fake
|
||||
else "crumble_block" if self.crumble else "moving_block"
|
||||
)
|
||||
),
|
||||
)
|
||||
self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis
|
||||
# `home` (default): sense the trigger from the resting cell, so the block
|
||||
# moving away can't toggle its own trigger (no jitter). `current`: sense
|
||||
# from the live position — for blocks the player rides (e.g. a dropper),
|
||||
@@ -463,8 +487,9 @@ class Block(Trap):
|
||||
# `home` sensing tracks the resting cell as it rides along the parent.
|
||||
ox, oy = self._origin
|
||||
offx, offy = self._mount_off
|
||||
self.base_rect = pygame.Rect(round(ox + offx), round(oy + offy),
|
||||
self.tile, self.tile)
|
||||
self.base_rect = pygame.Rect(
|
||||
round(ox + offx), round(oy + offy), self.tile, self.tile
|
||||
)
|
||||
|
||||
def reset(self):
|
||||
self._reset_trigger()
|
||||
@@ -474,12 +499,12 @@ class Block(Trap):
|
||||
# LOCAL — the live rect is always _origin + (x, y).
|
||||
self._origin = (0.0, 0.0)
|
||||
self.prev = (self.x, self.y)
|
||||
self.dir = 1 # pingpong direction
|
||||
self.phase = "rest" # once mode: rest|extending|extended|retracting
|
||||
self.dir = 1 # pingpong direction
|
||||
self.phase = "rest" # once mode: rest|extending|extended|retracting
|
||||
self._release_t = 0.0
|
||||
# index of the waypoint we're AT (once) / heading toward (patrol)
|
||||
self.idx = 0 if self.mode == "once" else (1 if len(self.points) > 1 else 0)
|
||||
self.cstate = "solid" # crumble: solid|crumbling|gone
|
||||
self.cstate = "solid" # crumble: solid|crumbling|gone
|
||||
self.ctimer = 0.0
|
||||
self.shake = 0.0
|
||||
self.emerge_kill = False
|
||||
@@ -491,7 +516,7 @@ class Block(Trap):
|
||||
# block executes its path/move relative to the parent it rides.
|
||||
if not self._mounted:
|
||||
self.prev = (self.x, self.y)
|
||||
active = self.triggered(game, dt) # call every frame to keep the timer live
|
||||
active = self.triggered(game, dt) # call every frame to keep the timer live
|
||||
if len(self.points) >= 2:
|
||||
step = self.speed * dt
|
||||
if self.mode == "once":
|
||||
@@ -505,8 +530,9 @@ class Block(Trap):
|
||||
self.emerge_kill = False
|
||||
r = self._rect()
|
||||
p = game.player.rect
|
||||
on_top = (abs(p.bottom - r.top) <= 4
|
||||
and p.right > r.left + 2 and p.left < r.right - 2)
|
||||
on_top = (
|
||||
abs(p.bottom - r.top) <= 4 and p.right > r.left + 2 and p.left < r.right - 2
|
||||
)
|
||||
if self.cstate == "solid":
|
||||
if on_top:
|
||||
self.cstate = "crumbling"
|
||||
@@ -590,8 +616,7 @@ class Block(Trap):
|
||||
|
||||
def _rect(self):
|
||||
ox, oy = self._origin
|
||||
return pygame.Rect(round(ox + self.x), round(oy + self.y),
|
||||
self.tile, self.tile)
|
||||
return pygame.Rect(round(ox + self.x), round(oy + self.y), self.tile, self.tile)
|
||||
|
||||
def current_rect(self):
|
||||
return self._rect()
|
||||
@@ -654,6 +679,7 @@ class ArrowShooter(Trap):
|
||||
direction: up/down/left/right. speed: px/s. interval: seconds between shots.
|
||||
trigger: only fires while the condition holds (default ``always``).
|
||||
"""
|
||||
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
self.direction = spec.get("direction", "left")
|
||||
@@ -676,10 +702,14 @@ class ArrowShooter(Trap):
|
||||
rect = pygame.Rect(0, 0, w, h)
|
||||
rect.center = r.center
|
||||
# nudge the arrow to the emitting edge
|
||||
if dx == -1: rect.right = r.left
|
||||
elif dx == 1: rect.left = r.right
|
||||
elif dy == -1: rect.bottom = r.top
|
||||
elif dy == 1: rect.top = r.bottom
|
||||
if dx == -1:
|
||||
rect.right = r.left
|
||||
elif dx == 1:
|
||||
rect.left = r.right
|
||||
elif dy == -1:
|
||||
rect.bottom = r.top
|
||||
elif dy == 1:
|
||||
rect.top = r.bottom
|
||||
self.arrows.append(Arrow(rect, dx * self.speed, dy * self.speed))
|
||||
|
||||
def update(self, dt, game):
|
||||
@@ -704,8 +734,9 @@ class ArrowShooter(Trap):
|
||||
return [a.rect for a in self.arrows]
|
||||
|
||||
def draw(self, surface, assets):
|
||||
surface.blit(assets.get("arrow_shooter", self.tile, self.tile),
|
||||
self._rt(self.base_rect))
|
||||
surface.blit(
|
||||
assets.get("arrow_shooter", self.tile, self.tile), self._rt(self.base_rect)
|
||||
)
|
||||
for a in self.arrows:
|
||||
surface.blit(assets.get("arrow", a.rect.w, a.rect.h), self._rt(a.rect))
|
||||
|
||||
@@ -717,7 +748,8 @@ class Warp(Trap):
|
||||
destination and fades away, so the (otherwise invisible) teleport reads on
|
||||
screen. The level's ``debug`` flag tints it (and draws a line to its
|
||||
destination) while designing."""
|
||||
_PULSE_TIME = 0.35 # seconds the activation aura takes to fade out
|
||||
|
||||
_PULSE_TIME = 0.35 # seconds the activation aura takes to fade out
|
||||
|
||||
# Concentric rings of the aura: (radius x tile, alpha x, colour). Drawn
|
||||
# largest-first so the bright core sits on top.
|
||||
@@ -735,8 +767,8 @@ class Warp(Trap):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self._armed = True # re-arms once the player has left the tile
|
||||
self._pulse = 0.0 # 1.0 at activation, fades to 0 over _PULSE_TIME
|
||||
self._armed = True # re-arms once the player has left the tile
|
||||
self._pulse = 0.0 # 1.0 at activation, fades to 0 over _PULSE_TIME
|
||||
|
||||
def update(self, dt, game):
|
||||
if self._pulse > 0.0:
|
||||
@@ -749,7 +781,7 @@ class Warp(Trap):
|
||||
p.vx = p.vy = 0.0
|
||||
p._sync_rect()
|
||||
self._armed = False
|
||||
self._pulse = 1.0 # flash at both ends this frame, then fade
|
||||
self._pulse = 1.0 # flash at both ends this frame, then fade
|
||||
elif not inside:
|
||||
self._armed = True
|
||||
|
||||
@@ -763,8 +795,9 @@ class Warp(Trap):
|
||||
c.render(surface, assets)
|
||||
|
||||
def _dest_rect(self):
|
||||
return pygame.Rect(self.dest[0] * self.tile, self.dest[1] * self.tile,
|
||||
self.tile, self.tile)
|
||||
return pygame.Rect(
|
||||
self.dest[0] * self.tile, self.dest[1] * self.tile, self.tile, self.tile
|
||||
)
|
||||
|
||||
def _draw_aura(self, surface, rect):
|
||||
# An expanding, fading glow centred on the cell. As the pulse decays the
|
||||
@@ -788,8 +821,13 @@ class Warp(Trap):
|
||||
if self.level.debug:
|
||||
self._debug_tint(surface, (210, 80, 235), alpha=90)
|
||||
dest = self._dest_rect()
|
||||
pygame.draw.line(surface, (210, 80, 235),
|
||||
self._rt(self.base_rect).center, self._rt(dest).center, 1)
|
||||
pygame.draw.line(
|
||||
surface,
|
||||
(210, 80, 235),
|
||||
self._rt(self.base_rect).center,
|
||||
self._rt(dest).center,
|
||||
1,
|
||||
)
|
||||
self._debug_tint(surface, (210, 80, 235), dest, 45)
|
||||
|
||||
|
||||
@@ -799,6 +837,7 @@ class PhaseBlock(Trap):
|
||||
a solid obstacle over ``fade`` seconds (and fades back out when the trigger
|
||||
releases). If the player is standing in the cell the instant it *starts*
|
||||
appearing, they're killed."""
|
||||
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
self.fade = float(spec.get("fade", 0.3))
|
||||
@@ -814,14 +853,17 @@ class PhaseBlock(Trap):
|
||||
# How deep the player may be into the cell and still be nudged clear rather
|
||||
# than killed. A shallow clip (feet/shoulder in the cell) gets shoved out;
|
||||
# forming through their middle stays lethal.
|
||||
_EDGE_GRACE = 0.5 # fraction of a tile
|
||||
_EDGE_GRACE = 0.5 # fraction of a tile
|
||||
|
||||
def update(self, dt, game):
|
||||
self.emerge_kill = False
|
||||
active = self.triggered(game, dt)
|
||||
if active:
|
||||
if self.alpha == 0.0 and not self.solid \
|
||||
and game.player.rect.colliderect(self.base_rect):
|
||||
if (
|
||||
self.alpha == 0.0
|
||||
and not self.solid
|
||||
and game.player.rect.colliderect(self.base_rect)
|
||||
):
|
||||
# Forming into the player. If they're only clipping an edge, shove
|
||||
# them clear and let the block solidify behind them; only if it's
|
||||
# forming through their middle — or the shove would squish them
|
||||
@@ -847,15 +889,15 @@ class PhaseBlock(Trap):
|
||||
b = self.base_rect
|
||||
# Distance to move the player to clear the block on each side.
|
||||
outs = {
|
||||
"up": p.bottom - b.top,
|
||||
"down": b.bottom - p.top,
|
||||
"left": p.right - b.left,
|
||||
"up": p.bottom - b.top,
|
||||
"down": b.bottom - p.top,
|
||||
"left": p.right - b.left,
|
||||
"right": b.right - p.left,
|
||||
}
|
||||
side = min(outs, key=outs.get)
|
||||
dist = outs[side]
|
||||
if dist > self.tile * self._EDGE_GRACE:
|
||||
return False # deep overlap — forming through them
|
||||
return False # deep overlap — forming through them
|
||||
dx, dy = _DIRS[side]
|
||||
moved = p.move(dx * dist, dy * dist)
|
||||
# The block isn't solid yet, so it's absent from solid_rects(); any hit
|
||||
@@ -891,8 +933,9 @@ class PhaseBlock(Trap):
|
||||
self._debug_tint(surface, (120, 210, 240), alpha=45)
|
||||
return
|
||||
img = assets.get("phase_block", self.tile, self.tile).copy()
|
||||
img.fill((255, 255, 255, int(255 * self.alpha)),
|
||||
special_flags=pygame.BLEND_RGBA_MULT)
|
||||
img.fill(
|
||||
(255, 255, 255, int(255 * self.alpha)), special_flags=pygame.BLEND_RGBA_MULT
|
||||
)
|
||||
surface.blit(img, self._rt(self.base_rect))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user