Initial commit
This commit is contained in:
1
game/__init__.py
Normal file
1
game/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Dying Phone — a single-screen 2D platformer engine built on pygame."""
|
||||
72
game/assets.py
Normal file
72
game/assets.py
Normal 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
377
game/game.py
Normal 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
170
game/level.py
Normal 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
270
game/player.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""The player character and all of its physics.
|
||||
|
||||
Movement: A / D to run, Space (or W) to jump with variable height, S to drop
|
||||
through one-way platforms. Collision is swept-axis AABB against the level's
|
||||
solid rects, with separate handling for one-way platforms and moving platforms
|
||||
the player can ride.
|
||||
"""
|
||||
|
||||
import pygame
|
||||
|
||||
from . import settings as S
|
||||
|
||||
|
||||
class InputState:
|
||||
__slots__ = ("left", "right", "down", "jump_pressed", "jump_held")
|
||||
|
||||
def __init__(self):
|
||||
self.left = self.right = self.down = False
|
||||
self.jump_pressed = False # edge: pressed this frame
|
||||
self.jump_held = False # level: currently down
|
||||
|
||||
|
||||
class Player:
|
||||
def __init__(self, level):
|
||||
self.level = level
|
||||
self.w = int(level.tile * 0.72)
|
||||
self.h = int(level.tile * 0.92)
|
||||
self.rect = pygame.Rect(0, 0, self.w, self.h)
|
||||
self.respawn()
|
||||
|
||||
def respawn(self):
|
||||
sx, sy = self.level.spawn
|
||||
# center the player horizontally in its spawn tile, feet at tile bottom
|
||||
self.fx = float(sx + (self.level.tile - self.w) / 2)
|
||||
self.fy = float(sy + (self.level.tile - self.h))
|
||||
self.vx = 0.0
|
||||
self.vy = 0.0
|
||||
self.on_ground = False
|
||||
self.coyote = 0.0
|
||||
self.jump_buffer = 0.0
|
||||
self.facing = 1
|
||||
self.drop_through_timer = 0.0
|
||||
self.was_jump_held = False
|
||||
self.crushed = False
|
||||
self.carry = (0.0, 0.0)
|
||||
self._sync_rect()
|
||||
|
||||
def _sync_rect(self):
|
||||
self.rect.x = round(self.fx)
|
||||
self.rect.y = round(self.fy)
|
||||
|
||||
# --- main update ---------------------------------------------------------
|
||||
def update(self, dt, inp):
|
||||
self._ride_platforms()
|
||||
self._push_by_movers()
|
||||
self._horizontal(dt, inp)
|
||||
self._vertical(dt, inp)
|
||||
self.crushed = self._check_crush()
|
||||
|
||||
def _push_by_movers(self):
|
||||
"""A solid moving horizontally into our side shoves us along that axis.
|
||||
|
||||
Without this the player never resolves against a block that walks into
|
||||
them (they aren't moving), and the vertical pass then mis-reads the side
|
||||
overlap as a downward collision — burying them in the floor.
|
||||
"""
|
||||
p = self.rect
|
||||
for rect, dx, dy in self.level.carriers():
|
||||
if dx == 0 or not p.colliderect(rect):
|
||||
continue
|
||||
# Only treat it as a side hit when we share a real chunk of height —
|
||||
# a shallow overlap just means we're standing on top of the block.
|
||||
v_overlap = min(p.bottom, rect.bottom) - max(p.top, rect.top)
|
||||
if v_overlap < self.h * 0.5:
|
||||
continue
|
||||
# Push out the *nearer* horizontal side, not blindly the way the
|
||||
# block is travelling — otherwise hitting its trailing face (e.g.
|
||||
# jumping into the left side of a right-moving block) teleports us
|
||||
# clear across it. Minimal displacement keeps the shove-along and
|
||||
# shove-into-wall behaviours intact.
|
||||
pen_right = rect.right - p.left # displacement to exit rightward
|
||||
pen_left = p.right - rect.left # displacement to exit leftward
|
||||
if pen_right <= pen_left:
|
||||
p.left = rect.right
|
||||
else:
|
||||
p.right = rect.left
|
||||
self.fx = float(p.x)
|
||||
|
||||
def _check_crush(self):
|
||||
"""A crush = a moving block pressing us against a solid on the opposite
|
||||
side (squished into the floor/ceiling/wall, or pinned while riding).
|
||||
|
||||
We probe a thin strip on each side of the player: if a solid backs us on
|
||||
one side and a *moving* block is closing in from the other side on the
|
||||
same axis, we're pinched and die.
|
||||
"""
|
||||
movers = [(r, dx, dy) for (r, dx, dy) in self.level.carriers() if dx or dy]
|
||||
if not movers:
|
||||
return False
|
||||
|
||||
solids = self.level.solid_rects()
|
||||
p = self.rect
|
||||
e = 4 # probe depth (a touch larger than a fast block's per-frame step)
|
||||
up = pygame.Rect(p.left + 2, p.top - e, max(1, p.width - 4), e)
|
||||
down = pygame.Rect(p.left + 2, p.bottom, max(1, p.width - 4), e)
|
||||
left = pygame.Rect(p.left - e, p.top + 2, e, max(1, p.height - 4))
|
||||
right = pygame.Rect(p.right, p.top + 2, e, max(1, p.height - 4))
|
||||
|
||||
def backed(probe):
|
||||
return any(probe.colliderect(s) for s in solids)
|
||||
|
||||
bu, bd = backed(up), backed(down)
|
||||
bl, br = backed(left), backed(right)
|
||||
|
||||
# Are we actually compressed? After this frame's resolution we still
|
||||
# overlap a solid because we couldn't be separated. If a descending
|
||||
# block stops with us fitting underneath, there's no overlap — no crush.
|
||||
pinned = any(p.colliderect(s) for s in solids)
|
||||
|
||||
for r, dx, dy in movers:
|
||||
if dy > 0 and bd and pinned and r.colliderect(up): # squished down onto floor
|
||||
return True
|
||||
if dy < 0 and bu and pinned and r.colliderect(down): # squished up into ceiling
|
||||
return True
|
||||
if dx > 0 and br and r.colliderect(left): # pushed right into a wall
|
||||
return True
|
||||
if dx < 0 and bl and r.colliderect(right): # pushed left into a wall
|
||||
return True
|
||||
|
||||
# Riding a platform that carries us *sideways* into a wall: the pushing
|
||||
# block is under our feet, so the side-probes above miss it — use the
|
||||
# carry direction instead. (Vertical carry crushes are caught above.)
|
||||
cdx, _ = self.carry
|
||||
if cdx > 0 and br:
|
||||
return True
|
||||
if cdx < 0 and bl:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _ride_platforms(self):
|
||||
# If standing on a moving platform, inherit its motion this frame.
|
||||
self.carry = (0.0, 0.0)
|
||||
for rect, dx, dy in self.level.carriers():
|
||||
if (abs(self.rect.bottom - rect.top) <= 3
|
||||
and self.rect.right > rect.left + 1
|
||||
and self.rect.left < rect.right - 1):
|
||||
self.fx += dx
|
||||
self.fy += dy
|
||||
self._sync_rect()
|
||||
self.carry = (dx, dy)
|
||||
break
|
||||
|
||||
def _horizontal(self, dt, inp):
|
||||
target = 0.0
|
||||
if inp.left:
|
||||
target -= S.MOVE_SPEED
|
||||
self.facing = -1
|
||||
if inp.right:
|
||||
target += S.MOVE_SPEED
|
||||
self.facing = 1
|
||||
|
||||
if target != 0.0:
|
||||
accel = S.ACCEL if self.on_ground else S.AIR_ACCEL
|
||||
if self.vx < target:
|
||||
self.vx = min(self.vx + accel * dt, target)
|
||||
else:
|
||||
self.vx = max(self.vx - accel * dt, target)
|
||||
else:
|
||||
# friction toward zero (only meaningful decel on the ground)
|
||||
fr = S.FRICTION if self.on_ground else S.AIR_ACCEL * 0.5
|
||||
if self.vx > 0:
|
||||
self.vx = max(0.0, self.vx - fr * dt)
|
||||
elif self.vx < 0:
|
||||
self.vx = min(0.0, self.vx + fr * dt)
|
||||
|
||||
self.fx += self.vx * dt
|
||||
self._sync_rect()
|
||||
self._resolve_axis(axis="x")
|
||||
|
||||
def _vertical(self, dt, inp):
|
||||
# timers
|
||||
self.coyote = self.coyote - dt if self.coyote > 0 else 0.0
|
||||
if inp.jump_pressed:
|
||||
self.jump_buffer = S.JUMP_BUFFER
|
||||
else:
|
||||
self.jump_buffer = max(0.0, self.jump_buffer - dt)
|
||||
self.drop_through_timer = max(0.0, self.drop_through_timer - dt)
|
||||
if inp.down and inp.jump_pressed:
|
||||
# Space + S: drop through one-way platforms.
|
||||
self.drop_through_timer = 0.12
|
||||
|
||||
# jump (buffered + coyote)
|
||||
if self.jump_buffer > 0 and (self.on_ground or self.coyote > 0) \
|
||||
and self.drop_through_timer <= 0:
|
||||
self.vy = -S.JUMP_SPEED
|
||||
self.on_ground = False
|
||||
self.coyote = 0.0
|
||||
self.jump_buffer = 0.0
|
||||
|
||||
# variable jump height: the frame Space is released mid-rise, cut the
|
||||
# remaining upward velocity once (a quick tap = a short hop).
|
||||
if self.was_jump_held and not inp.jump_held and self.vy < 0:
|
||||
self.vy *= S.JUMP_CUT
|
||||
self.was_jump_held = inp.jump_held
|
||||
|
||||
# gravity
|
||||
self.vy = min(self.vy + S.GRAVITY * dt, S.MAX_FALL)
|
||||
|
||||
was_on_ground = self.on_ground
|
||||
self.on_ground = False
|
||||
self.fy += self.vy * dt
|
||||
self._sync_rect()
|
||||
self._resolve_axis(axis="y")
|
||||
|
||||
# start coyote window the frame we walk off a ledge
|
||||
if was_on_ground and not self.on_ground and self.vy >= 0:
|
||||
self.coyote = S.COYOTE_TIME
|
||||
|
||||
# --- collision -----------------------------------------------------------
|
||||
def _resolve_axis(self, axis):
|
||||
solids = self.level.solid_rects()
|
||||
if axis == "x":
|
||||
for s in solids:
|
||||
if self.rect.colliderect(s):
|
||||
# Resolve toward the nearer edge (not by velocity sign) so
|
||||
# clipping a block's corner can't warp us across it. But only
|
||||
# if the overlap is *more horizontal than vertical* — a block
|
||||
# sitting on top of us is a vertical collision; pushing us
|
||||
# sideways out from under it would dodge a crush.
|
||||
pen_left = self.rect.right - s.left # sank in from the left
|
||||
pen_right = s.right - self.rect.left # sank in from the right
|
||||
pen_y = min(self.rect.bottom - s.top, s.bottom - self.rect.top)
|
||||
if min(pen_left, pen_right) > pen_y:
|
||||
continue # let the Y pass handle it
|
||||
if pen_right <= pen_left:
|
||||
self.rect.left = s.right
|
||||
else:
|
||||
self.rect.right = s.left
|
||||
self.fx = float(self.rect.x)
|
||||
self.vx = 0.0
|
||||
else: # y
|
||||
for s in solids:
|
||||
if self.rect.colliderect(s):
|
||||
# Resolve toward the nearer edge, not by velocity sign — so a
|
||||
# block descending onto us can't pop us out its top.
|
||||
overlap_top = self.rect.bottom - s.top # sank onto its top
|
||||
overlap_bottom = s.bottom - self.rect.top # rose into its underside
|
||||
if overlap_top <= overlap_bottom:
|
||||
self.rect.bottom = s.top
|
||||
self.on_ground = True
|
||||
else:
|
||||
self.rect.top = s.bottom
|
||||
self.fy = float(self.rect.y)
|
||||
self.vy = 0.0
|
||||
# one-way platforms: land only when falling and the feet just
|
||||
# crossed the platform's top edge this frame.
|
||||
if self.vy >= 0 and self.drop_through_timer <= 0:
|
||||
max_pen = self.vy / S.FPS + 8 # how far feet could have sunk this frame
|
||||
for o in self.level.oneway_rects():
|
||||
if self.rect.colliderect(o):
|
||||
penetration = self.rect.bottom - o.top
|
||||
if 0 <= penetration <= max_pen:
|
||||
self.rect.bottom = o.top
|
||||
self.fy = float(self.rect.y)
|
||||
self.vy = 0.0
|
||||
self.on_ground = True
|
||||
|
||||
# --- rendering -----------------------------------------------------------
|
||||
def draw(self, surface, assets):
|
||||
surface.blit(assets.get("player", self.rect.w, self.rect.h), self.rect)
|
||||
48
game/settings.py
Normal file
48
game/settings.py
Normal 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
883
game/traps.py
Normal 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
|
||||
Reference in New Issue
Block a user