ajhahn.de
← the-way-out commits

Commit

the-way-out

v0.10.0

ajhahnde · May 2026 · 4c8248e41326b8af74469c43edc36477192abdd5 · parent: 7e4c35b · view on GitHub →

modified CHANGELOG.md
@@ -1,5 +1,26 @@
# CHANGELOG
## v0.10.0
Adds an inter-level loading screen between the level menu and play —
one focused beat showing the level title, tagline, your character, and
the controls before the room becomes interactive.
### UI
- New loading screen between the level menu and play. Shows the
level's title and tagline, an idle frame of your selected character,
and a control hint bar. Auto-advances after a few seconds, or press
Enter / Space / left-click to skip. Esc cancels back to the level
menu (or the editor, if launched from Test).
- Retries are snappy: pressing R after a death, or Restart from the
pause menu, reloads the room directly without the loading screen.
- The previous in-level title card that overlaid the live world is
gone — the loading screen now carries the title surface, and the
fade-in covers the visual handoff into the room. Contact damage is
still suspended during the fade so the player can't be hit before
they can react.
## v0.9.0
Hits, abilities, doors and pickups now have sound. Adds 18
modified VERSION
@@ -1 +1 @@
v0.9.0
v0.10.0
modified levels.py
@@ -208,8 +208,9 @@ class LevelManager:
self.map_surface = None
# Identity of the loaded level. ``level_id`` is the stable
# string id from the catalog (used by save.py); ``level_title``
# and ``level_tagline`` come from the same entry and drive the
# intro card. None until a level is loaded.
# and ``level_tagline`` come from the same entry and are now
# surfaced by ``loading_screen.LoadingScreen`` before the load.
# None until a level is loaded.
self.level_id = ""
self.level_title = ""
self.level_tagline = ""
@@ -223,7 +224,11 @@ class LevelManager:
self.completed = False
self.failed = False
self.time = 0.0
self.intro_timer = 0.0
# No contact damage during the fade-in window — the player can't
# react yet. Lifted from the retired in-level intro card; tied to
# the R8 fade so the grace lasts exactly as long as the visual
# transition.
self._first_frame_grace = 0.0
self._saved = False
# Escape-room state.
@@ -255,7 +260,6 @@ class LevelManager:
self._hit_pause = 0.0
self.title_font = theme.font(90)
self.big_font = theme.font(110)
self.hint_font = theme.font(36)
self.label_font = theme.font(28)
self.banner_font = theme.font(34)
@@ -339,7 +343,7 @@ class LevelManager:
self.completed = False
self.failed = False
self.time = 0.0
self.intro_timer = 3.0
self._first_frame_grace = FADE_IN_TIME
self.level_id = entry.id
self.level_title = entry.title
self.level_tagline = entry.tagline
@@ -565,8 +569,9 @@ class LevelManager:
def update(self, dt):
self.time += dt
if self.intro_timer > 0:
self.intro_timer = max(0.0, self.intro_timer - dt)
if self._first_frame_grace > 0:
self._first_frame_grace = max(
0.0, self._first_frame_grace - dt)
self.camera.update_shake(dt)
self.particles.update(dt)
@@ -629,7 +634,7 @@ class LevelManager:
self._handle_key()
if (self.boss is not None and self.boss.hp > 0
and self.intro_timer <= 0
and self._first_frame_grace <= 0
and self.player.invuln_timer <= 0
and self.boss.hitbox.colliderect(self.player.hitbox)):
self.player.take_damage(BOSS_TOUCH_DAMAGE)
@@ -650,7 +655,7 @@ class LevelManager:
if en.hp < prev:
self._emit_enemy_hit(en)
en._last_hp_for_fx = en.hp
if (self.intro_timer <= 0
if (self._first_frame_grace <= 0
and self.player.invuln_timer <= 0
and en.hitbox.colliderect(self.player.hitbox)):
self.player.take_damage(en.touch_damage)
@@ -785,8 +790,8 @@ class LevelManager:
gate.open()
def _handle_hazards(self):
# No damage during the intro card — the player can't react yet.
if self.intro_timer > 0 or self.player.invuln_timer > 0:
# No damage during the fade-in — the player can't react yet.
if self._first_frame_grace > 0 or self.player.invuln_timer > 0:
return
for sp in self.spikes:
if sp.deadly and sp.hitbox.colliderect(self.player.hitbox):
@@ -919,7 +924,6 @@ class LevelManager:
if self.boss is not None:
self.draw_boss_health(screen)
self._draw_objective(screen)
self._draw_intro(screen)
if self.completed:
self.draw_end_overlay(
@@ -1145,33 +1149,6 @@ class LevelManager:
screen.blit(panel, bg)
screen.blit(surf, rect)
def _draw_intro(self, screen):
if self.intro_timer <= 0:
return
title = self.level_title or "LEVEL"
sub = self.level_tagline
# Fade out over the last second.
alpha = int(255 * min(1.0, self.intro_timer))
cx, cy = self.width // 2, self.height // 2 - 60
t = self.big_font.render(title, True, theme.TITLE_C)
t.set_alpha(alpha)
screen.blit(t, t.get_rect(center=(cx, cy)))
if sub:
s = self.hint_font.render(sub, True, theme.MUTED)
s.set_alpha(alpha)
screen.blit(s, s.get_rect(center=(cx, cy + 90)))
# Quick controls reminder during the first second
if self.intro_timer > 2.0:
d = theme.HINT_DOT
hint = self.hint_font.render(
f"WASD/Arrows to move & aim {d} Space to shoot {d} "
f"Shift for ability {d} E to use",
True, theme.MUTED)
hint.set_alpha(alpha)
screen.blit(hint, hint.get_rect(center=(cx, cy + 160)))
def draw_end_overlay(self, screen, text, color):
overlay = pygame.Surface(screen.get_size())
overlay.set_alpha(210)
added loading_screen.py
@@ -0,0 +1,113 @@
"""Inter-level loading screen.
Sits between the level menu and gameplay so the player gets one focused
beat — title, tagline, character avatar, control hints — before the
room becomes interactive. Replaces the older in-level intro card that
used to draw over a live world.
Press-or-timeout: auto-advances after ``LOADING_SCREEN_DURATION`` or on
Enter / Space / left-click / Esc. ``main.py`` gates this screen inside
``_start_level``; R-retry and pause-restart bypass it deliberately so
death-heavy levels stay snappy.
"""
import pygame
import theme
from settings import LOADING_SCREEN_DURATION
from units import CHARACTER_INFO
def _asset_folder_for(character_id):
"""Map the menu's character id (e.g. ``"c_wiz"``) to its sprite-
sheet folder name. Falls back to the Wizard's folder so a missing
id renders something instead of crashing."""
for cid, cls, _name, _tagline in CHARACTER_INFO:
if cid == character_id:
return cls.asset_folder
return "wizard"
class LoadingScreen:
"""One-shot scene shown before a level loads."""
AVATAR_SCALE = 3
def __init__(self, width, height, level_entry, character_id):
self.width = width
self.height = height
self.level_entry = level_entry
self.timer = LOADING_SCREEN_DURATION
self.skipped = False
self._dust = theme.PixelDust(width, height, seed=41)
self._title_font = theme.font(theme.FONT_TITLE)
self._tagline_font = theme.font(theme.FONT_HEADING)
self._hint_font = theme.font(theme.FONT_CAPTION)
folder = _asset_folder_for(character_id)
self._avatar_frames = theme._load_idle_frames(
folder, self.AVATAR_SCALE)
self._ticks_at_start = pygame.time.get_ticks()
@property
def done(self):
return self.skipped or self.timer <= 0
def update(self, dt):
if self.timer > 0:
self.timer = max(0.0, self.timer - dt)
def handle_input(self, event):
"""Return True when the player skipped the screen. Esc is left
alone so main.py's global Esc handler can route it (cancel back
to the level menu, rather than advancing into the level)."""
if event.type == pygame.KEYDOWN and event.key in (
pygame.K_RETURN, pygame.K_SPACE):
self.skipped = True
return True
if (event.type == pygame.MOUSEBUTTONDOWN
and event.button == 1):
self.skipped = True
return True
return False
def draw(self, screen):
screen.fill(theme.BG)
self._dust.draw(screen)
cx = self.width // 2
title_y = self.height // 3 - 30
title = (self.level_entry.title or "LEVEL").upper()
t_surf = self._title_font.render(title, True, theme.TITLE_C)
screen.blit(t_surf, t_surf.get_rect(center=(cx, title_y)))
ly = t_surf.get_rect(center=(cx, title_y)).bottom + 16
pygame.draw.line(screen, theme.LINE_C,
(cx - 170, ly), (cx + 170, ly), 2)
tagline = self.level_entry.tagline or ""
if tagline:
s_surf = self._tagline_font.render(tagline, True, theme.MUTED)
screen.blit(s_surf, s_surf.get_rect(center=(cx, ly + 50)))
# Avatar: pick one idle frame; cycle slowly so it reads as alive
# without distracting from the title.
if self._avatar_frames:
ticks = pygame.time.get_ticks() - self._ticks_at_start
idx = (ticks // 200) % len(self._avatar_frames)
frame = self._avatar_frames[idx]
screen.blit(frame, frame.get_rect(
center=(cx, self.height // 2 + 90)))
d = theme.HINT_DOT
hint = (f"WASD/Arrows move {d} Space shoot {d} "
f"Shift ability {d} E use")
h_surf = self._hint_font.render(hint, True, theme.MUTED)
screen.blit(h_surf, h_surf.get_rect(
center=(cx, self.height - 70)))
skip_hint = self._hint_font.render(
"Enter / Space / click to skip", True, theme.MUTED)
screen.blit(skip_hint, skip_hint.get_rect(
center=(cx, self.height - 36)))
modified main.py
@@ -10,7 +10,9 @@ from menu import (
MainMenu, SettingsMenu, LevelMenu, CharacterMenu, PauseMenu)
from levels import LevelManager
from editor import LevelEditor
from loading_screen import LoadingScreen
import audio
import level_catalog
# Setup & Initalisation
pygame.init()
@@ -61,6 +63,7 @@ _BGM_FOR_STATE = {
"settings": "menu",
"char_select": "menu",
"lvls": "menu",
"loading": "menu",
"editor": "menu",
}
@@ -73,6 +76,14 @@ game_state = "menu"
return_state = "lvls"
current_character = "c_wiz"
# Loading screen is shown only on first entry into a level (level menu
# or editor Test); R-retry and pause-restart deliberately bypass it via
# their direct level_manager.load_level() calls. The pending_* fields
# stash the (level_id, return_to) pair until the screen finishes.
loading_screen = None
pending_level_id = None
pending_return_to = "lvls"
# Threaded update flow. The worker writes into update_state; the main
# loop polls each frame and renders an animated status. phase is what
# the worker is doing right now ("checking" / "updating"); result is set
@@ -120,18 +131,37 @@ def _run_update():
def _start_level(level_id, return_to="lvls"):
"""Load level by id (catalog string) and switch into the game state.
"""Push to the loading screen for ``level_id``; the actual load
happens when the screen finishes (see ``_finish_loading``).
``return_to`` is what we'll switch to when the level ends."""
global game_state, return_state
global game_state, loading_screen, pending_level_id, pending_return_to
entry = level_catalog.find(level_id)
if entry is None:
# Unknown id — route to wherever this launch came from, mirroring
# the failure branch _finish_loading uses below.
if return_to == "editor":
editor.reset_pointer_state()
game_state = "editor"
else:
_to_level_menu()
return
loading_screen = LoadingScreen(
SCREEN_W, SCREEN_H, entry, current_character)
pending_level_id = level_id
pending_return_to = return_to
game_state = "loading"
def _finish_loading():
"""Run the deferred ``load_level`` and hand off to the game state.
Bad/empty/missing level files route back to the launch origin —
same B17/B19/B20 "editor Test returns to editor" contract."""
global game_state, return_state, loading_screen, pending_level_id
level_id = pending_level_id
return_to = pending_return_to
loading_screen = None
pending_level_id = None
if not level_manager.load_level(level_id, current_character):
# Bad/empty/missing level file — don't strand the state machine
# in "game" with player=None (update() would crash). Route the
# failure to wherever this launch came from, mirroring
# _leave_game(): an editor Test that can't load must land back
# in the editor canvas, not the level menu, or the user is
# bounced past the editor — the same B17/B19/B20 "editor Test
# returns to editor" contract. return_state isn't set until the
# success path below, so key off the return_to parameter here.
if return_to == "editor":
editor.reset_pointer_state()
game_state = "editor"
@@ -208,6 +238,17 @@ while running:
# moved on.
main_menu.clear_status()
game_state = "menu"
elif game_state == "loading":
# Cancel the pending level launch and bail back to the
# origin (level menu, or editor if the editor's Test
# button kicked this off).
loading_screen = None
pending_level_id = None
if pending_return_to == "editor":
editor.reset_pointer_state()
game_state = "editor"
else:
_to_level_menu()
elif game_state == "paused":
game_state = "game"
elif game_state == "game":
@@ -303,6 +344,12 @@ while running:
if action:
_start_level(action)
# Loading screen — Enter / Space / Esc / click skip ahead. The
# screen also auto-advances on its own timer in the draw block.
elif game_state == "loading":
if loading_screen is not None:
loading_screen.handle_input(event)
# Pause overlay
elif game_state == "paused":
action = pause_menu.handle_input(event)
@@ -409,6 +456,13 @@ while running:
character_menu.draw(screen, current_character)
elif game_state == "lvls":
level_menu.draw(screen)
elif game_state == "loading":
if loading_screen is not None:
loading_screen.update(dt)
loading_screen.draw(screen)
if loading_screen.done:
# Finalise the deferred load; next frame draws the level.
_finish_loading()
elif game_state == "editor":
editor.update(dt)
editor.draw(screen)
modified settings.py
@@ -106,6 +106,11 @@ ABILITY_COLOR_WOLF = (240, 240, 240) # speed white
FADE_IN_TIME = 0.22 # level start
FADE_OUT_TIME = 0.35 # level complete / death-to-retry
# Inter-level loading screen shown between the level menu and play.
# Press-or-timeout: auto-advance after this many seconds, or any
# dismiss-input (Enter / Space / left-click / Esc) skips early.
LOADING_SCREEN_DURATION = 3.0
# --- Hazards / Puzzles ---
# Spike traps run on one shared clock so the rhythm is readable:
# safe -> warning (telegraph) -> deadly -> safe ...