Python 496 lines
import os
import subprocess
import sys
import threading
import pygame
import audio
import level_catalog
from editor import LevelEditor
from levels import LevelManager
from loading_screen import LoadingScreen
from menu import CharacterMenu, LevelMenu, MainMenu, PauseMenu, SettingsMenu
from settings import FPS, HEIGHT, WIDTH
# Setup & Initalisation
pygame.init()
# set_icon must happen BEFORE set_mode for the icon to take effect on
# the actual macOS window (not just the Dock).
try:
pygame.display.set_icon(
pygame.image.load(os.path.join("assets", "icon_1024.png")))
except (pygame.error, FileNotFoundError, OSError):
pass
# Always boot fullscreen at the monitor's own resolution — there is no
# in-game resolution picker. settings.WIDTH/HEIGHT is only the fallback
# if the desktop size can't be read.
_desktop = pygame.display.get_desktop_sizes()
SCREEN_W, SCREEN_H = (_desktop[0] if _desktop and _desktop[0][0] > 0
else (WIDTH, HEIGHT))
screen = pygame.display.set_mode(
(SCREEN_W, SCREEN_H),
pygame.FULLSCREEN | pygame.DOUBLEBUF | pygame.SCALED)
pygame.display.set_caption("The Way Out")
clock = pygame.time.Clock()
main_menu = MainMenu(SCREEN_W, SCREEN_H)
settings_menu = SettingsMenu(SCREEN_W, SCREEN_H)
level_menu = LevelMenu(SCREEN_W, SCREEN_H)
character_menu = CharacterMenu(SCREEN_W, SCREEN_H)
pause_menu = PauseMenu(SCREEN_W, SCREEN_H)
level_manager = LevelManager(SCREEN_W, SCREEN_H)
editor = LevelEditor(SCREEN_W, SCREEN_H)
# Apply the persisted sound + music-volume preferences before any
# level can start. Volume goes through audio.set_music_volume so the
# value is stored even though the mixer is still cold (it'll re-apply
# on the first play_music).
audio.set_enabled(settings_menu.sound_on)
audio.set_music_volume(settings_menu.music_vol)
# Background-music bed per screen. The start screen gets its own track;
# every submenu (and the editor) shares a lighter "menu" bed; gameplay
# music is owned by levels.py (the level's manifest "music"), so "game"
# is intentionally absent here. "paused" is absent too — the level's
# track keeps playing under the overlay. audio.play_music no-ops when
# the name is unchanged, so submenu↔submenu navigation never re-fades
# the bed, and missing track files just stay silent.
_BGM_FOR_STATE = {
"menu": "title",
"updating": "title",
"settings": "menu",
"char_select": "menu",
"lvls": "menu",
"loading": "menu",
"editor": "menu",
}
# Game state machine. ``paused`` is a frozen-world overlay; it preserves
# every bit of level_manager state so Resume picks up mid-frame.
# ``return_state`` remembers where to go when a game/run ends — normally
# "lvls" (level menu), but "editor" when the level was launched via the
# editor's Test button so the user lands back in the canvas.
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
# exactly once when the worker is done.
update_state = {"phase": None, "result": None}
update_anim_t = 0.0
_UPDATE_PHASE_TEXT = {
"checking": "Checking for updates",
"updating": "Updating",
}
_UPDATE_RESULT_TEXT = {
"uptodate": "Already up to date.",
"offline": "No internet - try again later.",
"unreachable": "Update server unreachable - try again later.",
"failed": "Update failed - try again later.",
"error": "Update error - try again later.",
}
def _run_update():
"""Worker thread: drive check + apply_update without blocking the
event loop. Dict writes are GIL-atomic, which is enough for the
one-writer / one-reader hand-off here."""
try:
import updater
update_state["phase"] = "checking"
_loc, rem, avail = updater.check()
if rem is None:
# rem is None for "no net" AND "GitHub down / rate-limited /
# slow". Probe real connectivity so we don't tell a user with
# working internet that they have none.
update_state["result"] = (
"offline" if not updater.online() else "unreachable")
return
if not avail:
update_state["result"] = "uptodate"
return
update_state["phase"] = "updating"
if updater.apply_update(expected_sha=rem):
update_state["result"] = "done"
else:
update_state["result"] = "failed"
except Exception:
update_state["result"] = "error"
def _start_level(level_id, return_to="lvls"):
"""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, 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):
if return_to == "editor":
editor.reset_pointer_state()
game_state = "editor"
else:
_to_level_menu()
return
game_state = "game"
return_state = return_to
def _to_level_menu():
"""Bail to the level select — always refreshes so a freshly beaten
level lights up immediately and any new custom level appears."""
global game_state
level_menu.refresh()
game_state = "lvls"
def _leave_game():
"""End the run and route back to whatever opened the level."""
global game_state
if return_state == "editor":
editor.reset_pointer_state()
game_state = "editor"
else:
_to_level_menu()
if __name__ == "__main__":
running = True
while running:
# Clamp dt so a hitch (focus loss, level load, the update HTTP
# call, an OS stall) can't teleport the player or fast-forward
# timers. Cap at ~3 frames; below that the sim stays frame-fair.
dt = min(clock.tick(FPS) / 1000.0, 3.0 / FPS)
# Events -----------------------------------------------------
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# Cmd+Q (macOS): quit immediately from any state. Handled
# here before per-state delegation so the editor's bare-Q
# tool toggle (editor.py) can't consume it on the way out.
if (event.type == pygame.KEYDOWN
and event.key == pygame.K_q
and (event.mod & pygame.KMOD_META)):
running = False
continue
# Losing focus while fullscreen (Cmd-Tab, Mission Control,
# a notification) makes SDL freeze key state: get_pressed()
# keeps reporting the last-held key, so the player would run
# on forever. Auto-pause live gameplay; the user resumes
# from the pause menu with a clean input state.
if event.type in (pygame.WINDOWFOCUSLOST,
pygame.WINDOWMINIMIZED):
if (game_state == "game"
and not (level_manager.completed
or level_manager.failed)):
game_state = "paused"
# Same SDL freeze hits the editor: a held mouse button
# can get stuck down, so a mid-Shift-drag would later
# commit a stray box-fill. Drop the editor's transient
# pointer state.
elif game_state == "editor":
editor.reset_pointer_state()
# Esc is shared by every menu / overlay state — handle it
# here so the routing stays in one place. ``editor``
# swallows its own Esc via handle_input so the user can quit
# while typing a filename without nuking the session.
if (event.type == pygame.KEYDOWN
and event.key == pygame.K_ESCAPE):
if game_state in ("lvls", "settings", "char_select"):
# Returning to the title screen — drop any stale
# update toast so it doesn't reappear long after the
# user has 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":
if level_manager.completed or level_manager.failed:
_leave_game()
# Esc is consumed here. Without this, when the
# level was launched from the editor's Test
# button (return_state == "editor")
# _leave_game() switches to "editor" and the
# *same* Esc then falls through to
# editor.handle_input below, which reads it as
# "back" and bounces the user past the editor to
# the main menu instead of the editor canvas.
continue
else:
game_state = "paused"
# In a finished level: R retries, Enter/Space bails out.
if (game_state == "game"
and (level_manager.completed
or level_manager.failed)
and event.type == pygame.KEYDOWN):
if event.key in (pygame.K_RETURN, pygame.K_SPACE):
_leave_game()
# Same reasoning as the Esc-finished branch above:
# consume the key so it can't also drive
# editor.handle_input on a return_state == "editor"
# session (Enter would commit a half-typed level
# name, R would append 'r' to it).
continue
elif event.key == pygame.K_r:
if not level_manager.load_level(
level_manager.level_id, current_character):
_leave_game()
continue
# Main menu
if game_state == "menu":
action = main_menu.handle_input(event)
if action == "lvls":
main_menu.clear_status()
_to_level_menu()
elif action == "editor":
main_menu.clear_status()
editor.reset_pointer_state()
game_state = "editor"
elif action == "settings":
main_menu.clear_status()
game_state = "settings"
elif action == "chars":
main_menu.clear_status()
game_state = "char_select"
elif action == "update":
# Hand the work off to a thread so the event loop
# can keep pumping (no macOS beachball) and animate
# the status. The main loop polls update_state each
# frame.
update_state["phase"] = "checking"
update_state["result"] = None
update_anim_t = 0.0
main_menu.clear_status()
threading.Thread(
target=_run_update, daemon=True).start()
game_state = "updating"
elif action == "quit":
running = False
# Editor — Esc returns to menu; Test (F5 or button)
# requests a play session that lands back here when it ends.
elif game_state == "editor":
action = editor.handle_input(event)
if action == "back":
game_state = "menu"
elif action == "test":
level_menu.refresh() # so the new custom shows later
_start_level(editor.test_level_id,
return_to="editor")
editor.request_test = False
# Settings
elif game_state == "settings":
action = settings_menu.handle_input(event)
if action == "back":
game_state = "menu"
# Charakter select
elif game_state == "char_select":
action = character_menu.handle_input(event)
if action:
current_character = action
main_menu.set_character(current_character)
game_state = "menu"
# Levels select — action is the chosen level id (catalog).
elif game_state == "lvls":
action = level_menu.handle_input(event)
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)
if action == "resume":
game_state = "game"
elif action == "restart":
if level_manager.load_level(
level_manager.level_id, current_character):
game_state = "game"
else:
_leave_game()
elif action == "quit":
_leave_game()
# BGM follows the state machine. Game/paused are deliberately
# absent: levels.py owns the in-level track via the manifest,
# and pause should not swap the bed (the level's music keeps
# playing under the overlay). audio.play_music guards same-name
# calls, so this is a no-op when the screen didn't change.
_bgm = _BGM_FOR_STATE.get(game_state)
if _bgm is not None:
audio.play_music(_bgm)
# Mouse cursor: hidden during live gameplay (combat is keyboard
# + 4-way facing — no aim cursor); visible everywhere else,
# including the keyboard-driven level-end screen so the player
# can still see the cursor land in pause/menu/editor cleanly.
in_active_game = (game_state == "game"
and not level_manager.completed
and not level_manager.failed)
pygame.mouse.set_visible(not in_active_game)
# Keyboard grab while a level is live: SDL routes macOS system
# shortcuts (Cmd-Tab, Mission Control, Spaces) to the game
# instead of the OS, so they can't yank focus mid-fight.
# Released in menus, pause and the level-end screen so the
# player can always tab away; the game's own Cmd-Q handler
# still fires (the combo reaches the app, which quits cleanly).
pygame.event.set_keyboard_grab(in_active_game)
# Auto-dismiss the main-menu status toast once its TTL elapses
# so a stale "Already up to date." doesn't sit on screen
# forever.
if (main_menu.status_until is not None
and pygame.time.get_ticks() / 1000.0
> main_menu.status_until):
main_menu.clear_status()
# Draw & Update ----------------------------------------------
if game_state == "menu":
main_menu.update(dt)
main_menu.draw(screen)
elif game_state == "updating":
update_anim_t += dt
result = update_state["result"]
main_menu.update(dt)
if result == "done":
main_menu.set_status("Updated - restarting...",
ttl=None)
main_menu.draw(screen)
pygame.display.flip()
pygame.time.delay(900)
pygame.quit()
# On a PyInstaller --windowed macOS bundle, os.execv
# re-execs the bootloader from inside its Python child
# while the parent bootloader keeps its NSApplication
# alive — net result: two windows. `open -n` +
# SystemExit hands off cleanly via LaunchServices so
# only the new instance survives. Mirrors
# launcher._relocate_to_applications().
bundle = None
if (getattr(sys, "frozen", False)
and sys.platform == "darwin"):
contents_macos = os.path.dirname(
os.path.realpath(sys.executable))
candidate = os.path.dirname(
os.path.dirname(contents_macos))
if (candidate.endswith(".app")
and os.path.isdir(candidate)):
bundle = candidate
if bundle is not None:
subprocess.Popen(["/usr/bin/open", "-n", bundle])
raise SystemExit(0)
if (getattr(sys, "frozen", False)
and sys.platform == "darwin"):
# Bundle path unresolvable on a frozen darwin build
# — os.execv here would reproduce B28 (two windows).
# Exit cleanly; the user re-launches manually.
raise SystemExit(0)
if getattr(sys, "frozen", False):
os.execv(sys.executable, [sys.executable])
else:
os.execv(sys.executable,
[sys.executable, os.path.abspath(__file__)])
elif result is not None:
main_menu.set_status(_UPDATE_RESULT_TEXT.get(
result, "Update failed - try again later."))
game_state = "menu"
main_menu.draw(screen)
else:
phase = update_state["phase"] or "checking"
dots = "." * (1 + int(update_anim_t * 2) % 3)
main_menu.set_status(
f"{_UPDATE_PHASE_TEXT.get(phase, 'Updating')}{dots}",
ttl=None)
main_menu.draw(screen)
elif game_state == "settings":
settings_menu.draw(screen)
elif game_state == "char_select":
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)
elif game_state == "game":
level_manager.update(dt)
level_manager.draw(screen)
elif game_state == "paused":
# Render the frozen world, then the pause overlay on top.
level_manager.draw(screen)
pause_menu.draw(screen)
pygame.display.flip()
pygame.quit()
sys.exit()