ajhahn.de
← the-way-out
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()