ajhahn.de
← the-way-out commits

Commit

the-way-out

v1.0.1

ajhahnde · May 2026 · 1a68fca35d7b0df515ad4d2e109656d44005493e · parent: fc32d30 · view on GitHub →

added .github/workflows/ci.yml
@@ -0,0 +1,82 @@
# CI: lint, test, headless import smoke, plus a macOS PyInstaller build
# that uploads the .zip the in-app updater would otherwise wait for the
# operator to ship by hand. Runs on every PR and on push to main.
# Read-only token by design.
name: ci
on:
pull_request:
push:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
check:
name: lint + tests
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup-python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: install
# Pinned to the same versions pyproject.toml declares so a green
# CI run says the same thing as a green local run.
run: |
python -m pip install --upgrade pip
pip install "pygame==2.6.1" "ruff==0.15.14" "pytest==9.0.3"
- name: ruff
run: ruff check .
- name: pytest
env:
SDL_VIDEODRIVER: dummy
SDL_AUDIODRIVER: dummy
run: pytest
- name: import smoke
# Belt-and-braces: pytest covers the testable subset; this also
# exercises the menu/level/editor constructors that run at
# ``import main``. The dummy SDL drivers are required — the
# runner has no display and no audio device.
env:
SDL_VIDEODRIVER: dummy
SDL_AUDIODRIVER: dummy
run: python -c "import main"
build:
name: build (macOS)
runs-on: macos-latest
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup-python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: prepare .venv
# build_mac.sh runs from .venv/bin/python — it explicitly forbids
# system python3 (3.14 is incompatible with PyInstaller 6.20).
# Pre-install pygame so the script's own --upgrade pyinstaller
# is the only network hop it has to do.
run: |
python -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install "pygame==2.6.1"
- name: build .app + zip
run: ./build_mac.sh
- name: upload artifact
uses: actions/upload-artifact@v4
with:
name: TheWayOut-mac
path: dist/TheWayOut-mac.zip
if-no-files-found: error
modified CHANGELOG.md
@@ -1,5 +1,42 @@
# CHANGELOG
## v1.0.1
Repo-hygiene release. No gameplay or save-file changes; existing saves
and custom levels load as-is. Brings the project's tooling in line with
its sibling repos (eeco, FlashOS) so it can be forked, built and
contributed to without the operator in the loop.
### Project
- **LICENSE** — Apache 2.0. The repo had no license until now, which
meant the source was legally all-rights-reserved and nobody could
fork it. Matches the licence both sibling repos already use.
- **`pyproject.toml`** — pins the runtime (`pygame==2.6.1`), the build
toolchain (`pyinstaller==6.20.0`, `certifi==2026.5.20`) and the dev
tools (`ruff==0.15.14`, `pytest==9.0.3`) so a fresh clone resolves to
the same versions that produced the v1.0.0 .app. Carries the project's
Ruff config (E/F/I/UP/B, 79-col, py312) — `.ruff_cache/` existed in
the tree but the config was missing, so contributors couldn't format
identically.
- **GitHub Actions CI** (`.github/workflows/ci.yml`) — two jobs on
every push and PR. `check` (ubuntu): `ruff check`, `pytest`, and a
headless `import main` smoke under the dummy SDL drivers. `build`
(macos): runs `build_mac.sh` and uploads
`dist/TheWayOut-mac.zip` as a workflow artifact.
- **Test suite** (`tests/`, 26 tests) — first ever. Covers the level
parser helpers (`_split_cells` / `_cell_variant` / `_pair_id`), the
`PressurePlate` charge/trip lifecycle, `Lever.use` single-shot, and
the `Character` attack + ability cooldown bookkeeping. Headless via a
conftest that wires the dummy SDL drivers before `pygame.init()`.
### Fixed
- `main.py` now wraps the game loop in `if __name__ == "__main__":`,
so `import main` is safe in tests and CI. Without this the loop ran
at module import and the smoke test would have hung forever.
- README's version line said `v0.2.2` (stale from v1.0.0); now `v1.0.1`.
## v1.0.0
The first real release. Bundles the v0.3.0 → v0.10.0 cuts into one
added LICENSE
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but not
limited to compiled object code, generated documentation, and
conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work (an
example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the
original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work, excluding
those notices that do not pertain to any part of the Derivative
Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and do
not modify the License. You may add Your own attribution notices
within Derivative Works that You distribute, alongside or as an
addendum to the NOTICE text from the Work, provided that such
additional attribution notices cannot be construed as modifying
the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 ajhahnde
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
modified README.md
@@ -4,7 +4,11 @@ A top-down pixel-art escape-room shooter. Pick a character, fight your
way through locked rooms, work the levers and pressure plates, and find
the way out.
**Version:** v0.2.2 — see [Changelog](CHANGELOG.md)
<p align="center">
<img src="assets/screenshot.png" alt="The Way Out — character select" width="780">
</p>
**Version:** v1.0.1 — see [Changelog](CHANGELOG.md)
Build with [eeco](https://github.com/ajhahnde/eeco)
modified VERSION
@@ -1 +1 @@
v1.0.0
v1.0.1
added assets/screenshot.png
binary file — no preview
modified editor.py
@@ -40,14 +40,13 @@ from pathlib import Path
import pygame
from settings import TILE_SIZE
from interactables import Spikes, Lever, Gate, KeyItem, PressurePlate
from static_objects import TileTextures
from tiles import REGISTRY, PALETTE_CATEGORIES, chars_for
import level_catalog
import tileset
import theme
import tileset
from interactables import Gate, KeyItem, Lever, PressurePlate, Spikes
from settings import TILE_SIZE
from static_objects import TileTextures
from tiles import PALETTE_CATEGORIES, REGISTRY, chars_for
# Used both as a directory and as the legal-filename charset.
SAFE_NAME = re.compile(r"[^a-zA-Z0-9_\-]+")
@@ -218,8 +217,8 @@ class LevelEditor:
"""Load an existing level file into the editor. Used to tweak
a built-in level or continue work on a custom one."""
try:
with open(entry.file, 'r') as f:
lines = [l.rstrip('\n') for l in f if l.strip()]
with open(entry.file) as f:
lines = [ln.rstrip('\n') for ln in f if ln.strip()]
except (FileNotFoundError, OSError) as e:
self._flash(f"Could not open {entry.file}: {e}")
return
@@ -1338,11 +1337,11 @@ class LevelEditor:
]
right = self.toolbar_rect.right - 20
self._toolbar_rects = {}
for name, label, width, kind in reversed(spec):
for name, _label, width, _kind in reversed(spec):
rect = pygame.Rect(right - width, y + 18, width, h - 36)
self._toolbar_rects[name] = rect
right -= width + 12
self._toolbar_button_meta = {n: (l, k) for n, l, _w, k in spec}
self._toolbar_button_meta = {n: (lbl, k) for n, lbl, _w, k in spec}
# Size +/- arrows (smaller, in the middle of the toolbar)
mid_x = self._name_rect.right + 30
modified interactables.py
@@ -8,11 +8,15 @@ so the level can blit and collision-test it like any other sprite.
"""
import pygame
import audio
from settings import (
TILE_SIZE, SPIKE_CYCLE, SPIKE_DANGER_TIME, SPIKE_WARN_TIME,
PLATE_TRIGGER_DELAY,
SPIKE_CYCLE,
SPIKE_DANGER_TIME,
SPIKE_WARN_TIME,
TILE_SIZE,
)
import audio
TS = TILE_SIZE
modified launcher.py
@@ -19,7 +19,7 @@ import subprocess
import sys
import traceback
import updater # bundled bootstrap copy — NOT re-implemented here
import updater # bundled bootstrap copy — NOT re-implemented here
APP_NAME = "The Way Out.app"
APPLICATIONS = "/Applications"
modified level_catalog.py
@@ -15,10 +15,9 @@ import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from settings import SAVE_DIR
import tileset
from settings import SAVE_DIR
CUSTOM_DIR = SAVE_DIR / "custom_levels"
MANIFEST_PATH = Path("assets/levels/manifest.json")
@@ -46,16 +45,16 @@ class LevelEntry:
title: str
tagline: str
custom: bool
music: Optional[str] = None
floor_tile: Optional[str] = None
wall_tile: Optional[str] = None
music: str | None = None
floor_tile: str | None = None
wall_tile: str | None = None
def _load_manifest():
"""Built-in levels from manifest.json. Empty list on any IO error so
a missing/corrupt manifest never crashes the menu."""
try:
with open(MANIFEST_PATH, 'r') as f:
with open(MANIFEST_PATH) as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return []
@@ -95,7 +94,7 @@ def read_custom_theme(txt_path):
breaks the level list."""
sidecar = Path(txt_path).with_suffix(".json")
try:
with open(sidecar, 'r') as f:
with open(sidecar) as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return tileset.DEFAULT_THEME
modified levels.py
@@ -4,29 +4,52 @@ import zlib
from collections import deque
import pygame
from settings import (
TILE_SIZE, BOSS_TOUCH_DAMAGE, PLAYER_INVULN_TIME,
SPIKE_DAMAGE, LEVER_REACH, SLOW_SCALE,
HIT_PAUSE_PLAYER_HIT, HIT_PAUSE_BOSS_HIT,
HIT_PAUSE_BOSS_DEATH, HIT_PAUSE_PLAYER_DEATH,
PARTICLES_PLAYER_HIT, PARTICLES_ENEMY_HIT, PARTICLES_ENEMY_DEATH,
PARTICLES_BOSS_HIT, PARTICLES_BOSS_DEATH, PARTICLES_ABILITY,
ABILITY_COLOR_WIZARD, ABILITY_COLOR_PENGUIN, ABILITY_COLOR_ELF,
ABILITY_COLOR_SHIGGY, ABILITY_COLOR_WOLF,
FADE_IN_TIME, FADE_OUT_TIME,
)
from units import (
Wizard, Penguin, Elf, Shiggy, Wolf,
Boss, BOSS_ROSTER, CHARACTER_INFO, ENEMY_INFO)
from static_objects import Tile, TileTextures, Prop
from interactables import Spikes, Lever, Gate, KeyItem, PressurePlate
from tiles import PROP_CHARS
from effects import ParticleField, FadeState
import tileset
import audio
import level_catalog
import save
import audio
import theme
import tileset
from effects import FadeState, ParticleField
from interactables import Gate, KeyItem, Lever, PressurePlate, Spikes
from settings import (
ABILITY_COLOR_ELF,
ABILITY_COLOR_PENGUIN,
ABILITY_COLOR_SHIGGY,
ABILITY_COLOR_WIZARD,
ABILITY_COLOR_WOLF,
BOSS_TOUCH_DAMAGE,
FADE_IN_TIME,
FADE_OUT_TIME,
HIT_PAUSE_BOSS_DEATH,
HIT_PAUSE_BOSS_HIT,
HIT_PAUSE_PLAYER_DEATH,
HIT_PAUSE_PLAYER_HIT,
LEVER_REACH,
PARTICLES_ABILITY,
PARTICLES_BOSS_DEATH,
PARTICLES_BOSS_HIT,
PARTICLES_ENEMY_DEATH,
PARTICLES_ENEMY_HIT,
PARTICLES_PLAYER_HIT,
PLAYER_INVULN_TIME,
SLOW_SCALE,
SPIKE_DAMAGE,
TILE_SIZE,
)
from static_objects import Prop, Tile, TileTextures
from tiles import PROP_CHARS
from units import (
BOSS_ROSTER,
CHARACTER_INFO,
ENEMY_INFO,
Boss,
Elf,
Penguin,
Shiggy,
Wizard,
Wolf,
)
# Per-character ability burst colour, keyed by player class.
_ABILITY_COLORS = {
@@ -377,7 +400,7 @@ class LevelManager:
BOSS_ROSTER[seed % len(BOSS_ROSTER)]
try:
with open(entry.file, 'r') as f:
with open(entry.file) as f:
raw = [line.rstrip('\n') for line in f if line.strip()]
except FileNotFoundError:
print(f"Level file {entry.file} not found!")
modified main.py
@@ -5,14 +5,13 @@ import threading
import pygame
from settings import WIDTH, HEIGHT, FPS
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
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()
@@ -190,291 +189,307 @@ def _leave_game():
_to_level_menu()
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":
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()
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:
# 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()
# 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.
# 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
else:
game_state = "paused"
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)
# 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):
# 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()
continue
# Main menu
# 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":
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":
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"
elif action == "test":
level_menu.refresh() # so the new custom shows up later
_start_level(editor.test_level_id, return_to="editor")
editor.request_test = False
# Settings
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":
action = settings_menu.handle_input(event)
if action == "back":
game_state = "menu"
# Charakter select
settings_menu.draw(screen)
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 (from catalog).
character_menu.draw(screen, current_character)
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.
level_menu.draw(screen)
elif game_state == "loading":
if loading_screen is not None:
loading_screen.handle_input(event)
# Pause overlay
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":
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 actually 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()
# 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()
modified menu.py
@@ -1,19 +1,29 @@
import pygame
from units import CHARACTER_INFO
import audio
import level_catalog
import save
import audio
import theme
from version import VERSION
# Palette, font cache and the shared title / back-hint / hover
# primitives. Bound to module-private aliases to match the internal
# naming used by the screens below.
from theme import (
BG, INK, MUTED, ACCENT, TITLE_C, DONE_C, SEL_C, LINE_C,
ACCENT,
BG,
DONE_C,
INK,
LINE_C,
MUTED,
SEL_C,
TITLE_C,
measure,
draw_title as _draw_title,
draw_back_hint as _draw_back_hint,
hover_marker as _hover_marker)
)
from theme import draw_back_hint as _draw_back_hint
from theme import draw_title as _draw_title
from theme import hover_marker as _hover_marker
from units import CHARACTER_INFO
from version import VERSION
class MainMenu:
@@ -478,7 +488,7 @@ class CharacterMenu:
# still everywhere except the focus.
self.previews = {}
self.thumbs = {}
for key, cls, label, tagline in CHARACTER_INFO:
for key, cls, _label, _tagline in CHARACTER_INFO:
self.previews[key] = self._load_idle_frames(cls, 220)
self.thumbs[key] = self._load_idle_frames(cls, 72)
added pyproject.toml
@@ -0,0 +1,47 @@
[project]
name = "the-way-out"
version = "1.0.1"
description = "A top-down pixel-art escape-room shooter."
readme = "README.md"
license = { text = "Apache-2.0" }
requires-python = ">=3.12"
authors = [{ name = "ajhahnde" }]
dependencies = [
"pygame==2.6.1",
]
[project.optional-dependencies]
# Pinned to what build_mac.sh installs into .venv and what produced
# v1.0.0's TheWayOut-mac.zip — bumping these requires re-shipping a
# new launcher build, not just a `git push` via the in-game updater.
build = [
"pyinstaller==6.20.0",
"certifi==2026.5.20",
]
dev = [
"ruff==0.15.14",
"pytest==9.0.3",
]
[project.urls]
Homepage = "https://github.com/ajhahnde/the-way-out"
Changelog = "https://github.com/ajhahnde/the-way-out/blob/main/CHANGELOG.md"
# No build-system table on purpose: the-way-out ships as a PyInstaller
# .app (build_mac.sh) and runs from source in dev. It is not a wheel
# and is not published to PyPI.
[tool.ruff]
target-version = "py312"
line-length = 79
extend-exclude = ["assets", "build", "dist", ".venv", ".venv-intel"]
[tool.ruff.lint]
# Defaults (E4/E7/E9, F) plus import-order (I), pyupgrade (UP) and
# bugbear (B). Wide enough to catch real bugs without forcing a
# stylistic rewrite of the existing code.
select = ["E4", "E7", "E9", "F", "I", "UP", "B"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
modified save.py
@@ -37,7 +37,7 @@ def _load():
"""The whole save document as a dict. Empty dict on any error or
if the file holds something other than an object."""
try:
with open(SAVE_FILE, 'r') as f:
with open(SAVE_FILE) as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
modified static_objects.py
@@ -1,4 +1,5 @@
import pygame
from settings import TILE_SIZE
TS = TILE_SIZE
added tests/__init__.py
added tests/conftest.py
@@ -0,0 +1,26 @@
"""Headless pygame for the whole suite.
Force the dummy SDL drivers before ``pygame`` is imported anywhere else
in the suite, then ``pygame.init()`` once so sprite constructors that
build Surfaces work without a display server. The CI runner has no
monitor and no audio device.
"""
import os
import sys
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
# Tests import game modules by their bare name (``import units`` etc.) —
# the repo root holds them, so make it importable when pytest is invoked
# from anywhere.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
import pygame # noqa: E402
pygame.init()
# Create a tiny offscreen Surface so any ``convert_alpha`` call inside
# sprite asset loaders has a valid video context.
pygame.display.set_mode((1, 1))
added tests/test_cooldowns.py
@@ -0,0 +1,106 @@
"""Attack + ability cooldown bookkeeping on the Character base class
and the per-character ABILITY_COOLDOWN catalogue.
The Character constructor wants an obstacle_sprites group and a real
asset folder. ``Wizard`` is the simplest playable (defaults across the
board); the units module's ``load_assets`` tolerates missing PNGs by
returning an empty frame list (``_placeholder_frame`` covers the
draw)."""
import pygame
from settings import ATTACK_COOLDOWN, DASH_COOLDOWN
from units import Elf, Penguin, Shiggy, Wizard, Wolf
def _wizard():
return Wizard(0, 0, obstacle_sprites=pygame.sprite.Group())
def _elf():
return Elf(0, 0, obstacle_sprites=pygame.sprite.Group())
# --- Per-character ABILITY_COOLDOWN catalogue ----------------------------
def test_default_ability_cooldown_constants():
# Numbers are the contract the HUD's ability-ring draws against
# — bumping one without bumping the CHANGELOG is a regression.
assert Wizard.ABILITY_COOLDOWN == 12.0
assert Penguin.ABILITY_COOLDOWN == 11.0
assert Elf.ABILITY_COOLDOWN == 9.0
assert Wolf.ABILITY_COOLDOWN == 8.0
assert Shiggy.ABILITY_COOLDOWN == DASH_COOLDOWN
def test_default_attack_cooldown_is_module_constant():
# Wizard does not override attack_cooldown, so the class attribute
# is the module-wide ATTACK_COOLDOWN.
assert Wizard.attack_cooldown == ATTACK_COOLDOWN
# --- attack_timer tick + clamp ------------------------------------------
def test_attack_timer_starts_at_zero():
w = _wizard()
assert w.attack_timer == 0.0
def test_attack_timer_clamps_at_zero_after_overrun():
w = _wizard()
w.attack_timer = 0.05
w.attack_timer = max(0.0, w.attack_timer - 0.5)
assert w.attack_timer == 0.0
def test_current_attack_cooldown_default_equals_class_attr():
w = _wizard()
assert w.current_attack_cooldown() == Wizard.attack_cooldown
# --- Elf VOLLEY halves the cadence while active --------------------------
def test_elf_cooldown_halved_during_active_ability():
e = _elf()
base = e.current_attack_cooldown()
assert base == Elf.attack_cooldown
e.ability_active = True
assert e.current_attack_cooldown() == Elf.attack_cooldown * 0.5
e.ability_active = False
assert e.current_attack_cooldown() == base
# --- ability_cooldown_timer tick + clamp ---------------------------------
def test_ability_cooldown_timer_starts_at_zero():
w = _wizard()
assert w.ability_cooldown_timer == 0.0
def test_ability_cooldown_timer_ticks_down_and_clamps():
w = _wizard()
w.ability_cooldown_timer = 0.4
# Mirror the per-frame countdown handle_ability runs.
w.ability_cooldown_timer = max(0.0, w.ability_cooldown_timer - 0.1)
assert abs(w.ability_cooldown_timer - 0.3) < 1e-9
w.ability_cooldown_timer = max(0.0, w.ability_cooldown_timer - 5.0)
assert w.ability_cooldown_timer == 0.0
def test_ability_gating_uses_cooldown_timer_and_active_flag():
# Mirror the gate handle_ability checks: ready only when not active
# and cooldown timer is zero. No keyboard polling here — the trigger
# check is just (not active) and (timer == 0).
w = _wizard()
assert not w.ability_active
assert w.ability_cooldown_timer == 0.0
ready = (not w.ability_active and w.ability_cooldown_timer == 0)
assert ready is True
w.ability_cooldown_timer = 0.5
ready = (not w.ability_active and w.ability_cooldown_timer == 0)
assert ready is False
w.ability_cooldown_timer = 0.0
w.ability_active = True
ready = (not w.ability_active and w.ability_cooldown_timer == 0)
assert ready is False
added tests/test_level_parser.py
@@ -0,0 +1,61 @@
"""Parser helpers in levels.py: row tokenisation, variant + pair-id
extraction. Pure functions, no pygame needed beyond import."""
from levels import _cell_variant, _pair_id, _split_cells
# --- _split_cells: dense vs whitespace ----------------------------------
def test_split_cells_dense_row():
# Legacy format — no internal whitespace, each char is one cell.
assert _split_cells("####") == ["#", "#", "#", "#"]
def test_split_cells_dense_with_mixed_glyphs():
assert _split_cells(".p#G") == [".", "p", "#", "G"]
def test_split_cells_tokenised_row():
# Any internal whitespace -> whitespace-separated tokens.
assert _split_cells("T1 T2 T3") == ["T1", "T2", "T3"]
def test_split_cells_tokenised_preserves_multi_char_tokens():
assert _split_cells("# T3 P2 G1") == ["#", "T3", "P2", "G1"]
def test_split_cells_trailing_whitespace_dense():
# Trailing newline/whitespace must not flip a dense row into the
# tokenised branch.
assert _split_cells("abc\n") == ["a", "b", "c"]
# --- _cell_variant -------------------------------------------------------
def test_cell_variant_defaults_to_one():
# No trailing digits -> variant 1 (the base tile).
assert _cell_variant("T") == 1
assert _cell_variant("#") == 1
def test_cell_variant_reads_trailing_digits():
assert _cell_variant("T3") == 3
assert _cell_variant("P12") == 12
def test_cell_variant_ignores_non_digit_tail():
# Only fully-numeric tails count as a variant.
assert _cell_variant("Tx") == 1
# --- _pair_id ------------------------------------------------------------
def test_pair_id_none_when_no_digits():
# Distinct from _cell_variant: no explicit pair id -> None, meaning
# "pair by reading order".
assert _pair_id("L") is None
assert _pair_id("P") is None
def test_pair_id_reads_trailing_digits():
assert _pair_id("L1") == 1
assert _pair_id("G7") == 7
assert _pair_id("P12") == 12
added tests/test_pressure_plate.py
@@ -0,0 +1,81 @@
"""PressurePlate charge/trip behaviour and Lever single-shot toggle.
Both use lazy-built surfaces, so they need a video context — provided
by conftest's dummy SDL setup."""
import pygame
from interactables import Lever, PressurePlate
from settings import PLATE_TRIGGER_DELAY
def _plate():
return PressurePlate((0, 0), [pygame.sprite.Group()], gate_group=("ord", 0))
def _lever():
return Lever((0, 0), [pygame.sprite.Group()], gate_group=("ord", 0))
# --- PressurePlate -------------------------------------------------------
def test_plate_starts_idle():
p = _plate()
assert p.activated is False
assert p.charge == 0.0
def test_plate_charge_accumulates_below_threshold():
p = _plate()
half = PLATE_TRIGGER_DELAY / 2
tripped = p.step_on(half)
assert tripped is False
assert p.activated is False
assert p.charge == half
def test_plate_trips_at_threshold_exactly_once():
p = _plate()
# Two equal halves exactly hit PLATE_TRIGGER_DELAY — that's the
# tripping frame, and step_on returns True only on it.
half = PLATE_TRIGGER_DELAY / 2
assert p.step_on(half) is False
assert p.step_on(half) is True
assert p.activated is True
# Subsequent ticks must not re-fire — the level opens the gate
# exactly once.
assert p.step_on(0.1) is False
def test_plate_stays_tripped_after_step_off():
p = _plate()
p.step_on(PLATE_TRIGGER_DELAY) # trip it
p.step_off()
assert p.activated is True # still down
def test_step_off_resets_charge_only_while_untripped():
p = _plate()
p.step_on(PLATE_TRIGGER_DELAY / 3)
p.step_off()
assert p.charge == 0.0
# After tripping, charge is frozen (the plate stays down forever),
# so step_off must be a no-op on the charge.
p.step_on(PLATE_TRIGGER_DELAY) # trip
charge_after_trip = p.charge
p.step_off()
assert p.charge == charge_after_trip
# --- Lever ---------------------------------------------------------------
def test_lever_starts_inactive():
lev = _lever()
assert lev.activated is False
def test_lever_use_toggles_once_and_returns_true_only_first_time():
lev = _lever()
assert lev.use() is True
assert lev.activated is True
# Second pull is a no-op: returns False, stays activated.
assert lev.use() is False
assert lev.activated is True
modified theme.py
@@ -13,6 +13,7 @@ import os
import random
import pygame
from settings import FONT, TILE_SIZE
# --- palette ---------------------------------------------------------
modified tiles.py
@@ -18,7 +18,6 @@ Category vocabulary used by the editor's palette grouping:
"""
from dataclasses import dataclass
from typing import Optional
import tileset
from units import ENEMY_INFO
@@ -39,7 +38,7 @@ class TileSpec:
description: str
solid: bool = False
variant_count: int = 1
tileset_category: Optional[str] = None
tileset_category: str | None = None
# Prop letters → tileset category. The only duplication left between
modified units.py
@@ -4,17 +4,34 @@ import pygame
import audio
from settings import (
PLAYER_SCALE, PLAYER_SPEED, PLAYER_HITBOX_SIZE,
PLAYER_MAX_HP, ATTACK_COOLDOWN,
PROJECTILE_SPEED, PROJECTILE_DAMAGE, PROJECTILE_LIFETIME,
PROJECTILE_RADIUS,
BOSS_SCALE, BOSS_SPEED, BOSS_MAX_HP, BOSS_HITBOX_SIZE,
BOSS_PHASE2_HP_RATIO, BOSS_CHASE_TIME_MIN, BOSS_CHASE_TIME_MAX,
BOSS_WINDUP_TIME, BOSS_DASH_TIME, BOSS_DASH_SPEED_MULT,
BOSS_RECOVER_TIME, BOSS_AIM_TIME,
BOSS_PROJECTILE_DAMAGE, BOSS_PROJECTILE_SPEED,
DASH_DURATION, DASH_SPEED_MULT, DASH_COOLDOWN, DASH_INVULN_BONUS,
ATTACK_COOLDOWN,
BOSS_AIM_TIME,
BOSS_CHASE_TIME_MAX,
BOSS_CHASE_TIME_MIN,
BOSS_DASH_SPEED_MULT,
BOSS_DASH_TIME,
BOSS_HITBOX_SIZE,
BOSS_MAX_HP,
BOSS_PHASE2_HP_RATIO,
BOSS_PROJECTILE_DAMAGE,
BOSS_PROJECTILE_SPEED,
BOSS_RECOVER_TIME,
BOSS_SCALE,
BOSS_SPEED,
BOSS_WINDUP_TIME,
DASH_COOLDOWN,
DASH_DURATION,
DASH_INVULN_BONUS,
DASH_SPEED_MULT,
HIT_FLASH_TIME,
PLAYER_HITBOX_SIZE,
PLAYER_MAX_HP,
PLAYER_SCALE,
PLAYER_SPEED,
PROJECTILE_DAMAGE,
PROJECTILE_LIFETIME,
PROJECTILE_RADIUS,
PROJECTILE_SPEED,
)
# Unit vector for each facing direction (used as aim fallback when no