Initial commit

This commit is contained in:
James Campbell
2026-07-21 19:36:02 -04:00
commit d84e4627c3
39 changed files with 4386 additions and 0 deletions

1
game/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Dying Phone — a single-screen 2D platformer engine built on pygame."""

72
game/assets.py Normal file
View File

@@ -0,0 +1,72 @@
"""Sprite loading with graceful placeholders.
Every drawable in the game asks the AssetStore for a surface by name. If a PNG
named ``<name>.png`` exists in the assets directory it is loaded and scaled to
the requested size; otherwise a labeled colored rectangle is drawn instead, so
the game is fully playable before any art exists.
"""
import os
import pygame
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._font = None
def _font_for(self, h):
# Lazily build a font sized to the tile so placeholder labels fit.
size = max(10, int(h * 0.5))
return pygame.font.SysFont("consolas,menlo,monospace", size, bold=True)
def _load_raw(self, name):
if name in self._raw:
return self._raw[name]
path = os.path.join(self.assets_dir, name + ".png")
surf = None
if os.path.isfile(path):
try:
surf = pygame.image.load(path).convert_alpha()
except pygame.error:
surf = None
self._raw[name] = surf
return surf
def get(self, name, w, h, angle=0):
"""Return a Surface of exactly (w, h) for the given sprite name,
optionally rotated counter-clockwise by ``angle`` degrees first (used to
point directional sprites like spikes the right way)."""
w, h = int(w), int(h)
angle %= 360
key = (name, w, h, angle)
if key in self._cache:
return self._cache[key]
raw = self._load_raw(name)
if raw is not None:
if angle:
raw = pygame.transform.rotate(raw, angle)
# Nearest-neighbour keeps the pixel-art sprites crisp at any size.
surf = pygame.transform.scale(raw, (w, h))
else:
surf = self._make_placeholder(name, w, h)
self._cache[key] = surf
return surf
def _make_placeholder(self, name, w, h):
color, label = settings.PLACEHOLDERS.get(name, ((200, 60, 200), "?"))
surf = pygame.Surface((w, h), pygame.SRCALPHA)
surf.fill(color)
# A subtle border helps distinguish adjacent tiles of the same color.
pygame.draw.rect(surf, (0, 0, 0, 90), surf.get_rect(), max(1, w // 16))
if label:
font = self._font_for(h)
text = font.render(label, True, (15, 15, 20))
surf.blit(text, text.get_rect(center=(w // 2, h // 2)))
return surf

377
game/game.py Normal file
View File

@@ -0,0 +1,377 @@
"""Top-level game loop, states, HUD, and the death/respawn/win flow."""
import os
import glob
import pygame
from . import settings as S
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
class Game:
def __init__(self, level_paths, assets_dir, debug=False):
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.assets = AssetStore(assets_dir)
self.clock = pygame.time.Clock()
self.hud_font = pygame.font.SysFont("consolas,menlo,monospace", 22, bold=True)
self.big_font = pygame.font.SysFont("consolas,menlo,monospace", 40, bold=True)
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
# Size the window ONCE to fit the largest level, then never resize it —
# calling set_mode again mid-session recreates the window (it flickers /
# looks like it closes). Smaller levels are centred within it.
dims = [(lv.width, lv.height) for lv in map(Level, self.level_paths)]
self.win_w = max(max(w for w, _ in dims), MIN_W)
self.win_h = max(h for _, h in dims) + HUD_H
self.screen = pygame.display.set_mode((self.win_w, self.win_h))
self._load_current()
# --- level management ----------------------------------------------------
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.player = Player(self.level)
self.battery = self.level.battery
self.world = pygame.Surface((self.level.width, self.level.height))
# Centre the play area in the fixed window, below the HUD.
ox = (self.win_w - self.level.width) // 2
oy = HUD_H + (self.win_h - HUD_H - self.level.height) // 2
self.world_pos = (ox, oy)
self.state = "playing"
self.death_timer = 0.0
self.death_rect = None
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.
# Traps are deliberately NOT reset yet — that happens on respawn.
if self.state != "playing":
return
self.deaths += 1
self.level_deaths += 1
self.state = "dying"
self.death_timer = S.DEATH_PAUSE
self.death_rect = self.player.rect.copy()
# Let traps settle into a final look before the scene freezes — e.g. a
# phase block that was mid-materialise snaps to fully visible.
for t in self.level.traps:
t.finalize_all()
def _respawn(self):
if self._pending_reload:
# F5 hot-reload: re-read the level file from disk (picks up edits),
# keeping the death the reload just counted.
self._pending_reload = False
kept = self.level_deaths
try:
self._load_current()
self.level_deaths = kept
except Exception as ex: # bad edit -> don't crash
print(f"[reload] {self.level_paths[self.index]}: {ex}")
self.player.respawn()
self.level.reset()
self.battery = self.level.battery
else:
self.player.respawn()
self.level.reset()
self.battery = self.level.battery # phone gets plugged back in at start
self.state = "playing"
def _reload_level(self):
# Hot-reload the current level from disk. Counts as a death — plays the
# death beat and bumps the counters — so it also escapes soft-locks.
if self.state != "playing":
return
self._pending_reload = True
self._start_death()
def _reach_goal(self):
# Freeze everything and play the battery-charging animation; the win
# splash follows once it finishes.
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._final = self.index + 1 >= len(self.level_paths)
def _advance(self):
self.index += 1
self._load_current()
def _replay(self):
# Fresh session after clearing everything.
self.index = 0
self.deaths = 0
self._load_current()
def _start_fade(self, action):
# Fade to black, run `action` (load a level) at full black, then fade in.
# Snapshot the current frame (the splash) so the fade-out dips *it* to
# black instead of flashing the underlying world.
self.fade_snapshot = self.screen.copy()
self.state = "fading"
self.fade_timer = 0.0
self.fade_phase = "out"
self.fade_action = action
def _update_fade(self, dt):
self.fade_timer += dt
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_phase = "in"
self.fade_timer = 0.0
else:
self.state = "playing"
# --- input ---------------------------------------------------------------
def _poll_events(self):
inp = InputState()
for e in pygame.event.get():
if e.type == pygame.QUIT:
self.running = False
elif e.type == pygame.KEYDOWN:
if e.key == pygame.K_ESCAPE:
self.running = False
elif e.key in (pygame.K_SPACE, pygame.K_w, pygame.K_UP):
inp.jump_pressed = True
elif e.key == pygame.K_r:
self._start_death() # manual give-up / restart
elif e.key == pygame.K_F5:
self._reload_level() # hot-reload from disk (counts as a death)
elif self.state == "won_level" and e.key == pygame.K_RETURN:
self._start_fade(self._advance)
elif self.state == "won_all" and e.key == pygame.K_RETURN:
self._start_fade(self._replay)
keys = pygame.key.get_pressed()
inp.left = keys[pygame.K_a] or keys[pygame.K_LEFT]
inp.right = keys[pygame.K_d] or keys[pygame.K_RIGHT]
inp.down = keys[pygame.K_s] or keys[pygame.K_DOWN]
inp.jump_held = keys[pygame.K_SPACE] or keys[pygame.K_w] or keys[pygame.K_UP]
return inp
# --- main loop -----------------------------------------------------------
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
inp = self._poll_events()
if self.state == "playing":
self._update_play(dt, inp)
elif self.state == "dying":
# Scene is frozen; just count down, then reset and respawn.
self.death_timer -= dt
if self.death_timer <= 0:
self._respawn()
elif self.state == "charging":
# Everything stops while the battery animates up to full.
self.charge_timer += dt
if self.charge_timer >= S.CHARGE_TIME:
self.state = "won_all" if self._final else "won_level"
elif self.state == "fading":
self._update_fade(dt)
self._draw()
pygame.quit()
def _update_play(self, dt, inp):
self.battery -= dt
self.level.update(dt, self)
self.player.update(dt, inp)
# 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
if not died:
for hz in self.level.hazard_rects():
if pr.colliderect(hz):
died = True
break
if died:
self._start_death()
return
# reached the charger?
if pr.colliderect(self.level.goal_rect):
self._reach_goal()
# --- rendering -----------------------------------------------------------
def _draw(self):
# Fade-out: dip the frozen splash frame to black (level not swapped yet).
if self.state == "fading" and self.fade_phase == "out":
self.screen.blit(self.fade_snapshot, (0, 0))
p = min(1.0, self.fade_timer / S.FADE_TIME)
overlay = pygame.Surface(self.screen.get_size())
overlay.fill((0, 0, 0))
overlay.set_alpha(int(255 * p))
self.screen.blit(overlay, (0, 0))
pygame.display.flip()
return
self.world.fill(S.COLOR_BG)
self.level.draw(self.world, self.assets)
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)
self.world.blit(sprite, self.death_rect)
else:
self.player.draw(self.world, self.assets)
self.screen.fill((12, 12, 18))
self.screen.blit(self.world, self.world_pos)
self._draw_hud(hide_battery=(self.state == "charging"))
if self.state == "dying":
# A red tint that fades as the corpse lingers.
alpha = int(140 * (self.death_timer / S.DEATH_PAUSE))
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((200, 40, 40, alpha))
self.screen.blit(overlay, (0, 0))
if self.state == "charging":
self._draw_charge_anim()
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")
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")
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))
overlay.set_alpha(int(255 * (1 - p)))
self.screen.blit(overlay, (0, 0))
pygame.display.flip()
@staticmethod
def _draw_battery(surf, rect, fill_frac, col):
"""Draw a battery (frame + fill + nub) into an arbitrary rect, so the
same widget serves the HUD and the blown-up charging animation."""
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)
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)
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))
def _draw_hud(self, hide_battery=False):
w = self.screen.get_width()
pygame.draw.rect(self.screen, (18, 20, 30), (0, 0, w, HUD_H))
# battery bar. The phone is dying, so the bar *looks* near-empty from the
# start (scaled by the level's battery_pct); the seconds text below is the
# real timer. Fill drains proportionally to the time left. Hidden while
# the charge animation flies the battery to centre stage.
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
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)
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)
self.screen.blit(deaths, (w - deaths.get_width() - 12, by - 1))
name = self.hud_font.render(self.level.name, True, S.COLOR_HUD)
self.screen.blit(name, (w - deaths.get_width() - name.get_width() - 32, by - 1))
def _hero_battery(self):
"""The enlarged battery's resting rect — centre of screen, ~60% wide.
Shared by the charge animation (its target) and the win splash (so it
stays put, full, while the splash is up)."""
W, H = self.screen.get_size()
bar_w, bar_h = 180, 20
scale = min(W * 0.6, 520) / bar_w
rect = pygame.Rect(0, 0, round(bar_w * scale), round(bar_h * scale))
rect.center = (W // 2, H // 2)
return rect
def _draw_charge_anim(self):
"""The charging beat: dim the frozen scene, then fly the battery out of
the HUD toward the centre and grow it (brought toward the viewer) while
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%
fill = self.charge_from + (1.0 - self.charge_from) * fill_p
dim = pygame.Surface((W, H), pygame.SRCALPHA)
dim.fill((6, 8, 14, int(195 * move_p)))
self.screen.blit(dim, (0, 0))
bar_w, bar_h = 180, 20
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.center = (round(pos.x), round(pos.y))
self._draw_battery(self.screen, rect, fill, (90, 220, 120))
pct = int(round(fill * 100))
num = self.big_font.render(f"{pct}%", True, (150, 245, 180))
self.screen.blit(num, num.get_rect(center=(W // 2, rect.top - 36)))
def _draw_win_splash(self, title, subtitle):
"""Level-complete overlay: keep the charged hero battery centred, with
the message above and below it."""
W, H = self.screen.get_size()
dim = pygame.Surface((W, H), pygame.SRCALPHA)
dim.fill((10, 12, 20, 205))
self.screen.blit(dim, (0, 0))
rect = self._hero_battery()
self._draw_battery(self.screen, rect, 1.0, (90, 220, 120))
t = self.big_font.render(title, True, (120, 240, 160))
self.screen.blit(t, t.get_rect(center=(W // 2, rect.top - 40)))
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")))
return paths

170
game/level.py Normal file
View File

@@ -0,0 +1,170 @@
"""Level loading: YAML -> geometry + traps.
A level file has an ASCII ``map`` for static geometry and a ``traps`` list for
the interactive/nasty bits. Grid coordinates are ``[col, row]`` (0-based, from
the top-left of the map).
Map legend:
# solid block
- one-way platform (stand on top, jump up through it)
P player spawn
G goal (the phone charger)
. or space empty
"""
import os
import pygame
import yaml
from . import settings
from . import traps as traps_mod
class Level:
def __init__(self, path):
with open(path, "r") as fh:
data = yaml.safe_load(fh) or {}
self.path = path
self.name = data.get("name", os.path.splitext(os.path.basename(path))[0])
self.tile = int(data.get("tile_size", settings.TILE))
self.battery = float(data.get("battery_seconds", settings.DEFAULT_BATTERY))
# How full the battery bar *looks* at the start (percent). Visual only —
# the actual time limit is battery_seconds.
self.battery_pct = float(data.get("battery_pct", settings.DEFAULT_BATTERY_PCT))
# Debug view: reveal everything normally hidden — one-way platforms,
# invisible walls, warps, dormant phase blocks, fake blocks, and
# not-yet-sprung spikes. Traps read this via ``self.level.debug``.
self.debug = bool(data.get("debug", False))
rows = data.get("map", "").splitlines()
# Strip a leading blank line from block-scalar formatting, keep shape.
rows = [r for r in rows if r.strip("\n") != "" or True]
rows = [r.rstrip("\n") for r in rows]
if rows and rows[0] == "":
rows = rows[1:]
self.cols = max((len(r) for r in rows), default=0)
self.grid_rows = len(rows)
self.width = self.cols * self.tile
self.height = self.grid_rows * self.tile
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)
for r, line in enumerate(rows):
for c, ch in enumerate(line):
rect = self.cell_rect(c, r)
if ch == "#":
self.solids.append(rect)
elif ch == "-":
self.oneways.append(rect)
elif ch == "P":
self.spawn = (rect.x, rect.y)
elif ch == "G":
self.goal_rect = rect
# Build traps from the config via the factory. `count`/`spacing` on a
# spec expands into a line/grid of copies first.
self.traps = []
for spec in data.get("traps", []) or []:
for one in traps_mod.expand_spec(spec):
trap = traps_mod.make_trap(one, self)
if trap is not None:
self.traps.append(trap)
# Snap any mounted traps onto their parent's starting position so their
# geometry is correct even before the first update tick.
self.reset()
# --- helpers -------------------------------------------------------------
def cell_rect(self, col, row):
return pygame.Rect(col * self.tile, row * self.tile, self.tile, self.tile)
def reset(self):
"""Reset every trap to its initial state (called on player death)."""
for t in self.traps:
t.reset_all()
def update(self, dt, game):
for t in self.traps:
t.tick(dt, game)
# Rects that block movement this frame: static solids + any trap-provided
# solids (moving/patrol blocks). One-way platforms are handled separately.
# The all_* wrappers fold in anything mounted on a trap.
def solid_rects(self):
rects = list(self.solids)
for t in self.traps:
rects.extend(t.all_solid_rects())
return rects
def oneway_rects(self):
rects = list(self.oneways)
for t in self.traps:
rects.extend(t.all_oneway_rects())
return rects
# Moving platforms the player can ride: (rect, dx, dy) moved this frame.
def carriers(self):
out = []
for t in self.traps:
out.extend(t.all_carriers())
return out
# Rects that kill the player on contact.
def hazard_rects(self):
rects = []
for t in self.traps:
rects.extend(t.all_hazard_rects())
return rects
def draw(self, surface, assets):
t = self.tile
block = assets.get("block", t, t)
for rect in self.solids:
surface.blit(block, rect)
# One-way platforms look identical to solid blocks — you only discover
# them by falling through. `debug` fades them so a level designer can
# see which is which.
oneway_img = block
if self.debug:
oneway_img = block.copy()
oneway_img.fill((255, 255, 255, 110), special_flags=pygame.BLEND_RGBA_MULT)
for rect in self.oneways:
surface.blit(oneway_img, rect)
surface.blit(assets.get("goal", self.goal_rect.w, self.goal_rect.h),
self.goal_rect)
for tr in self.traps:
tr.render(surface, assets)
if self.debug:
self._draw_grid(surface)
_grid_font = None
def _draw_grid(self, surface):
"""A faint tile grid with col/row labels, so `at: [col,row]` placement
is eyeballable while designing."""
if Level._grid_font is None:
Level._grid_font = pygame.font.SysFont("consolas,menlo,monospace", 10)
overlay = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
line = (255, 255, 255, 26)
for c in range(self.cols + 1):
x = c * self.tile
pygame.draw.line(overlay, line, (x, 0), (x, self.height))
for r in range(self.grid_rows + 1):
y = r * self.tile
pygame.draw.line(overlay, line, (0, y), (self.width, y))
label = (150, 162, 190)
for c in range(self.cols):
overlay.blit(Level._grid_font.render(str(c), True, label),
(c * self.tile + 2, 1))
for r in range(self.grid_rows):
overlay.blit(Level._grid_font.render(str(r), True, label),
(1, r * self.tile + 1))
surface.blit(overlay, (0, 0))

270
game/player.py Normal file
View 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)

48
game/settings.py Normal file
View File

@@ -0,0 +1,48 @@
"""Global tunables. Anything a level does not override falls back to these."""
# --- Display -----------------------------------------------------------------
TILE = 32 # default tile size in pixels (levels may override)
FPS = 60
CAPTION = "Dying Phone"
# --- 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
# --- Gameplay ----------------------------------------------------------------
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
# --- Colors (placeholder rendering) ------------------------------------------
COLOR_BG = (24, 26, 38)
COLOR_HUD = (235, 235, 245)
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), ""),
"crumble_block": ((150, 120, 100), ""),
"arrow_shooter": ((80, 80, 95), ""),
"arrow": ((250, 220, 90), ">"),
"spike_block": ((150, 156, 172), "*"),
"phase_block": ((90, 180, 210), ""),
}

883
game/traps.py Normal file
View File

@@ -0,0 +1,883 @@
"""Traps: data-driven hazards and moving geometry.
Every trap subclasses :class:`Trap` and implements a few optional hooks that the
engine polls each frame:
solid_rects() -> rects that fully block movement (moving/patrol blocks)
oneway_rects() -> rects that block only from above
hazard_rects() -> rects that kill the player on contact
carriers() -> (rect, dx, dy) moved-this-frame platforms to ride
update(dt, game), draw(surface, assets), reset()
Add a new trap by writing a subclass and registering it in ``TRAP_TYPES``.
Nothing else in the engine needs to change.
"""
import pygame
from . import settings
# --- helpers -----------------------------------------------------------------
_DIRS = {
"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0),
}
# --- trigger conditions ------------------------------------------------------
# A trigger is a condition (or tree of conditions) evaluated against the player
# each frame. Leaves are spatial (within / dir) or temporal (timer); composites
# are all / any. Everything is measured against the trap's *current* rect, so a
# moving trap's sensors follow it automatically.
#
# trigger: always
# trigger: { within: 2 }
# trigger: { dir: left, range: 2, aligned: true }
# 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
def reset(self):
pass
class _Within:
"""Player within N tiles of the trap centre (Euclidean radius)."""
def __init__(self, n):
self.n = float(n)
def evaluate(self, trap, game, dt):
r = trap.sensor_rect()
p = game.player.rect
dx = p.centerx - r.centerx
dy = p.centery - r.centery
reach = self.n * trap.tile
return dx * dx + dy * dy <= reach * reach
def reset(self):
pass
class _Directional:
"""Player is to a given side of the trap.
range: cap the distance in that direction (tiles); omit = anywhere.
aligned: also require overlap on the perpendicular axis, i.e. *directly*
left/right (same rows) or *directly* above/below (same columns).
"""
def __init__(self, direction, rng, aligned):
self.dir = direction
self.rng = None if rng is None else float(rng)
self.aligned = bool(aligned)
def evaluate(self, trap, game, dt):
r = trap.sensor_rect()
p = game.player.rect
d = self.dir
if d in ("left", "right"):
if self.aligned and not (p.bottom > r.top and p.top < r.bottom):
return False
if d == "left":
if p.centerx >= r.left:
return False
dist = r.left - p.centerx
else:
if p.centerx <= r.right:
return False
dist = p.centerx - r.right
else: # above / below
if self.aligned and not (p.right > r.left and p.left < r.right):
return False
if d == "above":
if p.centery >= r.top:
return False
dist = r.top - p.centery
else:
if p.centery <= r.bottom:
return False
dist = p.centery - r.bottom
return self.rng is None or dist <= self.rng * trap.tile
def reset(self):
pass
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)
self.t = 0.0
def evaluate(self, trap, game, dt):
self.t += dt
return (self.t % (self.interval + self.up_time)) >= self.interval
def reset(self):
self.t = 0.0
class _All:
def __init__(self, subs):
self.subs = subs
def evaluate(self, trap, game, dt):
# Evaluate every child (so timers keep ticking), then combine.
return all([c.evaluate(trap, game, dt) for c in self.subs])
def reset(self):
for c in self.subs:
c.reset()
class _Any:
def __init__(self, subs):
self.subs = subs
def evaluate(self, trap, game, dt):
return any([c.evaluate(trap, game, dt) for c in self.subs])
def reset(self):
for c in self.subs:
c.reset()
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}")
if "all" in spec:
return _All([make_condition(s) for s in spec["all"]])
if "any" in spec:
return _Any([make_condition(s) for s in spec["any"]])
if "timer" in spec:
tm = spec["timer"]
return _Timer(tm.get("interval", 1.5), tm.get("up_time", 0.8))
if "within" in spec:
return _Within(spec["within"])
if "dir" in spec:
return _Directional(spec["dir"], spec.get("range"), spec.get("aligned", False))
raise ValueError(f"unrecognized trigger condition: {spec!r}")
class Trap:
def __init__(self, spec, level):
self.spec = spec
self.level = level
self.tile = level.tile
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)
# Any trap can be made invisible (still functional; revealed in debug).
self.invisible = bool(spec.get("invisible", False))
# Mounted traps ride on this one. Their `at` is read as a *relative*
# offset (in tiles) from this trap's cell; each frame they are moved to
# track this trap's current position (see tick/_follow). A mounted trap
# rides rigidly — it doesn't move independently.
self._mount_off = (0, 0)
self.mounts = []
for mspec in spec.get("mounts", []) or []:
child = make_trap(mspec, level)
if child is not None:
child._mount_off = (child.base_rect.x, child.base_rect.y)
child._mounted = True
self.mounts.append(child)
# --- geometry hooks (default: contribute nothing) ------------------------
def solid_rects(self):
return []
def oneway_rects(self):
return []
def hazard_rects(self):
return []
def carriers(self):
return []
# Where this trap currently is. Static traps stay at their cell; movers
# override to return their live position so mounted traps can follow.
def current_rect(self):
return self.base_rect
# Where this trap's trigger senses the player from. Defaults to the live
# position; a slider overrides it to sense from home so moving away can't
# toggle its own trigger.
def sensor_rect(self):
return self.current_rect()
# Debug helper: tint a rect so a normally-hidden trap is visible when the
# level's `debug` flag is on.
def _debug_tint(self, surface, rgb, rect=None, alpha=80):
r = rect if rect is not None else self.base_rect
overlay = pygame.Surface(r.size, pygame.SRCALPHA)
overlay.fill((*rgb, alpha))
surface.blit(overlay, r)
# Debug helper: a faded sprite + outline showing where something absent
# (e.g. a crumbled-away block) belongs.
def _debug_ghost(self, surface, assets, sprite_name, rect=None):
r = rect if rect is not None else self.base_rect
img = assets.get(sprite_name, r.w, r.h).copy()
img.fill((255, 255, 255, 70), special_flags=pygame.BLEND_RGBA_MULT)
surface.blit(img, r)
pygame.draw.rect(surface, (130, 140, 160), r, 1)
# Debug helper: outline a path through a list of tile cells (top-left px),
# connecting their centres. `closed` joins the last cell back to the first.
def _debug_path(self, surface, cells, closed=False, color=(214, 200, 96)):
rects = [pygame.Rect(x, y, self.tile, self.tile) for (x, y) in cells]
if len(rects) >= 2:
pygame.draw.lines(surface, color, closed, [r.center for r in rects], 1)
for r in rects:
pygame.draw.rect(surface, color, r, 1)
# --- lifecycle hooks -----------------------------------------------------
def update(self, dt, game):
pass
def draw(self, surface, assets):
pass
def reset(self):
pass
def finalize_on_death(self):
"""Hook: snap to a final appearance the instant the player dies, before
the scene freezes for the death animation. Default: nothing."""
pass
# --- mounting: aggregate self + mounted children -------------------------
# Levels call these wrappers so a trap and everything riding on it are
# treated as one unit. Subclasses keep overriding the plain hooks above.
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)
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.tick(dt, game)
def render(self, surface, assets):
# `invisible` traps skip their visible drawing, but still show up under
# the level's debug view (their draw() reveals them there).
if not (self.invisible and not self.level.debug):
self.draw(surface, assets)
for c in self.mounts:
c.render(surface, assets)
def reset_all(self):
self.reset()
for c in self.mounts:
c.reset_all()
c._follow(self)
def finalize_all(self):
self.finalize_on_death()
for c in self.mounts:
c.finalize_all()
def all_solid_rects(self):
out = list(self.solid_rects())
for c in self.mounts:
out.extend(c.all_solid_rects())
return out
def all_oneway_rects(self):
out = list(self.oneway_rects())
for c in self.mounts:
out.extend(c.all_oneway_rects())
return out
def all_hazard_rects(self):
out = list(self.hazard_rects())
for c in self.mounts:
out.extend(c.all_hazard_rects())
return out
def all_carriers(self):
out = list(self.carriers())
for c in self.mounts:
out.extend(c.all_carriers())
return out
# --- triggers ------------------------------------------------------------
# Traps with an activation condition call _init_trigger() in __init__,
# _reset_trigger() in reset(), and triggered() each frame.
def _init_trigger(self, spec):
self.trigger = make_condition(spec.get("trigger", "always"))
self.trig_delay = float(spec.get("delay", 0.0)) # arm delay (seconds)
self._trig_timer = 0.0
def _reset_trigger(self):
self._trig_timer = 0.0
self.trigger.reset()
def triggered(self, game, dt):
"""True while the trigger condition holds. If ``delay`` is set, the
condition must hold *continuously* for that long first; leaving the
condition resets the countdown."""
raw = self.trigger.evaluate(self, game, dt)
self._trig_timer = self._trig_timer + dt if raw else 0.0
return raw and self._trig_timer >= self.trig_delay
# --- spike: emerges to kill -------------------------------------------------
class Spike(Trap):
"""A spike that becomes deadly while its ``trigger`` condition holds.
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")
self._init_trigger(spec)
self.reset()
def reset(self):
self._reset_trigger()
self.active = False
def _hazard_rect(self):
# Spike occupies half the cell along its emerging edge.
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
return pygame.Rect(r.x, r.y + t // 2, t, t // 2)
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)
return pygame.Rect(r.x, r.y, t // 2, t)
return pygame.Rect(r.x + t // 2, 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}
def update(self, dt, game):
self.active = self.triggered(game, dt)
def hazard_rects(self):
return [self._hazard_rect()] if self.active else []
def draw(self, surface, assets):
if self.active:
hr = self._hazard_rect()
angle = self._ANGLE.get(self.direction, 0)
surface.blit(assets.get("spike", hr.w, hr.h, angle), hr)
elif self.level.debug:
# A dormant spike — show where it will strike.
self._debug_tint(surface, (230, 80, 80), self._hazard_rect(), 60)
# --- 3. block: the unified stationary / sliding / patrolling / spike block ---
class Block(Trap):
"""A block that may move and/or be deadly — one trap covering stationary
blocks, proximity sliders, patrolling platforms, and spike blocks.
path: list of [col,row] waypoints (default just ``[at]`` = stationary).
move: [dcol,drow] shorthand for a 2-point path [at, at+move] (a slider).
trigger: when it moves (default ``always``). Sensors track the block's live
position, so a condition like ``{dir: above}`` keeps it going while
the player rides it.
mode: ``once`` (default) extends to the last point while triggered and
retreats to the first when not — the slider/dropper behaviour;
``loop`` / ``pingpong`` cycle the whole path continuously (patrol).
deadly: true -> a hazard (spikes) instead of a solid.
speed: px/s. sprite: override (default spike_block if deadly, else moving_block).
delay/release: (``once`` mode) the trigger must hold for ``delay`` seconds to
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
if spec.get("path"):
pts = spec["path"]
elif spec.get("move"):
mv = spec["move"]
pts = [[self.col, self.row], [self.col + mv[0], self.row + mv[1]]]
else:
pts = [[self.col, self.row]]
self.points = [(p[0] * t, p[1] * t) for p in pts]
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.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
# `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),
# so it stays put while ridden instead of pulling back.
self.sense = spec.get("sense", "home")
self._init_trigger(spec)
self.reset()
def sensor_rect(self):
return self.base_rect if self.sense == "home" else self._rect()
def _follow(self, parent):
# Mounted on another trap. Our motion coordinates (self.x, self.y) live in
# a LOCAL frame relative to the parent; we track the parent's live
# position as our `_origin` and still run our own motion in update(). So a
# mount can slide/patrol while riding along with its carrier — e.g. a
# block that lunges up to catch a player leaping over, yet keeps drifting
# sideways with the platform it sits on. Capture `prev` (absolute) BEFORE
# shifting the origin so carriers() reports our *total* motion this frame
# (parent drift + our own move).
self.prev = (self._origin[0] + self.x, self._origin[1] + self.y)
self._origin = parent.current_rect().topleft
# `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)
def reset(self):
self._reset_trigger()
self.x, self.y = self.points[0]
# Parent top-left when mounted (set each frame by _follow); (0, 0) for a
# free block, so its x/y double as absolute coords. A mount's x/y are
# 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._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.ctimer = 0.0
self.shake = 0.0
self.emerge_kill = False
def update(self, dt, game):
# A mount's `prev` (absolute) was captured in _follow before its origin
# shifted; a free block records it here. Either way the motion below runs
# in our own frame (local for a mount, absolute otherwise), so a mounted
# 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
if len(self.points) >= 2:
step = self.speed * dt
if self.mode == "once":
self._update_once(active, dt, step)
else:
self._update_patrol(active, step)
if self.crumble:
self._update_crumble(dt, game)
def _update_crumble(self, dt, game):
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)
if self.cstate == "solid":
if on_top:
self.cstate = "crumbling"
self.ctimer = 0.0
elif self.cstate == "crumbling":
self.ctimer += dt
self.shake = (self.ctimer * 40) % 4 - 2
if self.ctimer >= self.crumble_delay:
self.cstate = "gone"
self.ctimer = 0.0
elif self.cstate == "gone":
self.ctimer += dt
if self.ctimer >= self.respawn:
# Re-forming into the player kills them (like the old crumble).
if p.colliderect(r):
self.emerge_kill = True
else:
self.cstate = "solid"
self.ctimer = 0.0
self.shake = 0.0
def _step_to(self, tgt, step):
"""Move toward tgt by step; snap and return True on arrival."""
tx, ty = tgt
dx, dy = tx - self.x, ty - self.y
dist = (dx * dx + dy * dy) ** 0.5
if dist <= step or dist == 0:
self.x, self.y = tx, ty
return True
self.x += dx / dist * step
self.y += dy / dist * step
return False
def _update_once(self, active, dt, step):
# A committed stroke: once moving we run to the endpoint regardless of
# the trigger flickering, and only reconsider it while parked — no
# mid-stroke reversal, so a block that moves out of its own sensor range
# can't buzz. Hysteresis (delay/release) smooths the parked decisions.
n = len(self.points)
if self.phase == "rest":
if active:
self.phase = "extending"
elif self.phase == "extending":
if self._step_to(self.points[self.idx + 1], step):
self.idx += 1
if self.idx >= n - 1:
self.phase = "extended"
self._release_t = 0.0
elif self.phase == "extended":
if active:
self._release_t = 0.0
else:
self._release_t += dt
if self._release_t >= self.release:
self.phase = "retracting"
elif self.phase == "retracting":
if self._step_to(self.points[self.idx - 1], step):
self.idx -= 1
if self.idx <= 0:
self.phase = "rest"
def _update_patrol(self, active, step):
tgt = self.points[self.idx] if active else self.points[0]
if self._step_to(tgt, step):
if active:
self._advance_patrol()
else:
self.idx = 1 if len(self.points) > 1 else 0
self.dir = 1
def _advance_patrol(self):
n = len(self.points)
if self.mode == "loop":
self.idx = (self.idx + 1) % n
else: # pingpong
nxt = self.idx + self.dir
if nxt >= n or nxt < 0:
self.dir *= -1
nxt = self.idx + self.dir
self.idx = nxt
def _rect(self):
ox, oy = self._origin
return pygame.Rect(round(ox + self.x), round(oy + self.y),
self.tile, self.tile)
def current_rect(self):
return self._rect()
def _intangible(self):
return self.deadly or self.fake or (self.crumble and self.cstate == "gone")
def solid_rects(self):
return [] if self._intangible() else [self._rect()]
def hazard_rects(self):
rects = []
if self.deadly:
rects.append(self._rect().inflate(-4, -4))
if self.crumble and self.emerge_kill:
rects.append(self._rect())
return rects
def carriers(self):
if self._intangible():
return []
ax = self._origin[0] + self.x
ay = self._origin[1] + self.y
return [(self._rect(), ax - self.prev[0], ay - self.prev[1])]
def draw(self, surface, assets):
if self.level.debug and len(self.points) > 1:
self._debug_path(surface, self.points, closed=(self.mode == "loop"))
# crumbled away: hidden (ghost in debug), unless re-forming into the player
if self.crumble and self.cstate == "gone" and not self.emerge_kill:
if self.level.debug:
self._debug_ghost(surface, assets, self.sprite)
return
rect = self._rect()
if self.crumble and self.cstate == "crumbling":
rect = rect.move(int(self.shake), 0)
surface.blit(assets.get(self.sprite, self.tile, self.tile), rect)
if self.fake and self.level.debug:
self._debug_tint(surface, (255, 40, 40), self._rect(), 90)
# --- 4. arrow shooter --------------------------------------------------------
class Arrow:
__slots__ = ("rect", "vx", "vy")
def __init__(self, rect, vx, vy):
self.rect = rect
self.vx = vx
self.vy = vy
class ArrowShooter(Trap):
"""A block that fires deadly arrows on an interval while triggered.
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")
self.speed = float(spec.get("speed", 260.0))
self.interval = float(spec.get("interval", 1.6))
self._init_trigger(spec)
self.reset()
def reset(self):
self.timer = 0.0
self.arrows = []
self._reset_trigger()
def _spawn(self):
dx, dy = _DIRS.get(self.direction, (-1, 0))
t = self.tile
w = t // 2 if dx else t // 3
h = t // 3 if dx else t // 2
r = self.base_rect
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
self.arrows.append(Arrow(rect, dx * self.speed, dy * self.speed))
def update(self, dt, game):
can_fire = self.triggered(game, dt)
self.timer += dt
if can_fire and self.timer >= self.interval:
self.timer = 0.0
self._spawn()
bounds = pygame.Rect(0, 0, self.level.width, self.level.height).inflate(80, 80)
alive = []
for a in self.arrows:
a.rect.x += round(a.vx * dt)
a.rect.y += round(a.vy * dt)
if bounds.contains(a.rect) or bounds.colliderect(a.rect):
# stop at solid walls
if not any(a.rect.colliderect(s) for s in self.level.solids):
alive.append(a)
self.arrows = alive
def hazard_rects(self):
return [a.rect for a in self.arrows]
def draw(self, surface, assets):
surface.blit(assets.get("arrow_shooter", self.tile, self.tile), self.base_rect)
for a in self.arrows:
surface.blit(assets.get("arrow", a.rect.w, a.rect.h), a.rect)
# --- warp: invisible teleporter ---------------------------------------------
class Warp(Trap):
"""An invisible tile that teleports the player to ``to: [col, row]`` on
contact. The level's ``debug`` flag tints it (and draws a line to its
destination) while designing."""
def __init__(self, spec, level):
super().__init__(spec, level)
self.invisible = True
to = spec.get("to", [self.col, self.row])
self.dest = (int(to[0]), int(to[1]))
self.reset()
def reset(self):
self._armed = True # re-arms once the player has left the tile
def update(self, dt, game):
inside = game.player.rect.colliderect(self.base_rect)
if inside and self._armed:
p = game.player
p.fx = float(self.dest[0] * self.tile + (self.tile - p.w) / 2)
p.fy = float(self.dest[1] * self.tile + (self.tile - p.h))
p.vx = p.vy = 0.0
p._sync_rect()
self._armed = False
elif not inside:
self._armed = True
def draw(self, surface, assets):
if self.level.debug:
self._debug_tint(surface, (210, 80, 235), alpha=90)
dest = pygame.Rect(self.dest[0] * self.tile, self.dest[1] * self.tile,
self.tile, self.tile)
pygame.draw.line(surface, (210, 80, 235),
self.base_rect.center, dest.center, 1)
self._debug_tint(surface, (210, 80, 235), dest, 45)
# --- 10. phase block: fades into a solid on trigger -------------------------
class PhaseBlock(Trap):
"""Invisible and intangible until its ``trigger`` fires, then it fades into
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))
self._init_trigger(spec)
self.reset()
def reset(self):
self._reset_trigger()
self.alpha = 0.0
self.solid = False
self.emerge_kill = False
# 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
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):
# 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
# into a solid — is it lethal.
if not self._eject_player(game):
self.emerge_kill = True
return
self.alpha = min(1.0, self.alpha + dt / self.fade)
self.solid = True
else:
self.alpha = max(0.0, self.alpha - dt / self.fade)
if self.alpha == 0.0:
self.solid = False
def _eject_player(self, game):
"""Nudge a player who's only clipping the forming block out of its cell.
Returns True if they were pushed clear (forgiving). Returns False — leave
it lethal — when the block is forming through the player's middle (too
deep to fairly eject) or the shove would press them into another solid
(squished against something, a crush as usual)."""
p = game.player.rect
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,
"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
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
# here is a *different* solid backing them — no room to dodge = squished.
if any(moved.colliderect(s) for s in self.level.solid_rects()):
return False
player = game.player
player.fx += dx * dist
player.fy += dy * dist
if dx:
player.vx = 0.0
if dy:
player.vy = 0.0
player._sync_rect()
return True
def finalize_on_death(self):
# If we were forming when the player died, snap to fully visible so the
# frozen death tableau shows the block that got them.
if self.emerge_kill or self.alpha > 0.0:
self.alpha = 1.0
self.solid = True
def solid_rects(self):
return [self.base_rect] if self.solid else []
def hazard_rects(self):
return [self.base_rect] if self.emerge_kill else []
def draw(self, surface, assets):
if self.alpha <= 0.0:
if self.level.debug:
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)
surface.blit(img, self.base_rect)
# --- registry + factory ------------------------------------------------------
TRAP_TYPES = {
"spike": Spike,
"block": Block,
"arrow_shooter": ArrowShooter,
"warp": Warp,
"phase_block": PhaseBlock,
}
def make_trap(spec, level):
ttype = spec.get("type")
cls = TRAP_TYPES.get(ttype)
if cls is None:
print(f"[level] unknown trap type: {ttype!r} — skipping")
return None
return cls(spec, level)
def expand_spec(spec):
"""Expand a trap spec's ``count`` into a line/grid of copies.
``count: [nx, ny]`` (or a single int for a horizontal line) places
nx-by-ny copies, each offset by ``spacing: [sx, sy]`` tiles (default 1).
Only ``at`` is shifted per copy (so ``move`` is relative and works;
absolute ``path`` is shared, so arrays suit stationary/simple traps).
A rectangle of ``invisible`` blocks replaces the old invisible wall.
"""
count = spec.get("count")
if count is None:
yield spec
return
if isinstance(count, (list, tuple)):
nx = int(count[0])
ny = int(count[1]) if len(count) > 1 else 1
else:
nx, ny = int(count), 1
spacing = spec.get("spacing", 1)
if isinstance(spacing, (list, tuple)):
sx = spacing[0]
sy = spacing[1] if len(spacing) > 1 else spacing[0]
else:
sx = sy = spacing
bc, br = spec.get("at", [0, 0])
for j in range(ny):
for i in range(nx):
s = dict(spec)
s.pop("count", None)
s.pop("spacing", None)
s["at"] = [bc + i * sx, br + j * sy]
yield s