423 lines
18 KiB
Python
423 lines
18 KiB
Python
"""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
|