"""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. The debug # view reveals a margin of overscan around a level, so budget for it here # (for any level that will be shown in debug) or it'd be clipped. dims = [self._draw_size(lv) 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 _debug_margin(self, level): """Pixels of overscan drawn around a level in the debug view (0 if it won't be shown in debug).""" if not (self.force_debug or level.debug): return 0 return S.DEBUG_VIEW_MARGIN * level.tile def _draw_size(self, level): """The pixel size the level's play surface occupies, including any debug overscan margin on both sides.""" m = self._debug_margin(level) return level.width + 2 * m, level.height + 2 * m 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 # In debug, grow the play surface by a margin and shift all drawing into # it (render_offset) so geometry just off the map is visible. m = self._debug_margin(self.level) self.level.render_offset = (m, m) world_w, world_h = self._draw_size(self.level) self.world = pygame.Surface((world_w, world_h)) # Centre the play area in the fixed window, below the HUD. ox = (self.win_w - world_w) // 2 oy = HUD_H + (self.win_h - HUD_H - world_h) // 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.move(self.level.render_offset)) 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