Commit
the-way-out
v0.1.0
added .gitignore
@@ -0,0 +1,27 @@
# Python Cache
__pycache__/
*.py[cod]
# MacOS Bin
.DS_Store
# Python virtualenvs (arm64 build env -> build_mac.sh;
# x86_64 cross-build env .venv-intel -> build_mac_intel.sh)
venv/
.venv/
.venv-intel/
# Local env file & editor config
.env
.vscode/
# Unused/local-only assets & notes
assets/background/test_bg_1.png
assets/background/test_bg_2.png
ajhahnde/
# PyInstaller build output (the self-updating Mac launcher)
build/
dist/
*.spec
added README.md
@@ -0,0 +1,61 @@
# The Way Out
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.1.0 — see [Release Notes](RELEASE_NOTES.md)
## Play
```bash
pip install pygame
python main.py
```
## Controls
| Input | Action |
|------------------|-------------------|
| WASD / Arrows | Move & aim (4-way)|
| Space | Shoot |
| Shift | Dash |
| E | Use / interact |
| Esc | Pause / back |
## Characters
Five playable characters, each with its own HP, speed, damage and
fire-rate profile: the balanced Wizard, the Penguin tank, the rapid-fire
Elf, the glass-cannon Shiggy, and the speedster Wolf.
## Levels
Three hand-authored escape rooms. Levels are plain text maps under
`assets/levels/` (see `LEGEND.md` for the tile vocabulary) plus a
`manifest.json`, so new rooms can be added without touching code. An
in-game editor (`editor.py`) edits them live.
## Project layout
| Path | Purpose |
|------------------|------------------------------------------|
| `main.py` | Entry point & game loop |
| `menu.py` | Title, settings, character & level menus |
| `levels.py` | Level loading & runtime |
| `units.py` | Player & enemy logic |
| `interactables.py` / `static_objects.py` / `tileset.py` | World objects |
| `editor.py` | In-game level editor |
| `theme.py` | Shared palette & UI helpers |
| `audio.py` | Music & SFX |
| `assets/` | Sprites, audio, fonts, level maps |
## Build (macOS)
```bash
./build_mac.sh # arm64 .app
./build_mac_intel.sh # x86_64 .app
```
The app self-updates from this repository on launch via `updater.py`;
save data lives outside the app bundle and is never touched by updates.
added RELEASE_NOTES.md
@@ -0,0 +1,28 @@
# Release Notes
## v0.1.0
Initial release.
### Gameplay
- Top-down pixel-art escape-room shooter with 4-directional aim,
ranged combat, and a Shift dash with i-frames.
- Five playable characters with distinct HP, speed, damage, and
fire-rate profiles: Wizard, Penguin, Elf, Shiggy, Wolf.
- Three hand-authored escape rooms with levers, pressure plates,
gates, spike hazards, and a two-phase boss (Mr. Green).
### Tools
- In-game level editor for the text-based map format.
- Data-driven levels (`assets/levels/`, `manifest.json`) — new rooms
require no code changes.
### App
- Self-updating macOS build; save data is stored outside the app
bundle and is preserved across updates.
- Accurate connectivity detection: the updater distinguishes "no
internet" from "update server unreachable / rate-limited".
### UI
- Shared theme across all screens (single palette and font set).
- Readable status and stat text; animated character-select preview.
added assets/audio/music/ingame.ogg
binary file — no preview
added assets/audio/music/menu.ogg
binary file — no preview
added assets/audio/music/title.ogg
binary file — no preview
added assets/gui/bars/BarTile_01.png
binary file — no preview
added assets/gui/bars/BarTile_02.png
binary file — no preview
added assets/gui/bars/BarTile_03.png
binary file — no preview
added assets/gui/bars/BarTile_04.png
binary file — no preview
added assets/gui/bars/BarTile_05.png
binary file — no preview
added assets/gui/bars/BarTile_06.png
binary file — no preview
added assets/gui/bars/BarTile_07.png
binary file — no preview
added assets/gui/bars/BarTile_08.png
binary file — no preview
added assets/gui/bars/BarTile_09.png
binary file — no preview
added assets/gui/bars/BarTile_10.png
binary file — no preview
added assets/gui/bars/BarTile_11.png
binary file — no preview
added assets/gui/bars/BarTile_12.png
binary file — no preview
added assets/gui/bars/BarTile_13.png
binary file — no preview
added assets/gui/bars/BarTile_14.png
binary file — no preview
added assets/gui/bars/BarTile_15.png
binary file — no preview
added assets/gui/bars/BarTile_16.png
binary file — no preview
added assets/gui/bars/BarTile_17.png
binary file — no preview
added assets/gui/bars/BarTile_18.png
binary file — no preview
added assets/gui/bars/BarsMap.png
binary file — no preview
added assets/gui/buttons/Button1.png
binary file — no preview
added assets/gui/buttons/Button2.png
binary file — no preview
added assets/gui/buttons/Button3.png
binary file — no preview
added assets/gui/buttons/Button4.png
binary file — no preview
added assets/gui/buttons/Button5.png
binary file — no preview
added assets/gui/buttons/Button6.png
binary file — no preview
added assets/gui/buttons/ButtonTile_01.png
binary file — no preview
added assets/gui/buttons/ButtonTile_02.png
binary file — no preview
added assets/gui/buttons/ButtonTile_03.png
binary file — no preview
added assets/gui/buttons/ButtonTile_04.png
binary file — no preview
added assets/gui/buttons/ButtonTile_05.png
binary file — no preview
added assets/gui/buttons/ButtonTile_06.png
binary file — no preview
added assets/gui/buttons/ButtonTile_07.png
binary file — no preview
added assets/gui/buttons/ButtonTile_08.png
binary file — no preview
added assets/gui/buttons/ButtonTile_09.png
binary file — no preview
added assets/gui/buttons/ButtonTile_10.png
binary file — no preview
added assets/gui/buttons/ButtonTile_11.png
binary file — no preview
added assets/gui/buttons/ButtonTile_12.png
binary file — no preview
added assets/gui/buttons/ButtonTile_13.png
binary file — no preview
added assets/gui/buttons/ButtonTile_14.png
binary file — no preview
added assets/gui/buttons/ButtonTile_15.png
binary file — no preview
added assets/gui/buttons/ButtonTile_16.png
binary file — no preview
added assets/gui/buttons/ButtonTile_17.png
binary file — no preview
added assets/gui/buttons/ButtonTile_18.png
binary file — no preview
added assets/gui/buttons/ButtonsMap.png
binary file — no preview
added assets/gui/buttons/ButtonsMap2.png
binary file — no preview
added assets/gui/font/main_font.otf
binary file — no preview
added assets/gui/icons/Icon_01.png
binary file — no preview
added assets/gui/icons/Icon_02.png
binary file — no preview
added assets/gui/icons/Icon_03.png
binary file — no preview
added assets/gui/icons/Icon_04.png
binary file — no preview
added assets/gui/icons/Icon_05.png
binary file — no preview
added assets/gui/icons/Icon_06.png
binary file — no preview
added assets/gui/icons/Icon_07.png
binary file — no preview
added assets/gui/icons/Icon_08.png
binary file — no preview
added assets/gui/icons/Icon_09.png
binary file — no preview
added assets/gui/icons/Icon_10.png
binary file — no preview
added assets/gui/icons/Icon_11.png
binary file — no preview
added assets/gui/icons/Icon_12.png
binary file — no preview
added assets/gui/icons/Icon_13.png
binary file — no preview
added assets/gui/icons/Icon_14.png
binary file — no preview
added assets/gui/icons/Icon_15.png
binary file — no preview
added assets/gui/icons/Icon_16.png
binary file — no preview
added assets/gui/icons/Icon_17.png
binary file — no preview
added assets/gui/icons/Icon_18.png
binary file — no preview
added assets/gui/icons/Icon_19.png
binary file — no preview
added assets/gui/icons/Icon_20.png
binary file — no preview
added assets/gui/icons/Icon_21.png
binary file — no preview
added assets/gui/icons/Icon_22.png
binary file — no preview
added assets/gui/icons/Icon_23.png
binary file — no preview
added assets/gui/icons/Icon_24.png
binary file — no preview
added assets/gui/icons/Icon_25.png
binary file — no preview
added assets/gui/icons/Icon_26.png
binary file — no preview
added assets/gui/icons/Icon_27.png
binary file — no preview
added assets/gui/icons/Icon_28.png
binary file — no preview
added assets/gui/icons/Icon_29.png
binary file — no preview
added assets/gui/icons/Icon_30.png
binary file — no preview
added assets/gui/icons/Icon_31.png
binary file — no preview
added assets/gui/icons/Icon_32.png
binary file — no preview
added assets/gui/icons/Icon_33.png
binary file — no preview
added assets/gui/icons/Icon_34.png
binary file — no preview
added assets/gui/icons/Icon_35.png
binary file — no preview
added assets/gui/icons/Icon_36.png
binary file — no preview
added assets/gui/icons/Icon_37.png
binary file — no preview
added assets/gui/icons/Icon_38.png
binary file — no preview
added assets/gui/icons/Icon_39.png
binary file — no preview
added assets/gui/icons/Icon_40.png
binary file — no preview
added assets/gui/icons/Icon_41.png
binary file — no preview
added assets/gui/icons/Icon_42.png
binary file — no preview
added assets/gui/icons/Icon_43.png
binary file — no preview
added assets/gui/icons/Icon_44.png
binary file — no preview
added assets/gui/icons/Icon_45.png
binary file — no preview
added assets/gui/icons/Icon_46.png
binary file — no preview
added assets/gui/icons/Icon_47.png
binary file — no preview
added assets/gui/icons/Icon_48.png
binary file — no preview
added assets/gui/icons/Icon_49.png
binary file — no preview
added assets/gui/icons/Icon_50.png
binary file — no preview
added assets/gui/icons/Icon_51.png
binary file — no preview
added assets/gui/icons/Icon_52.png
binary file — no preview
added assets/gui/icons/Icon_53.png
binary file — no preview
added assets/gui/icons/Icon_54.png
binary file — no preview
added assets/gui/icons/Icon_55.png
binary file — no preview
added assets/gui/icons/Icon_56.png
binary file — no preview
added assets/gui/icons/Icon_57.png
binary file — no preview
added assets/gui/icons/Icon_58.png
binary file — no preview
added assets/gui/icons/Icon_59.png
binary file — no preview
added assets/gui/icons/Icon_60.png
binary file — no preview
added assets/gui/icons/Iconset1.png
binary file — no preview
added assets/gui/icons/Iconset2.png
binary file — no preview
added assets/gui/icons/Iconset3.png
binary file — no preview
added assets/gui/icons/Iconset4.png
binary file — no preview
added assets/gui/icons/Iconset5.png
binary file — no preview
added assets/gui/icons/Iconset6.png
binary file — no preview
added assets/gui/icons/Iconset7.png
binary file — no preview
added assets/gui/interface/TileMap1.png
binary file — no preview
added assets/gui/interface/TileMap2.png
binary file — no preview
added assets/gui/interface/TileMap3.png
binary file — no preview
added assets/gui/interface/TileMap4.png
binary file — no preview
added assets/gui/interface/TileMap5.png
binary file — no preview
added assets/gui/interface/TileMap6.png
binary file — no preview
added assets/gui/interface/TileMap7.png
binary file — no preview
added assets/gui/interface/TileMap8.png
binary file — no preview
added assets/gui/interface/Tile_01.png
binary file — no preview
added assets/gui/interface/Tile_02.png
binary file — no preview
added assets/gui/interface/Tile_03.png
binary file — no preview
added assets/gui/interface/Tile_04.png
binary file — no preview
added assets/gui/interface/Tile_05.png
binary file — no preview
added assets/gui/interface/Tile_06.png
binary file — no preview
added assets/gui/interface/Tile_07.png
binary file — no preview
added assets/gui/interface/Tile_08.png
binary file — no preview
added assets/gui/interface/Tile_09.png
binary file — no preview
added assets/gui/interface/Tile_10.png
binary file — no preview
added assets/gui/interface/Tile_11.png
binary file — no preview
added assets/gui/interface/Tile_12.png
binary file — no preview
added assets/gui/interface/Tile_13.png
binary file — no preview
added assets/gui/interface/Tile_14.png
binary file — no preview
added assets/gui/interface/Tile_15.png
binary file — no preview
added assets/gui/interface/Tile_16.png
binary file — no preview
added assets/gui/interface/Tile_17.png
binary file — no preview
added assets/gui/interface/Tile_18.png
binary file — no preview
added assets/gui/interface/Tile_19.png
binary file — no preview
added assets/gui/interface/Tile_20.png
binary file — no preview
added assets/gui/interface/Tile_21.png
binary file — no preview
added assets/gui/interface/Tile_22.png
binary file — no preview
added assets/gui/interface/Tile_23.png
binary file — no preview
added assets/gui/interface/Tile_24.png
binary file — no preview
added assets/gui/interface/Tile_25.png
binary file — no preview
added assets/gui/interface/Tile_26.png
binary file — no preview
added assets/gui/interface/Tile_27.png
binary file — no preview
added assets/gui/interface/Tile_28.png
binary file — no preview
added assets/gui/interface/Tile_29.png
binary file — no preview
added assets/gui/interface/Tile_30.png
binary file — no preview
added assets/gui/interface/Tile_31.png
binary file — no preview
added assets/gui/interface/Tile_32.png
binary file — no preview
added assets/gui/interface/Tile_33.png
binary file — no preview
added assets/gui/interface/Tile_34.png
binary file — no preview
added assets/gui/interface/Tile_35.png
binary file — no preview
added assets/gui/interface/Tile_36.png
binary file — no preview
added assets/gui/interface/Tile_37.png
binary file — no preview
added assets/gui/interface/Tile_38.png
binary file — no preview
added assets/gui/interface/Tile_39.png
binary file — no preview
added assets/gui/interface/Tile_40.png
binary file — no preview
added assets/gui/interface/Tile_41.png
binary file — no preview
added assets/gui/interface/Tile_42.png
binary file — no preview
added assets/gui/interface/Tile_43.png
binary file — no preview
added assets/gui/interface/Tile_44.png
binary file — no preview
added assets/gui/interface/Tile_45.png
binary file — no preview
added assets/gui/interface/Tile_46.png
binary file — no preview
added assets/gui/interface/Tile_47.png
binary file — no preview
added assets/gui/interface/Tile_48.png
binary file — no preview
added assets/gui/interface/Tile_49.png
binary file — no preview
added assets/gui/interface/Tile_50.png
binary file — no preview
added assets/gui/interface/Tile_51.png
binary file — no preview
added assets/gui/interface/Tile_52.png
binary file — no preview
added assets/gui/interface/Tile_53.png
binary file — no preview
added assets/gui/interface/Tile_54.png
binary file — no preview
added assets/gui/interface/Tile_55.png
binary file — no preview
added assets/gui/interface/Tile_56.png
binary file — no preview
added assets/gui/interface/Tile_57.png
binary file — no preview
added assets/gui/interface/Tile_58.png
binary file — no preview
added assets/gui/interface/Tile_59.png
binary file — no preview
added assets/gui/interface/Tile_60.png
binary file — no preview
added assets/gui/interface/Tile_61.png
binary file — no preview
added assets/gui/interface/Tile_62.png
binary file — no preview
added assets/gui/interface/Tile_64.png
binary file — no preview
added assets/gui/interface/Tile_65.png
binary file — no preview
added assets/gui/interface/Tile_67.png
binary file — no preview
added assets/gui/interface/Tile_68.png
binary file — no preview
added assets/gui/interface/Tile_70.png
binary file — no preview
added assets/gui/interface/Tile_71.png
binary file — no preview
added assets/gui/interface/Tile_72.png
binary file — no preview
added assets/gui/interface/Tile_73.png
binary file — no preview
added assets/gui/interface/Tile_74.png
binary file — no preview
added assets/gui/interface/Tile_76.png
binary file — no preview
added assets/gui/interface/Tile_77.png
binary file — no preview
added assets/gui/interface/Tile_79.png
binary file — no preview
added assets/gui/interface/Tile_80.png
binary file — no preview
added assets/gui/interface/Tile_82.png
binary file — no preview
added assets/gui/interface/Tile_83.png
binary file — no preview
added assets/gui/interface/Tile_84.png
binary file — no preview
added assets/gui/interface/Tile_85.png
binary file — no preview
added assets/gui/interface/Tile_87.png
binary file — no preview
added assets/gui/interface/Tile_88.png
binary file — no preview
added assets/gui/interface/Tile_90.png
binary file — no preview
added assets/gui/interface/Tile_91.png
binary file — no preview
added assets/gui/interface/Tile_93.png
binary file — no preview
added assets/gui/interface/Tile_94.png
binary file — no preview
added assets/gui/interface/Tile_95.png
binary file — no preview
added assets/gui/interface/Tile_96.png
binary file — no preview
added assets/gui/logo/1.png
binary file — no preview
added assets/gui/scrolling/ScrollingMap.png
binary file — no preview
added assets/gui/scrolling/ScrollingTile1.png
binary file — no preview
added assets/gui/scrolling/ScrollingTile2.png
binary file — no preview
added assets/gui/scrolling/ScrollingTile3.png
binary file — no preview
added assets/levels/LEGEND.md
@@ -0,0 +1,239 @@
# Level file legend
Levels are plain-text grids. Each cell is one tile (`64 px`). The map
is padded with wall on the right, so rows don't have to be the same
length — but keeping them aligned makes editing sane.
## Where levels live
There are two kinds of level and you never have to touch code to add
one:
* **Built-in** — `.txt` files in `assets/levels/`, listed in
`assets/levels/manifest.json`. To add one: drop the file in, add an
entry to the manifest (`id`, `file`, `title`, `tagline`, and the
optional `music` track name — see Audio). It appears in the level
menu on next launch. No Python edits.
* **Custom** — anything saved by the in-game **Editor** lands in
`~/.the-way-out/custom_levels/<name>.txt` and is auto-discovered (no
manifest entry needed). These show a `CUSTOM` pill in the level menu.
`level_catalog.py` is the single source of truth that merges both;
`tiles.py` (`REGISTRY`) is the single source of truth for the tile
vocabulary below — the editor palette and the level loader both read
it, so this table, the registry and the editor never drift apart.
## In-game Level Editor
Main menu → **Editor**. Build a level visually and play-test it
instantly.
| Action | Bind |
|----------------------|---------------------------------------|
| Place selected tile | Left mouse (hold to drag-paint) |
| Clear to floor | Right mouse (hold to drag-erase) |
| Box fill / box erase | Shift + drag left / right mouse |
| Eyedropper | `Q` or **Pick**, then click a cell |
| Wall the outer ring | **Border** button |
| Select a tile | Click it in the right-hand palette |
| Cycle prop variant | Mouse wheel |
| Pan the canvas | WASD / Arrow keys |
| Grow / shrink grid | `+W/-W` `+H/-H` buttons in the toolbar|
| Rename file | Click the FILE box, type, Enter |
| Save | Ctrl+S or the **Save** button |
| Save & play-test | F5 or the **Test** button |
| Back to menu | Esc |
`P` (player start) and `X` (exit) are singletons — placing a new one
removes the old. Save warns (but never blocks) if there's no `P`/`X`
or if trigger/gate counts look mismatched, so you can iterate freely.
Test launches with the character last picked in **Characters** and
returns you to the editor when the run ends.
## Row formats
There are two ways to write a row; you can mix formats between rows in
the same file.
1. **Dense** (legacy) — one character per cell, no spaces:
```
WWWWWWWW
W..P...W
WWWWWWWW
```
Quick to write, but **cannot use variants** (every cell is the
default look of its object).
2. **Spaced / tokenised** — cells separated by spaces, so a cell can
carry a *variant number*:
```
W W W W W W
W . T3 . A1 W
W W W W W W
```
A token is a letter optionally followed by digits. `T3` = torch
variant 3, `A1` = table variant 1, `M40` = misc decor 40. A bare
letter (`T`) or any dense cell uses variant 1. Out-of-range numbers
clamp back to 1.
A row counts as tokenised the moment it contains a space; otherwise
it's read dense. Recommended: use the spaced format for new levels
(see `level_2.txt` for a full example).
## Core gameplay tiles
These drive the escape-room logic and use the game's own built-in
artwork (not the tileset). No tileset variants — but `L`/`Y`/`G` take
an optional pairing digit (see Trigger ↔ gate pairing below).
| Char | Meaning | Notes |
|------|--------------------|--------------------------------------------------------------|
| `W` | Wall | Solid. Drawn from the tileset floor/wall art (see below). |
| `.` | Floor | Empty walkable cell. Any unrecognised char is also floor. |
| `P` | Player start | Where the chosen character spawns. Use exactly one. |
| `X` | Exit / way out | Opens once the boss is dead (if any) and the key is held. |
| `B` | Boss (Mr. Green) | Spawns lazily when the player first enters the boss room. |
| `N` | Enemy (chaser) | Roaming threat; chases the player, contact damage. Spawns at once. Does **not** gate the exit. |
| `S` | Spikes | Timed hazard (safe → warning → deadly loop). |
| `L` | Lever | Pull with **E**. Pairs by order, or `L2`…`L9` to a gate. |
| `Y` | Pressure plate | Stand on ~0.25 s. Pairs by order, or `Y2`…`Y9` to a gate. |
| `G` | Gate | Solid until its trigger fires. Adjacent `G` = one panel. |
| `K` | Key | Walk over to pick up; required before the exit opens. |
Trigger ↔ gate pairing has two modes you can mix freely in one level:
* **By reading order (default).** Levers and pressure plates form one
combined list in top-to-bottom, left-to-right order; the *i*-th
un-numbered trigger opens the *i*-th un-numbered gate panel. Author
a mixed sequence (plate, lever, plate, …) and rely on order alone —
this is the original behaviour and needs no digits.
* **Explicit pair id.** Give a trigger and its gate the same trailing
digit — `L2` opens the panel containing `G2`, `Y3` ↔ `G3`, etc.
(1–9). Numbered pairs are matched by their number regardless of
order and never interfere with the order-based pairing of the
un-numbered ones, so complex rooms stay unambiguous. Put the digit
on at least one cell of a multi-cell gate panel.
A panel is the set of 4-connected `G` cells; gates that touch are one
panel. In the **editor**, the mouse wheel on `L`/`Y`/`G` sets this
number: leave it at 1 to pair by order, or set 2–9 to pair a trigger
with the gate of the same number (the editor can't emit “1”, which is
fine — 1 just means “by order”).
## Tileset objects
Real tileset artwork. **Solid** objects block movement (you can't walk
through them); the rest are pure decoration drawn under the player.
The variant range is the count of images in that folder under
`assets/tileset/`.
| Char | Object | Solid | Variants | Source folder |
|------|--------------------|-------|----------|-------------------------------------|
| `T` | Torch | no | 1–8 | `static_objects/torches` |
| `C` | Chair | no | 1–14 | `static_objects/chairs` |
| `A` | Table | yes | 1–8 | `static_objects/tables` |
| `E` | Bookshelf | yes | 1–12 | `static_objects/bookshelf` |
| `D` | Bookshelf decor | no | 1–40 | `static_objects/bookshelf_decor` |
| `O` | Box / crate | yes | 1–16 | `static_objects/boxes` |
| `R` | Rubble / blockage | yes | 1–8 | `static_objects/blockage` |
| `M` | Misc clutter | no | 1–44 | `static_objects/other` |
| `Z` | Door (prop) | yes | 1–4 | `static_objects/doors` |
| `J` | Trapdoor | no | 1–6 | `static_objects/trapdoors` |
| `H` | Chest | yes | 1–2 | `interactables/Chest{n}_S.png` |
| `F` | Fire | no | 1 | `interactables/Fire1.png` |
A missing or unknown asset is drawn as a loud magenta block so it's
easy to spot. An unknown *letter* is just treated as floor.
## Controls (for level testing)
| Action | Bind |
|-------------------|-------------------------------|
| Move & aim | WASD / Arrow keys |
| Shoot | Space or **left mouse** |
| Aim | The way you're facing (4-dir) |
| Dash | Shift (~0.18 s, i-frames) |
| Use lever | E within reach |
| Pause | Esc (in level) |
| Retry (after end) | R |
| Back to menu | Esc (after end) / Enter / Space |
## Floor / wall look
The baked floor and wall come from two tiles in
`assets/tileset/tiles/`, set near the top of `tileset.py`:
```python
FLOOR_TILE = "Tile_42"
WALL_TILE = "Tile_03"
```
To restyle the dungeon, browse `assets/tileset/tiles/` (or the full
sheet `assets/tileset/Tileset.png` / `Palette.png`), pick a tile, and
put its file name here. If the name can't be loaded the level quietly
falls back to the original procedural stone, so a wrong value never
breaks anything.
**Per level:** a built-in `manifest.json` entry may override the look
just for that level with `"floor_tile"` / `"wall_tile"` (same tile
names, e.g. `"floor_tile": "Tile_18"`). Either key is optional and
falls back to the global default above; an unknown name still
degrades to procedural stone. Custom (editor-built) levels always use
the global default. Note: the **editor canvas preview** also always
draws the global default floor/wall — a per-level override only shows
when you actually play the level, not while editing it.
## Audio
Sound is **entirely optional and clone-safe** — the game runs silent
if no audio files are present. Just drop files in; no code changes.
Layout (the only convention):
```
assets/audio/sfx/<name>.wav (or .ogg)
assets/audio/music/<name>.ogg (or .wav / .mp3)
```
Sound effects the game already triggers (create the matching file to
hear it):
| File name (`assets/audio/sfx/…`) | Plays when |
|----------------------------------|------------------------------------|
| `shoot` | the player fires a projectile |
| `hit` | any unit takes damage |
| `dash` | the player dashes (Shift) |
| `boss_death` | Mr. Green is defeated |
| `player_death` | the player dies (run failed) |
| `level_complete` | the player reaches the exit |
Adding a new SFX is just a new file plus one `audio.play("name")` call
at the trigger site.
**Background music** is per level. In a `manifest.json` entry add
`"music": "<name>"` to loop `assets/audio/music/<name>.*` for that
level (omit it for silence). Custom (editor-built) levels look for
`assets/audio/music/default.*`. Music stops on win/lose; a level
reload/retry keeps the same track playing without a restart.
Audio files are read **once per session** (like the tileset), so add
them before launching. The **Settings → Sound** toggle mutes/unmutes
everything and is remembered across launches (stored in
`~/.the-way-out/save.json`).
## Minimal example
```
W W W W W W
W P . T3 . W
W . A1 . . W
W . . . . X
W W W W W W
```
Player spawns top-left, a torch (variant 3) and a solid table
(variant 1) decorate the room, exit on the right.
added assets/levels/level_1.txt
@@ -0,0 +1,46 @@
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W.........WWWWW.WW
WWW.......SSS......W........SSS.......W...............W.........W...W.WW
WWW.......SSS......W........SSS.......W...............W.........W.K.W.WW
WWW.......SSS......W........SSS.......W...............W.........W...W.WW
WWW.......SSS......W........SSS.......W...............W.........WSSSW.WW
WWW.......SSS......W........SSS.......W...............W.........W...W.WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS..L...W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW...P...SSS......W........SSS.......W...............W...............WW
WWW.......SSS......G........SSS.......................G...............WW
WWW.......SSS......G........SSS.......................G.......B.......WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W......L........W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWW.......SSS......W........SSS.......W...............W...........X...WW
WWW.......SSS......W........SSS.......W...............W...............WW
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
added assets/levels/level_2.txt
@@ -0,0 +1,10 @@
W W W W W W W W W W W W W W
W P . . T3 . . A1 C2 . . D5 . W
W . . . . . . . . . . . . W
W E1 E2 E3 . . O4 O5 . . F1 . . W
W . . . . . . . . . . . . W
W . R3 . . H1 . . M12 . . J2 . W
W . . . . . . . . . . . . W
W . . Z1 . . C7 . T6 . . . X W
W . . . . . . . . . . . . W
W W W W W W W W W W W W W W
added assets/levels/level_3.txt
@@ -0,0 +1,26 @@
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
WW..F..............WW.................WW..........H.......WW
WW...P.............WW.................WW..................WW
WW.................WW...F.............WW..........K.......WW
WW........Y........WW.................WW..................WW
WW.................WW.................WW..................WW
WW.................WW.................WW..................WW
WW.................WW.................WW..................WW
WW.................WW.................WW..................WW
WW.................GG.................WW..................WW
WW.................GG.................WW...............X..WW
WW.................WW.................WW..................WW
WW.................WW.................WW..................WW
WW.................WW...SSSSSSSSSSS...WW..................WW
WW.................WW...SSSSSSSSSSS...WW..................WW
WW.................WW.................WW..................WW
WW.................WW.................GG..................WW
WW.................WW.......Y.........GG..................WW
WW.................WW.................WW..................WW
WW.................WW.................WW..................WW
WW.................WW.................WW....F.............WW
WW..F..............WW.................WW..................WW
WW.................WW.................WW..................WW
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
added assets/levels/manifest.json
@@ -0,0 +1,25 @@
{
"levels": [
{
"id": "level_1",
"file": "level_1.txt",
"title": "LEVEL 1",
"tagline": "Mr. Green's Keep",
"music": "ingame"
},
{
"id": "level_2",
"file": "level_2.txt",
"title": "LEVEL 2",
"tagline": "The Cellar",
"music": "ingame"
},
{
"id": "level_3",
"file": "level_3.txt",
"title": "LEVEL 3",
"tagline": "The Plate Tomb",
"music": "ingame"
}
]
}
added assets/tileset/interactables/BigDoor_D.png
binary file — no preview
added assets/tileset/interactables/BigDoor_S.png
binary file — no preview
added assets/tileset/interactables/BigDoor_U.png
binary file — no preview
added assets/tileset/interactables/Chest1_D.png
binary file — no preview
added assets/tileset/interactables/Chest1_S.png
binary file — no preview
added assets/tileset/interactables/Chest1_U.png
binary file — no preview
added assets/tileset/interactables/Chest2_D.png
binary file — no preview
added assets/tileset/interactables/Chest2_S.png
binary file — no preview
added assets/tileset/interactables/Chest2_U.png
binary file — no preview
added assets/tileset/interactables/Door_D.png
binary file — no preview
added assets/tileset/interactables/Door_S.png
binary file — no preview
added assets/tileset/interactables/Door_U.png
binary file — no preview
added assets/tileset/interactables/Fire1.png
binary file — no preview
added assets/tileset/interactables/Lever1.png
binary file — no preview
added assets/tileset/interactables/Lever2.png
binary file — no preview
added assets/tileset/interactables/Spikes.png
binary file — no preview
added assets/tileset/interactables/Trapdoor_D.png
binary file — no preview
added assets/tileset/interactables/Trapdoor_S.png
binary file — no preview
added assets/tileset/interactables/Trapdoor_U.png
binary file — no preview
added assets/tileset/static_objects/Palette.png
binary file — no preview
added assets/tileset/static_objects/blockage/1.png
binary file — no preview
added assets/tileset/static_objects/blockage/2.png
binary file — no preview
added assets/tileset/static_objects/blockage/3.png
binary file — no preview
added assets/tileset/static_objects/blockage/4.png
binary file — no preview
added assets/tileset/static_objects/blockage/5.png
binary file — no preview
added assets/tileset/static_objects/blockage/6.png
binary file — no preview
added assets/tileset/static_objects/blockage/7.png
binary file — no preview
added assets/tileset/static_objects/blockage/8.png
binary file — no preview
added assets/tileset/static_objects/bookshelf/1.png
binary file — no preview
added assets/tileset/static_objects/bookshelf/10.png
binary file — no preview
added assets/tileset/static_objects/bookshelf/11.png
binary file — no preview
added assets/tileset/static_objects/bookshelf/12.png
binary file — no preview
added assets/tileset/static_objects/bookshelf/2.png
binary file — no preview
added assets/tileset/static_objects/bookshelf/3.png
binary file — no preview
added assets/tileset/static_objects/bookshelf/4.png
binary file — no preview
added assets/tileset/static_objects/bookshelf/5.png
binary file — no preview
added assets/tileset/static_objects/bookshelf/6.png
binary file — no preview
added assets/tileset/static_objects/bookshelf/7.png
binary file — no preview
added assets/tileset/static_objects/bookshelf/8.png
binary file — no preview
added assets/tileset/static_objects/bookshelf/9.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/1.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/10.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/11.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/12.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/13.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/14.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/15.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/16.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/17.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/18.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/19.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/2.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/20.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/21.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/22.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/23.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/24.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/25.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/26.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/27.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/28.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/29.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/3.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/30.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/31.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/32.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/33.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/34.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/35.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/36.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/37.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/38.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/39.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/4.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/40.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/5.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/6.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/7.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/8.png
binary file — no preview
added assets/tileset/static_objects/bookshelf_decor/9.png
binary file — no preview
added assets/tileset/static_objects/boxes/1.png
binary file — no preview
added assets/tileset/static_objects/boxes/10.png
binary file — no preview
added assets/tileset/static_objects/boxes/11.png
binary file — no preview
added assets/tileset/static_objects/boxes/12.png
binary file — no preview
added assets/tileset/static_objects/boxes/13.png
binary file — no preview
added assets/tileset/static_objects/boxes/14.png
binary file — no preview
added assets/tileset/static_objects/boxes/15.png
binary file — no preview
added assets/tileset/static_objects/boxes/16.png
binary file — no preview
added assets/tileset/static_objects/boxes/2.png
binary file — no preview
added assets/tileset/static_objects/boxes/3.png
binary file — no preview
added assets/tileset/static_objects/boxes/4.png
binary file — no preview
added assets/tileset/static_objects/boxes/5.png
binary file — no preview
added assets/tileset/static_objects/boxes/6.png
binary file — no preview
added assets/tileset/static_objects/boxes/7.png
binary file — no preview
added assets/tileset/static_objects/boxes/8.png
binary file — no preview
added assets/tileset/static_objects/boxes/9.png
binary file — no preview
added assets/tileset/static_objects/chairs/1.png
binary file — no preview
added assets/tileset/static_objects/chairs/10.png
binary file — no preview
added assets/tileset/static_objects/chairs/11.png
binary file — no preview
added assets/tileset/static_objects/chairs/12.png
binary file — no preview
added assets/tileset/static_objects/chairs/13.png
binary file — no preview
added assets/tileset/static_objects/chairs/14.png
binary file — no preview
added assets/tileset/static_objects/chairs/2.png
binary file — no preview
added assets/tileset/static_objects/chairs/3.png
binary file — no preview
added assets/tileset/static_objects/chairs/4.png
binary file — no preview
added assets/tileset/static_objects/chairs/5.png
binary file — no preview
added assets/tileset/static_objects/chairs/6.png
binary file — no preview
added assets/tileset/static_objects/chairs/7.png
binary file — no preview
added assets/tileset/static_objects/chairs/8.png
binary file — no preview
added assets/tileset/static_objects/chairs/9.png
binary file — no preview
added assets/tileset/static_objects/doors/1.png
binary file — no preview
added assets/tileset/static_objects/doors/2.png
binary file — no preview
added assets/tileset/static_objects/doors/3.png
binary file — no preview
added assets/tileset/static_objects/doors/4.png
binary file — no preview
added assets/tileset/static_objects/other/1.png
binary file — no preview
added assets/tileset/static_objects/other/10.png
binary file — no preview
added assets/tileset/static_objects/other/11.png
binary file — no preview
added assets/tileset/static_objects/other/12.png
binary file — no preview
added assets/tileset/static_objects/other/13.png
binary file — no preview
added assets/tileset/static_objects/other/14.png
binary file — no preview
added assets/tileset/static_objects/other/15.png
binary file — no preview
added assets/tileset/static_objects/other/16.png
binary file — no preview
added assets/tileset/static_objects/other/17.png
binary file — no preview
added assets/tileset/static_objects/other/18.png
binary file — no preview
added assets/tileset/static_objects/other/19.png
binary file — no preview
added assets/tileset/static_objects/other/2.png
binary file — no preview
added assets/tileset/static_objects/other/20.png
binary file — no preview
added assets/tileset/static_objects/other/21.png
binary file — no preview
added assets/tileset/static_objects/other/22.png
binary file — no preview
added assets/tileset/static_objects/other/23.png
binary file — no preview
added assets/tileset/static_objects/other/24.png
binary file — no preview
added assets/tileset/static_objects/other/25.png
binary file — no preview
added assets/tileset/static_objects/other/26.png
binary file — no preview
added assets/tileset/static_objects/other/27.png
binary file — no preview
added assets/tileset/static_objects/other/28.png
binary file — no preview
added assets/tileset/static_objects/other/29.png
binary file — no preview
added assets/tileset/static_objects/other/3.png
binary file — no preview
added assets/tileset/static_objects/other/30.png
binary file — no preview
added assets/tileset/static_objects/other/31.png
binary file — no preview
added assets/tileset/static_objects/other/32.png
binary file — no preview
added assets/tileset/static_objects/other/33.png
binary file — no preview
added assets/tileset/static_objects/other/34.png
binary file — no preview
added assets/tileset/static_objects/other/35.png
binary file — no preview
added assets/tileset/static_objects/other/36.png
binary file — no preview
added assets/tileset/static_objects/other/37.png
binary file — no preview
added assets/tileset/static_objects/other/38.png
binary file — no preview
added assets/tileset/static_objects/other/39.png
binary file — no preview
added assets/tileset/static_objects/other/4.png
binary file — no preview
added assets/tileset/static_objects/other/40.png
binary file — no preview
added assets/tileset/static_objects/other/41.png
binary file — no preview
added assets/tileset/static_objects/other/42.png
binary file — no preview
added assets/tileset/static_objects/other/43.png
binary file — no preview
added assets/tileset/static_objects/other/44.png
binary file — no preview
added assets/tileset/static_objects/other/5.png
binary file — no preview
added assets/tileset/static_objects/other/6.png
binary file — no preview
added assets/tileset/static_objects/other/7.png
binary file — no preview
added assets/tileset/static_objects/other/8.png
binary file — no preview
added assets/tileset/static_objects/other/9.png
binary file — no preview
added assets/tileset/static_objects/other/Fog.png
binary file — no preview
added assets/tileset/static_objects/tables/1.png
binary file — no preview
added assets/tileset/static_objects/tables/2.png
binary file — no preview
added assets/tileset/static_objects/tables/3.png
binary file — no preview
added assets/tileset/static_objects/tables/4.png
binary file — no preview
added assets/tileset/static_objects/tables/5.png
binary file — no preview
added assets/tileset/static_objects/tables/6.png
binary file — no preview
added assets/tileset/static_objects/tables/7.png
binary file — no preview
added assets/tileset/static_objects/tables/8.png
binary file — no preview
added assets/tileset/static_objects/torches/1.png
binary file — no preview
added assets/tileset/static_objects/torches/2.png
binary file — no preview
added assets/tileset/static_objects/torches/3.png
binary file — no preview
added assets/tileset/static_objects/torches/4.png
binary file — no preview
added assets/tileset/static_objects/torches/5.png
binary file — no preview
added assets/tileset/static_objects/torches/6.png
binary file — no preview
added assets/tileset/static_objects/torches/7.png
binary file — no preview
added assets/tileset/static_objects/torches/8.png
binary file — no preview
added assets/tileset/static_objects/trapdoors/1.png
binary file — no preview
added assets/tileset/static_objects/trapdoors/2.png
binary file — no preview
added assets/tileset/static_objects/trapdoors/3.png
binary file — no preview
added assets/tileset/static_objects/trapdoors/4.png
binary file — no preview
added assets/tileset/static_objects/trapdoors/5.png
binary file — no preview
added assets/tileset/static_objects/trapdoors/6.png
binary file — no preview
added assets/tileset/tiles/Tile_03.png
binary file — no preview
added assets/tileset/tiles/Tile_04.png
binary file — no preview
added assets/tileset/tiles/Tile_05.png
binary file — no preview
added assets/tileset/tiles/Tile_06.png
binary file — no preview
added assets/tileset/tiles/Tile_07.png
binary file — no preview
added assets/tileset/tiles/Tile_09.png
binary file — no preview
added assets/tileset/tiles/Tile_101.png
binary file — no preview
added assets/tileset/tiles/Tile_102.png
binary file — no preview
added assets/tileset/tiles/Tile_105.png
binary file — no preview
added assets/tileset/tiles/Tile_107.png
binary file — no preview
added assets/tileset/tiles/Tile_108.png
binary file — no preview
added assets/tileset/tiles/Tile_109.png
binary file — no preview
added assets/tileset/tiles/Tile_11.png
binary file — no preview
added assets/tileset/tiles/Tile_13.png
binary file — no preview
added assets/tileset/tiles/Tile_15.png
binary file — no preview
added assets/tileset/tiles/Tile_16.png
binary file — no preview
added assets/tileset/tiles/Tile_17.png
binary file — no preview
added assets/tileset/tiles/Tile_19.png
binary file — no preview
added assets/tileset/tiles/Tile_20.png
binary file — no preview
added assets/tileset/tiles/Tile_21.png
binary file — no preview
added assets/tileset/tiles/Tile_22.png
binary file — no preview
added assets/tileset/tiles/Tile_23.png
binary file — no preview
added assets/tileset/tiles/Tile_24.png
binary file — no preview
added assets/tileset/tiles/Tile_26.png
binary file — no preview
added assets/tileset/tiles/Tile_27.png
binary file — no preview
added assets/tileset/tiles/Tile_28.png
binary file — no preview
added assets/tileset/tiles/Tile_30.png
binary file — no preview
added assets/tileset/tiles/Tile_31.png
binary file — no preview
added assets/tileset/tiles/Tile_32.png
binary file — no preview
added assets/tileset/tiles/Tile_33.png
binary file — no preview
added assets/tileset/tiles/Tile_34.png
binary file — no preview
added assets/tileset/tiles/Tile_35.png
binary file — no preview
added assets/tileset/tiles/Tile_36.png
binary file — no preview
added assets/tileset/tiles/Tile_37.png
binary file — no preview
added assets/tileset/tiles/Tile_39.png
binary file — no preview
added assets/tileset/tiles/Tile_41.png
binary file — no preview
added assets/tileset/tiles/Tile_42.png
binary file — no preview
added assets/tileset/tiles/Tile_43.png
binary file — no preview
added assets/tileset/tiles/Tile_44.png
binary file — no preview
added assets/tileset/tiles/Tile_45.png
binary file — no preview
added assets/tileset/tiles/Tile_46.png
binary file — no preview
added assets/tileset/tiles/Tile_47.png
binary file — no preview
added assets/tileset/tiles/Tile_48.png
binary file — no preview
added assets/tileset/tiles/Tile_49.png
binary file — no preview
added assets/tileset/tiles/Tile_51.png
binary file — no preview
added assets/tileset/tiles/Tile_52.png
binary file — no preview
added assets/tileset/tiles/Tile_53.png
binary file — no preview
added assets/tileset/tiles/Tile_54.png
binary file — no preview
added assets/tileset/tiles/Tile_55.png
binary file — no preview
added assets/tileset/tiles/Tile_56.png
binary file — no preview
added assets/tileset/tiles/Tile_57.png
binary file — no preview
added assets/tileset/tiles/Tile_58.png
binary file — no preview
added assets/tileset/tiles/Tile_59.png
binary file — no preview
added assets/tileset/tiles/Tile_60.png
binary file — no preview
added assets/tileset/tiles/Tile_61.png
binary file — no preview
added assets/tileset/tiles/Tile_62.png
binary file — no preview
added assets/tileset/tiles/Tile_63.png
binary file — no preview
added assets/tileset/tiles/Tile_65.png
binary file — no preview
added assets/tileset/tiles/Tile_72.png
binary file — no preview
added assets/tileset/tiles/Tile_73.png
binary file — no preview
added assets/tileset/tiles/Tile_74.png
binary file — no preview
added assets/tileset/tiles/Tile_75.png
binary file — no preview
added assets/tileset/tiles/Tile_76.png
binary file — no preview
added assets/tileset/tiles/Tile_77.png
binary file — no preview
added assets/tileset/tiles/Tile_79.png
binary file — no preview
added assets/tileset/tiles/Tile_80.png
binary file — no preview
added assets/tileset/tiles/Tile_81.png
binary file — no preview
added assets/tileset/tiles/Tile_82.png
binary file — no preview
added assets/tileset/tiles/Tile_83.png
binary file — no preview
added assets/tileset/tiles/Tile_84.png
binary file — no preview
added assets/tileset/tiles/Tile_86.png
binary file — no preview
added assets/tileset/tiles/Tile_87.png
binary file — no preview
added assets/tileset/tiles/Tile_88.png
binary file — no preview
added assets/tileset/tiles/Tile_89.png
binary file — no preview
added assets/tileset/tiles/Tile_91.png
binary file — no preview
added assets/tileset/tiles/Tile_92.png
binary file — no preview
added assets/tileset/tiles/Tile_93.png
binary file — no preview
added assets/tileset/tiles/Tile_94.png
binary file — no preview
added assets/tileset/tiles/Tile_95.png
binary file — no preview
added assets/tileset/tiles/Tile_96.png
binary file — no preview
added assets/tileset/tiles/Tile_97.png
binary file — no preview
added assets/tileset/tiles/Tile_98.png
binary file — no preview
added assets/tileset/tiles/Tile_99.png
binary file — no preview
added assets/tileset/tiles/Tileset.png
binary file — no preview
added assets/units/elf/D_Idle.png
binary file — no preview
added assets/units/elf/D_Walk.png
binary file — no preview
added assets/units/elf/S_Idle.png
binary file — no preview
added assets/units/elf/S_Walk.png
binary file — no preview
added assets/units/elf/U_Idle.png
binary file — no preview
added assets/units/elf/U_Walk.png
binary file — no preview
added assets/units/mrgreen/D_Idle.png
binary file — no preview
added assets/units/mrgreen/D_Walk.png
binary file — no preview
added assets/units/mrgreen/S_Idle.png
binary file — no preview
added assets/units/mrgreen/S_Walk.png
binary file — no preview
added assets/units/mrgreen/U_Idle.png
binary file — no preview
added assets/units/mrgreen/U_Walk.png
binary file — no preview
added assets/units/orange/D_Idle.png
binary file — no preview
added assets/units/orange/D_Walk.png
binary file — no preview
added assets/units/orange/S_Idle.png
binary file — no preview
added assets/units/orange/S_Walk.png
binary file — no preview
added assets/units/orange/U_Idle.png
binary file — no preview
added assets/units/orange/U_Walk.png
binary file — no preview
added assets/units/penguin/D_Idle.png
binary file — no preview
added assets/units/penguin/D_Walk.png
binary file — no preview
added assets/units/penguin/S_Idle.png
binary file — no preview
added assets/units/penguin/S_Walk.png
binary file — no preview
added assets/units/penguin/U_Idle.png
binary file — no preview
added assets/units/penguin/U_Walk.png
binary file — no preview
added assets/units/shiggy/D_Idle.png
binary file — no preview
added assets/units/shiggy/D_Walk.png
binary file — no preview
added assets/units/shiggy/S_Idle.png
binary file — no preview
added assets/units/shiggy/S_Walk.png
binary file — no preview
added assets/units/shiggy/U_Idle.png
binary file — no preview
added assets/units/shiggy/U_Walk.png
binary file — no preview
added assets/units/wizard/D_Idle.png
binary file — no preview
added assets/units/wizard/D_Walk.png
binary file — no preview
added assets/units/wizard/S_Idle.png
binary file — no preview
added assets/units/wizard/S_Walk.png
binary file — no preview
added assets/units/wizard/U_Idle.png
binary file — no preview
added assets/units/wizard/U_Walk.png
binary file — no preview
added assets/units/wolf/D_Idle.png
binary file — no preview
added assets/units/wolf/D_Walk.png
binary file — no preview
added assets/units/wolf/S_Idle.png
binary file — no preview
added assets/units/wolf/S_Walk.png
binary file — no preview
added assets/units/wolf/U_Idle.png
binary file — no preview
added assets/units/wolf/U_Walk.png
binary file — no preview
added audio.py
@@ -0,0 +1,155 @@
"""Sound effects + background music.
Clone-safe and entirely optional, the same way the tileset is: the
mixer is initialised **lazily** on the first ``play``/``play_music``,
every lookup degrades to a silent no-op when there is no audio device,
no ``assets/audio`` tree or just a single missing file (mirrors the
``tileset.tile`` try/except → None pattern), and importing this module
touches nothing — so the headless smoke tests keep working.
Asset layout — drop files in, no code change needed:
assets/audio/sfx/<name>.wav # or .ogg
assets/audio/music/<name>.ogg # or .wav / .mp3
``play("shoot")`` looks up ``assets/audio/sfx/shoot.wav``; a level's
manifest ``"music"`` value is just such a ``<name>`` under music/.
Symmetry with ``tileset``: callers pass *names*, not paths, so the
audio asset convention lives only here. The names the game already
triggers are listed in ``assets/levels/LEGEND.md``.
"""
import os
import pygame
# Flipped by the Settings menu (persisted via save.py). Read live by
# every play call, so toggling it silences playback immediately.
enabled = True
# Music level (0.0..1.0). Independent of ``enabled``: muting flips
# enabled, the volume slider is the bed level when sound is on.
# Reapplied to ``pygame.mixer.music`` on every track change so a switch
# never resets it back to 1.0.
music_volume = 1.0
_SFX_DIR = os.path.join("assets", "audio", "sfx")
_MUSIC_DIR = os.path.join("assets", "audio", "music")
_SFX_EXTS = (".wav", ".ogg")
_MUSIC_EXTS = (".ogg", ".wav", ".mp3")
# One knob for every music transition (track switch, stop, mute). ~700
# ms reads as musical without dragging. ``pygame.mixer.music`` is a
# single stream, so there is no true crossfade — we fade the old track
# out and fade the new one in (the simple, fine-here option from the
# Style.md notes). Reused by stop_music so muting fades too, not clicks.
MUSIC_FADE_MS = 700
_mixer_ok = None # None = not tried yet, then True / False
_sounds = {} # name -> Sound | None (None = file absent)
_current_music = None # name of the looping track, or None
def _ensure_mixer():
"""Init the mixer once. Stays False forever if there is no audio
device, which turns everything here into a no-op."""
global _mixer_ok
if _mixer_ok is None:
try:
pygame.mixer.init()
_mixer_ok = True
except pygame.error:
_mixer_ok = False
return _mixer_ok
def _find(directory, name, exts):
for ext in exts:
path = os.path.join(directory, name + ext)
if os.path.isfile(path):
return path
return None
def play(name):
"""Fire-and-forget one-shot SFX. Silent when disabled, when there
is no device, or when ``assets/audio/sfx/<name>.*`` is missing."""
if not enabled or not _ensure_mixer():
return
if name not in _sounds:
path = _find(_SFX_DIR, name, _SFX_EXTS)
try:
_sounds[name] = pygame.mixer.Sound(path) if path else None
except pygame.error:
_sounds[name] = None
snd = _sounds[name]
if snd is not None:
snd.play()
def play_music(name):
"""Loop ``assets/audio/music/<name>.*`` as background music.
``name is None`` (the level declares no track) stops the music.
Re-requesting the track that is already playing is a no-op, so a
level reload / retry doesn't restart the loop."""
global _current_music
if name is None:
stop_music()
return
if not enabled or not _ensure_mixer():
return
if name == _current_music:
return
path = _find(_MUSIC_DIR, name, _MUSIC_EXTS)
if path is None:
stop_music()
return
try:
# Fade the outgoing track, then bring the new one up. Single
# stream → not a true crossfade; the incoming fade_ms is what
# kills the hard cut on a start↔menu↔game switch.
if pygame.mixer.music.get_busy():
pygame.mixer.music.fadeout(MUSIC_FADE_MS)
pygame.mixer.music.load(path)
pygame.mixer.music.set_volume(music_volume)
pygame.mixer.music.play(-1, fade_ms=MUSIC_FADE_MS)
_current_music = name
except pygame.error:
_current_music = None
def stop_music():
global _current_music
_current_music = None
# Only touch the mixer if it actually came up — never spin it up
# just to stop silence (keeps cold paths side-effect-free).
if _mixer_ok:
try:
# Fade rather than stop() so a mute / leaving a level goes
# quiet smoothly instead of clicking.
pygame.mixer.music.fadeout(MUSIC_FADE_MS)
except pygame.error:
pass
def set_enabled(flag):
"""Settings hook. Muting also kills the music so the world goes
quiet at once; SFX are short, they just stop being requested."""
global enabled
enabled = bool(flag)
if not enabled:
stop_music()
def set_music_volume(level):
"""Settings hook for the bed volume (0.0..1.0). Applied live so a
slider tweak audibly changes the current track without waiting for
the next track switch."""
global music_volume
music_volume = max(0.0, min(1.0, float(level)))
if _mixer_ok:
try:
pygame.mixer.music.set_volume(music_volume)
except pygame.error:
pass
added build_mac.sh
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Builds "The Way Out.app" — a thin launcher that auto-updates its game
# code from GitHub. Run on macOS only. Rebuild + re-send is only needed
# when the Python version or dependencies change (rare); normal code
# changes ship via `git push` + the in-game Update button.
set -euo pipefail
cd "$(dirname "$0")"
PY=".venv/bin/python" # NEVER system python3 (3.14)
[ -x "$PY" ] || { echo "missing $PY"; exit 1; }
"$PY" -m pip install --quiet --upgrade pyinstaller
APPNAME="The Way Out"
SEEDPARENT="$(mktemp -d)"; SEED="$SEEDPARENT/_seed"; mkdir -p "$SEED"
rsync -a --delete \
--exclude '.git' --exclude '.venv' --exclude '__pycache__' \
--exclude 'build' --exclude 'dist' --exclude '*.spec' \
--exclude '.DS_Store' --exclude 'ajhahnde' --exclude '.venv-intel' \
./ "$SEED/" # assets/ MUST be in (offline 1st run)
rm -rf build dist "$APPNAME.spec"
"$PY" -m PyInstaller --noconfirm --windowed --clean \
--name "$APPNAME" \
--osx-bundle-identifier de.ajhahn.thewayout \
--collect-all pygame \
--add-data "$SEED:_seed" \
launcher.py
# Declare the bundle as a game so macOS Sonoma+ auto-enables Game Mode
# (priority CPU/GPU, lower Bluetooth latency) whenever it runs
# fullscreen — there is no runtime API for this, only this plist key.
# Must run before codesign: the re-sign below covers the edited bundle.
PLIST="dist/$APPNAME.app/Contents/Info.plist"
/usr/libexec/PlistBuddy \
-c "Add :LSApplicationCategoryType string public.app-category-games" \
"$PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy \
-c "Set :LSApplicationCategoryType public.app-category-games" "$PLIST"
codesign --force --deep --sign - "dist/$APPNAME.app"
( cd dist && ditto -c -k --keepParent "$APPNAME.app" "TheWayOut-mac.zip" )
echo "Done: dist/TheWayOut-mac.zip — 1st launch: right-click > Open > Open"
added build_mac_intel.sh
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Cross-build "The Way Out.app" for Intel Macs (x86_64), to be tested by
# a friend on macOS Monterey 12.7.6. The WHOLE toolchain runs under
# Rosetta so the produced bundle is pure x86_64. The arm64 build
# (build_mac.sh / .venv/) is deliberately left untouched.
#
# Prereqs (see ajhahnde/Pipeline.md — Schritt 1+2, both DONE+verified):
# - Rosetta 2 installed and working
# - /Library/Frameworks/Python.framework/Versions/3.12 — universal2,
# python.org 3.12.10, x86_64 slice min-OS 10.13 (<= Monterey 12.7)
# - .venv-intel/ created from it under Rosetta
# (pygame 2.6.1, pyinstaller 6.20.0, x86_64-clean, no arm64 leak)
#
# This script self-verifies the result (no arm64 anywhere + min-OS
# <= 12.7) and fails loudly if the bundle would not run on the friend's
# Mac — that check is the whole point of the exercise.
set -euo pipefail
cd "$(dirname "$0")"
PY=".venv-intel/bin/python"
[ -x "$PY" ] || { echo "missing $PY — run ajhahnde/Pipeline.md Schritt 1+2 first"; exit 1; }
# Robust Rosetta probe. NOT 'arch -x86_64 uname -m': uname can report
# the real hardware (arm64) and that one-liner gives a false negative.
/usr/bin/arch -x86_64 /usr/bin/true 2>/dev/null \
|| { echo "Rosetta 2 not working (arch -x86_64 failed)"; exit 1; }
MACH="$(arch -x86_64 "$PY" -c 'import platform;print(platform.machine())')"
[ "$MACH" = "x86_64" ] \
|| { echo "venv python is not x86_64 under Rosetta (got: $MACH)"; exit 1; }
arch -x86_64 "$PY" -m pip install --quiet --upgrade pyinstaller
APPNAME="The Way Out"
# 'rm -rf dist' below would destroy an existing arm64 artifact. Back the
# arm64 zip up OUTSIDE the repo first (so neither the rm nor the seed
# rsync can touch it). The two builds' final zips have different names,
# but dist/ itself is NOT shared — only one .app lives there at a time.
if [ -f "dist/TheWayOut-mac.zip" ]; then
BK="../TheWayOut-mac-arm64-$(git rev-parse --short HEAD 2>/dev/null || date +%s).zip"
cp -p "dist/TheWayOut-mac.zip" "$BK"
echo "Backed up existing arm64 zip -> $BK"
fi
SEEDPARENT="$(mktemp -d)"; SEED="$SEEDPARENT/_seed"; mkdir -p "$SEED"
trap 'rm -rf "$SEEDPARENT"' EXIT
rsync -a --delete \
--exclude '.git' --exclude '.venv' --exclude '.venv-intel' \
--exclude '__pycache__' --exclude 'build' --exclude 'dist' \
--exclude '*.spec' --exclude '.DS_Store' \
--exclude 'ajhahnde' \
./ "$SEED/" # assets/ MUST be in (offline 1st run)
rm -rf build dist "$APPNAME.spec"
arch -x86_64 "$PY" -m PyInstaller --noconfirm --windowed --clean \
--name "$APPNAME" \
--osx-bundle-identifier de.ajhahn.thewayout \
--collect-all pygame \
--target-architecture x86_64 \
--add-data "$SEED:_seed" \
launcher.py
# Declare the bundle as a game so macOS Sonoma+ auto-enables Game Mode
# (parity with build_mac.sh). Must run before codesign.
PLIST="dist/$APPNAME.app/Contents/Info.plist"
/usr/libexec/PlistBuddy \
-c "Add :LSApplicationCategoryType string public.app-category-games" \
"$PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy \
-c "Set :LSApplicationCategoryType public.app-category-games" "$PLIST"
codesign --force --deep --sign - "dist/$APPNAME.app"
# ---- Hardened verification: this is why the script exists ----
APP="dist/$APPNAME.app"
EXE="$APP/Contents/MacOS/$APPNAME"
echo "=== verify: main executable ==="
file "$EXE"
echo "=== verify: NO arm64 slice anywhere in the bundle ==="
LEAK="$(find "$APP" -type f \( -name '*.so' -o -name '*.dylib' -o -perm -111 \) \
-exec sh -c 'lipo -archs "$1" 2>/dev/null | grep -qw arm64 && echo "$1"' _ {} \;)"
if [ -n "$LEAK" ]; then
echo "FAIL: arm64 slices present — will NOT run on the Intel Mac:"
echo "$LEAK"
exit 2
fi
echo "OK: no arm64 slices (pure x86_64 bundle)"
echo "=== verify: min-OS <= Monterey 12.7 on key binaries ==="
OK=1
for b in "$EXE" \
"$APP/Contents/Frameworks"/Python \
"$APP/Contents/Frameworks"/Python.framework/Versions/*/Python \
"$APP/Contents/Frameworks"/libpython*.dylib \
"$APP/Contents/Frameworks"/SDL2*.dylib ; do
[ -e "$b" ] || continue
MIN="$(otool -l "$b" 2>/dev/null \
| awk '/LC_VERSION_MIN_MACOSX|LC_BUILD_VERSION/{f=1} f&&/(minos|version) /{print $2;f=0}' \
| head -1)"
echo " $(basename "$b"): minos=${MIN:-unknown}"
case "$MIN" in
""|*[!0-9.]*) : ;; # unknown -> report only
*) maj=${MIN%%.*}; rest=${MIN#*.}; mn=${rest%%.*}; mn=${mn:-0}
if [ "$maj" -gt 12 ] || { [ "$maj" -eq 12 ] && [ "$mn" -gt 7 ]; }; then
echo " FAIL: $b requires macOS $MIN (> 12.7)"; OK=0
fi ;;
esac
done
[ "$OK" -eq 1 ] || { echo "FAIL: a binary needs newer than Monterey 12.7"; exit 2; }
echo "OK: all checked binaries run on Monterey 12.7.6"
( cd dist && ditto -c -k --keepParent "$APPNAME.app" "TheWayOut-mac-intel.zip" )
echo
echo "Done: dist/TheWayOut-mac-intel.zip (x86_64, Monterey-verified)"
echo "Next: Pipeline.md Schritt 6 — AirDrop this zip + send the friend block."
added editor.py
@@ -0,0 +1,1010 @@
"""In-game level editor.
A pygame-native palette → canvas editor that writes ``.txt`` files in
the same tokenised format the runtime loads. Output goes to
``~/.the-way-out/custom_levels/<name>.txt`` and is picked up by
:mod:`level_catalog` automatically, so a fresh level appears in the
level menu the next time it's opened.
Layout (anchored to the screen size, scales with it):
* **Canvas** on the left: scrolling grid, the level being authored.
* **Palette** on the right: tile thumbnails grouped by category
(terrain, special, hazard, enemy, prop). Click to select; the wheel
cycles the variant for prop tiles.
* **Toolbar** at the bottom: file name (click to rename), grid size,
Save / Test / Clear buttons.
Controls (also displayed in the toolbar/hint):
* LMB / RMB: place selected tile / clear to floor
* Shift + LMB/RMB drag: box-fill / box-erase a rectangle
* Q (or Pick): eyedropper — next canvas click adopts that cell
* Border: re-wall the outer ring
* Mouse wheel: cycle variant on the selected tile
* WASD / arrows: pan camera
* Esc: back to main menu
* F5 (or Test): save + launch the level
* Ctrl+S (or Save): save only
The editor is intentionally one self-contained file. It reuses
``tiles.REGISTRY`` for what tiles exist and ``tileset.sprite`` /
``interactables`` images for the canvas previews — so a new tile added
to the registry shows up automatically in the palette.
"""
import re
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
# Used both as a directory and as the legal-filename charset.
SAFE_NAME = re.compile(r"[^a-zA-Z0-9_\-]+")
MAX_NAME = 32
def sanitize(name):
"""Squash any non-portable characters out of a filename stem,
collapse runs of underscores, trim, and clamp length so the user
can paste anything weird and still get something writable."""
cleaned = SAFE_NAME.sub("_", name).strip("_")
cleaned = re.sub(r"_+", "_", cleaned)[:MAX_NAME]
return cleaned or "untitled"
class LevelEditor:
"""The editor surface + state. One instance lives for the whole
session; ``new_level`` / ``open_level`` reset the grid."""
# Palette layout. Cells are rendered as square thumbnails with a
# 6 px gap; the category headers sit between them.
PALETTE_W = 480
PALETTE_PAD = 18
CELL = 56
CELL_GAP = 8
# Toolbar
TOOLBAR_H = 110
# Default grid for a fresh level (with auto-walled border)
DEFAULT_COLS = 30
DEFAULT_ROWS = 20
# --- lifecycle -----------------------------------------------------
def __init__(self, width, height):
self.width = width
self.height = height
# Fonts
self.font = theme.font(36)
self.label_font = theme.font(22)
self.small_font = theme.font(18)
self.hint_font = theme.font(20)
self.head_font = theme.font(26)
# Geometry — canvas fills everything left of the palette and
# above the toolbar.
self.palette_rect = pygame.Rect(
width - self.PALETTE_W, 0,
self.PALETTE_W, height - self.TOOLBAR_H)
self.canvas_rect = pygame.Rect(
0, 0, width - self.PALETTE_W, height - self.TOOLBAR_H)
self.toolbar_rect = pygame.Rect(
0, height - self.TOOLBAR_H, width, self.TOOLBAR_H)
# State that resets per-level
self.grid = []
self.cols = 0
self.rows = 0
self.name = "my_level"
self.cam_x = 0.0
self.cam_y = 0.0
self.message = ""
self.message_timer = 0.0
# Palette / selection
self.selected_char = 'W'
self.selected_variant = 1
self._palette_rects = [] # list of (pygame.Rect, char)
self._tile_thumb_cache = {} # (char, variant) -> Surface
# Toolbar / buttons
self._toolbar_rects = {} # name -> pygame.Rect
self._name_rect = pygame.Rect(0, 0, 0, 0)
self.editing_name = False
# Drag-painting: while LMB or RMB is held, every frame where
# the mouse is on a new cell paints/erases.
self._mouse_buttons = [False, False, False] # left, middle, right
self._last_painted_cell = None
# 'paint' (default) or 'pick' (eyedropper, toggled with Q).
self.tool = 'paint'
# Shift+drag box fill/erase: anchor cell while the drag is live.
self._box_start = None
self._box_erase = False
# ``request_test`` flips True when the user hits F5/Test; main
# reads & clears it to switch into the game state.
self.request_test = False
self.test_level_id = None
self.new_level()
self._build_palette_layout()
self._build_toolbar_layout()
def new_level(self, cols=None, rows=None, name=None):
"""Reset the grid to the requested size (or default) with a
wall border and a single 'P' near the top-left so the level is
legal-ish out of the box."""
if cols is not None:
self.cols = cols
else:
self.cols = self.DEFAULT_COLS
if rows is not None:
self.rows = rows
else:
self.rows = self.DEFAULT_ROWS
if name is not None:
self.name = sanitize(name)
self.grid = [['.' for _ in range(self.cols)]
for _ in range(self.rows)]
self._wall_border()
# Helpful defaults so a smashed save still loads.
if self.rows > 2 and self.cols > 2:
self.grid[1][1] = 'P'
self.grid[self.rows - 2][self.cols - 2] = 'X'
self.cam_x = 0.0
self.cam_y = 0.0
def open_level(self, entry):
"""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()]
except (FileNotFoundError, OSError) as e:
self._flash(f"Could not open {entry.file}: {e}")
return
grid = []
for line in lines:
# Reuse the runtime parser so dense and tokenised rows
# round-trip identically.
if ' ' in line:
grid.append(line.split())
else:
grid.append(list(line))
if not grid:
self._flash("Empty level — starting blank instead")
self.new_level()
return
cols = max(len(r) for r in grid)
for row in grid:
row.extend('W' * (cols - len(row)))
self.rows = len(grid)
self.cols = cols
self.grid = grid
# Default name = file stem; user can rename before saving.
self.name = sanitize(Path(entry.file).stem)
self.cam_x = 0.0
self.cam_y = 0.0
def reset_pointer_state(self):
"""Drop any in-flight drag / box / held-button state.
The editor instance lives for the whole session, so a Shift-drag
box that was interrupted mid-gesture (Esc to menu, Test, window
focus loss) would otherwise keep ``_box_start`` / a stuck
``_mouse_buttons`` entry set and commit a spurious box-fill on
the next visit. ``main`` calls this whenever the editor (re)gains
or loses control."""
self._box_start = None
self._box_erase = False
self._mouse_buttons = [False, False, False]
self._last_painted_cell = None
# --- frame ---------------------------------------------------------
def update(self, dt):
# Decay the toast message.
if self.message_timer > 0:
self.message_timer = max(0.0, self.message_timer - dt)
if self.message_timer == 0:
self.message = ""
# Camera pan on held keys. Read pressed-state every frame so it
# feels continuous (event-driven would tick once per repeat).
pan_speed = TILE_SIZE * 12 * dt # ~12 tiles/sec
keys = pygame.key.get_pressed()
if not self.editing_name:
if keys[pygame.K_a] or keys[pygame.K_LEFT]:
self.cam_x -= pan_speed
if keys[pygame.K_d] or keys[pygame.K_RIGHT]:
self.cam_x += pan_speed
if keys[pygame.K_w] or keys[pygame.K_UP]:
self.cam_y -= pan_speed
if keys[pygame.K_s] or keys[pygame.K_DOWN]:
self.cam_y += pan_speed
self._clamp_camera()
# Drag-paint: if a button is still held, keep applying while
# the cursor crosses cells. Suppressed during a Shift box-drag
# and while the eyedropper is active (those are click-only).
if ((self._mouse_buttons[0] or self._mouse_buttons[2])
and self._box_start is None and self.tool == 'paint'):
mx, my = pygame.mouse.get_pos()
if self.canvas_rect.collidepoint(mx, my):
cell = self._screen_to_cell(mx, my)
if cell is not None and cell != self._last_painted_cell:
self._paint_cell(cell, erase=self._mouse_buttons[2])
def _clamp_camera(self):
max_x = max(0, self.cols * TILE_SIZE - self.canvas_rect.width)
max_y = max(0, self.rows * TILE_SIZE - self.canvas_rect.height)
self.cam_x = max(0, min(self.cam_x, max_x))
self.cam_y = max(0, min(self.cam_y, max_y))
# --- input ---------------------------------------------------------
def handle_input(self, event):
"""Return ``'back'`` to leave the editor, ``'test'`` to enter
game state with the currently-saved level, or ``None``."""
if event.type == pygame.KEYDOWN:
if self.editing_name:
if event.key == pygame.K_RETURN:
self.editing_name = False
self.name = sanitize(self.name) or "untitled"
elif event.key == pygame.K_ESCAPE:
# Cancel the rename. Re-sanitize so backspacing the
# name down to empty can't leave self.name == "".
self.editing_name = False
self.name = sanitize(self.name)
elif event.key == pygame.K_BACKSPACE:
self.name = self.name[:-1]
else:
ch = event.unicode
if ch and (ch.isalnum() or ch in "_-") and len(self.name) < MAX_NAME:
self.name += ch
return None
if event.key == pygame.K_ESCAPE:
return 'back'
if event.key == pygame.K_F5:
return self._do_test()
if event.key == pygame.K_s and (pygame.key.get_mods()
& pygame.KMOD_CTRL):
self._do_save()
return None
if event.key == pygame.K_q:
self.tool = 'pick' if self.tool == 'paint' else 'paint'
return None
elif event.type == pygame.MOUSEBUTTONDOWN:
mx, my = event.pos
shift = pygame.key.get_mods() & pygame.KMOD_SHIFT
if event.button == 1:
self._mouse_buttons[0] = True
if shift and self.canvas_rect.collidepoint(mx, my):
cell = self._screen_to_cell(mx, my)
if cell is not None:
self._box_start = cell
self._box_erase = False
return None
self._click_left(mx, my)
elif event.button == 3:
self._mouse_buttons[2] = True
if shift and self.canvas_rect.collidepoint(mx, my):
cell = self._screen_to_cell(mx, my)
if cell is not None:
self._box_start = cell
self._box_erase = True
return None
if self.canvas_rect.collidepoint(mx, my):
cell = self._screen_to_cell(mx, my)
if cell is not None:
self._paint_cell(cell, erase=True)
elif event.button == 4: # wheel up
self._cycle_variant(1)
elif event.button == 5: # wheel down
self._cycle_variant(-1)
elif event.type == pygame.MOUSEBUTTONUP:
if event.button == 1:
self._mouse_buttons[0] = False
self._last_painted_cell = None
if self._box_start is not None and not self._box_erase:
end = self._screen_to_cell(*event.pos) \
or self._box_start
self._commit_box(end, erase=False)
elif event.button == 3:
self._mouse_buttons[2] = False
self._last_painted_cell = None
if self._box_start is not None and self._box_erase:
end = self._screen_to_cell(*event.pos) \
or self._box_start
self._commit_box(end, erase=True)
elif event.type == pygame.MOUSEWHEEL:
# Some platforms emit MOUSEWHEEL instead of buttons 4/5.
if event.y:
self._cycle_variant(1 if event.y > 0 else -1)
return None
def _click_left(self, mx, my):
# Palette
if self.palette_rect.collidepoint(mx, my):
for rect, ch in self._palette_rects:
if rect.collidepoint(mx, my):
self.selected_char = ch
# Reset variant when switching tiles so it can't go
# out of range silently.
self.selected_variant = 1
return
return
# Toolbar
if self.toolbar_rect.collidepoint(mx, my):
if self._name_rect.collidepoint(mx, my):
self.editing_name = True
return
for name, rect in self._toolbar_rects.items():
if rect.collidepoint(mx, my):
if name == 'save':
self._do_save()
elif name == 'test':
self._do_test()
elif name == 'clear':
self.new_level(self.cols, self.rows, self.name)
self._flash("Cleared")
elif name == 'border':
self._wall_border()
self._flash("Border walled")
elif name == 'pick':
self.tool = ('pick' if self.tool == 'paint'
else 'paint')
elif name == 'grow_w':
self._resize(self.cols + 2, self.rows)
elif name == 'shrink_w':
self._resize(max(6, self.cols - 2), self.rows)
elif name == 'grow_h':
self._resize(self.cols, self.rows + 2)
elif name == 'shrink_h':
self._resize(self.cols, max(6, self.rows - 2))
return
return
# Canvas
if self.canvas_rect.collidepoint(mx, my):
cell = self._screen_to_cell(mx, my)
if cell is None:
return
if self.tool == 'pick':
self._pick_cell(cell)
else:
self._paint_cell(cell, erase=False)
def _cycle_variant(self, delta):
spec = REGISTRY.get(self.selected_char)
if spec is None or spec.variant_count <= 1:
return
self.selected_variant = (
(self.selected_variant - 1 + delta) % spec.variant_count) + 1
# --- grid editing --------------------------------------------------
def _screen_to_cell(self, sx, sy):
if not self.canvas_rect.collidepoint(sx, sy):
return None
wx = sx - self.canvas_rect.left + self.cam_x
wy = sy - self.canvas_rect.top + self.cam_y
c = int(wx // TILE_SIZE)
r = int(wy // TILE_SIZE)
if 0 <= r < self.rows and 0 <= c < self.cols:
return (r, c)
return None
def _cell_to_screen(self, r, c):
return (self.canvas_rect.left + c * TILE_SIZE - int(self.cam_x),
self.canvas_rect.top + r * TILE_SIZE - int(self.cam_y))
def _paint_cell(self, cell, erase=False):
r, c = cell
if erase:
self.grid[r][c] = '.'
else:
spec = REGISTRY.get(self.selected_char)
# Singleton enforcement: P and X must be unique.
if spec is not None and spec.category == 'special':
self._clear_char(spec.char)
self.grid[r][c] = self._selected_token()
self._last_painted_cell = cell
def _clear_char(self, ch):
"""Remove every existing occurrence of ``ch`` from the grid.
Used for singleton tiles (P, X) so painting another puts the
spawn where the user pointed instead of leaving two."""
for r in range(self.rows):
for c in range(self.cols):
if self.grid[r][c] and self.grid[r][c][0] == ch:
self.grid[r][c] = '.'
def _wall_border(self):
"""Set the whole outer ring to wall. Shared by ``new_level``
and the toolbar Border button."""
for r in range(self.rows):
self.grid[r][0] = 'W'
self.grid[r][self.cols - 1] = 'W'
for c in range(self.cols):
self.grid[0][c] = 'W'
self.grid[self.rows - 1][c] = 'W'
def _selected_token(self):
"""The token string the current selection writes — letter plus
a variant suffix only when it has a non-default variant (keeps
saved files readable)."""
spec = REGISTRY.get(self.selected_char)
if (spec is not None and spec.variant_count > 1
and self.selected_variant > 1):
return f"{spec.char}{self.selected_variant}"
return self.selected_char
def _pick_cell(self, cell):
"""Eyedropper: load the token under the cursor into the
selection, then drop back to paint so the next click draws."""
r, c = cell
tok = self.grid[r][c] or '.'
ch = tok[0]
if ch not in REGISTRY:
ch = '.'
self.selected_char = ch
spec = REGISTRY.get(ch)
v = _token_variant(tok)
self.selected_variant = (
v if spec is not None and 1 <= v <= spec.variant_count
else 1)
self.tool = 'paint'
def _commit_box(self, end_cell, erase):
"""Fill (or erase) the rectangle spanned by the Shift-drag.
Singleton specials (P/X) can't sensibly tile a box, so they
fall back to a single placement at the release cell."""
(r0, c0), (r1, c1) = self._box_start, end_cell
self._box_start = None
spec = REGISTRY.get(self.selected_char)
if (not erase and spec is not None
and spec.category == 'special'):
self._paint_cell(end_cell, erase=False)
return
token = '.' if erase else self._selected_token()
for r in range(min(r0, r1), max(r0, r1) + 1):
for c in range(min(c0, c1), max(c0, c1) + 1):
self.grid[r][c] = token
def _resize(self, new_cols, new_rows):
"""Grow / shrink the grid, keeping existing content in place.
New cells default to floor; the new outer border becomes wall
only if it's the very edge."""
new_grid = [['.' for _ in range(new_cols)] for _ in range(new_rows)]
for r in range(min(self.rows, new_rows)):
for c in range(min(self.cols, new_cols)):
new_grid[r][c] = self.grid[r][c]
# Re-wall border row/cols at the new edges.
for r in range(new_rows):
if new_grid[r][0] == '.':
new_grid[r][0] = 'W'
if new_grid[r][new_cols - 1] == '.':
new_grid[r][new_cols - 1] = 'W'
for c in range(new_cols):
if new_grid[0][c] == '.':
new_grid[0][c] = 'W'
if new_grid[new_rows - 1][c] == '.':
new_grid[new_rows - 1][c] = 'W'
self.rows = new_rows
self.cols = new_cols
self.grid = new_grid
self._clamp_camera()
# --- save / load / test --------------------------------------------
def _validate(self):
"""Return a list of warnings (empty if everything is fine).
Only blocks 'no player start' — anything else is a soft warn so
the user can experiment freely."""
warnings = []
flat = [tok for row in self.grid for tok in row]
chars = [t[0] if t else '.' for t in flat]
if 'P' not in chars:
warnings.append("No 'P' player start — level won't be playable")
if 'X' not in chars:
warnings.append("No 'X' exit — player can't escape")
triggers = sum(c in ('L', 'Y') for c in chars)
# G *cells* are counted, but multiple adjacent G are one panel
# at runtime. Cheap approximation: count G runs as panels.
gate_runs = 0
prev = None
for c in chars:
if c == 'G' and prev != 'G':
gate_runs += 1
prev = c
if triggers and gate_runs and triggers != gate_runs:
warnings.append(
f"{triggers} trigger(s) but ~{gate_runs} gate panel(s) — "
"pairings may be off")
return warnings
def _do_save(self):
# Last line of defence: every entry path *should* keep self.name
# safe, but sanitize at the write boundary too so an empty name
# can never produce a ".txt" dot-file / "custom_" id.
self.name = sanitize(self.name)
warnings = self._validate()
level_catalog.ensure_custom_dir()
path = level_catalog.CUSTOM_DIR / f"{self.name}.txt"
try:
with open(path, 'w') as f:
for row in self.grid:
f.write(' '.join(row) + '\n')
except OSError as e:
self._flash(f"Save failed: {e}")
return False
if warnings:
self._flash(f"Saved with warnings: {warnings[0]}")
else:
self._flash(f"Saved → {path.name}")
return True
def _do_test(self):
if not self._do_save():
return None
# Custom-level id matches the convention in level_catalog.
self.test_level_id = f"custom_{self.name}"
self.request_test = True
return 'test'
def _flash(self, msg, secs=2.6):
self.message = msg
self.message_timer = secs
# --- draw ---------------------------------------------------------
def draw(self, screen):
# Backdrop — canvas/palette/toolbar are shades derived from the
# shared BG so the split stays one family, not three tuples.
screen.fill(theme.BG)
pygame.draw.rect(screen, theme.shade(theme.BG, -6), self.canvas_rect)
pygame.draw.rect(screen, theme.shade(theme.BG, 10), self.palette_rect)
pygame.draw.rect(screen, theme.shade(theme.BG, -2), self.toolbar_rect)
self._draw_canvas(screen)
self._draw_palette(screen)
self._draw_toolbar(screen)
self._draw_message(screen)
# ----- canvas -----------------------------------------------------
def _draw_canvas(self, screen):
# Clip to the canvas area so tiles drawn past the panel boundary
# don't bleed into the palette.
prev_clip = screen.get_clip()
screen.set_clip(self.canvas_rect)
# Tiles. Only the visible window costs anything — we skip every
# row/col outside the camera.
cw = self.canvas_rect.width
ch = self.canvas_rect.height
first_col = max(0, int(self.cam_x) // TILE_SIZE)
first_row = max(0, int(self.cam_y) // TILE_SIZE)
last_col = min(self.cols - 1,
(int(self.cam_x) + cw) // TILE_SIZE)
last_row = min(self.rows - 1,
(int(self.cam_y) + ch) // TILE_SIZE)
floor_img = tileset.tile(tileset.FLOOR_TILE)
wall_img = tileset.tile(tileset.WALL_TILE)
floor_tex = TileTextures.get('floor')
wall_tex = TileTextures.get('wall')
for r in range(first_row, last_row + 1):
for c in range(first_col, last_col + 1):
sx, sy = self._cell_to_screen(r, c)
# Floor underneath everything.
screen.blit(floor_img or floor_tex, (sx, sy))
token = self.grid[r][c]
if not token or token == '.':
continue
ch_ = token[0]
if ch_ == 'W':
screen.blit(wall_img or wall_tex, (sx, sy))
else:
img = self._thumbnail(ch_, _token_variant(token),
large=True)
if img is not None:
screen.blit(img, (sx, sy))
# Grid lines (subtle) — a touch above the canvas shade.
grid_col = theme.shade(theme.BG, 22)
for c in range(first_col, last_col + 2):
x = self.canvas_rect.left + c * TILE_SIZE - int(self.cam_x)
pygame.draw.line(screen, grid_col,
(x, self.canvas_rect.top),
(x, self.canvas_rect.bottom), 1)
for r in range(first_row, last_row + 2):
y = self.canvas_rect.top + r * TILE_SIZE - int(self.cam_y)
pygame.draw.line(screen, grid_col,
(self.canvas_rect.left, y),
(self.canvas_rect.right, y), 1)
# Cursor preview — eyedropper shows just a cyan box (no ghost,
# since picking doesn't place the current selection).
mx, my = pygame.mouse.get_pos()
cell = self._screen_to_cell(mx, my)
if cell is not None and not self.editing_name:
r, c = cell
sx, sy = self._cell_to_screen(r, c)
if self.tool == 'pick':
pygame.draw.rect(screen, theme.ACCENT,
(sx, sy, TILE_SIZE, TILE_SIZE), 3)
else:
preview = self._thumbnail(self.selected_char,
self.selected_variant,
large=True)
if preview is not None:
ghost = preview.copy()
ghost.set_alpha(160)
screen.blit(ghost, (sx, sy))
pygame.draw.rect(screen, theme.ACCENT,
(sx, sy, TILE_SIZE, TILE_SIZE), 2)
# Box fill/erase marquee while a Shift-drag is live.
if self._box_start is not None:
r0, c0 = self._box_start
r1, c1 = self._screen_to_cell(mx, my) or self._box_start
x0, y0 = self._cell_to_screen(min(r0, r1), min(c0, c1))
w = (abs(c1 - c0) + 1) * TILE_SIZE
h = (abs(r1 - r0) + 1) * TILE_SIZE
tint = theme.FAIL if self._box_erase else theme.ACCENT
ov = pygame.Surface((w, h), pygame.SRCALPHA)
ov.fill((*tint, 45))
screen.blit(ov, (x0, y0))
pygame.draw.rect(screen, tint, (x0, y0, w, h), 2)
screen.set_clip(prev_clip)
# ----- palette ----------------------------------------------------
def _build_palette_layout(self):
"""Compute click rects for every palette tile + remember the
category headers' y-positions (used for drawing)."""
self._palette_rects = []
self._palette_headers = [] # (y, label)
x0 = self.palette_rect.left + self.PALETTE_PAD
y = self.palette_rect.top + 90 # leave room for "PALETTE" title
max_x = self.palette_rect.right - self.PALETTE_PAD
cell = self.CELL
gap = self.CELL_GAP
for category in PALETTE_CATEGORIES:
chars = chars_for(category)
if not chars:
continue
self._palette_headers.append((y, category.upper()))
y += 32
x = x0
for ch in chars:
if x + cell > max_x:
x = x0
y += cell + gap
rect = pygame.Rect(x, y, cell, cell)
self._palette_rects.append((rect, ch))
x += cell + gap
y += cell + gap + 14
# Selection info card sits below the last category.
self._palette_info_y = y + 6
def _draw_palette(self, screen):
# Title
title = self.font.render("PALETTE", True, theme.TITLE_C)
screen.blit(title, title.get_rect(
midtop=(self.palette_rect.centerx,
self.palette_rect.top + 24)))
# Headers
for y, label in self._palette_headers:
h = self.head_font.render(label, True, theme.MUTED)
screen.blit(h, (self.palette_rect.left + self.PALETTE_PAD, y))
pygame.draw.line(
screen, theme.LINE_C,
(self.palette_rect.left + self.PALETTE_PAD + 120, y + 12),
(self.palette_rect.right - self.PALETTE_PAD, y + 12), 1)
# Tile thumbnails
mp = pygame.mouse.get_pos()
for rect, ch in self._palette_rects:
is_sel = (ch == self.selected_char)
is_hov = rect.collidepoint(mp)
# Backplate
bp_color = (theme.shade(theme.BG, 28) if is_sel
else theme.shade(theme.BG, 16) if is_hov
else theme.shade(theme.BG, 6))
pygame.draw.rect(screen, bp_color, rect, border_radius=6)
# Tile preview — the thumb for char's variant 1
img = self._thumbnail(ch, 1, large=False)
if img is not None:
screen.blit(img, img.get_rect(center=rect.center))
# Letter overlay (top-left)
letter = self.small_font.render(ch, True, theme.INK)
screen.blit(letter, (rect.left + 4, rect.top + 2))
# Border
border = (theme.ACCENT if is_sel
else theme.MUTED if is_hov
else theme.LINE_C)
pygame.draw.rect(screen, border, rect, 2, border_radius=6)
# Selected-tile info card
spec = REGISTRY.get(self.selected_char)
if spec is None:
return
ix = self.palette_rect.left + self.PALETTE_PAD
iy = self._palette_info_y
w = self.palette_rect.width - 2 * self.PALETTE_PAD
# Flat spec block — no bordered box. Same language as the
# CharacterMenu stat block: name, one thin separator, then
# quiet detail text.
card = pygame.Rect(ix, iy, w, 130)
name = self.font.render(spec.label, True, theme.TITLE_C)
screen.blit(name, (card.left + 16, card.top + 10))
pygame.draw.line(screen, theme.LINE_C,
(card.left + 16, card.top + 48),
(card.right - 16, card.top + 48), 2)
if spec.variant_count > 1:
v = self.label_font.render(
f"variant {self.selected_variant} / {spec.variant_count} "
f"(scroll to cycle)", True, theme.MUTED)
screen.blit(v, (card.left + 16, card.top + 58))
# Wrap the description manually (cheap word-wrap)
desc_lines = self._wrap(spec.description, card.width - 30,
self.small_font)
for i, line in enumerate(desc_lines[:3]):
s = self.small_font.render(line, True, theme.MUTED)
screen.blit(s, (card.left + 16, card.top + 84 + i * 18))
def _wrap(self, text, max_w, font):
words = text.split()
lines, cur = [], []
for w in words:
cur.append(w)
if font.size(' '.join(cur))[0] > max_w:
cur.pop()
if cur:
lines.append(' '.join(cur))
cur = [w]
if cur:
lines.append(' '.join(cur))
return lines
# ----- toolbar ----------------------------------------------------
def _build_toolbar_layout(self):
# Button rects are positioned right-anchored; the filename and
# size info take the left side.
h = self.toolbar_rect.height
y = self.toolbar_rect.top
# Filename area (top-left)
self._name_rect = pygame.Rect(
24, y + 16, 460, 44)
# Buttons stacked horizontally, anchored to the right. Flat
# text-only buttons share the menu/list language — no coloured
# chips. Destructive intent ('clear') is signalled by FAIL on
# hover; the eyedropper ('pick') latches to ACCENT while armed.
spec = [
('pick', "Pick (Q)", 160, 'tool'),
('border', "Border", 140, 'tool'),
('clear', "Clear", 130, 'danger'),
('test', "Test (F5)", 180, 'tool'),
('save', "Save (Ctrl+S)", 220, 'tool'),
]
right = self.toolbar_rect.right - 20
self._toolbar_rects = {}
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}
# Size +/- arrows (smaller, in the middle of the toolbar)
mid_x = self._name_rect.right + 30
my = y + 22
for i, key in enumerate(['shrink_w', 'grow_w']):
self._toolbar_rects[key] = pygame.Rect(
mid_x + i * 38, my, 32, 32)
for i, key in enumerate(['shrink_h', 'grow_h']):
self._toolbar_rects[key] = pygame.Rect(
mid_x + 90 + i * 38, my, 32, 32)
self._toolbar_button_meta.update({
'shrink_w': ("-W", 'tool'),
'grow_w': ("+W", 'tool'),
'shrink_h': ("-H", 'tool'),
'grow_h': ("+H", 'tool'),
})
def _draw_toolbar(self, screen):
# Filename "input"
col = (theme.shade(theme.BG, 18) if self.editing_name
else theme.shade(theme.BG, 8))
pygame.draw.rect(screen, col, self._name_rect, border_radius=6)
pygame.draw.rect(screen, theme.LINE_C,
self._name_rect, 2, border_radius=6)
label = self.small_font.render(
"FILE", True, theme.MUTED)
screen.blit(label, (self._name_rect.left + 12,
self._name_rect.top - 18))
cursor = "_" if (self.editing_name and
(pygame.time.get_ticks() // 400) % 2 == 0) else ""
name_surf = self.label_font.render(
f"{self.name}{cursor}.txt", True, theme.INK)
screen.blit(name_surf, name_surf.get_rect(
midleft=(self._name_rect.left + 16,
self._name_rect.centery)))
# Grid size readout
size = self.label_font.render(
f"{self.cols} × {self.rows}", True, theme.MUTED)
screen.blit(size, (self._name_rect.right + 200,
self.toolbar_rect.top + 28))
# Buttons — flat text + thin underline. Hover or active state
# lights the text/underline ACCENT; destructive 'clear' goes
# FAIL on hover only.
mp = pygame.mouse.get_pos()
for name, rect in self._toolbar_rects.items():
text, kind = self._toolbar_button_meta[name]
active = (name == 'pick' and self.tool == 'pick')
hov = rect.collidepoint(mp)
if active:
text_col = theme.ACCENT
elif hov:
text_col = theme.FAIL if kind == 'danger' else theme.ACCENT
else:
text_col = theme.INK
t = self.label_font.render(text, True, text_col)
screen.blit(t, t.get_rect(center=rect.center))
if hov or active:
pygame.draw.line(screen, text_col,
(rect.left + 12, rect.bottom - 6),
(rect.right - 12, rect.bottom - 6), 2)
# Bottom-line hint
d = theme.HINT_DOT
hint = self.hint_font.render(
f"LMB place {d} RMB clear {d} Shift+drag box {d} Q pick {d} "
f"Wheel variant {d} WASD pan {d} Esc back {d} F5 test",
True, theme.MUTED)
screen.blit(hint, hint.get_rect(
midbottom=(self.toolbar_rect.centerx,
self.toolbar_rect.bottom - 6)))
def _draw_message(self, screen):
if not self.message:
return
s = self.label_font.render(self.message, True, theme.INK)
pad = s.get_rect().inflate(28, 14)
pad.center = (self.canvas_rect.centerx,
self.toolbar_rect.top - 40)
bg = pygame.Surface(pad.size, pygame.SRCALPHA)
bg.fill((*theme.BG, 220))
screen.blit(bg, pad)
pygame.draw.rect(screen, theme.LINE_C, pad, 2, border_radius=8)
screen.blit(s, s.get_rect(center=pad.center))
# --- thumbnail rendering ------------------------------------------
def _thumbnail(self, char, variant, large):
"""Cached preview surface for one tile.
``large=True`` returns a TILE_SIZE x TILE_SIZE image suitable
for the canvas; ``large=False`` returns a thumbnail-sized image
for the palette grid."""
size = TILE_SIZE if large else self.CELL - 12
key = (char, variant, size)
if key in self._tile_thumb_cache:
return self._tile_thumb_cache[key]
surf = self._build_thumbnail(char, variant, size)
self._tile_thumb_cache[key] = surf
return surf
def _build_thumbnail(self, char, variant, size):
spec = REGISTRY.get(char)
if spec is None:
return None
# Prop tiles draw straight from the tileset.
if spec.tileset_category is not None:
base = tileset.sprite(spec.tileset_category, variant)
return _scale_surface(base, size)
# Procedural / built-in tiles — fall through by character.
surf = pygame.Surface((TILE_SIZE, TILE_SIZE), pygame.SRCALPHA)
if char == 'W':
wall = (tileset.tile(tileset.WALL_TILE)
or TileTextures.get('wall'))
surf.blit(wall, (0, 0))
elif char == '.':
floor = (tileset.tile(tileset.FLOOR_TILE)
or TileTextures.get('floor'))
surf.blit(floor, (0, 0))
elif char == 'P':
_draw_marker(surf, theme.SUCCESS, "P")
elif char == 'X':
_draw_exit_marker(surf)
elif spec.category == 'enemy':
_draw_marker(surf, theme.FAIL, char)
elif char == 'S':
Spikes._build_images()
surf.blit(Spikes._imgs['up'], (0, 0))
elif char == 'L':
Lever._build_images()
surf.blit(Lever._imgs[False], (0, 0))
elif char == 'Y':
PressurePlate._build_images()
surf.blit(PressurePlate._imgs[False], (0, 0))
elif char == 'G':
Gate._build_images()
surf.blit(Gate._imgs[False], (0, 0))
elif char == 'K':
KeyItem._build_image()
surf.blit(KeyItem._img, (0, 0))
else:
# Last-resort marker for an unknown tile char — keep a loud
# magenta so it visibly screams "missing art" in the editor.
_draw_marker(surf, (180, 60, 180), char)
return _scale_surface(surf, size)
# --- module helpers --------------------------------------------------------
def _token_variant(token):
"""Same logic as ``levels._cell_variant`` — duplicated here to keep
editor.py importable without dragging the level runtime along."""
digits = token[1:]
return int(digits) if digits.isdigit() else 1
def _scale_surface(surf, size):
if size == TILE_SIZE:
return surf
return pygame.transform.smoothscale(surf, (size, size))
def _draw_marker(surf, color, letter):
"""Generic 'this tile has no art' marker — circle + bold letter."""
cx = cy = TILE_SIZE // 2
pygame.draw.circle(surf, color, (cx, cy), TILE_SIZE // 2 - 6)
pygame.draw.circle(surf, theme.BG,
(cx, cy), TILE_SIZE // 2 - 6, 3)
f = theme.font(32)
s = f.render(letter, True, theme.BG)
surf.blit(s, s.get_rect(center=(cx, cy)))
def _draw_exit_marker(surf):
"""Door-shaped silhouette so the exit reads as something to head
toward, not a generic marker."""
rect = pygame.Rect(8, 6, TILE_SIZE - 16, TILE_SIZE - 12)
pygame.draw.rect(surf, theme.shade(theme.BG, +6), rect, border_radius=4)
pygame.draw.rect(surf, theme.SUCCESS, rect, 3, border_radius=4)
inner = rect.inflate(-14, -10)
pygame.draw.rect(surf, theme.shade(theme.SUCCESS, -30), inner)
added interactables.py
@@ -0,0 +1,294 @@
"""Escape-room props: spike traps, levers, gates, the key and plates.
All visuals are procedural (one cached surface set per class, like
``static_objects.TileTextures``) so the game stays clone-safe and costs
only a handful of Surface allocations no matter how many props a level
has. Every prop is a normal ``Sprite`` with ``image``/``rect``/``hitbox``
so the level can blit and collision-test it like any other sprite.
"""
import pygame
from settings import (
TILE_SIZE, SPIKE_CYCLE, SPIKE_DANGER_TIME, SPIKE_WARN_TIME,
PLATE_TRIGGER_DELAY,
)
TS = TILE_SIZE
# Shared state cues for the escape-room props. The lever uses both;
# the plate's "off" state is intentionally a quiet grey (the plate is
# *inactive*, not *dangerous*), so only ON_COL is shared with it.
ON_COL = (90, 220, 130)
OFF_COL = (205, 90, 90)
class Spikes(pygame.sprite.Sprite):
"""Floor trap on a shared clock: safe -> warning -> deadly -> safe.
Every spike is created with the same phase, so the whole field
pulses together and the player can learn the rhythm. It only hurts
while fully extended; the warning frame telegraphs that so timing a
crossing is fair rather than a coin flip.
"""
_imgs = None
def __init__(self, pos, groups, phase=0.0):
super().__init__(groups)
self._build_images()
self.t = phase
self.image = self._imgs['down']
self.rect = self.image.get_rect(topleft=pos)
# Inset so you can stand on the very edge of a tile unharmed.
self.hitbox = self.rect.inflate(-16, -16)
self.deadly = False
@classmethod
def _build_images(cls):
if cls._imgs is not None:
return
def base():
s = pygame.Surface((TS, TS), pygame.SRCALPHA)
pygame.draw.rect(s, (26, 24, 32), (3, 3, TS - 6, TS - 6),
border_radius=4)
pygame.draw.rect(s, (12, 11, 16), (3, 3, TS - 6, TS - 6), 2,
border_radius=4)
for ox in range(2):
for oy in range(2):
cx, cy = TS // 4 + ox * TS // 2, TS // 4 + oy * TS // 2
pygame.draw.circle(s, (8, 8, 12), (cx, cy), 5)
return s
def teeth(color, h):
s = base()
for ox in range(2):
for oy in range(2):
cx, cy = TS // 4 + ox * TS // 2, TS // 4 + oy * TS // 2
pygame.draw.polygon(s, color, [
(cx - 7, cy + 6), (cx + 7, cy + 6), (cx, cy - h)])
pygame.draw.line(s, (255, 255, 255),
(cx - 2, cy - h + 4), (cx, cy - h), 2)
return s
cls._imgs = {
'down': base(),
'warn': teeth((148, 96, 74), 9),
'up': teeth((214, 82, 70), 18),
}
def update(self, dt):
self.t = (self.t + dt) % SPIKE_CYCLE
danger_start = SPIKE_CYCLE - SPIKE_DANGER_TIME
warn_start = danger_start - SPIKE_WARN_TIME
if self.t >= danger_start:
self.image, self.deadly = self._imgs['up'], True
elif self.t >= warn_start:
self.image, self.deadly = self._imgs['warn'], False
else:
self.image, self.deadly = self._imgs['down'], False
class Lever(pygame.sprite.Sprite):
"""Pull-once switch. ``use()`` flips it and the level opens every
gate whose ``group_id`` matches this lever's ``gate_group``."""
_imgs = None
def __init__(self, pos, groups, gate_group):
super().__init__(groups)
self._build_images()
self.gate_group = gate_group
self.activated = False
self.image = self._imgs[False]
self.rect = self.image.get_rect(topleft=pos)
self.hitbox = self.rect.copy()
@classmethod
def _build_images(cls):
if cls._imgs is not None:
return
def make(on):
s = pygame.Surface((TS, TS), pygame.SRCALPHA)
plate = pygame.Rect(TS // 4, TS // 6, TS // 2, TS * 2 // 3)
pygame.draw.rect(s, (42, 40, 52), plate, border_radius=6)
pygame.draw.rect(s, (16, 15, 22), plate, 2, border_radius=6)
pivot = (TS // 2, TS * 2 // 3)
knob = (TS * 2 // 3, TS // 3) if on else (TS // 3, TS // 3)
col = ON_COL if on else OFF_COL
pygame.draw.line(s, (18, 17, 24), pivot, knob, 8)
pygame.draw.line(s, (158, 158, 168), pivot, knob, 4)
pygame.draw.circle(s, col, knob, 9)
pygame.draw.circle(s, (235, 235, 240), knob, 9, 2)
pygame.draw.circle(s, (20, 19, 26), pivot, 5)
return s
cls._imgs = {False: make(False), True: make(True)}
def use(self):
if self.activated:
return False
self.activated = True
self.image = self._imgs[True]
return True
class Gate(pygame.sprite.Sprite):
"""One cell of a (possibly multi-cell) gate.
While shut it sits in the obstacle group for collision and draws as
a barred door. Its lever calls :meth:`open`, which pulls it out of
the obstacle group (collision gone) and swaps to the open frame.
"""
_imgs = None
def __init__(self, pos, draw_group, obstacle_group, group_id):
super().__init__(draw_group)
self._build_images()
self.group_id = group_id
self.obstacle_group = obstacle_group
obstacle_group.add(self)
self.opened = False
self.image = self._imgs[False]
self.rect = self.image.get_rect(topleft=pos)
self.hitbox = self.rect.inflate(0, -8)
@classmethod
def _build_images(cls):
if cls._imgs is not None:
return
shut = pygame.Surface((TS, TS), pygame.SRCALPHA)
shut.fill((28, 26, 34))
pygame.draw.rect(shut, (72, 68, 88), (0, 0, TS, TS), 3)
for i in range(1, 4):
x = i * TS // 4
pygame.draw.line(shut, (122, 118, 142), (x, 4), (x, TS - 4), 6)
pygame.draw.line(shut, (58, 56, 72), (x + 2, 4), (x + 2, TS - 4), 2)
for y in (TS // 3, 2 * TS // 3):
pygame.draw.line(shut, (98, 94, 114), (4, y), (TS - 4, y), 4)
opened = pygame.Surface((TS, TS), pygame.SRCALPHA)
pygame.draw.rect(opened, (60, 58, 74), (0, 0, TS, 6))
pygame.draw.rect(opened, (60, 58, 74), (0, TS - 6, TS, 6))
for x in (5, TS - 11): # bars retracted into the side jambs
pygame.draw.rect(opened, (92, 88, 108), (x, 6, 6, TS - 12))
cls._imgs = {False: shut, True: opened}
def open(self):
if self.opened:
return
self.opened = True
self.obstacle_group.remove(self)
self.image = self._imgs[True]
class KeyItem(pygame.sprite.Sprite):
"""The key to the way out. Walking over it picks it up; the level
bobs/glows it when drawing."""
_img = None
def __init__(self, pos, groups):
super().__init__(groups)
self._build_image()
self.image = self._img
self.rect = self.image.get_rect(topleft=pos)
self.hitbox = self.rect.inflate(-10, -10)
self.t = 0.0
@classmethod
def _build_image(cls):
if cls._img is not None:
return
s = pygame.Surface((TS, TS), pygame.SRCALPHA)
c, cx = (245, 205, 70), TS // 2
pygame.draw.circle(s, c, (cx, TS // 2 - 9), 12)
pygame.draw.circle(s, (58, 48, 16), (cx, TS // 2 - 9), 5)
pygame.draw.rect(s, c, (cx - 4, TS // 2 - 2, 8, 26))
pygame.draw.rect(s, c, (cx + 4, TS // 2 + 12, 10, 6))
pygame.draw.rect(s, c, (cx + 4, TS // 2 + 20, 8, 5))
cls._img = s
def update(self, dt):
self.t += dt
class PressurePlate(pygame.sprite.Sprite):
"""Floor plate that opens its paired gate when the player stands on
it long enough.
Levers need a button press (E) within reach; plates need only your
weight — but you have to commit to standing on them for a heartbeat
so a stray cross-the-room doesn't trip them by accident. Once
triggered the plate stays down for the rest of the run, matching
the one-shot feel of levers, and the level pairs them to gates by
reading order exactly like levers.
"""
_imgs = None
def __init__(self, pos, groups, gate_group):
super().__init__(groups)
self._build_images()
self.gate_group = gate_group
self.activated = False
self.charge = 0.0 # seconds player has stood on it
self.image = self._imgs[False]
self.rect = self.image.get_rect(topleft=pos)
# Trigger area covers most of the cell but not the very edges
# so brushing past doesn't count.
self.hitbox = self.rect.inflate(-12, -12)
@classmethod
def _build_images(cls):
if cls._imgs is not None:
return
def make(on):
s = pygame.Surface((TILE_SIZE, TILE_SIZE), pygame.SRCALPHA)
# Sunken stone base
base = pygame.Rect(6, 6, TILE_SIZE - 12, TILE_SIZE - 12)
pygame.draw.rect(s, (38, 36, 50), base, border_radius=8)
pygame.draw.rect(s, (14, 13, 20), base, 3, border_radius=8)
# Plate top — pressed lower & lit green when activated
inset = base.inflate(-10, -14)
if on:
inset.y += 4
plate_col = ON_COL
rim_col = (40, 110, 70)
else:
plate_col = (78, 76, 92)
rim_col = (28, 26, 36)
pygame.draw.rect(s, plate_col, inset, border_radius=6)
pygame.draw.rect(s, rim_col, inset, 2, border_radius=6)
# Rune mark on top — lit when on
cx, cy = TILE_SIZE // 2, TILE_SIZE // 2
mark_col = (235, 250, 215) if on else (110, 108, 120)
pygame.draw.circle(s, mark_col, (cx, cy), 6, 2)
pygame.draw.line(s, mark_col, (cx - 8, cy), (cx + 8, cy), 2)
return s
cls._imgs = {False: make(False), True: make(True)}
def step_on(self, dt):
"""Called by the level while the player overlaps this plate.
Returns True the moment the plate trips (so the level can open
the gate exactly once)."""
if self.activated:
return False
self.charge += dt
if self.charge >= PLATE_TRIGGER_DELAY:
self.activated = True
self.image = self._imgs[True]
return True
return False
def step_off(self):
"""Reset the charge if the player walks off before tripping."""
if not self.activated:
self.charge = 0.0
added launcher.py
@@ -0,0 +1,144 @@
"""Frozen entry-point for the packaged macOS app.
This is the *only* Python that PyInstaller bakes into ``The Way Out.app``.
It never changes after a build. Its whole job:
1. make sure runnable game code exists in ``~/.the-way-out/app/``
(pull the newest commit from GitHub, or fall back to the snapshot
baked into the .app on a first run with no internet),
2. hand control to that external ``app/main.py`` *inside this same
frozen Python* so ``import pygame`` resolves from the bundle.
Imports only stdlib + ``updater`` (the bundled bootstrap copy) +
``pygame`` (only for the offline error screen). No new dependencies.
"""
import os
import runpy
import sys
import traceback
import updater # bundled bootstrap copy — NOT re-implemented here
def _bundle_seed():
"""Folder PyInstaller unpacked the source snapshot into.
``--add-data "<seed>:_seed"`` puts it next to the frozen app; in a
plain ``python launcher.py`` dev run there is no ``_seed`` and
``updater.seed_from`` simply returns False (the GitHub path covers
dev)."""
base = getattr(sys, "_MEIPASS",
os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, "_seed")
def _log_crash(exc):
"""Persist a traceback so a failed launch is debuggable later."""
try:
updater.ROOT.mkdir(parents=True, exist_ok=True)
with open(updater.ROOT / "last_error.log", "w") as fh:
traceback.print_exception(
type(exc), exc, exc.__traceback__, file=fh)
except OSError:
pass
def _error_screen(lines):
"""Tiny pygame window for the one case we can't recover from: very
first launch with no code and no internet. Uses the game's pixel
font baked into ``_seed`` so this screen matches the rest of the
game; only falls back to the default font if the seed isn't
unpacked. Best-effort; never raises."""
try:
import pygame
pygame.init()
sw, sh = 720, 360
screen = pygame.display.set_mode((sw, sh))
pygame.display.set_caption("The Way Out")
# settings.FONT == "assets/gui/font/main_font.otf"; the frozen
# launcher can't import settings, so reconstruct that path
# under the bundled seed the same way _bundle_seed() does.
seed_font = os.path.join(_bundle_seed(), "assets", "gui",
"font", "main_font.otf")
try:
font = pygame.font.Font(seed_font, 30)
except (OSError, pygame.error):
font = pygame.font.Font(None, 30) # seed missing → default
clock = pygame.time.Clock()
running = True
while running:
for e in pygame.event.get():
if e.type == pygame.QUIT:
running = False
if (e.type == pygame.KEYDOWN
and e.key == pygame.K_ESCAPE):
running = False
# Hand-synced copies of theme.BG / theme.INK — the frozen
# launcher runs before the game's own modules are on the
# path, so it can't import theme.py. Keep these two tuples
# in sync with theme.py manually.
screen.fill((18, 18, 24))
for i, ln in enumerate(lines):
surf = font.render(ln, True, (214, 216, 226))
screen.blit(surf, surf.get_rect(
center=(sw // 2, 70 + i * 42)))
pygame.display.flip()
clock.tick(30)
pygame.quit()
except Exception:
pass
def ensure_code():
"""Guarantee ``app/`` holds a runnable game. Order: try update →
else recover from a crashed prior update → else seed from the baked
snapshot → else whatever is already there. Any network/IO failure is
swallowed: a stale-but-working install is always better than not
starting."""
try:
if updater.should_check():
loc, rem, available = updater.check()
if available and rem is not None:
updater.apply_update(expected_sha=rem)
except Exception:
pass # offline/error → fallbacks
if not updater.has_code():
updater.recover_from_prev() # crashed mid-rename → use prev
if not updater.has_code():
updater.seed_from(_bundle_seed()) # offline first-run snapshot
return updater.has_code()
def main():
if not ensure_code():
_error_screen([
"The Way Out",
"",
"Couldn't load the game.",
"Please connect to the internet and open it once.",
"After that it runs offline.",
"",
"(close this window to quit)",
])
return 1
app = str(updater.app_dir())
os.chdir(app) # CRITICAL: game uses relative asset paths
sys.path.insert(0, app) # so app/main.py finds its sibling modules
# The bootstrap updater has done its job. Drop it so the game's own
# ``import updater`` loads app/updater.py — that way the in-game
# "Update" button evolves with the repo instead of being pinned to
# the frozen bootstrap copy.
sys.modules.pop("updater", None)
try:
runpy.run_path(os.path.join(app, "main.py"), run_name="__main__")
return 0
except SystemExit as e:
return int(e.code) if isinstance(e.code, int) else 0
except BaseException as e: # last line of defence — log & exit clean
_log_crash(e)
return 1
if __name__ == "__main__":
raise SystemExit(main())
added level_catalog.py
@@ -0,0 +1,130 @@
"""Single source of truth for the level list.
Built-in levels come from ``assets/levels/manifest.json``. Custom levels
written by the in-game editor are discovered as ``*.txt`` files in
``~/.the-way-out/custom_levels/`` and appended at the end of the list.
Each level has a stable string id (``"level_1"`` for built-ins,
``"custom_<name>"`` for user-built). The id is what :mod:`save` records
as completed and what :meth:`LevelManager.load_level` takes — so adding
a built-in level or saving one in the editor needs no code changes
anywhere else.
"""
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from settings import SAVE_DIR
CUSTOM_DIR = SAVE_DIR / "custom_levels"
MANIFEST_PATH = Path("assets/levels/manifest.json")
@dataclass(frozen=True)
class LevelEntry:
"""One playable level — built-in or user-built.
``id`` stable handle used by save.py and the level menu.
``file`` path (working-dir relative for built-ins, absolute for
custom) to the level's .txt.
``title`` short header (e.g. "LEVEL 1").
``tagline`` one-liner under the title.
``custom`` True for editor-written levels; the level menu uses this
to mark them visually so a player can tell them apart.
``music`` optional background-track name under
``assets/audio/music/`` (no extension); None = silent.
``floor_tile`` / ``wall_tile`` optional per-level tileset PNG names
(under ``assets/tileset/tiles/``); None = the global
``tileset.FLOOR_TILE`` / ``WALL_TILE`` default.
"""
id: str
file: str
title: str
tagline: str
custom: bool
music: Optional[str] = None
floor_tile: Optional[str] = None
wall_tile: Optional[str] = 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:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return []
# Valid JSON that isn't the expected shape (top level not an object,
# "levels" not a list, an entry not a dict) must also degrade to an
# empty list, not raise — the docstring promises the menu never
# crashes on a corrupt manifest, and AttributeError/TypeError from
# the wrong shape aren't caught above.
if not isinstance(data, dict):
return []
levels = data.get("levels", [])
if not isinstance(levels, list):
return []
out = []
for raw in levels:
try:
out.append(LevelEntry(
id=raw["id"],
file=os.path.join("assets/levels", raw["file"]),
title=raw.get("title", raw["id"]),
tagline=raw.get("tagline", ""),
custom=False,
music=raw.get("music"),
floor_tile=raw.get("floor_tile"),
wall_tile=raw.get("wall_tile")))
except (KeyError, TypeError):
continue
return out
def _scan_custom():
"""Levels saved by the editor under ``~/.the-way-out/custom_levels/``.
The file name (without extension) becomes the human-readable title
and is also part of the id, so renaming a file = a "new" level for
the save system."""
if not CUSTOM_DIR.exists():
return []
out = []
for path in sorted(CUSTOM_DIR.glob("*.txt")):
name = path.stem
out.append(LevelEntry(
id=f"custom_{name}",
file=str(path),
title=name.replace("_", " ").title(),
tagline="Custom",
custom=True,
music="default"))
return out
def load_catalog():
"""Return every playable level in display order:
built-ins (manifest order) followed by custom levels (alphabetical)."""
return _load_manifest() + _scan_custom()
def find(level_id):
"""Look up a level by id. Returns ``None`` if it's not in the
catalog (e.g. user deleted a custom file)."""
for entry in load_catalog():
if entry.id == level_id:
return entry
return None
def ensure_custom_dir():
"""Make sure the custom-level directory exists; safe to call any
time."""
try:
CUSTOM_DIR.mkdir(parents=True, exist_ok=True)
except OSError:
pass
added levels.py
@@ -0,0 +1,1001 @@
import math
import random
from collections import deque
import pygame
from settings import (
TILE_SIZE, BOSS_TOUCH_DAMAGE, PLAYER_INVULN_TIME,
SPIKE_DAMAGE, LEVER_REACH, DASH_COOLDOWN,
)
from units import Wizard, Boss, CHARACTER_INFO, ENEMY_INFO
from static_objects import Tile, TileTextures, Prop
from interactables import Spikes, Lever, Gate, KeyItem, PressurePlate
from tiles import PROP_CHARS
import tileset
import level_catalog
import save
import audio
import theme
# Boss state -> badge colour. One named set instead of inline tuples
# scattered through draw_boss_health (FAIL = imminent hit, ACCENT =
# ranged tell, INK = neutral).
_BOSS_BADGE = {
'windup': ("!! WINDUP !!", theme.FAIL),
'dash': ("DASH", theme.FAIL),
'aim': ("AIMING", theme.ACCENT),
'shoot': ("FIRE", theme.ACCENT),
'chase': ("PURSUIT", theme.INK),
'recover': ("stagger", theme.MUTED),
}
# CHARACTERS comes from the units catalogue so adding a character is a
# one-liner in units.py.
CHARACTERS = {key: cls for key, cls, _label, _tagline in CHARACTER_INFO}
# Level token char -> enemy class (parallel to CHARACTERS).
ENEMIES = {char: cls for char, cls, _label in ENEMY_INFO}
# Built-in + custom levels come from ``level_catalog`` — the single
# source of truth for "what levels exist". The full LegendMD prop /
# letter table lives in :mod:`tiles` (``REGISTRY``); ``PROP_CHARS`` is
# the back-compat view used by the load_level switch below.
def _split_cells(line):
"""A map row is either dense — one character per cell, the legacy
format — or whitespace-separated tokens, which lets a cell carry a
variant (``T3``). A row with any internal spaces is tokenised."""
parts = line.split()
if len(parts) == 1 and parts[0] == line.strip():
return list(parts[0]) # dense single-char cells (no variants)
return parts # tokenised cells
def _cell_variant(cell):
"""Trailing digits of a token are the 1-based variant; default 1."""
digits = cell[1:]
return int(digits) if digits.isdigit() else 1
def _pair_id(cell):
"""Explicit trigger/gate pair id from a token's trailing digits
(``L2``→2, ``G3``→3), or ``None`` when the token has no digit — in
which case the legacy reading-order pairing is used.
Distinct from :func:`_cell_variant` (which defaults to 1) because
pairing must tell a bare ``L`` from an explicit ``L1``."""
digits = cell[1:]
return int(digits) if digits.isdigit() else None
class Camera:
"""Scrolls the world so the player stays centred, clamped to the
level bounds so the view never leaves the map.
Also owns the screen-shake offset: any system that wants a punch
of feedback (player hit, boss death, ...) calls :meth:`shake` and
the camera adds a decaying jitter to its effective offset.
The follow is intentionally *not* 1:1. It models the camera used by
well-regarded top-down action games (Zelda, Hyper Light Drifter,
Death's Door): the view leads slightly toward the direction the
player is moving so you see what you walk into, and the whole thing
is eased with frame-rate-independent exponential smoothing so the
camera trails softly instead of being glued to the sprite. It
recenters when the player stands still. No dead zone — that is a
platformer device and reads wrong for free 2D movement.
"""
# Exponential smoothing rate (per second). Higher = snappier,
# lower = floatier. ~6 reads as a soft trail without feeling sluggish.
FOLLOW_SPEED = 6.0
# How far the camera leads ahead of the player in the movement
# direction, as a fraction of the screen size.
LOOKAHEAD = 0.14
def __init__(self, screen_w, screen_h):
self.screen_w = screen_w
self.screen_h = screen_h
self.level_w = screen_w
self.level_h = screen_h
self.offset = pygame.math.Vector2(0, 0)
self.shake_offset = pygame.math.Vector2(0, 0)
self._shake_amount = 0.0
self._shake_time = 0.0
self._shake_total = 0.0
def set_level_size(self, level_w, level_h):
self.level_w = level_w
self.level_h = level_h
def follow(self, target, dt=None):
# Lead the view toward where the player is heading so they see
# what they walk into. When idle (direction == 0) the lead is
# zero and the camera eases back to centred.
lead_x = lead_y = 0.0
d = getattr(target, "direction", None)
if d is not None and d.magnitude() != 0:
lead_x = d.x * self.screen_w * self.LOOKAHEAD
lead_y = d.y * self.screen_h * self.LOOKAHEAD
# Offset that centres the target, plus the lead.
tx = target.rect.centerx - self.screen_w // 2 + lead_x
ty = target.rect.centery - self.screen_h // 2 + lead_y
if dt is None:
# Level load / teleport: snap so we never start mid-pan.
ox, oy = tx, ty
else:
# Ease toward that target. 1 - e^(-k·dt) is the same curve
# regardless of frame rate, unlike a raw lerp factor which
# speeds up / stutters when dt varies.
t = 1.0 - math.exp(-self.FOLLOW_SPEED * dt)
ox = self.offset.x + (tx - self.offset.x) * t
oy = self.offset.y + (ty - self.offset.y) * t
max_x = max(0, self.level_w - self.screen_w)
max_y = max(0, self.level_h - self.screen_h)
self.offset.x = max(0, min(ox, max_x))
self.offset.y = max(0, min(oy, max_y))
def shake(self, amount, duration):
"""Stack a shake event. Stronger / longer events take precedence
over weaker ones still in flight."""
if amount > self._shake_amount or duration > self._shake_time:
self._shake_amount = max(self._shake_amount, amount)
self._shake_time = max(self._shake_time, duration)
self._shake_total = max(self._shake_total, self._shake_time)
def update_shake(self, dt):
if self._shake_time <= 0:
self.shake_offset.update(0, 0)
self._shake_amount = 0.0
self._shake_total = 0.0
return
self._shake_time = max(0.0, self._shake_time - dt)
# Linear decay over the full duration so the jolt rings out
# rather than cutting off abruptly.
decay = (self._shake_time / self._shake_total
if self._shake_total > 0 else 0)
amp = self._shake_amount * decay
# Random direction each tick — coherent noise would be nicer
# but for short shakes pure random looks great.
self.shake_offset.update(
random.uniform(-amp, amp), random.uniform(-amp, amp))
def world_to_screen(self, rect):
return rect.move(
-int(self.offset.x + self.shake_offset.x),
-int(self.offset.y + self.shake_offset.y))
class LevelManager:
def __init__(self, width, height):
self.width = width
self.height = height
self.display_surface = pygame.display.get_surface()
# Walls live here for collision only; they are *not* drawn one by
# one — the whole static map is baked into ``map_surface`` once.
self.obstacle_sprites = pygame.sprite.Group()
self.entities = pygame.sprite.Group() # player + boss
self.player_sprites = pygame.sprite.Group() # just the player; boss aims here
self.enemy_sprites = pygame.sprite.Group()
self.projectile_sprites = pygame.sprite.Group()
self.interactable_sprites = pygame.sprite.Group() # spikes/levers/...
self.camera = Camera(width, height)
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.
self.level_id = ""
self.level_title = ""
self.level_tagline = ""
self.player = None
self.boss = None
self.exit_rect = None
self.completed = False
self.failed = False
self.time = 0.0
self.intro_timer = 0.0
self._saved = False
# Escape-room state.
self.spikes = []
self.levers = []
self.plates = []
self.gates = []
self.triggers = [] # ordered union of levers+plates for ID assignment
self.props = []
self.key_item = None
self.needs_key = False
self.has_key = False
# Boss is spawned lazily: only once the player steps into the
# final hall, so it can't be whittled down through a doorway.
self.has_boss = False
self.boss_defeated = False
self.boss_spawn_pos = None
self.arena_rect = None
self._e_was_down = False
# Damage-edge tracking for screen shake.
self._last_player_hp = 0
self._last_boss_hp = None
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)
self._vignette = self._build_vignette()
self._shadow_cache = {}
# --- setup -------------------------------------------------------
def _build_vignette(self):
"""Screen-sized darkened-edges overlay, built once."""
vig = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
cx, cy = self.width / 2, self.height / 2
max_d = (cx ** 2 + cy ** 2) ** 0.5
step = 8 # coarse blocks; cheap and the gradient still reads
vc = theme.shade(theme.BG, -12) # one tint below the map floor
for y in range(0, self.height, step):
for x in range(0, self.width, step):
d = (((x - cx) ** 2 + (y - cy) ** 2) ** 0.5) / max_d
a = int(150 * max(0.0, d - 0.55) / 0.45)
if a > 0:
vig.fill((*vc, min(160, a)),
(x, y, step, step))
return vig
def _bake_map(self, grid, cols, rows, floor_tile, wall_tile):
"""Render background + every floor/wall cell into one big
surface. Floor/wall use the named tileset PNGs (per-level
override, else the ``tileset`` defaults); if a name is missing
we fall back to the old procedural stone so a bad tile name
never blanks the level."""
level_w, level_h = cols * TILE_SIZE, rows * TILE_SIZE
surf = pygame.Surface((level_w, level_h)).convert()
# Base gradient so any gap reads as deep dungeon, not a void.
top = theme.shade(theme.BG, -2)
bot = theme.shade(theme.BG, -10)
for y in range(rows):
t = y / max(1, rows - 1)
col = tuple(int(top[i] + (bot[i] - top[i]) * t) for i in range(3))
surf.fill(col, (0, y * TILE_SIZE, level_w, TILE_SIZE))
wall_img = tileset.tile(wall_tile)
floor_img = tileset.tile(floor_tile)
wall_tex = TileTextures.get('wall')
floor_tex = TileTextures.get('floor')
floor_alt = TileTextures.get('floor_alt')
for r, row in enumerate(grid):
for c, cell in enumerate(row):
pos = (c * TILE_SIZE, r * TILE_SIZE)
if cell[0] == 'W':
surf.blit(wall_img or wall_tex, pos)
elif floor_img is not None:
surf.blit(floor_img, pos)
else:
surf.blit(floor_alt if (r + c) % 2 else floor_tex, pos)
return surf
def load_level(self, entry_or_id, char_type="c_wiz"):
"""Load a level from a :class:`level_catalog.LevelEntry` or its
id string. Returns True on success, False if the level could
not be loaded (unknown id, missing or empty file) — the caller
must not switch into the game state on a False return, since
``self.player`` stays None and ``update`` would then crash."""
entry = (entry_or_id if hasattr(entry_or_id, 'file')
else level_catalog.find(entry_or_id))
if entry is None:
print(f"the-way-out: unknown level {entry_or_id!r}")
return False
self.obstacle_sprites.empty()
self.entities.empty()
self.player_sprites.empty()
self.enemy_sprites.empty()
self.projectile_sprites.empty()
self.interactable_sprites.empty()
self.player = None
self.boss = None
self.exit_rect = None
self.completed = False
self.failed = False
self.time = 0.0
self.intro_timer = 3.0
self.level_id = entry.id
self.level_title = entry.title
self.level_tagline = entry.tagline
self._saved = False
self.spikes = []
self.levers = []
self.plates = []
self.gates = []
self.triggers = []
self.props = []
self.key_item = None
self.needs_key = False
self.has_key = False
self.has_boss = False
self.boss_defeated = False
self.boss_spawn_pos = None
self.arena_rect = None
self._e_was_down = False
self._last_boss_hp = None
try:
with open(entry.file, 'r') as f:
raw = [line.rstrip('\n') for line in f if line.strip()]
except FileNotFoundError:
print(f"Level file {entry.file} not found!")
return False
# An empty or all-whitespace file would make ``cols = max(...)``
# below raise ValueError; bail the same way as a missing file so
# a stray empty .txt in custom_levels can't crash the game.
if not raw:
print(f"Level file {entry.file} is empty!")
return False
# Each row is a list of cell tokens: a single char in legacy
# dense rows, or a letter (+ optional variant digits) in spaced
# rows. Short rows pad out with wall.
grid = [_split_cells(line) for line in raw]
rows = len(grid)
cols = max(len(r) for r in grid)
for row in grid:
row.extend('W' * (cols - len(row)))
level_w, level_h = cols * TILE_SIZE, rows * TILE_SIZE
self.camera.set_level_size(level_w, level_h)
# Per-level tileset override, else the global default.
floor_tile = entry.floor_tile or tileset.FLOOR_TILE
wall_tile = entry.wall_tile or tileset.WALL_TILE
self.map_surface = self._bake_map(
grid, cols, rows, floor_tile, wall_tile)
player_pos = (TILE_SIZE, TILE_SIZE)
gate_cells = []
for r, row in enumerate(grid):
for c, cell in enumerate(row):
x, y = c * TILE_SIZE, r * TILE_SIZE
ch = cell[0]
if ch == 'W':
# Collision only — never individually drawn.
Tile((x, y), [self.obstacle_sprites], 'wall')
elif ch == 'P':
player_pos = (x, y)
elif ch == 'X':
self.exit_rect = pygame.Rect(
x, y, TILE_SIZE, TILE_SIZE)
elif ch == 'B':
self.boss_spawn_pos = (x, y)
self.has_boss = True
elif ch == 'S':
self.spikes.append(
Spikes((x, y), [self.interactable_sprites]))
elif ch == 'L':
# gate_group filled in once all triggers are known;
# _pair_id is the explicit digit (or None = order).
lever = Lever(
(x, y), [self.interactable_sprites], None)
lever._pair_id = _pair_id(cell)
self.levers.append(lever)
self.triggers.append(lever)
elif ch == 'Y':
plate = PressurePlate(
(x, y), [self.interactable_sprites], None)
plate._pair_id = _pair_id(cell)
self.plates.append(plate)
self.triggers.append(plate)
elif ch == 'G':
gate_cells.append((r, c, _pair_id(cell)))
elif ch == 'K':
self.key_item = KeyItem(
(x, y), [self.interactable_sprites])
self.needs_key = True
elif ch in ENEMIES:
# Generic enemies spawn now (the boss alone stays
# lazy). target wired once the player exists.
enemy = ENEMIES[ch](x, y, self.obstacle_sprites)
self.enemy_sprites.add(enemy)
self.entities.add(enemy)
elif ch in PROP_CHARS:
category = PROP_CHARS[ch]
solid = tileset.is_solid(category)
self.props.append(Prop(
(x, y),
tileset.sprite(category, _cell_variant(cell)),
solid,
self.obstacle_sprites if solid else None))
# Flood-fill connected 'G' cells into gate panels. Panels and
# triggers (levers + plates, in reading order) get matching
# group ids — the i-th trigger opens the i-th panel. Author
# them in the order you want paired.
self._build_gates(gate_cells)
# Triggers with an explicit digit pair by that digit
# (namespaced so it can never collide with the reading-order
# fallback); the rest keep the legacy sequential pairing, so a
# level with no digits at all behaves exactly as before.
seq = 0
for trig in self.triggers:
if trig._pair_id is None:
trig.gate_group = ('seq', seq)
seq += 1
else:
trig.gate_group = ('pair', trig._pair_id)
player_class = CHARACTERS.get(char_type, Wizard)
self.player = player_class(
player_pos[0], player_pos[1], self.obstacle_sprites)
self.entities.add(self.player)
self.player_sprites.add(self.player)
self.player.projectile_group = self.projectile_sprites
self.player.projectile_targets = self.enemy_sprites
# Only generic enemies are in the group now (boss is lazy).
for enemy in self.enemy_sprites:
enemy.target = self.player
self._last_player_hp = self.player.hp
if self.has_boss:
self.arena_rect = self._compute_arena_rect(grid)
self.camera.follow(self.player)
# Per-level track (manifest "music" / "default" for custom);
# play_music degrades to silence if the file is absent.
audio.play_music(entry.music)
return True
def _build_gates(self, gate_cells):
"""Group adjacent gate cells (4-connectivity) into panels. A
panel with an explicit digit on any of its cells (``G2``) pairs
by that digit; panels with no digit fall back to reading order,
so a level using no digits is grouped exactly as the legacy
code did (the key is a tuple now, but ``_open_gates_for`` only
ever compares for equality)."""
pid_by_cell = {(r, c): pid for r, c, pid in gate_cells}
coords = [(r, c) for r, c, _ in gate_cells]
remaining = set(coords)
panels = []
for cell in coords: # already in r,c reading order
if cell not in remaining:
continue
comp, q = [], deque([cell])
remaining.discard(cell)
while q:
r, c = q.popleft()
comp.append((r, c))
for nr, nc in ((r + 1, c), (r - 1, c),
(r, c + 1), (r, c - 1)):
if (nr, nc) in remaining:
remaining.discard((nr, nc))
q.append((nr, nc))
panels.append(comp)
seq = 0
for comp in panels:
digits = [pid_by_cell[cell] for cell in comp
if pid_by_cell[cell] is not None]
if digits:
group = ('pair', digits[0])
else:
group = ('seq', seq)
seq += 1
for r, c in comp:
self.gates.append(Gate(
(c * TILE_SIZE, r * TILE_SIZE),
self.interactable_sprites, self.obstacle_sprites,
group))
def _compute_arena_rect(self, grid):
"""Bounding box of the room containing the boss, found by
flood-fill from the boss tile (walls *and* shut gates block it).
Stepping into this box is what triggers the boss to spawn."""
bx, by = self.boss_spawn_pos
start = (by // TILE_SIZE, bx // TILE_SIZE)
seen = {start}
q = deque([start])
while q:
r, c = q.popleft()
for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
if (0 <= nr < len(grid) and 0 <= nc < len(grid[nr])
and (nr, nc) not in seen
and grid[nr][nc][0] not in ('W', 'G')):
seen.add((nr, nc))
q.append((nr, nc))
cs = [c for _, c in seen]
rs = [r for r, _ in seen]
return pygame.Rect(
min(cs) * TILE_SIZE, min(rs) * TILE_SIZE,
(max(cs) - min(cs) + 1) * TILE_SIZE,
(max(rs) - min(rs) + 1) * TILE_SIZE)
# --- update ------------------------------------------------------
def update(self, dt):
self.time += dt
if self.intro_timer > 0:
self.intro_timer = max(0.0, self.intro_timer - dt)
self.camera.update_shake(dt)
if self.completed or self.failed:
return
self.entities.update(dt)
self.projectile_sprites.update(dt)
self.interactable_sprites.update(dt)
self.camera.follow(self.player, dt)
# Boss only materialises once you actually enter the final hall.
if (self.has_boss and self.boss is None and not self.boss_defeated
and self.arena_rect is not None
and self.player.hitbox.colliderect(self.arena_rect)):
bx, by = self.boss_spawn_pos
self.boss = Boss(
bx, by, self.obstacle_sprites, target=self.player,
projectile_group=self.projectile_sprites,
projectile_targets=self.player_sprites)
self.entities.add(self.boss)
self.enemy_sprites.add(self.boss)
self._last_boss_hp = self.boss.hp
self._handle_levers()
self._handle_plates(dt)
self._handle_hazards()
self._handle_key()
if (self.boss is not None and self.boss.hp > 0
and self.player.invuln_timer <= 0
and self.boss.hitbox.colliderect(self.player.hitbox)):
self.player.take_damage(BOSS_TOUCH_DAMAGE)
self.player.invuln_timer = PLAYER_INVULN_TIME
# Generic enemies: clear the dead, then apply contact damage.
# The boss keeps its own separate touch/death path (above and
# below) — the two are intentionally not merged.
for en in [e for e in self.enemy_sprites if e is not self.boss]:
if en.hp <= 0:
en.kill()
continue
if (self.player.invuln_timer <= 0
and en.hitbox.colliderect(self.player.hitbox)):
self.player.take_damage(en.touch_damage)
self.player.invuln_timer = PLAYER_INVULN_TIME
# Damage / death feedback — compare to last frame so spikes,
# projectiles and contact damage all shake the camera uniformly.
if (self.player is not None
and self.player.hp < self._last_player_hp):
self.camera.shake(5, 0.18)
if self.player is not None:
self._last_player_hp = self.player.hp
if self.boss is not None:
if (self._last_boss_hp is not None
and self.boss.hp < self._last_boss_hp):
self.camera.shake(2, 0.08)
self._last_boss_hp = self.boss.hp
if self.boss is not None and self.boss.hp <= 0:
self.camera.shake(12, 0.7)
audio.play("boss_death")
self.boss.kill()
self.boss = None
self.boss_defeated = True
if self.player is not None and self.player.hp <= 0:
self.camera.shake(8, 0.4)
audio.stop_music()
audio.play("player_death")
self.failed = True
return
boss_clear = (not self.has_boss) or self.boss_defeated
have_key = (not self.needs_key) or self.has_key
if (self.exit_rect is not None and boss_clear and have_key
and self.player is not None
and self.player.hitbox.colliderect(self.exit_rect)):
self.completed = True
if not self._saved:
save.mark_complete(self.level_id)
save.record_time(self.level_id, self.time)
audio.stop_music()
audio.play("level_complete")
self._saved = True
def _handle_levers(self):
"""Edge-detected 'E' near a lever pulls it and opens its gate."""
e_down = pygame.key.get_pressed()[pygame.K_e]
pressed = e_down and not self._e_was_down
self._e_was_down = e_down
if not pressed:
return
pc = pygame.math.Vector2(self.player.hitbox.center)
for lever in self.levers:
if lever.activated:
continue
if pc.distance_to(lever.hitbox.center) <= LEVER_REACH:
if lever.use():
self._open_gates_for(lever.gate_group)
break
def _handle_plates(self, dt):
"""Plates fire when the player has stood on them for the
trigger delay; until then the charge bleeds off the moment the
player steps off, so you can't sneak by with a quick brush."""
for plate in self.plates:
if plate.activated:
continue
if plate.hitbox.colliderect(self.player.hitbox):
if plate.step_on(dt):
self._open_gates_for(plate.gate_group)
else:
plate.step_off()
def _open_gates_for(self, group_id):
for gate in self.gates:
if gate.group_id == group_id:
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:
return
for sp in self.spikes:
if sp.deadly and sp.hitbox.colliderect(self.player.hitbox):
self.player.take_damage(SPIKE_DAMAGE)
self.player.invuln_timer = PLAYER_INVULN_TIME
break
def _handle_key(self):
if (self.key_item is not None
and self.player.hitbox.colliderect(self.key_item.hitbox)):
self.has_key = True
self.key_item.kill()
self.key_item = None
# --- draw --------------------------------------------------------
def _shadow(self, w):
if w not in self._shadow_cache:
s = pygame.Surface((w, w // 3), pygame.SRCALPHA)
pygame.draw.ellipse(s, (0, 0, 0, 90), s.get_rect())
self._shadow_cache[w] = s
return self._shadow_cache[w]
def _blit_world(self, screen, image, rect):
"""Blit a world-space sprite at the camera offset, skipping it
entirely when it is off screen."""
r = self.camera.world_to_screen(rect)
if (r.right < 0 or r.left > self.width
or r.bottom < 0 or r.top > self.height):
return None
screen.blit(image, r)
return r
def _draw_interactables(self, screen):
# Tileset furniture/decoration, then floor/wall props — all
# drawn under the entities so the player walks visually on top
# of spikes and in front of furniture.
for pr in self.props:
self._blit_world(screen, pr.image, pr.rect)
for sp in self.spikes:
self._blit_world(screen, sp.image, sp.rect)
for plate in self.plates:
self._blit_world(screen, plate.image, plate.rect)
for gate in self.gates:
self._blit_world(screen, gate.image, gate.rect)
pc = pygame.math.Vector2(self.player.hitbox.center) \
if self.player is not None else None
for lever in self.levers:
r = self._blit_world(screen, lever.image, lever.rect)
if (r is not None and not lever.activated and pc is not None
and pc.distance_to(lever.hitbox.center) <= LEVER_REACH):
self._draw_key_prompt(screen, r.centerx, r.top - 14, "E")
if self.key_item is not None:
bob = int(math.sin(self.key_item.t * 3.0) * 6)
r = self.camera.world_to_screen(self.key_item.rect)
r.y += bob
if not (r.right < 0 or r.left > self.width):
pulse = 0.5 + 0.5 * abs((self.time * 1.8) % 2 - 1)
gr = int(TILE_SIZE * (0.5 + 0.25 * pulse))
glow = pygame.Surface((gr * 2, gr * 2), pygame.SRCALPHA)
pygame.draw.circle(glow, (250, 210, 90,
int(60 + 70 * pulse)),
(gr, gr), gr)
screen.blit(glow, glow.get_rect(center=r.center))
screen.blit(self.key_item.image, r)
def _draw_key_prompt(self, screen, cx, cy, text):
surf = self.label_font.render(text, True, theme.INK)
box = surf.get_rect(center=(cx, cy)).inflate(20, 12)
panel = pygame.Surface(box.size, pygame.SRCALPHA)
panel.fill((*theme.BG, 210))
pygame.draw.rect(panel, theme.LINE_C,
panel.get_rect(), 2, border_radius=6)
screen.blit(panel, box)
screen.blit(surf, surf.get_rect(center=box.center))
def _draw_world_sprite(self, screen, sprite):
r = self.camera.world_to_screen(sprite.rect)
sh = self._shadow(int(sprite.hitbox.width * 1.2))
screen.blit(sh, sh.get_rect(
center=(r.centerx, self.camera.world_to_screen(
sprite.hitbox).bottom - 6)))
screen.blit(sprite.image, r)
def draw(self, screen):
if self.map_surface is None:
screen.fill(theme.BG)
return
sox = self.camera.offset.x + self.camera.shake_offset.x
soy = self.camera.offset.y + self.camera.shake_offset.y
screen.blit(self.map_surface, (0, 0),
(int(sox), int(soy), self.width, self.height))
self._draw_interactables(screen)
self._draw_exit(screen)
# Y-sort so the player walks correctly in front of / behind the
# boss and any overlap reads right.
for sprite in sorted(self.entities, key=lambda s: s.hitbox.bottom):
self._draw_world_sprite(screen, sprite)
for proj in self.projectile_sprites:
screen.blit(proj.image, self.camera.world_to_screen(proj.rect))
screen.blit(self._vignette, (0, 0))
if self.player is not None and not self.completed:
self.draw_player_health(screen)
self.draw_dash_meter(screen)
if self.needs_key:
self.draw_key_status(screen)
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(
screen, "You found the way out!", theme.SUCCESS)
elif self.failed:
self.draw_end_overlay(
screen, "You were defeated...", theme.FAIL)
def _draw_exit(self, screen):
if self.exit_rect is None:
return
r = self.camera.world_to_screen(self.exit_rect)
if r.right < 0 or r.left > self.width:
return
open_ = (((not self.has_boss) or self.boss_defeated)
and ((not self.needs_key) or self.has_key))
pulse = 0.5 + 0.5 * abs((self.time * 1.6) % 2 - 1)
frame = theme.SUCCESS if open_ else theme.FAIL
glow_c = theme.shade(frame, +10)
# Soft glow halo around the doorway.
gr = int(TILE_SIZE * (0.9 + 0.4 * pulse))
glow = pygame.Surface((gr * 2, gr * 2), pygame.SRCALPHA)
pygame.draw.circle(glow, (*glow_c, int(70 + 80 * pulse)),
(gr, gr), gr)
screen.blit(glow, glow.get_rect(center=r.center))
# Door panel.
pygame.draw.rect(screen, theme.shade(theme.BG, +6),
r.inflate(-6, -6), border_radius=6)
pygame.draw.rect(screen, frame, r.inflate(-6, -6), 4,
border_radius=6)
if open_:
inner = r.inflate(-22, -18)
a = int(120 + 110 * pulse)
s = pygame.Surface(inner.size, pygame.SRCALPHA)
s.fill((*glow_c, a))
screen.blit(s, inner)
else:
for i in range(1, 4): # bars -> "sealed"
bx = r.left + i * r.width // 4
pygame.draw.line(screen, frame,
(bx, r.top + 10), (bx, r.bottom - 10), 5)
# --- HUD ---------------------------------------------------------
def _bar(self, screen, x, y, w, h, ratio, color):
theme.draw_bar(screen, pygame.Rect(x, y, w, h), ratio, color)
def draw_player_health(self, screen):
w, h = 460, 34
x, y = 40, self.height - h - 40
self._bar(screen, x, y, w, h,
self.player.hp / self.player.max_hp, theme.ACCENT)
label = self.label_font.render(
f"HP {int(self.player.hp)}/{self.player.max_hp}",
True, theme.INK)
screen.blit(label, (x, y - 36))
def draw_dash_meter(self, screen):
"""Small ring next to the HP showing dash readiness. Filled
ring = ready (Shift will fire); shrinking arc = cooldown left."""
if self.player is None:
return
radius = 22
cx = 40 + 460 + 36 + radius
cy = self.height - 40 - 34 // 2 - 6
ready = (self.player.dash_cooldown_timer == 0
and self.player.dash_timer == 0)
# Backplate
pygame.draw.circle(screen, theme.shade(theme.BG, -10),
(cx, cy), radius + 4)
pygame.draw.circle(screen, theme.LINE_C, (cx, cy), radius)
if ready:
pygame.draw.circle(screen, theme.ACCENT, (cx, cy), radius - 4)
else:
ratio = 1.0 - (self.player.dash_cooldown_timer
/ max(0.001, DASH_COOLDOWN))
# Draw filled wedge from -pi/2 sweeping clockwise.
ring = pygame.Surface((radius * 2 + 4, radius * 2 + 4),
pygame.SRCALPHA)
rc = (radius + 2, radius + 2)
# Approximate wedge with a polygon for cheap rendering.
pts = [rc]
steps = max(2, int(36 * ratio))
for i in range(steps + 1):
ang = -math.pi / 2 + (2 * math.pi) * (i / 36)
pts.append((rc[0] + math.cos(ang) * (radius - 4),
rc[1] + math.sin(ang) * (radius - 4)))
if len(pts) >= 3:
pygame.draw.polygon(ring, theme.MUTED, pts)
screen.blit(ring, (cx - radius - 2, cy - radius - 2))
pygame.draw.circle(screen, theme.shade(theme.BG, -6),
(cx, cy), radius, 2)
# Glyph: lightning-style chevron
col = theme.INK if ready else theme.MUTED
pygame.draw.polygon(screen, col, [
(cx - 6, cy - 9), (cx + 3, cy - 2),
(cx - 1, cy - 1), (cx + 6, cy + 9),
(cx - 3, cy + 2), (cx + 1, cy + 1),
])
cap = self.label_font.render(
"DASH" if ready else "...", True,
theme.INK if ready else theme.MUTED)
screen.blit(cap, (cx + radius + 14, cy - 14))
def draw_key_status(self, screen):
"""Small chip by the HP bar: dim when the key is still out
there, lit gold once it's in hand."""
x, y = 40, self.height - 34 - 40 - 64
got = self.has_key
col = theme.ACCENT if got else theme.MUTED
cx = x + 18
pygame.draw.circle(screen, col, (cx, y + 14), 11)
pygame.draw.circle(screen, theme.BG, (cx, y + 14), 5)
pygame.draw.rect(screen, col, (cx - 4, y + 22, 8, 22))
pygame.draw.rect(screen, col, (cx + 4, y + 34, 9, 5))
label = self.label_font.render(
"KEY" if got else "KEY ?", True,
theme.ACCENT if got else theme.MUTED)
screen.blit(label, (x + 44, y + 8))
def draw_boss_health(self, screen):
w, h = 900, 40
x = self.width // 2 - w // 2
y = 56
# Two-tone fill: phase-2 portion overlays in a brighter shade,
# so you read at a glance how close you are to the next phase.
ratio = self.boss.hp / self.boss.max_hp
self._bar(screen, x, y, w, h, ratio, theme.ACCENT)
# Phase divider line at 50%
div_x = x + w // 2
pygame.draw.line(screen, theme.LINE_C,
(div_x, y - 2), (div_x, y + h + 2), 2)
# State badge — useful during dev, fun for the player too.
state_text, badge_col = _BOSS_BADGE.get(
self.boss.state, ("", theme.INK))
if state_text:
badge = self.label_font.render(state_text, True, badge_col)
screen.blit(badge, badge.get_rect(
center=(self.width // 2, y + h + 24)))
label = self.label_font.render("MR. GREEN", True, theme.INK)
screen.blit(label, label.get_rect(center=(self.width // 2, y - 24)))
def _draw_objective(self, screen):
if self.completed or self.failed:
return
if self.boss is not None:
text, color = "Defeat Mr. Green!", theme.FAIL
elif self.has_boss and not self.boss_defeated:
if any(not lv.activated for lv in self.levers):
text, color = ("Pull the levers — the way is sealed",
theme.ACCENT)
elif any(not p.activated for p in self.plates):
text, color = ("Step on the plates — the way is sealed",
theme.ACCENT)
else:
text, color = ("Mr. Green guards the final hall",
theme.ACCENT)
elif any(not p.activated for p in self.plates):
text, color = ("Step on the pressure plates",
theme.ACCENT)
elif self.needs_key and not self.has_key:
text, color = ("Find the key to the way out",
theme.ACCENT)
else:
text, color = "The way out is open — escape!", theme.SUCCESS
surf = self.banner_font.render(text, True, color)
rect = surf.get_rect(center=(self.width // 2, self.height - 70))
bg = rect.inflate(40, 20)
panel = pygame.Surface(bg.size, pygame.SRCALPHA)
panel.fill((*theme.BG, 150))
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 to dash {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)
overlay.fill(theme.BG)
screen.blit(overlay, (0, 0))
cx = screen.get_width() // 2
cy = screen.get_height() // 2
# Caps title + thin centred separator — same language as the
# menu screens' theme.draw_title, but kept in the state colour
# (SUCCESS / FAIL) and centred rather than pinned to the top.
title = self.title_font.render(text.upper(), True, color)
t_rect = title.get_rect(center=(cx, cy - 60))
screen.blit(title, t_rect)
ly = t_rect.bottom + 16
pygame.draw.line(screen, theme.LINE_C,
(cx - 170, ly), (cx + 170, ly), 2)
d = theme.HINT_DOT
hint = self.hint_font.render(
f"R retry {d} Enter or Esc back to menu",
True, theme.MUTED)
screen.blit(hint, hint.get_rect(center=(cx, cy + 50)))
added main.py
@@ -0,0 +1,327 @@
import os
import sys
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
import audio
# Setup & Initalisation
pygame.init()
# 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",
"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"
# 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"):
"""Load level by id (catalog string) and switch into the game state.
``return_to`` is what we'll switch to when the level ends."""
global game_state, return_state
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). Bounce
# back to the level select instead.
_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()
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
# 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"):
game_state = "menu"
elif game_state == "paused":
game_state = "game"
elif game_state == "game":
if level_manager.completed or level_manager.failed:
_leave_game()
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()
elif event.key == pygame.K_r:
if not level_manager.load_level(
level_manager.level_id, current_character):
_leave_game()
# Main menu
if game_state == "menu":
action = main_menu.handle_input(event)
if action == "lvls":
_to_level_menu()
elif action == "editor":
editor.reset_pointer_state()
game_state = "editor"
elif action == "settings":
game_state = "settings"
elif action == "chars":
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.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 up 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
game_state = "menu"
# Levels select — action is the chosen level id (from catalog).
elif game_state == "lvls":
action = level_menu.handle_input(event)
if action:
_start_level(action)
# 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 actually change.
_bgm = _BGM_FOR_STATE.get(game_state)
if _bgm is not None:
audio.play_music(_bgm)
# Draw & Update --------------------------------------------------
if game_state == "menu":
main_menu.draw(screen)
elif game_state == "updating":
update_anim_t += dt
result = update_state["result"]
if result == "done":
main_menu.status = "Updated - restarting..."
main_menu.draw(screen)
pygame.display.flip()
pygame.time.delay(900)
pygame.quit() # before execv
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.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.status = (
f"{_UPDATE_PHASE_TEXT.get(phase, 'Updating')}{dots}")
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 == "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()
added menu.py
@@ -0,0 +1,543 @@
import pygame
from units import CHARACTER_INFO
import level_catalog
import save
import audio
import theme
# 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,
measure,
draw_title as _draw_title,
draw_back_hint as _draw_back_hint,
hover_marker as _hover_marker)
class MainMenu:
def __init__(self, width, height):
self.width = width
self.height = height
self.font = theme.font(46)
self.title_font = theme.font(78)
self.small_font = theme.font(24)
# Set by main.py's update flow; drawn under the tip line.
self.status = ""
self.buttons = [
{"text": "Levels", "rect": None, "action": "lvls"},
{"text": "Editor", "rect": None, "action": "editor"},
{"text": "Characters", "rect": None, "action": "chars"},
{"text": "Settings", "rect": None, "action": "settings"},
{"text": "Update", "rect": None, "action": "update"},
{"text": "Quit", "rect": None, "action": "quit"}
]
center_x = width // 2
start_y = height // 2 - 100
for i, btn in enumerate(self.buttons):
rect = measure(self.font, btn["text"])
rect.center = (center_x, start_y + i * 90)
btn["rect"] = rect
# Slow pixel-dust drifting upward — keeps the title screen alive
# without competing with the menu (matches the pixel aesthetic).
self.dust = theme.PixelDust(width, height, seed=7, count=60)
def draw(self, screen):
screen.fill(BG)
self.dust.draw(screen)
mouse_pos = pygame.mouse.get_pos()
title = self.title_font.render("THE WAY OUT", True, TITLE_C)
screen.blit(title, title.get_rect(
center=(self.width // 2, self.height // 2 - 210)))
for btn in self.buttons:
is_hovered = btn["rect"].collidepoint(mouse_pos)
# Thin separator above the last item (Quit) to set it apart.
if btn["action"] == "quit":
ly = btn["rect"].top - 22
pygame.draw.line(screen, LINE_C,
(self.width // 2 - 90, ly),
(self.width // 2 + 90, ly), 2)
color = ACCENT if is_hovered else INK
text_surf = self.font.render(btn["text"], True, color)
screen.blit(text_surf, btn["rect"])
if is_hovered:
_hover_marker(screen, btn["rect"])
d = theme.HINT_DOT
tip = self.small_font.render(
f"WASD/Arrows move & aim {d} Space shoot {d} "
f"Shift dash {d} E use",
True, MUTED)
screen.blit(tip, tip.get_rect(
center=(self.width // 2, self.height - 58)))
if self.status:
# INK, not ACCENT: the gold accent is too low-contrast for a
# full line of small text on the dark background.
status_surf = self.small_font.render(self.status, True, INK)
screen.blit(status_surf, status_surf.get_rect(
center=(self.width // 2, self.height - 98)))
def handle_input(self, event):
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mouse_pos = pygame.mouse.get_pos()
for btn in self.buttons:
if btn["rect"].collidepoint(mouse_pos):
return btn["action"]
return None
class SettingsMenu:
def __init__(self, width, height):
self.width = width
self.height = height
self.font = theme.font(46)
self.title_font = theme.font(56)
self.small_font = theme.font(24)
# Persisted preference; main.py applies it to the audio module
# at startup so it holds before Settings is ever opened.
_prefs = save.load_settings()
self.sound_on = _prefs.get("sound", True)
# Five-step bed level (0 / 25 / 50 / 75 / 100 %) — coarse on
# purpose so a click cycles through them clearly. Volume is
# independent of the sound toggle: muting kills audio outright,
# the slider sets the music level when audio is on.
raw_vol = _prefs.get("music_vol", 1.0)
self.music_vol = max(0.0, min(1.0,
float(raw_vol) if isinstance(raw_vol, (int, float)) else 1.0))
# Fullscreen vs. bordered window only — no resolution picker.
# The game always boots fullscreen at the monitor's own size
# (main.py); this toggle is session-only, never persisted.
self.toggle_screen = True
# Same idle motion as the title screen but quieter — a
# different seed gives each submenu its own pattern.
self.dust = theme.PixelDust(width, height, seed=11, count=35)
self.update_buttons()
def update_buttons(self):
sound_text = f"Sound: {'ON' if self.sound_on else 'OFF'}"
music_text = f"Music: {int(round(self.music_vol * 100))}%"
screen_text = (
f"Screen: {'FULLSCREEN' if self.toggle_screen else 'BORDERED'}")
self.buttons = [
{"text": sound_text, "rect": None, "action": "toggle_sound"},
{"text": music_text, "rect": None, "action": "cycle_music"},
{"text": screen_text, "rect": None, "action": "toggle_fs_w"},
]
center_x = self.width // 2
start_y = self.height // 2 - 100
for i, btn in enumerate(self.buttons):
rect = measure(self.font, btn["text"])
rect.center = (center_x, start_y + i * 100)
btn["rect"] = rect
def draw(self, screen):
screen.fill(BG)
self.dust.draw(screen)
_draw_title(screen, self.title_font, "Settings", self.width)
_draw_back_hint(screen, self.small_font)
mouse_pos = pygame.mouse.get_pos()
for btn in self.buttons:
is_hovered = btn["rect"].collidepoint(mouse_pos)
color = ACCENT if is_hovered else INK
screen.blit(self.font.render(
btn["text"], True, color), btn["rect"])
if is_hovered:
_hover_marker(screen, btn["rect"])
def handle_input(self, event):
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mouse_pos = pygame.mouse.get_pos()
for btn in self.buttons:
if btn["rect"].collidepoint(mouse_pos):
if btn["action"] == "toggle_sound":
self.sound_on = not self.sound_on
audio.set_enabled(self.sound_on)
save.set_setting("sound", self.sound_on)
self.update_buttons()
elif btn["action"] == "cycle_music":
# Cycle 0 → 25 → 50 → 75 → 100 → 0. Snap any
# off-step saved value to the next step up.
steps = (0.0, 0.25, 0.5, 0.75, 1.0)
cur = round(self.music_vol * 4) / 4
idx = (steps.index(cur) + 1) % len(steps) \
if cur in steps else 0
self.music_vol = steps[idx]
audio.set_music_volume(self.music_vol)
save.set_setting("music_vol", self.music_vol)
self.update_buttons()
elif btn["action"] == "toggle_fs_w":
self.toggle_screen = not self.toggle_screen
pygame.display.toggle_fullscreen()
self.update_buttons()
return btn["action"]
return None
class LevelMenu:
"""Level select with completion checkmarks read from ``save.py``.
Entries are rebuilt from ``level_catalog`` on every ``refresh()`` so:
* freshly-beaten levels light up the next time you back to the menu
* a custom level the player just saved in the editor appears
without restarting the game
Built-in levels are listed first (manifest order); user-built levels
follow, visually marked as ``Custom``.
"""
def __init__(self, width, height):
self.width = width
self.height = height
self.font = theme.font(46)
self.title_font = theme.font(56)
self.small_font = theme.font(24)
self.tag_font = theme.font(22)
self.best_font = theme.font(20)
self.times = {}
self.entries = []
# Idle motion, kept thinner than the title because this screen
# is text-dense (rows of titles, taglines and best times).
self.dust = theme.PixelDust(width, height, seed=13, count=25)
self.refresh()
def _layout(self):
"""Stack entries vertically, auto-shrinking spacing when the
catalog grows so custom levels still fit on screen."""
if not self.entries:
return
count = len(self.entries)
# 130 px per row up to 5 entries, then tighten so 10 still fit.
gap = max(60, min(130, (self.height - 240) // max(count, 1)))
center_x = self.width // 2
start_y = self.height // 2 - (count - 1) * gap // 2
for i, btn in enumerate(self.entries):
rect = measure(self.font, btn["text"])
rect.center = (center_x, start_y + i * gap)
btn["rect"] = rect
def refresh(self):
"""Rebuild entries from the catalog + reread completed ids and
best times."""
self.completed = save.load_completed()
self.times = save.load_times()
self.entries = []
for entry in level_catalog.load_catalog():
self.entries.append({
"text": entry.title,
"action": entry.id,
"tagline": entry.tagline,
"custom": entry.custom,
"rect": None,
})
self._layout()
def draw(self, screen):
screen.fill(BG)
self.dust.draw(screen)
_draw_title(screen, self.title_font, "Levels", self.width)
_draw_back_hint(screen, self.small_font)
if not self.entries:
empty = self.small_font.render(
"No levels found — check assets/levels/manifest.json",
True, MUTED)
screen.blit(empty, empty.get_rect(
center=(self.width // 2, self.height // 2)))
return
mouse_pos = pygame.mouse.get_pos()
for btn in self.entries:
is_hovered = btn["rect"].collidepoint(mouse_pos)
is_done = btn["action"] in self.completed
if is_hovered:
color = ACCENT
elif is_done:
color = DONE_C
else:
color = INK
text_surf = self.font.render(btn["text"], True, color)
screen.blit(text_surf, btn["rect"])
if is_hovered:
_hover_marker(screen, btn["rect"])
tag = btn["tagline"]
if btn["custom"]:
# No pill — a quiet prefix keeps the row flat.
tag = f"custom · {tag}"
tag_surf = self.tag_font.render(
tag, True,
MUTED if not is_done else theme.shade(DONE_C, -30))
screen.blit(tag_surf, tag_surf.get_rect(
center=(btn["rect"].centerx, btn["rect"].bottom + 16)))
best = self.times.get(btn["action"])
if best is not None:
m, s = divmod(int(best), 60)
# INK, not ACCENT: a persistent 20px label in the gold
# accent is too low-contrast to read (same reason the
# update status line uses INK).
bt = self.best_font.render(
f"best {m}:{s:02d}", True, INK)
screen.blit(bt, bt.get_rect(
center=(btn["rect"].centerx, btn["rect"].bottom + 42)))
if is_done:
# Minimal check: a thin tick, no filled circle.
tx = btn["rect"].left - 60
ty = btn["rect"].centery
pygame.draw.lines(screen, DONE_C, False, [
(tx - 9, ty),
(tx - 2, ty + 8),
(tx + 11, ty - 8)], 3)
def handle_input(self, event):
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mouse_pos = pygame.mouse.get_pos()
for btn in self.entries:
if btn["rect"] and btn["rect"].collidepoint(mouse_pos):
return btn["action"]
return None
class CharacterMenu:
"""Character select with the stat block of the currently-hovered
(or, if none, currently-selected) character shown alongside."""
def __init__(self, width, height):
self.width = width
self.height = height
self.title_font = theme.font(56)
self.card_font = theme.font(44)
self.name_font = theme.font(46)
self.small_font = theme.font(24)
self.stat_font = theme.font(24)
self.tagline_font = theme.font(22)
# Build entries from the units catalogue.
self.character = []
for key, cls, label, tagline in CHARACTER_INFO:
self.character.append({
"text": label,
"action": key,
"tagline": tagline,
"cls": cls,
"rect": None,
})
# Left-align every name on a single vertical line so the column
# doesn't zigzag with each name's width.
self.name_x = width // 2 - 320
start_y = height // 2 - 200
for i, btn in enumerate(self.character):
rect = measure(self.name_font, btn["text"])
rect.midleft = (self.name_x, start_y + i * 100)
btn["rect"] = rect
# One idle sprite per character, shown left of the list on hover.
self.previews = {}
for key, cls, label, tagline in CHARACTER_INFO:
self.previews[key] = self._load_preview(cls)
# Idle motion — quieter than the title screen so it doesn't
# compete with the stat block on the right.
self.dust = theme.PixelDust(width, height, seed=17, count=30)
def _load_preview(self, cls):
"""Every idle frame, scaled — the hovered character loops its
idle animation instead of standing on a single frame."""
try:
sheet = pygame.image.load(
f"assets/units/{cls.asset_folder}/D_Idle.png").convert_alpha()
except (pygame.error, FileNotFoundError):
return None
_, count = cls.SPRITE_SHEETS['idle_down']
fw = sheet.get_width() // count
fh = sheet.get_height()
target_h = 220
scale = target_h / fh
size = (int(fw * scale), int(fh * scale))
return [
pygame.transform.scale(
sheet.subsurface(pygame.Rect(i * fw, 0, fw, fh)), size)
for i in range(count)
]
def draw(self, screen, current_selected):
screen.fill(BG)
self.dust.draw(screen)
_draw_title(screen, self.title_font, "Select Character", self.width)
_draw_back_hint(screen, self.small_font)
mouse_pos = pygame.mouse.get_pos()
# Choose which character's stats to show: hovered first,
# otherwise the current selection.
focus = None
for btn in self.character:
if btn["rect"].collidepoint(mouse_pos):
focus = btn
break
if focus is None:
for btn in self.character:
if btn["action"] == current_selected:
focus = btn
break
for btn in self.character:
is_hovered = btn["rect"].collidepoint(mouse_pos)
if btn["action"] == current_selected:
color = SEL_C
elif is_hovered:
color = ACCENT
else:
color = INK
text_surf = self.name_font.render(btn["text"], True, color)
screen.blit(text_surf, btn["rect"])
if is_hovered:
_hover_marker(screen, btn["rect"])
tag = self.tagline_font.render(
btn["tagline"], True, MUTED)
screen.blit(tag, tag.get_rect(
topleft=(btn["rect"].left, btn["rect"].bottom + 4)))
if focus is not None:
frames = self.previews.get(focus["action"])
if frames:
# ~7 fps idle loop, timed off the wall clock so this
# screen doesn't need a dt plumbed in just for the sprite.
frame = frames[(pygame.time.get_ticks() // 140) % len(frames)]
pcx = self.name_x - 170
pcy = self.height // 2
screen.blit(frame, frame.get_rect(center=(pcx, pcy)))
self._draw_stat_card(screen, focus)
def _draw_stat_card(self, screen, btn):
cls = btn["cls"]
# No box: a flat column with one thin separator under the name.
card_w = 520
cx = self.width // 2 + 360
left = cx - card_w // 2
top = self.height // 2 - 220
name = self.card_font.render(btn["text"], True, TITLE_C)
screen.blit(name, name.get_rect(center=(cx, top)))
tag = self.tagline_font.render(btn["tagline"], True, MUTED)
screen.blit(tag, tag.get_rect(center=(cx, top + 44)))
pygame.draw.line(screen, LINE_C,
(left + 20, top + 78),
(left + card_w - 20, top + 78), 2)
stats = [
("HP", cls.max_hp, 200),
("SPEED", cls.speed, 900),
("DAMAGE", cls.attack_damage, 25),
("FIRE RATE", 1.0 / max(0.01, cls.attack_cooldown), 6.0),
]
bar_x = left + 130
bar_w = card_w - 170
bar_h = 10
y = top + 130
for label, val, vmax in stats:
text = self.stat_font.render(label, True, MUTED)
screen.blit(text, text.get_rect(midleft=(left + 20, y + 5)))
ratio = max(0.05, min(1.0, val / vmax))
theme.draw_bar(screen,
pygame.Rect(bar_x, y, bar_w, bar_h),
ratio, ACCENT, border=False)
num = self.stat_font.render(
f"{val:.1f}" if isinstance(val, float) else str(val),
True, INK)
screen.blit(num, num.get_rect(midleft=(bar_x + bar_w + 12, y + 5)))
y += 56
def handle_input(self, event):
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mouse_pos = pygame.mouse.get_pos()
for btn in self.character:
if btn["rect"].collidepoint(mouse_pos):
return btn["action"]
return None
class PauseMenu:
"""Translucent overlay over the live game.
The level keeps its state — ``main.py`` simply stops calling
``LevelManager.update`` while paused, so the next Resume picks up
exactly where you froze.
"""
def __init__(self, width, height):
self.width = width
self.height = height
self.font = theme.font(50)
self.title_font = theme.font(76)
self.hint_font = theme.font(24)
self.buttons = [
{"text": "Resume", "action": "resume"},
{"text": "Restart Level", "action": "restart"},
{"text": "Quit to Menu", "action": "quit"},
]
cx = width // 2
start_y = height // 2 - 30
for i, btn in enumerate(self.buttons):
rect = measure(self.font, btn["text"])
rect.center = (cx, start_y + i * 110)
btn["rect"] = rect
def draw(self, screen):
overlay = pygame.Surface(screen.get_size(), pygame.SRCALPHA)
overlay.fill((*BG, 210))
screen.blit(overlay, (0, 0))
title = self.title_font.render("PAUSED", True, TITLE_C)
t_rect = title.get_rect(
center=(self.width // 2, self.height // 2 - 200))
screen.blit(title, t_rect)
ly = t_rect.bottom + 16
pygame.draw.line(screen, LINE_C,
(self.width // 2 - 150, ly),
(self.width // 2 + 150, ly), 2)
mp = pygame.mouse.get_pos()
for btn in self.buttons:
hov = btn["rect"].collidepoint(mp)
col = ACCENT if hov else INK
screen.blit(self.font.render(btn["text"], True, col), btn["rect"])
if hov:
_hover_marker(screen, btn["rect"])
hint = self.hint_font.render("Esc to resume", True, MUTED)
screen.blit(hint, hint.get_rect(
center=(self.width // 2, self.height - 88)))
def handle_input(self, event):
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mp = pygame.mouse.get_pos()
for btn in self.buttons:
if btn["rect"].collidepoint(mp):
return btn["action"]
return None
added save.py
@@ -0,0 +1,155 @@
"""Tiny progress + preferences save file.
One JSON document at ``~/.the-way-out/save.json``::
{
"completed": ["level_1", ...], # beaten level ids
"times": {"level_1": 42.7}, # best clear time, seconds
"settings": {"sound": true} # persisted prefs
}
Survives reclones, keeps no per-run state. The level menu reads
``completed``/``times`` for the ✓ marks and best-time line,
``LevelManager`` writes both when a run finishes, and the Settings
menu reads/writes ``settings``.
Every public function goes through one ``_load``/``_write`` pair so a
write of one section never clobbers another, and any I/O or shape
error degrades to "no save data" / "save skipped" — a weird FS never
crashes the game.
Legacy migration: older saves stored integer indices (``0/1/2``) in
``completed`` before the catalog refactor; those map to
``level_1/2/3`` on read so a returning player keeps their checkmarks.
"""
import json
import os
from settings import SAVE_DIR, SAVE_FILE
# How a legacy int index maps to a string id. Indices 0..2 were the
# only ones ever shipped, so a flat lookup is enough.
_LEGACY_INDEX_TO_ID = {0: "level_1", 1: "level_2", 2: "level_3"}
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:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
return data if isinstance(data, dict) else {}
def _write(data):
"""Rewrite the whole file atomically. Silent no-op on FS errors so
a write-protected home never crashes the player out of a victory
screen. The tmp+rename keeps the previous file intact if the
process is killed mid-write — without it, a partial JSON reads
back as ``{}`` and silently wipes all progress."""
try:
SAVE_DIR.mkdir(parents=True, exist_ok=True)
tmp = SAVE_FILE.with_name(SAVE_FILE.name + '.tmp')
with open(tmp, 'w') as f:
json.dump(data, f)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, SAVE_FILE)
except OSError as e:
print(f"the-way-out: could not save progress ({e})")
# --- completion ---------------------------------------------------------
def _parse_completed(data):
"""Apply legacy int-index migration to ``data['completed']`` and
return a set of string level ids."""
out = set()
for item in data.get('completed', []):
if isinstance(item, str):
out.add(item)
elif isinstance(item, bool):
continue # bool is an int subclass — never a valid index
elif isinstance(item, int) and item in _LEGACY_INDEX_TO_ID:
out.add(_LEGACY_INDEX_TO_ID[item])
return out
def load_completed():
"""Return a ``set`` of completed level ids (strings). Empty set on
any error or missing file."""
return _parse_completed(_load())
def mark_complete(level_id):
"""Add ``level_id`` to the completed set and rewrite the file,
preserving ``times``/``settings``."""
if not isinstance(level_id, str):
return # guard against accidental int passthrough
data = _load()
completed = _parse_completed(data)
if level_id in completed:
return
completed.add(level_id)
data['completed'] = sorted(completed)
_write(data)
# --- best times ---------------------------------------------------------
def _parse_times(data):
"""Return a dict of valid level id → float seconds from
``data['times']``, dropping anything malformed so a hand-edited
file can't crash the menu."""
out = {}
raw = data.get('times', {})
if isinstance(raw, dict):
for k, v in raw.items():
if (isinstance(k, str) and isinstance(v, (int, float))
and not isinstance(v, bool) and v > 0):
out[k] = float(v)
return out
def load_times():
"""Map of level id → best clear time in seconds. Skips anything
malformed so a hand-edited file can't crash the menu."""
return _parse_times(_load())
def record_time(level_id, seconds):
"""Store ``seconds`` as the best time for ``level_id``, but only if
it beats (or sets) the existing record."""
if not isinstance(level_id, str) or seconds <= 0:
return
data = _load()
times = _parse_times(data)
best = times.get(level_id)
if best is not None and best <= seconds:
return
times[level_id] = float(seconds)
data['times'] = times
_write(data)
# --- preferences --------------------------------------------------------
def load_settings():
"""Persisted preference dict (e.g. ``{"sound": True}``). Empty dict
if unset or malformed."""
raw = _load().get('settings', {})
return raw if isinstance(raw, dict) else {}
def set_setting(key, value):
"""Set one preference key, preserving the rest of the document."""
data = _load()
settings = data.get('settings')
if not isinstance(settings, dict):
settings = {}
settings[key] = value
data['settings'] = settings
_write(data)
added settings.py
@@ -0,0 +1,92 @@
from pathlib import Path
# Screen — the game boots fullscreen at the monitor's native size
# (queried via pygame in main.py). These are only the fallback used
# if that query fails; there is deliberately no resolution option.
WIDTH = 2560
HEIGHT = 1600
FPS = 60
# Colors
BLACK = (0, 0, 0)
# Player (base / Wizard) — individual characters override these in
# ``units.py`` to give Penguin/Elf/Shiggy/Wolf distinct feels.
PLAYER_SPEED = 600 # Pixel pro Sekunde
PLAYER_SCALE = 5
FONT = "assets/gui/font/main_font.otf"
TILE_SIZE = 64
# Collision box of the player, in pixels. Much smaller than the scaled
# sprite (which is mostly transparent padding) so movement through
# corridors feels right. Centered on the sprite.
PLAYER_HITBOX_SIZE = 56
# --- Combat ---
PLAYER_MAX_HP = 100
PLAYER_INVULN_TIME = 0.9 # seconds of i-frames after taking a hit
ATTACK_COOLDOWN = 0.35 # seconds between shots
PROJECTILE_DAMAGE = 10 # base damage; characters can override
PROJECTILE_SPEED = 950 # pixels per second
PROJECTILE_LIFETIME = 1.6 # seconds before it fizzles out
PROJECTILE_RADIUS = 12
# --- Dash ---
# Short burst on Shift: the player keeps full control of the direction
# but moves at ``PLAYER_SPEED * DASH_SPEED_MULT`` for ``DASH_DURATION``
# seconds, with i-frames the whole time. The cooldown starts at dash
# *end*, so spamming Shift doesn't shortcut it.
DASH_DURATION = 0.18
DASH_SPEED_MULT = 3.2
DASH_COOLDOWN = 1.2
DASH_INVULN_BONUS = 0.05 # extra i-frames after the dash itself ends
# Boss (Mr. Green) — slower than the player so it can be kited.
BOSS_SCALE = 9
BOSS_SPEED = 250
BOSS_MAX_HP = 220 # phase 2 keeps it onscreen longer
BOSS_HITBOX_SIZE = 150
BOSS_TOUCH_DAMAGE = 18
# Boss attack pattern (state machine in ``units.Boss``):
# chase -> windup -> dash -> recover -> chase
# chase -> aim -> shoot -> recover -> chase (phase 2 only)
# Phase 2 starts when boss HP drops below ``BOSS_PHASE2_HP_RATIO``.
BOSS_PHASE2_HP_RATIO = 0.5
BOSS_CHASE_TIME_MIN = 1.6
BOSS_CHASE_TIME_MAX = 2.6
BOSS_WINDUP_TIME = 0.55
BOSS_DASH_TIME = 0.45
BOSS_DASH_SPEED_MULT = 3.4
BOSS_RECOVER_TIME = 0.55
BOSS_AIM_TIME = 0.6
BOSS_SHOTS_PER_VOLLEY = 3 # phase 2: spread shot
BOSS_PROJECTILE_DAMAGE = 14
BOSS_PROJECTILE_SPEED = 720
# Hit feedback shared by every unit.
HIT_FLASH_TIME = 0.12
# --- Hazards / Puzzles ---
# Spike traps run on one shared clock so the rhythm is readable:
# safe -> warning (telegraph) -> deadly -> safe ...
SPIKE_CYCLE = 3.0 # full period, seconds
SPIKE_DANGER_TIME = 1.1 # seconds fully extended (deadly)
SPIKE_WARN_TIME = 0.5 # telegraph before extending (still safe)
SPIKE_DAMAGE = 16
LEVER_REACH = 95 # px: how close the player must be to pull a lever
# Pressure plates auto-activate when the player's hitbox overlaps them.
# Once triggered they stay on (just like levers) and open the matching
# gate panel by reading order.
PLATE_TRIGGER_DELAY = 0.25 # player must stand on it for this long
# --- Persistence ---
# Completed level indices are stored as a JSON list under the user's
# home directory so the save survives a fresh clone.
SAVE_DIR = Path.home() / ".the-way-out"
SAVE_FILE = SAVE_DIR / "save.json"
added static_objects.py
@@ -0,0 +1,112 @@
import pygame
from settings import TILE_SIZE
TS = TILE_SIZE
def _shade(color, d):
"""Lighten (d > 0) or darken (d < 0) a color, clamped to 0-255."""
return tuple(max(0, min(255, c + d)) for c in color)
class TileTextures:
"""Lazily-built, cached 64x64 tile surfaces.
One surface per kind is reused for every tile of that kind, so a
4608x2944 level costs a handful of Surface allocations, not thousands.
Built on first use (after the display exists).
"""
WALL_BASE = (54, 52, 70)
FLOOR_BASE = (32, 31, 44)
_cache = {}
@classmethod
def _build_wall(cls):
s = pygame.Surface((TS, TS)).convert()
s.fill(cls.WALL_BASE)
# Two-brick course with mortar lines for a dungeon-stone read.
mortar = _shade(cls.WALL_BASE, -22)
pygame.draw.line(s, mortar, (0, TS // 2), (TS, TS // 2), 3)
pygame.draw.line(s, mortar, (TS // 2, 0), (TS // 2, TS // 2), 3)
pygame.draw.line(s, mortar, (TS // 4, TS // 2), (TS // 4, TS), 3)
pygame.draw.line(s, mortar, (3 * TS // 4, TS // 2), (3 * TS // 4, TS), 3)
# Bevel: lit top/left, shadowed bottom/right -> blocks pop.
hi = _shade(cls.WALL_BASE, 30)
lo = _shade(cls.WALL_BASE, -34)
pygame.draw.line(s, hi, (0, 0), (TS - 1, 0), 3)
pygame.draw.line(s, hi, (0, 0), (0, TS - 1), 3)
pygame.draw.line(s, lo, (0, TS - 1), (TS - 1, TS - 1), 3)
pygame.draw.line(s, lo, (TS - 1, 0), (TS - 1, TS - 1), 3)
return s
@classmethod
def _build_floor(cls, alt):
base = cls.FLOOR_BASE if not alt else _shade(cls.FLOOR_BASE, 5)
s = pygame.Surface((TS, TS)).convert()
s.fill(base)
pygame.draw.rect(s, _shade(base, -10), (0, 0, TS, TS), 1)
pygame.draw.rect(s, _shade(base, 8), (4, 4, TS - 8, TS - 8), 1)
return s
@classmethod
def get(cls, kind):
if kind not in cls._cache:
if kind == 'wall':
cls._cache[kind] = cls._build_wall()
elif kind == 'floor':
cls._cache[kind] = cls._build_floor(False)
elif kind == 'floor_alt':
cls._cache[kind] = cls._build_floor(True)
else: # unknown -> magenta marker, easy to spot
surf = pygame.Surface((TS, TS)).convert()
surf.fill((120, 40, 120))
cls._cache[kind] = surf
return cls._cache[kind]
class StaticObject(pygame.sprite.Sprite):
pass
class Tile(pygame.sprite.Sprite):
"""A static map cell.
Walls go into the obstacle group for collision; the level pre-renders
the look into one big surface, so every wall shares one cached image
(no per-tile Surface allocation).
"""
def __init__(self, pos, groups, sprite_type, surface=None):
super().__init__(groups)
self.sprite_type = sprite_type
self.image = surface if surface is not None else TileTextures.get(
sprite_type if sprite_type in ('wall', 'floor') else 'wall')
self.rect = self.image.get_rect(topleft=pos)
# Trim the vertical hitbox slightly so corners feel less sticky.
self.hitbox = self.rect.inflate(0, -8)
class Prop(pygame.sprite.Sprite):
"""A tileset furniture/decoration object placed from the map.
The art is bottom-anchored inside its tile (see ``tileset._fit``).
``solid`` props join the obstacle group so the player can't walk
through them — their hitbox is just the lower footprint so you can
still slip past the visual top. Decorations are draw-only.
"""
def __init__(self, pos, image, solid=False, obstacle_group=None):
super().__init__()
self.image = image
self.rect = self.image.get_rect(topleft=pos)
if solid:
self.hitbox = self.rect.inflate(-10, -TILE_SIZE // 2)
self.hitbox.bottom = self.rect.bottom - 4
if obstacle_group is not None:
obstacle_group.add(self)
else:
self.hitbox = self.rect.copy()
added theme.py
@@ -0,0 +1,155 @@
"""Shared minimal/clean UI theme.
One palette, one font cache, and the handful of draw primitives every
screen uses — so ``menu.py`` / ``levels.py`` / ``editor.py`` all look
like the same game (flat dark background, generous whitespace,
restrained hover accent, thin separators instead of boxes).
Centralising this keeps exactly one copy of every RGB tuple and of the
title / back-hint / hover primitives, so the screens cannot drift apart.
"""
import random
import pygame
from settings import FONT
# --- palette ---------------------------------------------------------
BG = (18, 18, 24) # every screen fills this
INK = (214, 216, 226) # primary text
MUTED = (120, 122, 138) # captions, hints, secondary text
ACCENT = (240, 208, 120) # hover / focus / bar fill
TITLE_C = (245, 240, 215) # screen + game title
DONE_C = (140, 230, 170) # completed level
SEL_C = (120, 220, 150) # active selection
LINE_C = (52, 54, 66) # separators, bar tracks
SUCCESS = (120, 220, 150) # win / level complete
FAIL = (220, 96, 96) # lose / defeat / danger
# One glyph for every "·" hint separator / bullet across the game.
HINT_DOT = "·"
def shade(color, delta):
"""A panel tint derived from another palette colour (used for the
editor's canvas / palette / toolbar splits so they stay a family
of ``BG`` instead of independent tuples)."""
return tuple(max(0, min(255, c + delta)) for c in color)
# --- fonts -----------------------------------------------------------
# A small ladder of conventional sizes so screens don't drift wildly.
# Callers pass the raw px to ``font(N)``; the cache means the same N
# is one Font object, not one per frame. The ladder is documentation,
# not enforced — pick the closest tier when adding a new caller.
#
# DISPLAY ~ 96 (level intro 'big' text)
# TITLE = 72 (game / pause / end-screen title)
# HEADING = 44 (subscreen title)
# BODY = 28 (button label, HUD label)
# CAPTION = 22 (tag, hint, secondary line)
FONT_TITLE = 72
FONT_HEADING = 44
FONT_BODY = 28
FONT_CAPTION = 22
_font_cache = {}
def font(px):
"""Cached ``pygame.font.Font`` for the shared game font at ``px``."""
f = _font_cache.get(px)
if f is None:
f = pygame.font.Font(FONT, px)
_font_cache[px] = f
return f
# --- helpers ---------------------------------------------------------
def measure(font_, text):
"""Size a string for a layout-only placement that is never blitted."""
return pygame.Rect((0, 0), font_.size(text))
def draw_title(screen, font_, text, width, y=96):
"""Screen title in caps with a thin centred underline."""
surf = font_.render(text.upper(), True, TITLE_C)
rect = surf.get_rect(center=(width // 2, y))
screen.blit(surf, rect)
ly = rect.bottom + 16
pygame.draw.line(screen, LINE_C,
(width // 2 - 170, ly), (width // 2 + 170, ly), 2)
def draw_back_hint(screen, font_):
surf = font_.render("ESC BACK", True, MUTED)
screen.blit(surf, surf.get_rect(topleft=(48, 44)))
def hover_marker(screen, rect):
"""Small accent square left of a hovered list item — font-
independent, so it works with the pixel font."""
cy = rect.centery
pygame.draw.rect(screen, ACCENT,
pygame.Rect(rect.left - 34, cy - 5, 10, 10))
def draw_bar(screen, rect, ratio, color, *, border=True):
"""Themed rectangular meter shared by every HUD/stat bar.
The track is always ``LINE_C``; the fill colour is the caller's
choice (``ACCENT`` for HP/dash, etc.). With ``border=True`` the bar
gets the HUD's beveled backplate + rim; ``False`` is the flat
inline version for compact lists like the character stat block."""
ratio = max(0.0, min(1.0, ratio))
if border:
pygame.draw.rect(screen, shade(BG, -10),
rect.inflate(8, 8), border_radius=6)
pygame.draw.rect(screen, LINE_C, rect,
border_radius=4 if border else 0)
if ratio > 0:
fill_rect = pygame.Rect(rect.left, rect.top,
int(rect.width * ratio), rect.height)
pygame.draw.rect(screen, color, fill_rect,
border_radius=4 if border else 0)
if border:
pygame.draw.rect(screen, shade(BG, -6), rect, 2,
border_radius=4)
class PixelDust:
"""Slow upward-drifting pixel particles for idle backgrounds.
Shared by every menu so they all carry the same motion: pass a
different seed for a different particle layout, same look (three
BG-derived tints, capped speed, deterministic layout per seed)."""
def __init__(self, width, height, seed=7, count=60):
self.width = width
self.height = height
self._rng = random.Random(seed)
self._last_ms = None
shades = [shade(BG, +20), shade(BG, +34), shade(BG, +52)]
self.particles = []
for _ in range(count):
self.particles.append({
"x": self._rng.uniform(0, width),
"y": self._rng.uniform(0, height),
"vy": self._rng.uniform(8, 26),
"s": self._rng.choice((2, 2, 3, 4)),
"c": self._rng.choice(shades),
})
def draw(self, screen):
now = pygame.time.get_ticks()
if self._last_ms is None:
self._last_ms = now
dt = min(0.1, (now - self._last_ms) / 1000.0)
self._last_ms = now
for p in self.particles:
p["y"] -= p["vy"] * dt
if p["y"] < -4:
p["y"] = self.height + 4
p["x"] = self._rng.uniform(0, self.width)
pygame.draw.rect(screen, p["c"],
(int(p["x"]), int(p["y"]), p["s"], p["s"]))
added tiles.py
@@ -0,0 +1,151 @@
"""The level tile vocabulary — one source of truth.
Every character that can appear in a level's ``.txt`` has a
:class:`TileSpec` here. ``levels.py`` reads it for prop dispatch at load
time, and ``editor.py`` reads the same registry to build its palette.
So adding a new tile is one entry here (and at most one branch in
``load_level``), never a triple-edit across files.
Category vocabulary used by the editor's palette grouping:
* ``terrain`` — wall, floor: structural cells
* ``special`` — player spawn, exit: singletons the level needs exactly
one of
* ``hazard`` — spikes, levers, plates, gates, key: escape-room
interactables
* ``enemy`` — boss (and any future enemies)
* ``prop`` — tileset furniture/decor (with variants)
"""
from dataclasses import dataclass
from typing import Optional
import tileset
from units import ENEMY_INFO
@dataclass(frozen=True)
class TileSpec:
"""Metadata for one map character.
``tileset_category`` is non-``None`` when the tile draws from the
art tileset — ``solid`` and ``variant_count`` then mirror
``tileset.CATEGORIES``. Otherwise the runtime renders the tile
procedurally (walls, spikes, levers, ...).
"""
char: str
label: str
category: str
description: str
solid: bool = False
variant_count: int = 1
tileset_category: Optional[str] = None
# Prop letters → tileset category. The only duplication left between
# this module and ``tileset.CATEGORIES``; everything else (variant count,
# solid flag) is read from tileset at registry build time.
_PROP_MAPPING = {
'T': ('torch', "Torch"),
'C': ('chair', "Chair"),
'A': ('table', "Table"),
'E': ('shelf', "Bookshelf"),
'D': ('decor', "Bookshelf decor"),
'O': ('box', "Box / crate"),
'R': ('rubble', "Rubble"),
'M': ('misc', "Misc clutter"),
'Z': ('door', "Door"),
'J': ('trapdoor', "Trapdoor"),
'H': ('chest', "Chest"),
'F': ('fire', "Fire"),
}
def _build_registry():
reg = {}
# --- terrain --------------------------------------------------------
reg['W'] = TileSpec(
'W', "Wall", 'terrain', "Solid wall, blocks movement",
solid=True)
reg['.'] = TileSpec(
'.', "Floor", 'terrain', "Walkable cell (default)")
# --- special (singletons) -------------------------------------------
reg['P'] = TileSpec(
'P', "Player start", 'special',
"Where the chosen character spawns. Use exactly one.")
reg['X'] = TileSpec(
'X', "Exit", 'special',
"The way out — opens after boss/key conditions are met.")
# --- hazards / puzzles ----------------------------------------------
reg['S'] = TileSpec(
'S', "Spikes", 'hazard',
"Timed trap (safe → warning → deadly cycle).")
# L/Y/G carry an optional pair id in their trailing digit. The
# variant_count is what the editor wheel cycles: 1 = pair by
# reading order (writes a bare token), 2..9 = explicitly pair the
# trigger with the gate of the same number.
reg['L'] = TileSpec(
'L', "Lever", 'hazard',
"Pull with E. Wheel: 1 = pair by order, 2-9 = pair with the "
"gate of that number.",
variant_count=9)
reg['Y'] = TileSpec(
'Y', "Pressure plate", 'hazard',
"Stand on ~0.25 s. Wheel: 1 = pair by order, 2-9 = pair with "
"the gate of that number.",
variant_count=9)
reg['G'] = TileSpec(
'G', "Gate", 'hazard',
"Solid until its trigger fires; adjacent G = one panel. Wheel: "
"1 = pair by order, 2-9 = pair id.",
solid=True, variant_count=9)
reg['K'] = TileSpec(
'K', "Key", 'hazard',
"Walk over to pick up; required before the exit opens.")
# --- enemies --------------------------------------------------------
reg['B'] = TileSpec(
'B', "Boss (Mr. Green)", 'enemy',
"Spawns lazily the first time the player enters its arena.")
# Generic enemies are derived from units.ENEMY_INFO so adding one
# there makes it appear in the editor palette automatically.
for ch, _cls, label in ENEMY_INFO:
reg[ch] = TileSpec(
ch, label, 'enemy',
"Roaming enemy — chases the player and deals contact "
"damage. Spawns at once; does not block the exit.")
# --- tileset props --------------------------------------------------
for ch, (cat, label) in _PROP_MAPPING.items():
meta = tileset.CATEGORIES.get(cat)
if meta is None:
continue
_folder, _pattern, count, solid = meta
reg[ch] = TileSpec(
ch, label, 'prop',
f"{label} ({count} variant{'s' if count > 1 else ''}).",
solid=solid, variant_count=count, tileset_category=cat)
return reg
REGISTRY = _build_registry()
# Palette category order used by the editor. REGISTRY insertion order
# is preserved within each category (CPython dicts are ordered).
PALETTE_CATEGORIES = ('terrain', 'special', 'hazard', 'enemy', 'prop')
# Back-compat shim for ``levels.py``: char -> tileset category, for the
# prop branch of the level-loading switch. Derived so editing REGISTRY
# is the only place to add a prop letter.
PROP_CHARS = {ch: spec.tileset_category
for ch, spec in REGISTRY.items()
if spec.tileset_category is not None}
def chars_for(category):
"""All characters in one palette category, in REGISTRY order."""
return [ch for ch, spec in REGISTRY.items() if spec.category == category]
added tileset.py
@@ -0,0 +1,133 @@
"""Tileset asset loader.
Every floor/wall tile and every furniture/decoration object the level
text files can place comes from ``assets/tileset/`` through here. The
art is tiny pixel-art (16x16 tiles, sub-tile props); this module scales
each piece up to one ``TILE_SIZE`` cell, caches the result, and hands
back a ready-to-blit surface. One surface per (category, variant) is
reused for every placement, mirroring the cheap-allocation approach in
``static_objects.TileTextures``.
Built lazily on first use, so it is safe to import before the pygame
display exists (the actual loads happen during ``load_level``).
"""
import os
import pygame
from settings import TILE_SIZE
TS = TILE_SIZE
_BASE = os.path.join("assets", "tileset")
# --- baked floor / wall layer -------------------------------------------
# Which Tile_XX.png the level's baked map surface uses. These are the
# only two values to tweak to restyle the dungeon floor/walls. To see
# the choices, open assets/tileset/tiles/ (or the full sheet at
# assets/tileset/Tileset.png / Palette.png) and put the file's number
# here. If a name fails to load the level falls back to the old
# procedural stone look automatically, so a bad value is harmless.
FLOOR_TILE = "Tile_42"
WALL_TILE = "Tile_03"
# --- placeable objects --------------------------------------------------
# map letter -> (folder under assets/tileset, filename pattern with
# "{n}" for the variant, variant count, solid?). ``solid`` props join
# the obstacle group (you can't walk through them); the rest are pure
# decoration drawn under the player. The letter -> category mapping
# lives in levels.py (PROP_CHARS); keep the two in sync with LEGEND.md.
CATEGORIES = {
"torch": ("static_objects/torches", "{n}.png", 8, False),
"chair": ("static_objects/chairs", "{n}.png", 14, False),
"table": ("static_objects/tables", "{n}.png", 8, True),
"shelf": ("static_objects/bookshelf", "{n}.png", 12, True),
"decor": ("static_objects/bookshelf_decor", "{n}.png", 40, False),
"box": ("static_objects/boxes", "{n}.png", 16, True),
"rubble": ("static_objects/blockage", "{n}.png", 8, True),
"misc": ("static_objects/other", "{n}.png", 44, False),
"door": ("static_objects/doors", "{n}.png", 4, True),
"trapdoor": ("static_objects/trapdoors", "{n}.png", 6, False),
"chest": ("interactables", "Chest{n}_S.png", 2, True),
"fire": ("interactables", "Fire1.png", 1, False),
}
_cache = {}
def _placeholder():
"""Magenta block for a missing/unknown asset — loud on purpose."""
if "__ph__" not in _cache:
s = pygame.Surface((TS, TS), pygame.SRCALPHA)
s.fill((180, 40, 160))
pygame.draw.rect(s, (0, 0, 0), s.get_rect(), 2)
_cache["__ph__"] = s
return _cache["__ph__"]
def _fit(surf):
"""Scale a small pixel-art sprite up to sit in one tile, keeping
aspect ratio (nearest-neighbour, anchored bottom-centre so the
object visually rests on the floor)."""
w, h = surf.get_size()
scale = TS / max(w, h)
nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
img = pygame.transform.scale(surf, (nw, nh))
cell = pygame.Surface((TS, TS), pygame.SRCALPHA)
cell.blit(img, ((TS - nw) // 2, TS - nh))
return cell
def tile(name):
"""A floor/wall tile scaled to exactly one cell, or None if the
named PNG is missing (caller then uses the procedural fallback)."""
key = ("tile", name)
if key not in _cache:
path = os.path.join(_BASE, "tiles", name + ".png")
try:
s = pygame.image.load(path).convert_alpha()
_cache[key] = pygame.transform.scale(s, (TS, TS))
except (pygame.error, FileNotFoundError):
_cache[key] = None
return _cache[key]
def is_solid(category):
cat = CATEGORIES.get(category)
return bool(cat and cat[3])
def variant_count(category):
cat = CATEGORIES.get(category)
return cat[2] if cat else 0
def sprite(category, variant=1):
"""One tile-sized surface for ``category``/``variant`` (1-based).
Out-of-range or non-numeric variants clamp to 1. Unknown category
or a missing file yields the magenta placeholder."""
cat = CATEGORIES.get(category)
if cat is None:
return _placeholder()
sub, pattern, count, _solid = cat
n = variant if isinstance(variant, int) and 1 <= variant <= count else 1
key = (category, n)
if key in _cache:
return _cache[key]
path = os.path.join(_BASE, sub, pattern.format(n=n))
try:
raw = pygame.image.load(path).convert_alpha()
if category == "fire":
# Fire1.png is a horizontal animation strip; the first
# square frame is enough for a static torch-fire decoration.
h = raw.get_height()
raw = raw.subsurface((0, 0, h, h)).copy()
img = _fit(raw)
except (pygame.error, FileNotFoundError, ValueError):
img = _placeholder()
_cache[key] = img
return img
added units.py
@@ -0,0 +1,723 @@
import random
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,
HIT_FLASH_TIME,
)
# Unit vector for each facing direction (used as aim fallback when no
# explicit aim is set and for the boss's targeting).
FACING_VECTORS = {
'down': (0, 1),
'up': (0, -1),
'left': (-1, 0),
'right': (1, 0),
}
class Character(pygame.sprite.Sprite):
"""Base class for all units (player and AI).
Subclasses set ``asset_folder`` plus, optionally, the tuning class
attributes below; all loading, animation, movement, collision and
health behaviour is shared. The player is keyboard-driven via
``get_input``; enemies override it with AI.
"""
asset_folder = None
# Tuning — overridable per subclass.
scale = PLAYER_SCALE
speed = PLAYER_SPEED
hitbox_size = PLAYER_HITBOX_SIZE
max_hp = PLAYER_MAX_HP
attack_damage = PROJECTILE_DAMAGE
attack_cooldown = ATTACK_COOLDOWN
# name -> frame count in the sprite sheet
SPRITE_SHEETS = {
'idle_down': ('D_Idle', 4),
'walk_down': ('D_Walk', 6),
'idle_up': ('U_Idle', 4),
'walk_up': ('U_Walk', 6),
'idle_left': ('S_Idle', 4),
'walk_left': ('S_Walk', 6),
}
def __init__(self, x, y, obstacle_sprites=None):
super().__init__()
if self.asset_folder is None:
raise ValueError(
f"{type(self).__name__} must define an 'asset_folder'")
self.facing = 'down'
self.load_assets()
self.status = 'idle_down'
self.frame_index = 0
self.animation_speed = 10
self.image = self.animations[self.status][self.frame_index]
self.rect = self.image.get_rect(topleft=(x, y))
# Smaller collision box, centered on the sprite. Walls are
# checked against this, not the (mostly transparent) image rect.
self.hitbox = self.rect.inflate(
self.hitbox_size - self.rect.width,
self.hitbox_size - self.rect.height)
# Sprites this unit cannot walk through. None -> no collision.
self.obstacle_sprites = obstacle_sprites
self.pos = pygame.math.Vector2(x, y)
self.direction = pygame.math.Vector2()
# Health / combat
self.hp = self.max_hp
self.invuln_timer = 0.0
self.attack_timer = 0.0
self.hit_flash_timer = 0.0
# Boss telegraphs are driven by ``tint_color``: an (r,g,b,a) tuple
# the base ``animate`` will additively overlay onto this frame's
# opaque pixels (so transparent padding stays transparent).
self.tint_color = None
# Dash state — the player gets a Shift burst with i-frames and
# a cooldown; enemies don't dash through this path (Boss has its
# own state-machine dash).
self.dash_timer = 0.0 # seconds remaining of active dash
self.dash_cooldown_timer = 0.0 # seconds until dash can be used
self.speed_mult = 1.0 # >1 during dash
self._dash_dir = pygame.math.Vector2()
# Set by the level for the player only; enemies leave these None
# unless the level wires them up (boss phase 2 needs them too).
self.projectile_group = None
self.projectile_targets = None
def load_assets(self):
path = f"assets/units/{self.asset_folder}/"
def import_frames(name, frame_count):
img_path = f"{path}{name}.png"
try:
sheet = pygame.image.load(img_path).convert_alpha()
except (pygame.error, FileNotFoundError):
print(f"{img_path} not found.")
return [] # crash safety
frames = []
width = sheet.get_width() // frame_count
height = sheet.get_height()
for i in range(frame_count):
rect = pygame.Rect(i * width, 0, width, height)
surf = sheet.subsurface(rect)
scaled = pygame.transform.scale(
surf, (width * self.scale, height * self.scale))
frames.append(scaled)
return frames
self.animations = {
status: import_frames(name, count)
for status, (name, count) in self.SPRITE_SHEETS.items()
}
# Right-facing frames are mirrored left-facing frames.
self.animations['idle_right'] = [
pygame.transform.flip(img, True, False)
for img in self.animations['idle_left']
]
self.animations['walk_right'] = [
pygame.transform.flip(img, True, False)
for img in self.animations['walk_left']
]
# --- input / movement -------------------------------------------
def get_status(self):
if self.direction.magnitude() != 0:
if abs(self.direction.x) > abs(self.direction.y):
self.facing = 'right' if self.direction.x > 0 else 'left'
else:
self.facing = 'down' if self.direction.y > 0 else 'up'
self.status = f'walk_{self.facing}'
else:
self.status = f'idle_{self.facing}'
def get_input(self):
# Belt-and-braces for the focus-loss hitch: while the window is
# unfocused SDL keeps reporting the last-held keys, so treat
# "unfocused" as "no input" even if the pause event raced us.
if not pygame.key.get_focused():
self.direction.update(0, 0)
return
keys = pygame.key.get_pressed()
right = keys[pygame.K_RIGHT] or keys[pygame.K_d]
left = keys[pygame.K_LEFT] or keys[pygame.K_a]
down = keys[pygame.K_DOWN] or keys[pygame.K_s]
up = keys[pygame.K_UP] or keys[pygame.K_w]
self.direction.x = int(right) - int(left)
self.direction.y = int(down) - int(up)
if self.direction.magnitude() != 0:
self.direction = self.direction.normalize()
def move(self, dt):
speed = self.speed * self.speed_mult
# Move and resolve collisions one axis at a time so the unit
# slides along walls instead of getting stuck on them.
self.pos.x += self.direction.x * speed * dt
self.rect.x = round(self.pos.x)
self.hitbox.centerx = self.rect.centerx
self.collide('horizontal')
self.pos.y += self.direction.y * speed * dt
self.rect.y = round(self.pos.y)
self.hitbox.centery = self.rect.centery
self.collide('vertical')
def collide(self, direction):
if self.obstacle_sprites is None:
return
for sprite in self.obstacle_sprites:
if not sprite.hitbox.colliderect(self.hitbox):
continue
if direction == 'horizontal':
if self.direction.x > 0: # moving right
self.hitbox.right = sprite.hitbox.left
elif self.direction.x < 0: # moving left
self.hitbox.left = sprite.hitbox.right
self.rect.centerx = self.hitbox.centerx
self.pos.x = self.rect.x
else:
if self.direction.y > 0: # moving down
self.hitbox.bottom = sprite.hitbox.top
elif self.direction.y < 0: # moving up
self.hitbox.top = sprite.hitbox.bottom
self.rect.centery = self.hitbox.centery
self.pos.y = self.rect.y
def _toward_target(self, min_len=0):
"""Unit vector from this unit to ``self.target`` (set by the AI
subclasses — Boss/Enemy), or zero when closer than
``min_len``."""
to = (pygame.math.Vector2(self.target.hitbox.center)
- pygame.math.Vector2(self.hitbox.center))
if to.length() > max(1, min_len):
return to.normalize()
return pygame.math.Vector2(0, 0)
# --- damage / feedback ------------------------------------------
def take_damage(self, amount):
"""Subtract HP. Each hit always lands (no i-frames here).
Contact-spam protection for the player lives in the level, which
gates repeated hits with ``invuln_timer`` — projectiles, by
contrast, should damage the boss on every shot.
"""
if self.hp <= 0:
return False
self.hp = max(0, self.hp - amount)
# White flash on hit so the damage reads instantly even on the
# boss's huge sprite.
self.hit_flash_timer = HIT_FLASH_TIME
audio.play("hit")
return True
# --- combat -----------------------------------------------------
def handle_attack(self, dt):
"""Player only: fire a projectile in the facing direction on
Space or left-click.
Aim follows the way the character is looking (the 4 facings) —
there is deliberately no free mouse aim; the cursor doesn't
steer shots.
"""
if self.projectile_group is None:
return
self.attack_timer = max(0.0, self.attack_timer - dt)
keys = pygame.key.get_pressed()
mouse_left = pygame.mouse.get_pressed()[0]
if (keys[pygame.K_SPACE] or mouse_left) and self.attack_timer <= 0:
aim = pygame.math.Vector2(*FACING_VECTORS[self.facing])
spawn = pygame.math.Vector2(self.hitbox.center)
spawn += aim * (self.hitbox_size / 2 + 8)
Projectile(
spawn, aim,
self.obstacle_sprites, self.projectile_targets,
[self.projectile_group],
damage=self.attack_damage)
audio.play("shoot")
self.attack_timer = self.attack_cooldown
def handle_dash(self, dt):
"""Shift triggers a short, i-framed speed burst.
Direction is locked at dash start (current input dir, falling
back to facing) so a panic dash always commits somewhere
sensible. Only the player goes through this — the Boss has its
own dash state.
"""
# Tick timers first. Cooldown only counts once the dash itself
# has ended.
if self.dash_timer > 0:
self.dash_timer = max(0.0, self.dash_timer - dt)
if self.dash_timer == 0:
self.speed_mult = 1.0
self.dash_cooldown_timer = DASH_COOLDOWN
# A whisker of i-frames after landing helps you cross
# contact-damage windows with frame-perfect dashes.
self.invuln_timer = max(self.invuln_timer,
DASH_INVULN_BONUS)
else:
# Lock direction during dash so mid-burst input can't
# change course.
self.direction.update(self._dash_dir)
elif self.dash_cooldown_timer > 0:
self.dash_cooldown_timer = max(
0.0, self.dash_cooldown_timer - dt)
if self.projectile_group is None:
return # only the player dashes via input
keys = pygame.key.get_pressed()
shift = keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT]
if (shift and self.dash_timer == 0
and self.dash_cooldown_timer == 0):
d = self.direction if self.direction.magnitude() != 0 \
else pygame.math.Vector2(*FACING_VECTORS[self.facing])
self._dash_dir = d.normalize()
self.direction.update(self._dash_dir)
self.dash_timer = DASH_DURATION
self.speed_mult = DASH_SPEED_MULT
self.invuln_timer = max(self.invuln_timer, DASH_DURATION)
audio.play("dash")
# --- animation / tick -------------------------------------------
def _apply_overlay(self, frame, color_rgba):
"""Additive RGB overlay that respects the sprite's alpha mask.
``BLEND_RGB_ADD`` adds the overlay's RGB to opaque destination
pixels without touching the destination alpha, so a hit flash on
a transparent-padded sprite stays a flash on the *character* and
doesn't fill the bounding box with white.
"""
if frame.get_flags() & pygame.SRCALPHA == 0:
frame = frame.copy()
else:
frame = frame.copy()
intensity = pygame.Surface(frame.get_size(), pygame.SRCALPHA)
intensity.fill(color_rgba)
frame.blit(intensity, (0, 0),
special_flags=pygame.BLEND_RGB_ADD)
return frame
def animate(self, dt):
current_animation = self.animations[self.status]
if not current_animation:
return
self.frame_index += self.animation_speed * dt
self.frame_index %= len(current_animation)
frame = current_animation[int(self.frame_index)]
# Flicker while invulnerable so hits read clearly.
if self.invuln_timer > 0 and int(self.invuln_timer * 20) % 2 == 0:
frame = frame.copy()
frame.set_alpha(90)
# Boss telegraph tint (red windup / gold aim) — applied before
# the brighter hit flash so a hit during windup still pops.
if self.tint_color is not None:
frame = self._apply_overlay(frame, self.tint_color)
if self.hit_flash_timer > 0:
v = int(180 * (self.hit_flash_timer / HIT_FLASH_TIME))
frame = self._apply_overlay(frame, (v, v, v, 255))
self.image = frame
def update(self, dt):
if self.invuln_timer > 0:
self.invuln_timer -= dt
if self.hit_flash_timer > 0:
self.hit_flash_timer = max(0.0, self.hit_flash_timer - dt)
self.get_input()
self.handle_dash(dt)
self.get_status()
self.move(dt)
self.animate(dt)
self.handle_attack(dt)
# --- player-character variants -----------------------------------------
# Each character is mechanically distinct (not just a skin).
# The blurbs in CHARACTER_INFO are what the menu shows; keep them in
# sync if you tune the numbers.
class Wizard(Character):
"""Balanced reference character."""
asset_folder = "wizard"
# defaults: HP 100, spd 600, cd 0.35, dmg 10
class Penguin(Character):
"""Tank — slower but takes more punishment and hits a bit harder."""
asset_folder = "penguin"
max_hp = 140
speed = 480
attack_damage = 12
attack_cooldown = 0.45
class Elf(Character):
"""Archer — rapid-fire, lower damage per shot, fragile."""
asset_folder = "elf"
max_hp = 90
speed = 580
attack_damage = 7
attack_cooldown = 0.20
class Shiggy(Character):
"""Glass cannon — biggest hit, smallest health pool."""
asset_folder = "shiggy"
max_hp = 70
speed = 620
attack_damage = 20
attack_cooldown = 0.40
class Wolf(Character):
"""Scout — very fast, modest combat stats."""
asset_folder = "wolf"
max_hp = 85
speed = 760
attack_damage = 9
attack_cooldown = 0.40
# Catalogue used by the character menu to display stats and by
# levels.py to instantiate the chosen class. Order = menu order.
CHARACTER_INFO = [
("c_wiz", Wizard, "Wizard", "Balanced"),
("c_peng", Penguin, "Penguin", "Tank"),
("c_elf", Elf, "Elf", "Rapid-fire"),
("c_shig", Shiggy, "Shiggy", "Glass cannon"),
("c_wolf", Wolf, "Wolf", "Speedster"),
]
# --- boss --------------------------------------------------------------
class Boss(Character):
"""AI enemy with a small state-machine fighting style.
Phase 1 (HP above ``BOSS_PHASE2_HP_RATIO``): chase, then telegraphed
dash attack. Phase 2 (below): also fires a 3-shot spread between
chases. Each new state picks itself the moment the previous timer
runs out, so the rhythm is readable and you can dodge by pattern.
"""
asset_folder = "mrgreen"
scale = BOSS_SCALE
speed = BOSS_SPEED
hitbox_size = BOSS_HITBOX_SIZE
max_hp = BOSS_MAX_HP
def __init__(self, x, y, obstacle_sprites=None, target=None,
projectile_group=None, projectile_targets=None):
super().__init__(x, y, obstacle_sprites)
self.target = target
# The level wires these so phase 2 can spawn boss projectiles
# that hurt only the player (not other enemies / the boss itself).
self.projectile_group = projectile_group
self.projectile_targets = projectile_targets
self.state = 'chase'
self.state_timer = random.uniform(
BOSS_CHASE_TIME_MIN, BOSS_CHASE_TIME_MAX)
self.dash_dir = pygame.math.Vector2(0, 1)
def get_input(self):
if self.target is None or self.target.hp <= 0 or self.hp <= 0:
self.direction.update(0, 0)
return
if self.state == 'chase':
self.direction = self._toward_target(min_len=6)
elif self.state == 'dash':
self.direction.update(self.dash_dir)
else: # windup / aim / recover / shoot — hold still
self.direction.update(0, 0)
def handle_attack(self, dt):
# Boss doesn't fire via input; ranged volleys are scheduled by
# the state machine in :meth:`update`.
return
def handle_dash(self, dt):
# Boss dash is a state, not a button — skip the player path.
return
def _in_phase2(self):
return self.hp <= self.max_hp * BOSS_PHASE2_HP_RATIO
def _enter(self, state):
self.state = state
if state == 'chase':
self.state_timer = random.uniform(
BOSS_CHASE_TIME_MIN, BOSS_CHASE_TIME_MAX)
self.speed_mult = 1.0
elif state == 'windup':
# Lock in the dash direction at the *start* of the windup so
# the player has the full telegraph to read it.
self.dash_dir = self._toward_target()
if self.dash_dir.length() == 0:
self.dash_dir.update(0, 1)
self.state_timer = BOSS_WINDUP_TIME
self.speed_mult = 0.0
elif state == 'dash':
self.state_timer = BOSS_DASH_TIME
self.speed_mult = BOSS_DASH_SPEED_MULT
elif state == 'recover':
self.state_timer = BOSS_RECOVER_TIME
self.speed_mult = 0.0
elif state == 'aim':
self.state_timer = BOSS_AIM_TIME
self.speed_mult = 0.0
elif state == 'shoot':
# Shoot has zero duration: we fire on the next FSM tick and
# immediately transition to recover.
self.state_timer = 0.0
self.speed_mult = 0.0
def _fire_volley(self):
"""3-shot spread aimed at the player. Reuses :class:`Projectile`."""
if (self.target is None or self.projectile_group is None
or self.projectile_targets is None):
return
base = self._toward_target()
if base.length() == 0:
return
spawn = pygame.math.Vector2(self.hitbox.center)
for deg in (-12, 0, 12):
v = base.rotate(deg)
Projectile(
spawn + v * (self.hitbox_size / 2 + 12), v,
self.obstacle_sprites, self.projectile_targets,
[self.projectile_group],
damage=BOSS_PROJECTILE_DAMAGE,
speed=BOSS_PROJECTILE_SPEED,
color=(255, 130, 70))
def _update_tint(self):
"""Red ramp during windup, gold ramp during aim — the colour
cue tells the player which attack is coming."""
if self.state == 'windup':
ramp = 1.0 - (self.state_timer / BOSS_WINDUP_TIME)
ramp = max(0.0, min(1.0, ramp))
self.tint_color = (140, 30, 30, int(40 + 80 * ramp))
elif self.state == 'aim':
ramp = 1.0 - (self.state_timer / BOSS_AIM_TIME)
ramp = max(0.0, min(1.0, ramp))
self.tint_color = (140, 110, 30, int(40 + 80 * ramp))
else:
self.tint_color = None
def update(self, dt):
# Tick the FSM first so this frame's direction / speed reflect
# the current state.
if self.hp > 0 and self.target is not None and self.target.hp > 0:
self.state_timer = max(0.0, self.state_timer - dt)
if self.state_timer == 0.0:
self._advance_state()
self._update_tint()
if self.invuln_timer > 0:
self.invuln_timer -= dt
if self.hit_flash_timer > 0:
self.hit_flash_timer = max(0.0, self.hit_flash_timer - dt)
self.get_input()
self.get_status()
prev = self.rect.topleft
self.move(dt)
# A dash that slammed into a wall ends early — scraping along
# the wall for the rest of the dash feels broken.
if self.state == 'dash' and self.rect.topleft == prev:
self._enter('recover')
self.animate(dt)
# Boss has no handle_attack — see _fire_volley instead.
def _advance_state(self):
"""Pick the next state. The boss alternates chase with one of
its attacks; phase 2 unlocks ranged volleys."""
if self.state == 'chase':
# In phase 2, ~50% of the attacks are ranged volleys.
if self._in_phase2() and random.random() < 0.5:
self._enter('aim')
else:
self._enter('windup')
elif self.state == 'windup':
self._enter('dash')
elif self.state == 'dash':
self._enter('recover')
elif self.state == 'recover':
self._enter('chase')
elif self.state == 'aim':
self._enter('shoot')
elif self.state == 'shoot':
self._fire_volley()
self._enter('recover')
# --- enemies -----------------------------------------------------------
# Generic placeable threats (a token char in the level text), as
# opposed to the single scripted Boss. Adding one is a class + a line
# in ENEMY_INFO + a sprite folder; tiles.REGISTRY and the editor
# palette pick it up from ENEMY_INFO automatically.
class Enemy(Character):
"""Plain chaser: walks straight at the player and relies on contact
damage (applied by the level, like the boss touch).
No FSM, no ranged attack, no dash — unlike :class:`Boss` it can be
dropped anywhere and spawns immediately rather than lazily, and it
does **not** gate the exit (only the boss/key do)."""
scale = 6
speed = 300
max_hp = 45
hitbox_size = 70
touch_damage = 12
def __init__(self, x, y, obstacle_sprites=None, target=None):
super().__init__(x, y, obstacle_sprites)
self.target = target
def get_input(self):
if self.target is None or self.target.hp <= 0 or self.hp <= 0:
self.direction.update(0, 0)
return
self.direction = self._toward_target(min_len=4)
def handle_attack(self, dt):
return # contact-only
def handle_dash(self, dt):
return # no dash
class Orange(Enemy):
"""The orange blob — the basic roaming enemy."""
asset_folder = "orange"
# Catalogue parallel to CHARACTER_INFO: (level token char, class,
# label). One line here + a sprite folder = a new placeable enemy.
ENEMY_INFO = [
("N", Orange, "Chaser"),
]
# --- projectile --------------------------------------------------------
class Projectile(pygame.sprite.Sprite):
"""A simple orb that flies straight, hurts its targets and dies on walls.
The colour and speed can be customised so the boss's volley reads
visually different from the player's shots.
"""
def __init__(self, pos, direction, obstacle_sprites, targets, groups,
damage=PROJECTILE_DAMAGE, color=(120, 230, 255),
speed=PROJECTILE_SPEED):
super().__init__(groups)
self.obstacle_sprites = obstacle_sprites
self.targets = targets
self.damage = damage
self.speed = speed
r = PROJECTILE_RADIUS
size = (r + 4) * 2
self.image = pygame.Surface((size, size), pygame.SRCALPHA)
# Soft outer halo.
for i in range(3):
pygame.draw.circle(
self.image, (*color, 70 - i * 20),
(size // 2, size // 2), r + 3 - i)
pygame.draw.circle(self.image, color,
(size // 2, size // 2), r)
pygame.draw.circle(self.image, (255, 255, 255),
(size // 2, size // 2), r // 2)
self.rect = self.image.get_rect(center=(int(pos.x), int(pos.y)))
# Smaller hitbox than the visual halo so glancing shots feel fair.
self.hitbox = self.rect.inflate(-8, -8)
self.pos = pygame.math.Vector2(pos)
self.direction = pygame.math.Vector2(direction)
if self.direction.magnitude() != 0:
self.direction = self.direction.normalize()
self.life = PROJECTILE_LIFETIME
def update(self, dt):
self.life -= dt
if self.life <= 0:
self.kill()
return
# Substep so a fast shot can't tunnel a thin wall or a small
# target in one tick: the per-tick move at 950 px/s under the
# dt cap can exceed two projectile hitboxes.
distance = self.speed * dt
max_step = max(1.0, min(self.hitbox.width, self.hitbox.height) * 0.5)
steps = 1 if distance <= max_step else int(distance / max_step) + 1
step_vec = self.direction * (distance / steps)
for _ in range(steps):
self.pos += step_vec
self.rect.center = (round(self.pos.x), round(self.pos.y))
self.hitbox.center = self.rect.center
# Targets first: a shot at an enemy pressed flush against a
# wall is still credited before the wall kills the orb.
if self.targets is not None:
for target in self.targets:
if target.hitbox.colliderect(self.hitbox):
# Honour i-frames so a single tick of overlap
# from a spread shot can't burn the player's
# whole bar.
if getattr(target, 'invuln_timer', 0) > 0:
continue
target.take_damage(self.damage)
self.kill()
return
if self.obstacle_sprites is not None:
for wall in self.obstacle_sprites:
if wall.hitbox.colliderect(self.hitbox):
self.kill()
return
added updater.py
@@ -0,0 +1,237 @@
"""Self-update engine for The Way Out.
The packaged macOS app is only a thin launcher: the actual game code
lives *outside* the frozen bundle in ``~/.the-way-out/app/`` and is
refreshed straight from GitHub's ``main`` branch. That keeps the
"author pushes, player gets it" workflow without ever rebuilding the
.app.
Pure standard library on purpose — this module is itself part of the
auto-updated payload, so it can evolve, but it must never require a
``pip install`` on the player's machine.
Hard safety rule: this module only ever writes inside ``ROOT`` and
specifically swaps ``app/``. The save game lives at
``~/.the-way-out/save.json`` (a *sibling* of ``app/``) and is never
touched, so updating cannot wipe progress.
"""
import io
import json
import os
import shutil
import socket
import tempfile
import time
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
REPO = "ajhahnde/the-way-out"
BRANCH = "main"
ROOT = Path.home() / ".the-way-out" # holds save.json + app/
APP_DIR = ROOT / "app" # the live game code
VERSION_FILE = APP_DIR / ".version" # remote commit sha we ran
LAST_CHECK_FILE = ROOT / ".last_check" # mtime = last successful check
_API_COMMIT = f"https://api.github.com/repos/{REPO}/commits/{BRANCH}"
_ZIP_BASE = f"https://codeload.github.com/{REPO}/zip"
_HEADERS = {"User-Agent": "the-way-out-updater"} # GitHub API needs a UA
def app_dir() -> Path:
return APP_DIR
def has_code() -> bool:
"""True when ``app/`` actually contains a runnable game."""
return (APP_DIR / "main.py").is_file()
def local_sha():
"""Commit sha we last installed, or None."""
try:
return (VERSION_FILE.read_text().strip() or None)
except OSError:
return None
def remote_sha(timeout: float = 6):
"""Latest commit sha on the branch, or None if offline/blocked."""
try:
req = urllib.request.Request(_API_COMMIT, headers=_HEADERS)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.load(resp).get("sha")
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
return None
def online(timeout: float = 2) -> bool:
"""True when the machine actually has internet.
Distinguishes "no network at all" from "GitHub unreachable / API
rate-limited / slow link" — :func:`remote_sha` returns None for all
of those, so the caller can't tell which from the sha alone.
Probes 1.1.1.1:53 (Cloudflare DNS) with a raw TCP connect: no DNS
lookup, no HTTP, not GitHub, so it stays up even when the GitHub
API is throttling us. Best-effort; any failure means "offline".
"""
for host in ("1.1.1.1", "8.8.8.8"):
try:
socket.create_connection((host, 53), timeout=timeout).close()
return True
except OSError:
continue
return False
def check(timeout: float = 6):
"""Return ``(local, remote, update_available)``.
``update_available`` is True when there is a reachable remote sha
that differs from the local one, *or* when there is no local code
yet (first run). When offline, ``remote`` is None and the result
is False so the caller just runs whatever is already installed.
"""
loc = local_sha()
rem = remote_sha(timeout=timeout)
if rem is None:
return loc, None, False
_mark_checked()
available = (not has_code()) or (loc != rem)
return loc, rem, available
def _mark_checked():
"""Record that we just successfully reached GitHub.
Used by :func:`should_check` to throttle the cold-start network
call. Best-effort: a write failure simply means the next launch
will probe the network again.
"""
try:
ROOT.mkdir(parents=True, exist_ok=True)
LAST_CHECK_FILE.touch()
except OSError:
pass
def should_check(min_interval_s: float = 86400.0) -> bool:
"""True when a cold-start update probe is worth doing.
First run (no installed code) always returns True. After that we
skip the probe until ``min_interval_s`` has passed since the last
successful one, so a slow/captive network doesn't pause every
launch by up to the request timeout. The in-game Update action
calls :func:`check` directly and bypasses this gate.
"""
if not has_code():
return True
try:
last = LAST_CHECK_FILE.stat().st_mtime
except OSError:
return True
return (time.time() - last) >= min_interval_s
def _download_zip(ref: str, timeout: float) -> bytes:
req = urllib.request.Request(f"{_ZIP_BASE}/{ref}", headers=_HEADERS)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
def apply_update(expected_sha=None, timeout: float = 90) -> bool:
"""Download the branch zip and atomically replace ``app/``.
Returns True on success. On *any* failure the existing install is
left untouched (download/extract happen in a temp area first). The
previous version is kept as ``app.prev`` for manual rollback.
"""
# Pin the download to expected_sha so the extracted code matches
# the sha we write into .version. Without this, ``main`` can advance
# between the commits API call in ``check()`` and the codeload fetch
# here, leaving .version one commit behind the actual install.
ref = expected_sha or f"refs/heads/{BRANCH}"
try:
blob = _download_zip(ref, timeout)
except (urllib.error.URLError, OSError, TimeoutError):
return False
ROOT.mkdir(parents=True, exist_ok=True)
staging = Path(tempfile.mkdtemp(prefix="twout-stage-", dir=ROOT))
try:
with zipfile.ZipFile(io.BytesIO(blob)) as zf:
zf.extractall(staging)
# GitHub wraps everything in a single ``<repo>-<branch>/`` dir.
roots = [p for p in staging.iterdir() if p.is_dir()]
if len(roots) != 1 or not (roots[0] / "main.py").is_file():
return False
src = roots[0]
if expected_sha:
(src / ".version").write_text(expected_sha)
new_dir = ROOT / "app.new"
prev_dir = ROOT / "app.prev"
if new_dir.exists():
shutil.rmtree(new_dir, ignore_errors=True)
# Same filesystem (under ROOT) → these renames are atomic.
os.replace(src, new_dir)
if prev_dir.exists():
shutil.rmtree(prev_dir, ignore_errors=True)
if APP_DIR.exists():
os.replace(APP_DIR, prev_dir)
os.replace(new_dir, APP_DIR)
return True
except (OSError, zipfile.BadZipFile, ValueError):
return False
finally:
shutil.rmtree(staging, ignore_errors=True)
def recover_from_prev() -> bool:
"""Move ``app.prev`` back into ``app/`` after a crashed update.
Between the two renames in :func:`apply_update` the live ``app/``
does not exist. If the process dies in that window, restoring
``app.prev`` here keeps the launcher from silently falling back to
the (older) bundled seed. Returns True when a restore happened.
"""
if has_code():
return False
prev_dir = ROOT / "app.prev"
if not (prev_dir / "main.py").is_file():
return False
try:
os.replace(prev_dir, APP_DIR)
return True
except OSError:
return False
def seed_from(seed_dir, force: bool = False) -> bool:
"""Copy a bundled source snapshot into ``app/``.
The launcher uses this so the very first launch works even with no
internet. A no-op (returns True) if code already exists and
``force`` is False.
"""
seed = Path(seed_dir)
if not (seed / "main.py").is_file():
return False
if has_code() and not force:
return True
ROOT.mkdir(parents=True, exist_ok=True)
tmp = Path(tempfile.mkdtemp(prefix="twout-seed-", dir=ROOT))
try:
dst = tmp / "app"
shutil.copytree(seed, dst)
if APP_DIR.exists():
shutil.rmtree(APP_DIR, ignore_errors=True)
os.replace(dst, APP_DIR)
return True
except OSError:
return False
finally:
shutil.rmtree(tmp, ignore_errors=True)