diff --git a/CONFIGURATION.md b/CONFIGURATION.md new file mode 100644 index 0000000..6aaa2dc --- /dev/null +++ b/CONFIGURATION.md @@ -0,0 +1,94 @@ +# Zanthor – Configuration System + +Zanthor ships with a simple, modular configuration system that lets +players tweak **display, audio, controls and gameplay** settings either +from the in‑game **Options** screen or by hand‑editing a JSON file. + +--- + +## In‑game Options Screen + +Open the game → **Options** on the title menu. + +| Input | Action | +|---|---| +| Mouse click | Select tab / row / button; drag a slider; click again to edit | +| `Tab` / `Shift+Tab` | Cycle tabs | +| `↑` / `↓` | Move selection | +| `←` / `→` | Adjust current value (slider / choice / toggle) | +| `Enter` / `Space` | Toggle / cycle / start key‑rebind | +| Mouse wheel | Scroll the options list | +| `Esc` | Back (auto‑saves on exit) | + +Key‑rebind: select a key‑binding row → `Enter` → press any key, or +`Backspace` to clear the binding, or `Esc` to cancel. + +Settings marked with a red `*` require restarting the game. + +--- + +## Configuration file + +The file lives at a platform‑appropriate location: + +| Platform | Path | +|---|---| +| Linux / BSD | `~/.config/zanthor/settings.json` | +| macOS | `~/Library/Application Support/zanthor/settings.json` | +| Windows | `%APPDATA%\zanthor\settings.json` | +| Override | set `$ZANTHOR_CONFIG_DIR` | + +It is **safe to hand‑edit** – the loader validates and clamps every +value. Missing keys fall back to defaults; unknown keys are ignored; +a corrupt file simply yields the defaults (and is logged, not thrown). + +Example: +```json +{ + "display": { + "resolution": [1024, 768], + "fullscreen": true, + "fps_cap": 60, + "skip_intro": true + }, + "audio": { + "music_volume": 0.5, + "sfx_volume": 0.8, + "mute": false + }, + "controls": { + "move_up": ["i"], + "move_down": ["k"], + "move_left": ["j"], + "move_right": ["l"], + "fire": ["e"], + "mouse_look": false + }, + "gameplay": { + "auto_pickup": false, + "holes_are_tiles": 1 + } +} +``` + +Key names are the human‑readable names returned by +`pygame.key.name()` (`"space"`, `"left ctrl"`, `"return"` …). + +--- + +## For developers + +The two new modules are drop‑in: + +* **`zanthor/settings.py`** – schema‑driven `Settings` singleton. + Adding a new option is one line in the `SCHEMA` dict and it shows up + in the UI automatically. `settings.get("section.key")`, + `settings.keys("move_up")`, `settings.save()` … that's the whole API. + +* **`zanthor/options.py`** – a `pgu.engine.State` that renders the + Options screen purely with pygame primitives (no extra deps). + +`const.py` now sources its previously hard‑coded values from +`settings`, and `KEYS_UP()`, `KEYS_FIRE()` etc. helpers are used by +`castle.py`, `menu.py` and `level.py` instead of literal `K_*` +constants, so rebinds take effect immediately. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..90114fc --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,77 @@ +# Zanthor Installation Guide + +Zanthor is a game built with Python and Pygame where you play an evil robot castle. + +## Requirements +- **Python 3.6 or higher** (Python 3.12+ recommended) +- **Pygame** (or `pygame-ce`, which is recommended for modern Python versions) + +--- + +## 🚀 Running from Source (Recommended) + +Follow these steps to run the game directly from the downloaded source code without installing it permanently to your system. + +### 1. Extract the Game Files +If you downloaded a `.zip` file, extract it to a folder of your choice. Open your terminal or command prompt and navigate to that folder: +```bash +cd path/to/zanthor +``` + +### 2. Install Dependencies +The game requires the `pygame` library. On modern versions of Python, it is highly recommended to install Pygame Community Edition (`pygame-ce`), as it provides pre-built wheels and avoids compilation errors. + +```bash +python -m pip install pygame-ce +``` +*(Note: If you are on Linux/macOS and `python` defaults to Python 2, use `python3 -m pip install pygame-ce` instead).* + +### 3. Run the Game +Once the installation finishes, you can start the game right away: + +```bash +python run_game.py +``` + +--- + +## 📦 System-wide Installation (Optional) + +If you prefer to install Zanthor so you can launch it from anywhere on your computer using the `zanthor` command: + +### 1. Navigate to the Source Folder +```bash +cd path/to/zanthor +``` + +### 2. Install using pip +Run the following command to install the project and its dependencies: + +```bash +python -m pip install . +``` + +*Troubleshooting note:* The `setup.py` explicitly demands `pygame`. If this fails on your system due to missing build tools for older pygame versions, install `pygame-ce` first (`pip install pygame-ce`) and try again, or just use the **Running from Source** method above. + +### 3. Launch from Anywhere +After a successful installation, you can launch the game from any terminal window simply by typing: +```bash +zanthor +``` + +--- + +## 🎮 Controls + +### Keyboard +- **Move**: Arrow keys or `W`, `A`, `S`, `D` +- **Fire**: `F`, `Space`, or `Ctrl` +- **Pause**: `F10` + +### Mouse +- **Fire**: Left Button (Button 1) +- **Move**: Right Button (Button 2) + +### Gamepad / Joystick +- **Move**: D-Pad or Analog Stick +- **Fire**: Button 1 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d5dce19 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,340 @@ +# Architecture Overview + +This document describes the technical architecture of Zanthor. + +## High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ main.py │ +│ (Entry Point & Game Loop) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ States │ │ Level │ │ Interface │ │ +│ │ (title, │ │ (gameplay │ │ (HUD, status bars, │ │ +│ │ menu, │ │ logic) │ │ messages) │ │ +│ │ intro) │ │ │ │ │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ Castle │ │ Units │ │ Items │ │ +│ │ (player │ │ (stats & │ │ (coal, water, │ │ +│ │ entity) │ │ behavior) │ │ upgrades) │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +│ │ +├─────────────────────────────────────────────────────────────┤ +│ PGU Engine │ +│ (isovid, engine, timer, gui, vid) │ +├─────────────────────────────────────────────────────────────┤ +│ Pygame │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Directory Structure + +``` +zanthor/ +├── main.py # Package entry point (main.main) +├── run_game.py # Alternative entry point +├── setup.py # Package installation +├── zanthor/ +│ ├── __init__.py +│ ├── __main__.py # Module entry point +│ ├── main.py # Game initialization & main loop +│ ├── const.py # Constants & configuration +│ │ +│ ├── # Core Game Modules +│ ├── level.py # Level loading & game state +│ ├── castle.py # Player castle entity +│ ├── units.py # Unit stats & behavior +│ ├── items.py # Collectible items +│ ├── tiles.py # Tile interaction handlers +│ │ +│ ├── # Game States +│ ├── states.py # Game state classes +│ ├── intro.py # Intro sequence +│ ├── title.py # Title screen +│ ├── menu.py # Level select menu +│ │ +│ ├── # Enemy Entities +│ ├── robot.py # Peasant AI +│ ├── flock.py # Flocking behavior +│ ├── cannon.py # Enemy cannons +│ ├── truck.py # Coal trucks +│ ├── factory.py # Factory buildings +│ │ +│ ├── # Visual Effects +│ ├── effect.py # Effect system +│ ├── steam.py # Steam particle effects +│ ├── explode.py # Explosion effects +│ ├── fire.py # Fire effects +│ │ +│ ├── # UI & Interface +│ ├── interface.py # HUD & status display +│ ├── messages.py # Procedural message generator +│ ├── html.py # HTML text rendering +│ │ +│ ├── # Audio +│ ├── sounds.py # Sound manager +│ ├── sound_info.py # Sound definitions +│ │ +│ ├── # Engine Components +│ ├── isovid.py # Isometric renderer +│ ├── algo.py # A* pathfinding +│ ├── cyclic_list.py # Utility data structure +│ ├── util.py # Utility functions +│ │ +│ ├── pgu/ # Phil's Game Utilities library +│ │ ├── engine.py # State machine engine +│ │ ├── vid.py # Video/tile engine base +│ │ ├── isovid.py # Isometric extension +│ │ ├── timer.py # Timer utilities +│ │ ├── gui/ # GUI components +│ │ └── ... +│ │ +│ └── data/ # Game assets +│ ├── gfx/ # Graphics (tiles, sprites) +│ ├── sounds/ # Sound effects +│ ├── intro/ # Intro assets & music +│ ├── menu/ # Menu assets +│ ├── levels/ # Level data (TGA files) +│ └── themes/ # UI themes +``` + +## Core Systems + +### State Machine (pgu.engine) + +The game uses a state machine pattern for managing screens: + +```python +class Game(engine.Game): + def init(self): + # Initialize game + + def tick(self): + # Called each frame + + def event(self, e): + # Handle global events + +# State transitions +Intro → Title → Menu → Level → (NextLevel | GameOver | GameWon) → Menu/Title +``` + +State classes implement: +- `init()` - Initialize state +- `paint(screen)` - Initial render +- `update(screen)` - Per-frame update +- `loop()` - Game logic (may return new state) +- `event(e)` - Event handling (may return new state) + +### Isometric Engine (isovid.py) + +Extends the PGU vid system for isometric rendering: + +``` +Coordinate Systems: +- Screen coords (pixels on display) +- View coords (world pixels, offset by view position) +- Iso coords (isometric world coordinates) +- Tile coords (grid cell coordinates) + +Conversion Functions: +- iso_to_view(pos) - Convert iso to view coords +- view_to_iso(pos) - Convert view to iso coords +- tile_to_view(pos) - Convert tile to view coords +- screen_to_tile(pos) - Convert screen to tile coords +``` + +Tile layers: +- `tlayer` - Foreground tiles (interactive) +- `blayer` - Background tiles (decoration) +- `clayer` - Code layer (spawn points) +- `zlayer` - Z-height layer +- `robot_layer` - Pathfinding for robots +- `castle_layer` - Pathfinding for castle + +### Entity System + +Entities are sprites with attached behavior: + +```python +class Sprite: + rect # Position & size + image # Current image + groups # Collision groups (bitmask) + agroups # Groups to collide against + loop # Per-frame callback: loop(g, sprite) + hit # Collision callback: hit(g, self, other) +``` + +Entity types: +- Castle (player) +- Robot (peasant) +- Cannon (enemy turret) +- Truck (resource transport) +- Factory (enemy building) +- Cball (cannonball projectile) + +### Unit System (units.py) + +Units have stats dictionaries: + +```python +stats = { + 'Health': 10.0, + 'MaxHealth': 10.0, + 'Coal': 65.0, + 'MaxCoal': 70.0, + 'Water': 65.0, + 'MaxWater': 70.0, + 'Steam': 100.0, + 'MaxSteam': 100.0, + 'Speed': 10.0, + 'Armour': 0.0, + 'Damage': 1.0, + 'EngineEfficiency': 3.0, + 'CannonPressure': 0.0, + 'MaxCannonPressure': 16.0, + # ... more stats +} +``` + +Unit classes: Castle, Robot, CannonTower, CoalTruck, Factory + +### Flocking System (flock.py) + +Implements Boids-like flocking for peasants: + +```python +class Flock: + def __init__(self, rect, spacing): + # Spatial grid for neighbor lookup + + def loop(self): + # Per-frame update: + # 1. Update spatial grid + # 2. Calculate velocities + # 3. Find neighbors + # 4. Apply flocking rules: + # - Cohesion (move toward center) + # - Separation (avoid neighbors) + # - Alignment (match velocity) + # 5. Enforce min/max velocity +``` + +### Sound System (sounds.py) + +```python +class SoundManager: + def Load(self, sound_list) # Load sounds + def Play(self, name, ...) # Play sound + def Stop(self, name) # Stop sound + def PlayMusic(self, name) # Play background music + def Update(self, elapsed) # Process queued sounds +``` + +Sound queuing modes: +- 0: Play immediately (overlap) +- 1: Queue if already playing +- 2: Skip if already playing +- 3: Play immediately, no queue + +### Interface System (interface.py) + +```python +class Interface: + def load(self) # Load UI graphics + def update(self, tv, screen) # Render HUD + def event(self, g, e) # Handle UI events + +class StatsDraw: + def update_stats(self, screen, stats, ...) # Render stat bars +``` + +Dirty flag system for efficient updates: +- Only redraws changed UI elements +- Tracks: messages, backgrounds, equipment, etc. + +## Data Flow + +### Frame Update Cycle + +``` +1. Game.tick() + - Update timers + - Update sound manager + +2. State.loop() + - Game logic + - Entity updates + - Flock calculations + - Collision detection + - Win/lose checks + +3. State.update(screen) / State.paint(screen) + - Render background + - Render tiles + - Render entities (z-sorted) + - Render effects + - Render UI + +4. Event Processing + - Handle pygame events + - Pass to state.event() + - Pass to entity event handlers +``` + +### Level Loading + +``` +1. Load tile graphics (tga_load_tiles) +2. Load level map (tga_load_level) +3. Initialize pathfinding layers +4. Run spawn codes (run_codes) +5. Initialize flocking system +6. Load interface +``` + +### Collision System + +Collision groups (bitmask): +- `castle` - Player castle +- `robot` - Peasants +- `cball` - Cannonballs +- `cannon` - Enemy cannons +- `factory` - Factory buildings +- `truck` - Coal trucks + +Collision checking: +- `sprite.groups` - What groups sprite belongs to +- `sprite.agroups` - What groups to check collisions against +- On collision: calls `sprite.hit(g, self, other)` + +## Configuration + +Key configuration in `const.py`: +- Screen dimensions (SW, SH) +- Frame rate (FPS) +- UI rectangles (S_VIEW, S_STATUS, etc.) +- Gameplay constants (MIN_CANNON_PRESSURE, etc.) +- Debug flags (CACHE_USE_LEVEL_CACHE, etc.) + +## Performance Considerations + +1. **Background Caching**: Pre-rendered background cached to disk +2. **Spatial Grid**: Flocking uses grid for O(1) neighbor lookup +3. **Dirty Rectangles**: UI only redraws changed elements +4. **Pathfinding Layers**: Pre-computed walkability grids + +## Extension Points + +To add new features: + +1. **New Entity Type**: Create sprite with loop/hit callbacks, add to level.py cdata +2. **New Upgrade**: Add to units.upgrade_amounts, update upgrade_part() +3. **New Level**: Create TGA file in data/levels/, add to menu.data +4. **New Effect**: Create Effect class with loop/paint methods +5. **New State**: Extend engine.State, wire into state transitions diff --git a/docs/assets.md b/docs/assets.md new file mode 100644 index 0000000..d638376 --- /dev/null +++ b/docs/assets.md @@ -0,0 +1,271 @@ +# Asset Reference + +This document describes the game assets and their organization. + +## Asset Directory Structure + +``` +zanthor/data/ +├── gfx/ # Graphics (sprites, tiles, UI) +├── sounds/ # Sound effects +├── intro/ # Intro sequence assets +├── menu/ # Menu assets (fonts) +├── levels/ # Level data files +├── themes/ # UI theme resources +└── cache/ # Runtime cache (generated) +``` + +## Graphics (data/gfx/) + +### Tile Graphics + +**tiles2.tga** (or .png) +- Main tileset for all levels +- 32x64 pixel tiles arranged in a grid +- Contains terrain, walls, items, and structures + +Tile indices: +| Index | Content | +|-------|---------| +| 0 | Empty/grass | +| 1 | Coal | +| 2 | Water | +| 3-6 | Walls (various damage states) | +| 7 | Rubble | +| 8-15 | Upgrade parts | +| 23 | Wall | +| 28 | Limit tile (boundary) | +| 30 | Hole (impassable) | +| 32-35 | Cannonballs (different sizes) | + +### Sprite Graphics + +Castle sprites extracted from tiles2.tga: +- `castle` - Main castle sprite +- `castle.left` - Castle facing left +- `castle.right` - Castle facing right (flipped) + +Other sprite names: +- `robot` - Peasant sprite +- `coal`, `water` - Resource sprites +- `factory`, `cannon`, `truck` - Enemy structures +- `cball`, `cball2`, `cball3`, `cball4` - Cannonball sizes +- `hole`, `hole2`, `hole3`, `hole4` - Ground holes + +### UI Graphics + +| Filename | Purpose | +|----------|---------| +| background_status_left.png | Left panel background | +| background_bottom.png | Bottom panel background | +| background_illustration.png | Castle illustration | +| background_equipment.png | Equipment panel | +| background_buttons.png | Button panel | +| background_messages.png | Message area | +| heart.png | Health meter heart | +| heart_pulse.png | Health meter pulse frame | +| health_overlay.png | Health bar overlay | +| coal_overlay.png | Coal bar overlay | +| water_overlay.png | Water bar overlay | +| steam_overlay.png | Steam bar overlay | +| hairs.tga/png | Crosshair cursor | +| caastles.png | Level select background | + +### Button Graphics + +Each button has three states: +- `button_*.png` - Normal state +- `button_*_rollover.png` - Hover state +- `button_*_down.png` - Pressed state + +Buttons: save, load, quit, news + +## Sound Effects (data/sounds/) + +### Combat Sounds + +| Filename | Event | +|----------|-------| +| cannon.wav/ogg | Cannon fire (low power) | +| cannon2.wav/ogg | Cannon fire (medium) | +| cannon3.wav/ogg | Cannon fire (high power) | +| hitenemy.wav/ogg | Hit enemy | +| hitwall.wav/ogg | Hit wall | +| hitwall2.wav/ogg | Hit wall (alternate) | +| hitground.wav/ogg | Cannonball hits ground | +| destroyenemy.wav/ogg | Enemy destroyed | + +### Damage Sounds + +| Filename | Event | +|----------|-------| +| ouch1.wav/ogg | Player hit | +| ouch2.wav/ogg | Player hit (alternate) | +| squish1.wav/ogg | Peasant crushed | +| squish2.wav/ogg | Peasant crushed (alternate) | + +### Resource Sounds + +| Filename | Event | +|----------|-------| +| coal.wav/ogg | Coal pickup | +| water.wav/ogg | Water pickup | +| upgrade.wav/ogg | Upgrade applied | + +### Ambient Sounds + +| Filename | Event | +|----------|-------| +| birds1.wav/ogg | Bird ambience | +| birds2.wav/ogg | Bird ambience | +| peasants1.wav/ogg | Peasant noise | +| peasants2.wav/ogg | Peasant noise | +| peasants3.wav/ogg | Peasant noise | + +### Engine Sounds + +| Filename | Event | +|----------|-------| +| engine-slow.wav/ogg | Engine idle | +| engine-fast.wav/ogg | Engine running | +| release.wav/ogg | Steam release | + +## Music (data/intro/) + +| Filename | Usage | +|----------|-------| +| intro1.ogg | Intro sequence | +| zanthor.ogg | Title screen | +| soundtrack1.ogg | Easy levels (1-4) | +| soundtrack2.ogg | Medium levels (5-7) | +| soundtrack3.ogg | Final level (8) | +| grass.ogg | Victory screen | + +## Fonts (data/intro/, data/menu/) + +| Filename | Usage | +|----------|-------| +| WALSHES.TTF | Intro text, title | +| vinque.ttf | Menu text, HUD | + +## Level Files (data/levels/) + +Levels are stored as TGA images where pixel colors encode tile data: + +### TGA Format + +- Red channel: Foreground tile index +- Green channel: Background tile index +- Blue channel: Code layer (spawn points) + +### Level Files + +| Filename | Level Name | +|----------|------------| +| level1.tga | Simple City | +| level2.tga | Valiant Village | +| level3.tga | Humble Hamlet | +| level4.tga | Congenial Burg | +| level5.tga | Truthful Tower | +| level6.tga | Loyal Lookout | +| level7.tga | Observatory of Honor | +| level8.tga | Comfy Castle | +| test2.tga | Test level | + +### Spawn Codes (Blue Channel) + +| Value | Spawns | +|-------|--------| +| 1 | Castle (player) | +| 4 | Factory | +| 5 | Truck | +| 6 | Cannon | +| 7 | Robot (peasant) | + +## Themes (data/themes/) + +UI theme resources for the PGU GUI system. Contains style definitions and themed widget graphics. + +## Creating New Assets + +### Adding New Tiles + +1. Edit `tiles2.tga` to add tile graphics at an unused index +2. In `level.py`, add tile data mapping: + ```python + tdata = { + NEW_INDEX: ('collision_groups', handler_function, config), + } + ``` +3. Optionally create a handler function in `tiles.py` + +### Adding New Sounds + +1. Place sound file in `data/sounds/` (.wav or .ogg) +2. Add to `sound_info.py`: + ```python + newsound = cl(['newsound1', 'newsound2']) + ``` +3. Play in code: + ```python + g.level.game.sm.Play(sound_info.newsound.nextone()) + ``` + +### Adding New Levels + +1. Create TGA image with appropriate size +2. Paint tiles using red channel +3. Paint spawn points using blue channel +4. Add to `menu.py` data array: + ```python + (lock_level, pygame.Rect(...), 'Name', population, + 'filename.tga', win_percent, 'Zanthor Name', + 'music.ogg', (row, col)) + ``` + +### Level Design Tips + +- Ensure castle spawn point (code 1) exists +- Place coal (tile 1) and water (tile 2) for resources +- Use walls (tiles 3-6) to create obstacles +- Scatter robots (code 7) across the map +- Consider player progression with upgrade parts (tiles 8-15) + +## Asset Technical Details + +### Image Formats + +- TGA: Preferred for tiles and levels (lossless, supports alpha) +- PNG: Alternative format (fallback in code) +- Images are loaded with `pygame.image.load()` +- Alpha transparency via `.convert_alpha()` where needed +- Color key transparency via `.set_colorkey((255, 0, 255))` + +### Audio Formats + +- OGG: Preferred (compressed, good quality) +- WAV: Alternative (uncompressed) +- Loaded via `pygame.mixer.Sound()` +- Music via `pygame.mixer.music` + +### Font Formats + +- TTF: TrueType fonts +- Various sizes rendered at runtime + +## Cache System + +The game caches pre-rendered level backgrounds to speed up loading: + +Location: `data/cache/levels/` + +Cache files: +- Named after level files (e.g., `level1.jpg`) +- Created automatically if loading takes > 1 second +- Invalidated if level or tile files are modified +- Controlled by `CACHE_USE_LEVEL_CACHE` in `const.py` + +To clear cache: +```bash +rm -rf zanthor/data/cache/ +``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..621d516 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,370 @@ +# Game Configuration + +This document describes configurable parameters in Zanthor. + +## Screen Settings + +Location: `zanthor/const.py` + +```python +# Base screen dimensions (for scaling calculations) +BSW, BSH = 640, 480 + +# Actual screen dimensions +SW, SH = 640, 480 # Default resolution + +# Derived scale factors +DX = float(SW) / BSW +DY = float(SH) / BSH +``` + +### Changing Resolution + +To change resolution, modify `SW` and `SH`: + +```python +SW, SH = 800, 600 # 800x600 +SW, SH = 1024, 768 # 1024x768 +SW, SH = 1280, 800 # 1280x800 +``` + +Note: UI elements scale automatically based on `DX` and `DY`. + +### Fullscreen Mode + +Launch with command line: +```bash +python run_game.py fullscreen +``` + +Or modify in `main.py`: +```python +flags = FULLSCREEN # Instead of flags = 0 +``` + +## Performance Settings + +### Frame Rate + +```python +FPS = 30 # Target frames per second +``` + +Higher values increase smoothness but CPU usage. + +### Background Caching + +```python +CACHE_USE_LEVEL_CACHE = 1 # Enable background caching +CACHE_CHECK_TILES = 1 # Check tile modification time +CACHE_CHECK_LEVEL = 1 # Check level modification time +``` + +When enabled, pre-rendered backgrounds are saved to `data/cache/levels/` to speed up level loading. + +## Gameplay Settings + +### Cannon + +```python +MIN_CANNON_PRESSURE = 6 # Minimum pressure required to fire +``` + +### Pickup Behavior + +```python +AUTO_PICKUP = 1 # 1 = auto pickup items, 0 = prompt for pickup +``` + +### Ground Holes + +```python +HOLES_ARE_TILES = 0 +# 0 = holes are visual only +# 1 = holes become solid tiles +# 2 = large holes become solid +``` + +### Mouse Look + +```python +DISABLE_MOUSE_LOOK = 0 # 0 = enabled, 1 = disabled +``` + +Can be toggled in-game with `M` key. + +Mouse look settings: +```python +def get_mouse_info(): + if DISABLE_MOUSE_LOOK: + SCROLL_MOUSE = 0 # Speed of mouse scroll + SCROLL_AUTO = 9 # Speed of auto-return + SCROLL_BORDER = 70 # Scroll border size + else: + SCROLL_MOUSE = 12 + SCROLL_AUTO = 9 + SCROLL_BORDER = 160 +``` + +### Flocking + +```python +FLOCKING_FLUCT = 1 # Enable flocking fluctuation +``` + +Robot flocking parameters in `robot.py`: +```python +MIN = 8.0 # Minimum velocity +MIN_RAND = 4 # Min velocity randomization +MAX = 16.0 # Maximum velocity +MAX_RAND = 8 # Max velocity randomization +RADIUS = 32.0 # Separation radius +RADIUS_RAND = 12 # Radius randomization +CENTER = 4.0 # Centering strength +CENTER_RAND = 2 # Center randomization +SEARCH = 128.0 # Search radius +``` + +## Control Settings + +### Joystick + +```python +JOY_FIRE_BUTTON = 1 # Fire button index +JOY_PICKUP_BUTTON = 2 # Pickup button index +``` + +### Keyboard + +Default controls are hardcoded in `castle.py`: +- Movement: Arrow keys, WASD +- Fire: F, Space, Ctrl +- Pause: H, P, Enter + +## Unit Stats + +### Castle Initial Stats + +Location: `zanthor/units.py`, class `Castle` + +```python +self.stats = { + 'Armour': 0.0, + 'Speed': 10.0, + 'Weight': 1.0, + 'Health': 10.0, + 'Water': 65.0, + 'Coal': 65.0, + 'Steam': 100.0, + 'EngineEfficiency': 3.0, + 'CannonPressure': 0.0, + 'MaxCannonPressure': 16.0, + 'MaxHealth': 10.0, + 'MaxWater': 70.0, + 'MaxCoal': 70.0, + 'MaxSteam': 100.0, + 'Temperature': 0.0, + 'Damage': 1.0, + 'WeaponSteam': 1.0, + 'MoveSteam': 0.05, + 'GenerateSteamCoal': 0.05, + 'GenerateSteamWater': 0.05, +} +``` + +### Upgrade Amounts + +```python +upgrade_amounts = {} +u = upgrade_amounts +cl = cyclic_list.cyclic_list + +# Each upgrade can be applied multiple times with increasing effect +u['UpEngine Efficiency'] = cl([1.0, 2.0, 2.0, 2.0, 2.0, 3.0, 3.0, ...]) +u['UpEngine Speed'] = cl([1.0, 2.0, 2.0, 2.0, 2.0, 3.0, 3.0, ...]) +u['UpCannon Balls'] = cl([1.0, 2.0, 2.0, 2.0, 2.0, 3.0, 3.0, ...]) +u['UpArmour'] = cl([1.0, 2.0, 2.0, 2.0, 2.0, 3.0, 3.0, ...]) +u['UpSteam Tank'] = cl([1.0, 2.0, 2.0, 2.0, 2.0, 3.0, 3.0, ...]) +u['UpWater Tank'] = cl([1.0, 2.0, 2.0, 2.0, 2.0, 3.0, 3.0, ...]) +u['UpCoal Tank'] = cl([1.0, 2.0, 2.0, 2.0, 2.0, 3.0, 3.0, ...]) +u['UpCannon Power'] = cl([1.0, 2.0, 2.0, 2.0, 2.0, 3.0, 3.0, ...]) +``` + +## Level Configuration + +### Level Data + +Location: `zanthor/menu.py` + +```python +data = [ + # (lock, rect, title, population, filename, percent, ztitle, music, grid_pos) + (7, pygame.Rect(...), 'Comfy Castle', 4397, 'level8.tga', 70, ...), + (4, pygame.Rect(...), 'Truthful Tower', 1232, 'level5.tga', 70, ...), + # ... +] +``` + +Fields: +- `lock` - Number of levels required to unlock (0 = always available) +- `rect` - Clickable area on map +- `title` - Display name +- `population` - Number of citizens (peasants) +- `filename` - Level file in `data/levels/` +- `percent` - Kill percentage required to win (70-80) +- `ztitle` - Zanthor's name for the location +- `music` - Background music file +- `grid_pos` - Selection grid position + +### Tile Data + +Location: `zanthor/level.py` + +```python +tdata = { + 0x01: ('castle', tiles.tile_coal, None), + 0x02: ('castle', tiles.tile_water, None), + 0x04: ('cball', tiles.tile_wall, None), + 0x05: ('cball', tiles.tile_wall, None), + 0x06: ('cball', tiles.tile_wall, None), + 0x07: ('castle', tiles.tile_rubble, None), + 8: ('castle', tiles.tile_part, None), # Engine Efficiency + 9: ('castle', tiles.tile_part, None), # Engine Speed + 10: ('castle', tiles.tile_part, None), # Cannon Balls + 11: ('castle', tiles.tile_part, None), # Armour + 12: ('castle', tiles.tile_part, None), # Steam Tank + 13: ('castle', tiles.tile_part, None), # Water Tank + 14: ('castle', tiles.tile_part, None), # Coal Tank + 15: ('castle', tiles.tile_part, None), # Cannon Power + 0x1C: ('castle,cball,robot', tiles.tile_limit, None), # Out of bounds +} +``` + +### Spawn Codes + +```python +cdata = { + 1: (castle.castle_new, None), # Castle spawn + 4: (factory.factory_new, None), # Factory spawn + 5: (truck.truck_new, None), # Truck spawn + 6: (cannon.cannon_new, None), # Cannon spawn + 7: (robot.robot_new, None), # Robot spawn +} +``` + +## Sound Configuration + +### Sound Mappings + +Location: `zanthor/sound_info.py` + +```python +cannon = cl(['cannon', 'cannon2', 'cannon3', 'cannon', 'cannon']) +hitenemy = cl(['hitenemy']) +hitwall = cl(['hitwall2']) +hitground = cl(['hitground']) +destroyenemy = cl(['destroyenemy']) +ushit = cl(['ouch1', 'ouch2']) +coal = cl(['coal']) +water = cl(['water']) +birds = cl(['birds1', 'birds2', 'birds2']) +peasants = cl(['peasants1', 'peasants2', 'peasants3']) +squish = cl(['squish1', 'squish2']) +``` + +Sounds are located in `data/sounds/` as `.wav` or `.ogg` files. + +### Audio Settings + +Location: `zanthor/main.py` + +```python +pygame.mixer.pre_init(22050, -16, 2, 1024) +# Sample rate: 22050 +# Bit depth: -16 (16-bit signed) +# Channels: 2 (stereo) +# Buffer: 1024 +``` + +## UI Rectangles + +All UI positions are defined as rectangles in `const.py`: + +```python +# Game view area +S_VIEW = multr(dxdy, (76, 0, 380, 340)) + +# Left status panel +S_STATUS = multr(dxdy, (0, 0, 76, 340)) + +# Stat bars +S_HEALTH = multr(dxdy, (10, 4, 60, 96)) +S_COAL = multr(dxdy, (10, 100, 60, 70)) +S_WATER = multr(dxdy, (10, 190, 60, 70)) +S_STEAM = multr(dxdy, (10, 260, 60, 70)) + +# Cannon pressure +S_CANNON_PRESSURE = multr(dxdy, (76, 0, 5, 340)) + +# Peasants remaining +S_PEASANTS_REMAINING = multr(dxdy, (487, 24, 122, 114)) + +# Robot illustration +S_ROBOT = multr(dxdy, (456, 0, 184, 163)) + +# Equipment area +S_ITEMS = multr(dxdy, (456, 163, 184, 177)) + +# Button area +S_BUTTONS = pygame.Rect(470, 340, 170, 140) + +# Message area +S_MESSAGES = multr(dxdy, (95, 365, 355, 110)) +``` + +## Message Generator + +Location: `zanthor/messages.py` + +The message generator uses a context-free grammar: + +```python +data = { + 'SENTENCE': ['[_SENTENCE]'], + '_SENTENCE': [ + '[NAME] [WILL] [KILL] [HUMANS].', + '[FEAR] [NAME].', + # ... more patterns + ], + 'NAME': ['[_NAME]', '[_NAME] the [GREAT]', ...], + 'GREAT': ['awesome', 'great', 'magnificent', ...], + 'KILL': ['kill', 'maim', 'destroy', ...], + # ... more vocabulary +} +``` + +Add new patterns or vocabulary to customize messages. + +## Debug Settings + +### Upgrade Fun Mode + +```python +UPGRADE_FUN = 1 # Enable number keys 1-8 for instant upgrades +``` + +### Tile Debug + +In `tiles.py`: +```python +PRINTDEBUG = 0 # Set to 1 for tile interaction messages +``` + +### Profiling + +Run with profiling: +```bash +python run_game.py profile +``` + +This uses Python's `hotshot` profiler to identify performance bottlenecks. diff --git a/docs/gameplay_guide.md b/docs/gameplay_guide.md new file mode 100644 index 0000000..00b48f7 --- /dev/null +++ b/docs/gameplay_guide.md @@ -0,0 +1,183 @@ +# Gameplay Guide + +This guide explains how to play Zanthor. + +## Game Objective + +You are **Zanthor**, a steam-powered robot castle. Your goal is to crush a percentage of peasants in each level to progress through the Sunflower Kingdom. Complete all 8 levels to win the game. + +## Controls + +### Keyboard + +| Key | Action | +|-----|--------| +| Arrow Keys / WASD | Move castle | +| F / Space / Ctrl | Fire cannon | +| H / P / Enter | Pause / Help | +| M | Toggle mouse look | +| Escape | Quit prompt | +| 1-8 (in upgrade screen) | Select upgrade | + +### Mouse + +| Button | Action | +|--------|--------| +| Left Click | Fire cannon (hold to charge, release to fire) | +| Right Click | Move to location | + +### Gamepad/Joystick + +| Control | Action | +|---------|--------| +| D-Pad / Analog Stick | Move castle | +| Button 1 | Fire cannon | +| Button 2 | Pickup item | + +## Game Mechanics + +### Steam System + +Zanthor runs on steam, which is essential for movement and firing: + +1. **Coal** (black bar) - Fuel source +2. **Water** (blue bar) - Combined with coal to make steam +3. **Steam** (white bar) - Powers all castle actions + +**Steam Generation**: Coal + Water → Steam (rate depends on Engine Efficiency) + +### Resources + +Pick up resources by moving over them: +- **Coal Tiles**: Replenish coal supply +- **Water Tiles**: Replenish water supply +- **Upgrade Parts**: Enhance castle capabilities + +### Health + +- **Health** (red heart) - Your life meter +- Taking damage from enemy fire reduces health +- Health does not regenerate between levels +- Reaching zero health ends the game + +### Cannon Mechanics + +1. **Press and hold** the fire button to build pressure +2. **Cannon Pressure** determines shot power and distance +3. **Release** to fire +4. Larger cannon balls deal more damage +5. Shots consume steam based on pressure + +Different pressure levels produce different effects: +- Low pressure: Small cannon ball, short range +- Medium pressure: Medium cannon ball +- High pressure: Large cannon ball, long range +- Maximum pressure: Devastating blast + +## Enemies and Obstacles + +### Peasants (Robots) +- Primary targets to crush +- Exhibit flocking behavior +- Run away when you approach +- Must eliminate a percentage per level to win + +### Walls +- Block movement +- Can be destroyed with cannon fire +- Take multiple hits to break + +### Cannons +- Enemy defenses that fire at you +- Destroy them to reduce incoming damage + +### Factories +- Enemy structures +- Can be destroyed for bonus + +## Level Progression + +### The Sunflower Kingdom + +Eight levels of increasing difficulty: + +| Level | Location | Citizens | Difficulty | +|-------|----------|----------|------------| +| 1 | Simple City | 214 | Easy | +| 2 | Valiant Village | 319 | Easy | +| 3 | Humble Hamlet | 182 | Easy | +| 4 | Congenial Burg | 225 | Easy | +| 5 | Truthful Tower | 1,232 | Medium | +| 6 | Loyal Lookout | 982 | Medium | +| 7 | Observatory of Honor | 1,444 | Medium | +| 8 | Comfy Castle | 4,397 | Hard | + +### Victory Conditions + +- Eliminate the required percentage of peasants (70-80% depending on level) +- Survive with health > 0 + +### Upgrade Screen + +After completing each level, you receive 4 upgrade points: + +1. **Engine Efficiency** - More steam per coal/water +2. **Engine Speed** - Faster movement +3. **Cannon Balls** - Increased damage +4. **Cannon Power** - Higher max pressure +5. **Armour** - Reduced damage taken +6. **Steam Tank** - Larger steam capacity +7. **Water Tank** - Larger water capacity +8. **Coal Tank** - Larger coal capacity + +Press 1-8 to apply upgrades, then Enter to continue. + +## Interface + +### Left Panel +- **Heart**: Health meter +- **Black Bar**: Coal level +- **Blue Bar**: Water level +- **Gray Bar**: Steam level +- **Thin Bar**: Cannon pressure + +### Right Panel +- **Top**: Castle illustration +- **Middle**: Equipment/upgrade status +- **Bottom**: Remaining peasants display + +### Bottom +- Messages from Zanthor (procedurally generated) + +## Tips and Strategies + +1. **Manage Resources**: Always watch your coal and water. Running out of steam leaves you helpless. + +2. **Charge Shots**: Hold the fire button to build pressure for more powerful shots. + +3. **Herd Peasants**: Use movement to group peasants together, then hit them with charged shots. + +4. **Prioritize Upgrades**: Engine Efficiency and Speed are good early upgrades. Cannon Power for later levels. + +5. **Use the Map**: Levels have coal and water scattered around. Plan your route. + +6. **Exploit Flocking**: Peasants flock together and flee from you. Use this to your advantage. + +## Cheat Keys + +For testing/development (may affect game experience): + +| Key | Effect | +|-----|--------| +| F10 | Game over screen | +| F11 / N | Skip to next level | +| F12 / V | Win game | +| Numpad +/- | Adjust speed | + +## Game Over + +Game ends when: +- Health reaches zero → Game Over screen +- All 8 levels completed → Victory screen + +From either screen, press Enter to return to the menu. diff --git a/docs/getting_started.md b/docs/getting_started.md new file mode 100644 index 0000000..7c72424 --- /dev/null +++ b/docs/getting_started.md @@ -0,0 +1,112 @@ +# Getting Started + +This guide covers installation and running Zanthor. + +## System Requirements + +- **Python**: 3.6 or higher (Python 3.12+ recommended) +- **Pygame**: pygame or pygame-ce library +- **Operating System**: Windows, macOS, or Linux +- **Display**: 640x480 minimum resolution + +## Installation Methods + +### Method 1: Running from Source (Recommended) + +1. **Extract or clone the game files**: + ```bash + cd path/to/zanthor + ``` + +2. **Install pygame dependency**: + ```bash + python -m pip install pygame-ce + ``` + + > Note: `pygame-ce` (Pygame Community Edition) is recommended for modern Python versions as it provides pre-built wheels. + +3. **Run the game**: + ```bash + python run_game.py + ``` + +### Method 2: System-wide Installation + +1. **Navigate to source folder**: + ```bash + cd path/to/zanthor + ``` + +2. **Install using pip**: + ```bash + python -m pip install . + ``` + +3. **Run from anywhere**: + ```bash + zanthor + ``` + +## Command-Line Options + +The game supports several command-line arguments: + +| Argument | Description | +|----------|-------------| +| `fullscreen` or `full` | Launch in fullscreen mode | +| `nointro` or `no` | Skip intro sequence, go directly to menu | +| `speed` | Unlock frame rate (for testing) | +| `profile` | Run with profiling enabled | +| `l ` | Jump to specific level by index | +| `level.tga` | Jump to specific level by filename | + +### Examples + +```bash +# Fullscreen mode +python run_game.py fullscreen + +# Skip intro +python run_game.py nointro + +# Jump to level 5 +python run_game.py l 5 + +# Fullscreen without intro +python run_game.py full no +``` + +## Troubleshooting + +### No Sound +- Ensure pygame.mixer is initialized properly +- Check system audio settings +- The game will run without sound if mixer fails to initialize + +### Display Issues +- Default resolution is 640x480 +- Try windowed mode if fullscreen causes problems +- Resolution can be modified in `const.py` + +### Controller Not Working +- Ensure joystick is connected before starting the game +- The game initializes all connected joysticks on startup + +### Import Errors +- Ensure pygame or pygame-ce is installed +- For pygame-ce: `pip install pygame-ce` +- For original pygame: `pip install pygame` + +## Verifying Installation + +After installation, you should see: +1. The intro sequence with "You are Zanthor" text +2. The title screen with animated Zanthor logo +3. The level select screen showing the Sunflower Kingdom + +If any of these don't appear, check the console for error messages. + +## Next Steps + +- See [Gameplay Guide](gameplay_guide.md) for how to play +- See [Configuration](configuration.md) for tweaking game settings diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..5fd4bb7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,41 @@ +# Zanthor Documentation + +Welcome to the documentation for **Zanthor**, a Python/Pygame-based arcade game where you play as an evil robot castle powered by steam. + +## Documentation Contents + +- [Getting Started](getting_started.md) - Installation and running the game +- [Gameplay Guide](gameplay_guide.md) - How to play Zanthor +- [Architecture Overview](architecture.md) - Technical architecture and code structure +- [Module Reference](modules.md) - Detailed module documentation +- [Game Configuration](configuration.md) - Configurable game parameters +- [Asset Reference](assets.md) - Game assets and resources + +## Quick Links + +- **Run the game**: `python run_game.py` +- **Install as package**: `pip install .` +- **GitHub**: [pygame/zanthor](http://github.com/pygame/zanthor/) + +## About Zanthor + +Zanthor is a real-time strategy/arcade game where you control Zanthor, a steam-powered robot castle. Your mission is to crush peasants across the Sunflower Kingdom while managing your resources (coal, water, steam) and upgrading your castle. + +The game was originally created as a PyWeek 2 entry by team "toba" (Aaron, Phil, Rene, Tim). + +## Game Features + +- **Steam-Powered Mechanics**: Manage coal and water to generate steam for movement and firing +- **8 Unique Levels**: Progress through the Sunflower Kingdom conquering villages and cities +- **Upgrade System**: Collect upgrade parts to enhance engine efficiency, cannon power, armor, and more +- **Multiple Control Schemes**: Keyboard, mouse, and joystick support +- **Flocking AI**: Peasants exhibit flocking behavior for realistic crowd movement + +## Requirements + +- Python 3.6+ (Python 3.12+ recommended) +- Pygame or pygame-ce + +## License + +Zanthor is released under the GNU Lesser General Public License v2 or later (LGPLv2+). diff --git a/docs/modules.md b/docs/modules.md new file mode 100644 index 0000000..78f8d32 --- /dev/null +++ b/docs/modules.md @@ -0,0 +1,890 @@ +# Module Reference + +Detailed documentation for each module in Zanthor. + +## Core Modules + +### main.py + +Entry point and game initialization. + +**Classes:** + +```python +class Game(engine.Game): + """Main game controller.""" + + def init(self): + """Initialize timer, sound manager, and game data.""" + + def tick(self): + """Called each frame. Updates timers and sound.""" + + def data_reset(self): + """Reset game data for new game.""" + + def event(self, e): + """Handle global events (quit, Android pause).""" +``` + +**Functions:** + +```python +def main(): + """Parse command-line args and start game.""" + +def do_main(no_intro=0, the_level=0): + """Initialize pygame and run game loop.""" +``` + +--- + +### const.py + +Game constants and configuration. + +**Screen Configuration:** +- `SW, SH` - Screen width/height (default 640x480) +- `FPS` - Target frame rate (default 30) +- `BSW, BSH` - Base size for scaling (640x480) +- `DX, DY` - Scale factors + +**UI Rectangles:** +- `S_VIEW` - Game view area +- `S_STATUS` - Left status panel +- `S_HEALTH`, `S_COAL`, `S_WATER`, `S_STEAM` - Stat bar positions +- `S_ROBOT` - Castle illustration area +- `S_ITEMS` - Equipment display area +- `S_BUTTONS` - Button area +- `S_MESSAGES` - Message display area + +**Gameplay Constants:** +- `MIN_CANNON_PRESSURE` - Minimum pressure to fire +- `JOY_FIRE_BUTTON`, `JOY_PICKUP_BUTTON` - Joystick button mappings +- `AUTO_PICKUP` - Auto-pickup items (default 1) +- `HOLES_ARE_TILES` - Ground holes become solid (0/1/2) +- `DISABLE_MOUSE_LOOK` - Disable mouse viewport control +- `FLOCKING_FLUCT` - Flocking fluctuation + +**Functions:** +```python +def data_dir(*args): + """Get path to data directory.""" + +def multr(dx_dy, r): + """Scale rectangle by display ratio.""" + +def get_mouse_info(): + """Get mouse scroll configuration.""" +``` + +--- + +### level.py + +Level loading and main gameplay state. + +**Class: Level** + +```python +class Level: + def __init__(self, game, _round, perc, music): + """ + Initialize level. + + Args: + game: Game instance + _round: Level filename or index + perc: Percentage of peasants to kill + music: Background music filename + """ + + def init(self): + """Called when level becomes active.""" + + def paint(self, screen): + """Render the level.""" + + def update(self, screen): + """Update and render.""" + + def loop(self): + """Game logic. Returns next state if level complete.""" + + def event(self, e): + """Handle input events.""" +``` + +**Key Attributes:** +- `tv` - Isovid renderer instance +- `interface` - Interface instance +- `round` - Current level +- `percent` - Win percentage + +--- + +### castle.py + +Player castle entity. + +**Class: CastleSprite(isovid.Sprite)** + +```python +class CastleSprite(isovid.Sprite): + """Player-controlled castle.""" + + def __init__(self, *args, **kwargs): + """Initialize castle with unit stats.""" + + def no_move(self): + """Disable movement.""" + + def yes_move(self): + """Enable movement.""" + + def yes_pickup(self, e): + """Accept item pickup.""" + + def no_pickup(self, e): + """Decline item pickup.""" + + def check_for_pickup(self, g, t): + """Check if tile has item to pickup.""" + + def upgrade_something(self, upgrade_what): + """Apply upgrade to castle.""" + + def event(self, g, e): + """Handle castle-specific input.""" + + def reset_castle(self): + """Reset stats from backup.""" + + def backup_castle(self): + """Save current stats.""" +``` + +**Functions:** + +```python +def castle_new(g, t, value): + """Spawn castle at tile.""" + +def castle_hit(g, s, a): + """Handle castle hitting something.""" + +def castle_loop(g, s): + """Per-frame castle update.""" + +def cball_new(g, pos, dest, damage=1.0, pressure=16): + """Create cannonball projectile.""" + +def cball_loop(g, s): + """Per-frame cannonball update.""" + +def cball_hit(g, a, b): + """Handle cannonball collision.""" +``` + +--- + +### units.py + +Unit stats and behavior. + +**Upgrade Configuration:** + +```python +upgrade_amounts = { + 'UpEngine Efficiency': cyclic_list([1.0, 2.0, ...]), + 'UpEngine Speed': cyclic_list([...]), + 'UpCannon Balls': cyclic_list([...]), + 'UpArmour': cyclic_list([...]), + 'UpSteam Tank': cyclic_list([...]), + 'UpWater Tank': cyclic_list([...]), + 'UpCoal Tank': cyclic_list([...]), + 'UpCannon Power': cyclic_list([...]), +} + +upgrade_words = [ + 'UpEngine Efficiency', + 'UpEngine Speed', + 'UpCannon Balls', + 'UpCannon Power', + 'UpArmour', + 'UpSteam Tank', + 'UpWater Tank', + 'UpCoal Tank', +] +``` + +**Class: BaseUnit** + +```python +class BaseUnit: + """Base class for all units.""" + + def __init__(self): + """Initialize with default stats.""" + + def pickup_item(self, item): + """Add item to inventory. Returns 1 if successful.""" + + def hit(self, damage_amount): + """Take damage. Returns 1 if dead.""" + + def try_move(self): + """Attempt movement. Returns 1 if enough steam.""" + + def try_fire(self): + """Attempt to fire. Returns 1 if enough steam.""" + + def prep_fire(self): + """Start charging cannon.""" + + def try_do_fire(self): + """Fire cannon. Returns pressure used.""" + + def try_use_steam(self, amount): + """Use steam. Returns 1 if successful.""" + + def loop(self): + """Per-frame update (generate steam).""" + + def generate_steam(self): + """Convert coal+water to steam.""" + + def upgrade_part(self, part_up, amount): + """Apply upgrade to unit stats.""" +``` + +**Unit Classes:** +- `Castle(BaseUnit)` - Player castle +- `Robot(BaseUnit)` - Peasant enemy +- `CannonTower(BaseUnit)` - Enemy turret +- `CoalTruck(BaseUnit)` - Resource transport +- `Factory(BaseUnit)` - Enemy building + +--- + +### items.py + +Collectible items. + +**Item Types (bitmask):** +```python +ITEM_COAL = 0x0001 +ITEM_WATER = 0x0002 +ITEM_PART = 0x0010 +ITEM_CANNON = 0x0020 +ITEM_ENGINE = 0x0030 +ITEM_WATERTANK = 0x0040 +ITEM_COALSTORAGEROOM = 0x0050 +ITEM_ARMOUR = 0x0060 +ITEM_RUBBLE = 0x0100 +``` + +**Item Classes:** + +```python +class Coal: + type = ITEM_COAL + def __init__(self, amount=1.0) + +class Water: + type = ITEM_WATER + def __init__(self, amount=1.0) + +class Part: + type = ITEM_PART + def __init__(self, name, pickup_string) + +class Rubble: + type = ITEM_RUBBLE + +class Cannon, Engine, WaterTank, CoalStorageRoom, Armour: + # Additional item types +``` + +--- + +### robot.py + +Peasant AI. + +**Configuration:** +```python +MIN = 8.0 # Minimum velocity +MAX = 16.0 # Maximum velocity +RADIUS = 32.0 # Separation radius +CENTER = 4.0 # Centering strength +SEARCH = 128.0 # Search radius +``` + +**Functions:** + +```python +def robot_new(g, t, v): + """Spawn peasant at tile.""" + +def robot_loop(g, s): + """Per-frame peasant update.""" + +def robot_shove(g, s, (x, y)): + """Push peasant to position.""" + +def robot_hit(g, a, b): + """Handle peasant collision.""" +``` + +--- + +### flock.py + +Flocking behavior system. + +**Class: Part** +```python +class Part: + """Individual flocking entity.""" + x, y # Position + _x, _y # Previous position + min # Minimum velocity + max # Maximum velocity + radius # Separation radius + center # Centering strength + near # List of nearby parts +``` + +**Class: Flock** +```python +class Flock: + def __init__(self, rect, spacing): + """ + Create flock with spatial grid. + + Args: + rect: Bounding rectangle + spacing: Grid cell size + """ + + def append(self, p): + """Add entity to flock.""" + + def remove(self, p): + """Remove entity from flock.""" + + def loop(self): + """Update all entities each frame.""" +``` + +**Flocking Algorithm:** +1. Update spatial grid positions +2. Calculate velocities from previous frame +3. Find neighbors using spatial grid +4. Add average velocity (alignment) +5. Move toward center (cohesion) +6. Enforce minimum velocity (random movement if stuck) +7. Separate from neighbors (separation) +8. Enforce maximum velocity + +--- + +## State Modules + +### states.py + +Game state classes. + +```python +class SPause(engine.State): + """Simple pause state.""" + +class GameOver(engine.State): + """Game over screen.""" + +class NextLevel(engine.State): + """Level complete - upgrade screen.""" + +class GameWon(engine.State): + """Victory screen.""" + +class News(engine.State): + """News popup.""" + +class Pause(engine.State): + """Pause with help text.""" + +class Prompt(engine.State): + """Yes/no prompt.""" +``` + +--- + +### intro.py + +Intro sequence. + +```python +class Intro(engine.State): + """Animated intro sequence.""" + + # Timing data for text display + data = [ + (0, "you", 0, 64), + (8, "are", 90, 64), + (16, "Zanthor", 128, 144), + ... + ] +``` + +--- + +### title.py + +Title screen and menus. + +```python +class Title(engine.State): + """Main title screen with menu.""" + +class Help(engine.State): + """Help screen.""" + +class Credits(engine.State): + """Credits screen.""" +``` + +--- + +### menu.py + +Level selection screen. + +```python +# Level data: (lock, rect, title, pop, filename, percent, ztitle, music, selected) +data = [ + (7, pygame.Rect(...), "Comfy Castle", 4397, "level8.tga", 70, ...), + ... +] + +class Menu(engine.State): + """Level selection map.""" + + def find_select_place(self, tobe): + """Check if position is valid level.""" + + def set_selected_parts(self, tobe): + """Update selection with bounds checking.""" +``` + +--- + +## Effect Modules + +### effect.py + +Effect system. + +```python +def effect_new(g, rect, e, f): + """ + Create sprite with effect. + + Args: + g: Game instance + rect: Position + e: Effect object + f: Frame count (< 1 for infinite) + """ + +def effect_loop(g, s): + """Per-frame effect update.""" +``` + +--- + +### steam.py + +Steam particle effect. + +```python +class Part: + """Single steam particle.""" + pos # Current position + _pos # Previous position + frame # Age in frames + z # Z-order + +class Effect: + def __init__(self, total, add, region=1, respawn=1, color=(255,255,255)): + """ + Args: + total: Maximum particles + add: Particles to add per frame + region: Spawn radius + respawn: Whether particles respawn + color: Particle color + """ + + def loop(self, pos): + """Update particles.""" + + def paint(self, screen, origin): + """Render particles.""" + + def zloop(self, pos, z): + """Update with z-ordering.""" + + def zpaint(self): + """Return z-sorted render data.""" +``` + +--- + +### explode.py + +Explosion particle effect. Similar structure to steam.py with different particle behavior. + +--- + +## UI Modules + +### interface.py + +HUD and status display. + +```python +class StatsDraw: + """Draws stat bars (health, coal, water, steam).""" + + def draw_img(self, screen, min, max, current, color, where_rect, astat): + """Draw stat as image (for health).""" + + def draw_rect(self, screen, min, max, current, color, where_rect): + """Draw stat as filled rectangle.""" + + def update_stats(self, screen, stats, robots, max_robots): + """Update all stat displays.""" + +class Interface: + """Main HUD controller.""" + + def __init__(self): + """Initialize fonts and message system.""" + + def load(self): + """Load UI graphics.""" + + def event(self, g, e): + """Handle UI events.""" + + def new_random_message(self): + """Generate new Zanthor message.""" + + def new_upgrade_message(self, upgrade_what): + """Show upgrade confirmation.""" + + def new_equipment(self, stats): + """Update equipment display.""" + + def update(self, tv, screen): + """Render all UI elements. Returns dirty rects.""" +``` + +--- + +### messages.py + +Procedural message generator. + +```python +# Grammar definition +data = { + 'SENTENCE': ['[_SENTENCE]'], + '_SENTENCE': ['[NAME] [WILL] [KILL] [HUMANS].', ...], + 'NAME': ['[_NAME]', '[_NAME] the [GREAT]', ...], + # ... more grammar rules +} + +def generate(): + """Generate random message using grammar.""" + +def generate_upgrade_message(upgrade_what): + """Generate upgrade confirmation message.""" +``` + +--- + +## Audio Modules + +### sounds.py + +Sound management. + +```python +class SoundManager: + def __init__(self, sound_list, sound_path, extensions): + """Initialize sound system.""" + + def Load(self, sound_list=[]): + """Load sounds into memory.""" + + def GetSound(self, name): + """Get Sound object by name.""" + + def Play(self, name, volume=[1.0, 1.0], wait=0, loop=0): + """ + Play sound. + + wait modes: + 0 - Play immediately + 1 - Queue if playing + 2 - Skip if playing + 3 - No queue + """ + + def Stop(self, name): + """Stop sound.""" + + def StopAll(self): + """Stop all sounds.""" + + def Update(self, elapsed_time): + """Process queued sounds.""" + + def PlayMusic(self, musicname): + """Play background music.""" + + def PauseMusic(self): + def UnPauseMusic(self): + +class ChannelFader: + """Fade audio channel in/out.""" + + def fade_in(self, seconds, volume_to): + def fade_out(self, seconds, volume_to): + def Update(self, elapsed_time): +``` + +--- + +### sound_info.py + +Sound definitions. + +```python +cannon = cl(['cannon', 'cannon2', 'cannon3', ...]) +hitenemy = cl(['hitenemy']) +hitwall = cl(['hitwall2']) +hitground = cl(['hitground']) +destroyenemy = cl(['destroyenemy']) +ushit = cl(['ouch1', 'ouch2']) +coal = cl(['coal']) +water = cl(['water']) +birds = cl(['birds1', 'birds2', 'birds2']) +peasants = cl(['peasants1', 'peasants2', 'peasants3']) +squish = cl(['squish1', 'squish2']) + +def get_upgrade_sound(upgrade_what): + """Get sound for upgrade type.""" +``` + +--- + +## Engine Modules + +### isovid.py + +Isometric renderer. + +```python +class Isovid(Vid): + """Isometric tile engine.""" + + def paint(self, screen): + """Render isometric view.""" + + def bkgr_blit(self, img, pos): + """Blit to background at iso position.""" + + def iso_to_view(self, pos): + """Convert iso to view coordinates.""" + + def view_to_iso(self, pos): + """Convert view to iso coordinates.""" + + def tile_to_view(self, pos): + """Convert tile to view coordinates.""" + + def screen_to_tile(self, pos): + """Convert screen to tile coordinates.""" + + def tga_load_tiles(self, fname, size, tdata={}): + """Load tiles from TGA image.""" + + def tga_load_level(self, fname, bg=0): + """Load level from TGA image.""" + + def set(self, pos, v): + """Set tile and update pathfinding layers.""" +``` + +--- + +### algo.py + +A* pathfinding. + +```python +def astar(start, end, layer, dist_func): + """ + Find path from start to end. + + Args: + start: (x, y) start position + end: (x, y) end position + layer: 2D array (0=walkable, 1=blocked) + dist_func: Heuristic function + + Returns: + List of (x, y) positions or empty list + """ +``` + +--- + +### cyclic_list.py + +Utility data structure. + +```python +class cyclic_list: + """List that cycles through elements.""" + + def __init__(self, items): + """Initialize with item list.""" + + def cur(self): + """Get current item.""" + + def nextone(self): + """Get next item and advance.""" + + def __next__(self): + """Advance to next item.""" +``` + +--- + +### tiles.py + +Tile interaction handlers. + +```python +def tile_block(g, t, s): + """Handle solid tile collision.""" + +def tile_wall(g, t, s): + """Handle wall hit (destructible).""" + +def tile_coal(g, t, s): + """Handle coal pickup.""" + +def tile_water(g, t, s): + """Handle water pickup.""" + +def tile_rubble(g, t, s): + """Handle rubble pickup.""" + +def tile_part(g, t, s): + """Handle upgrade part pickup.""" + +def tile_limit(g, t, s): + """Handle out-of-bounds (remove sprite).""" +``` + +--- + +## PGU Library (pgu/) + +Phil's Game Utilities - embedded game framework. + +### pgu/engine.py + +State machine engine. + +```python +class State: + """Base state class.""" + + def init(self): pass + def paint(self, screen): pass + def update(self, screen): pass + def loop(self): pass + def event(self, e): pass + +class Game: + """Game controller.""" + + def run(self, state, screen): + """Run game loop with initial state.""" + +class Quit(State): + """Quit signal state.""" +``` + +### pgu/vid.py + +Base tile video engine. + +```python +class Tile: + """Single tile type.""" + image, image_h + agroups, hit + config + +class Sprite: + """Game entity.""" + image, rect, shape + groups, agroups + loop, hit + +class Vid: + """Tile-based video engine.""" + + tiles # List of Tile objects + sprites # List of Sprite objects + tlayer # Foreground tile layer + blayer # Background tile layer + clayer # Code layer + + def resize(self, size, bg=0) + def set(self, pos, v) + def get(self, pos) + def paint(self, screen) + def loop(self) +``` + +### pgu/timer.py + +Timing utilities. + +```python +class Timer: + """Frame rate limiter.""" + + def tick(self): + """Limit frame rate.""" + +class Speedometer: + """FPS counter.""" + + def tick(self): + """Returns FPS every second.""" +``` + +### pgu/gui/ + +GUI widget system (tables, buttons, input, etc.). diff --git a/zanthor/castle.py b/zanthor/castle.py index cef5885..0e53d6c 100644 --- a/zanthor/castle.py +++ b/zanthor/castle.py @@ -287,7 +287,7 @@ def event(self, g, e): self.fire_state = "firing" if (e.type == JOYBUTTONDOWN and e.button == JOY_FIRE_BUTTON) or ( - e.type == KEYDOWN and e.key in [K_f, K_LCTRL, K_RCTRL, K_SPACE] + e.type == KEYDOWN and e.key in KEYS_FIRE() ): sx, sy = self.rect.centerx // tw, self.rect.centery // th @@ -297,7 +297,7 @@ def event(self, g, e): self.unit.prep_fire() if (e.type == JOYBUTTONUP and e.button == JOY_FIRE_BUTTON) or ( - e.type == KEYUP and e.key in [K_f, K_LCTRL, K_RCTRL, K_SPACE] + e.type == KEYUP and e.key in KEYS_FIRE() ): facing_x, facing_y = (self.last_direction_dx, self.last_direction_dy) facing_x, facing_y = iso_facing_to_screen_facing( @@ -333,34 +333,34 @@ def event(self, g, e): self.yes_pickup(e) if e.type == KEYUP: - if e.key in [K_UP, K_w]: + if e.key in KEYS_UP(): self.last_direction_dy = self.direction_dy self.direction_dy = 0 - elif e.key in [K_DOWN, K_s]: + elif e.key in KEYS_DOWN(): self.last_direction_dy = self.direction_dy self.direction_dy = 0 - elif e.key in [K_LEFT, K_a]: + elif e.key in KEYS_LEFT(): self.last_direction_dx = self.direction_dx self.direction_dx = 0 - elif e.key in [K_RIGHT, K_d]: + elif e.key in KEYS_RIGHT(): self.last_direction_dx = self.direction_dx self.direction_dx = 0 # some testing keys. if e.type == KEYDOWN: - if e.key in [K_UP, K_w]: + if e.key in KEYS_UP(): self.direction_dy = -1 self.last_direction_dy = self.direction_dy self.last_direction_dx = self.direction_dx - elif e.key in [K_DOWN, K_s]: + elif e.key in KEYS_DOWN(): self.direction_dy = 1 self.last_direction_dy = self.direction_dy self.last_direction_dx = self.direction_dx - elif e.key in [K_LEFT, K_a]: + elif e.key in KEYS_LEFT(): self.direction_dx = -1 self.last_direction_dx = self.direction_dx self.last_direction_dy = self.direction_dy - elif e.key in [K_RIGHT, K_d]: + elif e.key in KEYS_RIGHT(): self.direction_dx = 1 self.last_direction_dx = self.direction_dx self.last_direction_dy = self.direction_dy diff --git a/zanthor/const.py b/zanthor/const.py index abb1fd4..5d91937 100644 --- a/zanthor/const.py +++ b/zanthor/const.py @@ -1,5 +1,11 @@ """ this file has lots of tweaky things in it. +Historically every value here was hard‑coded. The module now pulls the +values that *should* be user‑configurable from :mod:`zanthor.settings` +(resolution, FPS, control keys, audio levels, …). Anything not in the +schema stays a plain constant so existing ``from .const import *`` +imports keep working unchanged. + Tweakable files (other) : sound_info.py - tweaking sounds. units.py - tweaking unit stats. @@ -11,6 +17,17 @@ import os +# --------------------------------------------------------------------- # +# User settings – loaded once at import time. +# --------------------------------------------------------------------- # +# The settings module is intentionally import‑safe: importing it never +# initialises pygame, and any I/O error simply yields defaults. This +# means ``const.py`` (which is imported from nearly every file in the +# game) can still be used as a source of module‑level "constants" +# while really reading from a JSON config the user can edit. +from . import settings as _settings + + _DATA_DIR = None @@ -24,20 +41,27 @@ def data_dir(*args): return os.path.join(*([_DATA_DIR] + list(args))) -# the base size. +# --------------------------------------------------------------------- # +# Display +# --------------------------------------------------------------------- # +# The base size every layout rect was authored against. All the S_* rects +# below are multiplied by (DX, DY) so the UI scales to whatever resolution +# the user picks. BSW, BSH = 640, 480 -SW, SH = 320, 200 -SW, SH = 800, 600 -SW, SH = 1280, 800 -SW, SH = 1024, 768 -SW, SH = 640, 480 + +# Previous code did: +# SW, SH = 640, 480 # <-- hard-coded! +# Now we ask the settings module. Users can change this either in the +# in-game Options screen or by editing ~/.config/zanthor/settings.json. +SW, SH = _settings.resolution() DX = float(SW) / BSW DY = float(SH) / BSH dxdy = (DX, DY) -FPS = 30 +# Likewise FPS is now configurable ("display.fps_cap" in settings.json). +FPS = _settings.fps_cap() MIN_CANNON_PRESSURE = 6 @@ -133,7 +157,12 @@ def multr(dx_dy, r): S_ = pygame.Rect(480, 360, 160, 120) -UPGRADE_FUN = 1 +# --------------------------------------------------------------------- # +# Gameplay toggles – now sourced from settings (with sane fallbacks). +# --------------------------------------------------------------------- # +# Debug: pressing 1-8 in-game grants free upgrades. Disabled by default +# for regular players; power users can flip it in the Options screen. +UPGRADE_FUN = 1 if _settings.get("gameplay.debug_upgrades", False) else 0 CACHE_USE_LEVEL_CACHE = 1 @@ -144,22 +173,27 @@ def multr(dx_dy, r): CACHE_CHECK_LEVEL = 1 -# joystick buttons -JOY_FIRE_BUTTON = 1 -JOY_PICKUP_BUTTON = 2 +# --------------------------------------------------------------------- # +# Controls +# --------------------------------------------------------------------- # +# joystick buttons (indices, not pygame constants) +JOY_FIRE_BUTTON = int(_settings.get("controls.joy_fire_button", 1)) +JOY_PICKUP_BUTTON = int(_settings.get("controls.joy_pickup_button", 2)) # should things be auto picked up or not? -AUTO_PICKUP = 1 +AUTO_PICKUP = 1 if _settings.get("gameplay.auto_pickup", True) else 0 # should holes that the castle hits the ground be # solid tiles # 0 = no, 1 = yes, 2= big holes -HOLES_ARE_TILES = 0 +HOLES_ARE_TILES = int(_settings.get("gameplay.holes_are_tiles", 0) or 0) # NOTE: mouselook seems to not work well on some systems... # can toggle it with the m key in game. -DISABLE_MOUSE_LOOK = 0 +# 1 disables mouse-look, 0 enables it – we derive it from the *positive* +# "mouse_look" boolean in settings so the JSON stays intuitive. +DISABLE_MOUSE_LOOK = 0 if _settings.get("controls.mouse_look", True) else 1 FLOCKING_FLUCT = 1 @@ -176,3 +210,40 @@ def get_mouse_info(): SCROLL_BORDER = 160 # size of scrolling border during auto-scroll return [SCROLL_MOUSE, SCROLL_AUTO, SCROLL_BORDER] + + +# --------------------------------------------------------------------- # +# Key-binding helpers +# --------------------------------------------------------------------- # +# Historically the castle/level/menu modules compared against hard-coded +# pygame K_* constants. They now call these helpers, which resolve the +# user's bindings on the fly. Calling settings.keys(...) is cheap – the +# settings module caches the resolved key-codes until a rebind happens. + +def KEYS_UP(): + """Pygame key-codes bound to 'move up'.""" + return _settings.keys("move_up") + + +def KEYS_DOWN(): + return _settings.keys("move_down") + + +def KEYS_LEFT(): + return _settings.keys("move_left") + + +def KEYS_RIGHT(): + return _settings.keys("move_right") + + +def KEYS_FIRE(): + return _settings.keys("fire") + + +def KEYS_PAUSE(): + return _settings.keys("pause") + + +def KEYS_TOGGLE_MOUSELOOK(): + return _settings.keys("toggle_mouselook") diff --git a/zanthor/intro.py b/zanthor/intro.py index 622e51c..c4dd507 100644 --- a/zanthor/intro.py +++ b/zanthor/intro.py @@ -38,6 +38,12 @@ def init(self): pygame.mixer.music.load(data_dir("intro", "intro1.ogg")) # pygame.time.wait(2500) #let fullscreen kick in, pygame.mixer.music.play() + # Honour the user-configured music volume. + try: + from . import settings + pygame.mixer.music.set_volume(settings.music_volume()) + except Exception: + pass else: self.cur_time = time.time() self.elapsed_time = 0.0 diff --git a/zanthor/level.py b/zanthor/level.py index 65caa1b..c2ba615 100644 --- a/zanthor/level.py +++ b/zanthor/level.py @@ -38,6 +38,12 @@ def __init__(self, game, _round, perc, music): if pygame.mixer: pygame.mixer.music.load(data_dir("intro", music)) pygame.mixer.music.play(-1) + # Honour the user-configured music volume. + try: + from . import settings + pygame.mixer.music.set_volume(settings.music_volume()) + except Exception: + pass tdata = { 0x01: ("castle", tiles.tile_coal, None), @@ -539,7 +545,7 @@ def event(self, e): self.tv.castle.event(self.tv, e) - if e.type == KEYDOWN and e.key in (K_RETURN, K_p, K_h): + if e.type == KEYDOWN and e.key in KEYS_PAUSE(): from . import states return states.Pause(self.game, "pause", self) @@ -569,7 +575,7 @@ def event(self, e): self.game.data["levels"].append(self.round) return states.GameWon(self.game) - if e.type == KEYDOWN and e.key in [K_m]: + if e.type == KEYDOWN and e.key in KEYS_TOGGLE_MOUSELOOK(): const.DISABLE_MOUSE_LOOK = not const.DISABLE_MOUSE_LOOK # bound the mouse position diff --git a/zanthor/main.py b/zanthor/main.py index 311d8b2..863b7c9 100644 --- a/zanthor/main.py +++ b/zanthor/main.py @@ -36,6 +36,8 @@ from .pgu import timer from .const import * +# User settings: fullscreen / intro-skip flags, etc. +from . import settings as _settings if 0: # this can be used to figure out how big the desktop is... @@ -206,6 +208,10 @@ def main(): the_level = None # flags ^= FULLSCREEN flags = 0 + # Pull fullscreen from the user settings file; sys.argv can still + # force-toggle it for quick debugging. + if _settings.fullscreen(): + flags |= FULLSCREEN if "fullscreen" in sys.argv or "full" in sys.argv: flags ^= FULLSCREEN @@ -221,6 +227,12 @@ def main(): else: no_intro = 0 + # Allow the user settings to skip the intro even without a CLI flag. + # CLI arguments still win – only apply if no_intro is still at its + # default value. + if no_intro == 0 and _settings.skip_intro(): + no_intro = 1 + # jump to any level by specifying, e.g. "level8.tga" for fname in sys.argv: if ".tga" in fname: diff --git a/zanthor/menu.py b/zanthor/menu.py index 114c1dd..e193a76 100644 --- a/zanthor/menu.py +++ b/zanthor/menu.py @@ -385,16 +385,16 @@ def event(self, e): if e.type in [KEYDOWN, JOYAXISMOTION]: self.show_selected = True - if e.type == KEYDOWN and e.key in [K_LEFT, K_a]: + if e.type == KEYDOWN and e.key in KEYS_LEFT(): self.set_selected_parts((self.selected_row, self.selected_collum - 1)) - if e.type == KEYDOWN and e.key in [K_RIGHT, K_d]: + if e.type == KEYDOWN and e.key in KEYS_RIGHT(): self.set_selected_parts((self.selected_row, self.selected_collum + 1)) - if e.type == KEYDOWN and e.key in [K_UP, K_w]: + if e.type == KEYDOWN and e.key in KEYS_UP(): self.set_selected_parts((self.selected_row - 1, self.selected_collum)) - if e.type == KEYDOWN and e.key in [K_DOWN, K_s]: + if e.type == KEYDOWN and e.key in KEYS_DOWN(): self.set_selected_parts((self.selected_row + 1, self.selected_collum)) if e.type == JOYAXISMOTION: diff --git a/zanthor/options.py b/zanthor/options.py new file mode 100644 index 0000000..c6f039a --- /dev/null +++ b/zanthor/options.py @@ -0,0 +1,832 @@ +""" +zanthor.options +=============== + +The in‑game **Options** screen. + +Visual design +------------- +The screen deliberately echoes Zanthor's existing title / menu look & feel: + +* The parchment‑and‑brass colour palette (cream, gold, black outlines) + matches the ``mybkgr.png`` art, which is reused as a dimmed backdrop. +* All text is rendered with the game's ``vinque.ttf`` fantasy font and the + same black‑halo outline helper (:func:`pgu.text.write`) used everywhere + else. +* Tabs are laid out along the top, options in a brass‑trimmed card on the + left, and a live description / value panel on the right. + +Interaction design +------------------ +* **Mouse** – click tabs to switch section, click an option to select + it, click **again** (or click the value column) to edit / toggle. + Dragging on a slider live‑updates the value. Bottom buttons for + *Save*, *Reset Section*, *Defaults* and *Back*. +* **Keyboard** – ``Tab / Shift+Tab`` cycles tabs, ``↑ / ↓`` moves the + selection, ``← / →`` adjusts, ``Enter`` toggles / starts a key‑rebind, + ``Esc`` goes back (also cancels an in‑progress rebind). + +All widgets are hand‑drawn – no external GUI toolkit – so the module is +drop‑in with zero extra dependencies. +""" + +import math + +import pygame +from pygame.locals import ( + KEYDOWN, + KEYUP, + K_DOWN, + K_ESCAPE, + K_LEFT, + K_RETURN, + K_RIGHT, + K_SPACE, + K_TAB, + K_UP, + K_BACKSPACE, + K_DELETE, + KMOD_SHIFT, + MOUSEBUTTONDOWN, + MOUSEBUTTONUP, + MOUSEMOTION, +) + +from .pgu import engine +from .pgu import text as pgu_text +from .const import data_dir, SW, SH +from . import settings + + +# --------------------------------------------------------------------------- +# Colour palette (steampunk parchment / brass) +# --------------------------------------------------------------------------- + +C_BG_OVERLAY = (0, 0, 0, 130) # dim overlay on top of parchment + +C_TITLE = (255, 232, 170) # warm gold +C_TEXT = (240, 230, 210) # cream +C_TEXT_DIM = (150, 140, 120) # inactive label + +C_ACCENT = (255, 175, 64) # brass highlight +C_ACCENT_DARK = (170, 110, 40) # brass shadow +C_PANEL = (26, 22, 18, 215) # dark brown semi‑opaque card +C_PANEL_BORDER = (90, 68, 38) + +C_SLIDER_TRACK = (70, 55, 40) +C_SLIDER_FILL = (200, 145, 60) +C_SLIDER_KNOB = (255, 220, 170) + +C_TOGGLE_ON = (92, 190, 80) +C_TOGGLE_OFF = (120, 60, 50) + +C_BUTTON = (50, 40, 30) +C_BUTTON_HOVER = (80, 60, 40) +C_RESTART = (255, 96, 96) # small restart badge + + +# --------------------------------------------------------------------------- +# Tiny drawing helpers +# --------------------------------------------------------------------------- + +def _round_rect(surface, color, rect, radius=8, width=0): + """Anti‑aliased rounded rectangle (filled when *width* == 0).""" + try: + pygame.draw.rect(surface, color, rect, width=width, border_radius=radius) + except TypeError: # pygame <2 fallback + pygame.draw.rect(surface, color, rect, width) + + +def _panel(surface, rect, radius=10): + """Draw a dark semi‑transparent card with a brass border.""" + panel = pygame.Surface(rect.size, pygame.SRCALPHA) + _round_rect(panel, C_PANEL, panel.get_rect(), radius) + surface.blit(panel, rect.topleft) + _round_rect(surface, C_PANEL_BORDER, rect, radius, width=2) + + +def _outline_text(surface, font, pos, color, txt, border=2): + pgu_text.write(surface, font, pos, color, txt, border) + + +def _text_size(font, txt): + return font.size(txt) + + +# --------------------------------------------------------------------------- +# The Options State +# --------------------------------------------------------------------------- + +class Options(engine.State): + """Full‑screen configuration editor, reachable from the title menu.""" + + # ------------------------------------------------------------------ # + # life‑cycle + # ------------------------------------------------------------------ # + def init(self): + # ---- fonts + self.font_title = pygame.font.Font(data_dir("menu", "vinque.ttf"), 42) + self.font_tab = pygame.font.Font(data_dir("menu", "vinque.ttf"), 26) + self.font_opt = pygame.font.Font(data_dir("menu", "vinque.ttf"), 22) + self.font_small = pygame.font.Font(data_dir("menu", "vinque.ttf"), 16) + + # ---- background: parchment dimmed with a dark overlay + try: + bg = pygame.image.load(data_dir("intro", "mybkgr.png")).convert() + bg = pygame.transform.scale(bg, (SW, SH)) + except Exception: + bg = pygame.Surface((SW, SH)) + bg.fill((28, 22, 18)) + overlay = pygame.Surface((SW, SH), pygame.SRCALPHA) + overlay.fill(C_BG_OVERLAY) + bg.blit(overlay, (0, 0)) + self.bkgr = bg + + # ---- state + self.tabs = settings.sections() # e.g. ['display', 'audio', ...] + self.tab_idx = 0 + self.sel_idx = 0 + self.hover_idx = None + self.scroll = 0 # first visible row index + + self.rebinding = None # action name while waiting for key + self.slider_drag = None # (dotted, rect) while dragging + self.flash = "" # transient status line + self.flash_timer = 0 + + # Snapshot so the user can *Cancel* (leave via Esc with unsaved changes + # and optionally revert – we revert only on explicit *Back w/o Save* + # prompt; currently we simply auto‑save on *Save* and leave values + # in memory on *Back*). + self._snapshot = settings.export_copy() + + # Geometry -------------------------------------------------------- + pad = int(SW * 0.03) + self.r_title = pygame.Rect(pad, pad, SW - 2 * pad, 50) + self.r_tabs = pygame.Rect(pad, self.r_title.bottom + 4, SW - 2 * pad, 42) + body_top = self.r_tabs.bottom + 12 + body_h = SH - body_top - 72 + opt_w = int((SW - 2 * pad) * 0.58) + self.r_opts = pygame.Rect(pad, body_top, opt_w, body_h) + self.r_desc = pygame.Rect( + self.r_opts.right + 12, body_top, SW - pad - self.r_opts.right - 12, body_h + ) + self.r_buttons = pygame.Rect(pad, SH - 64, SW - 2 * pad, 56) + + # Per‑frame layout caches (rebuilt each paint) + self._tab_rects = [] + self._opt_rects = [] # list of (dotted, label_rect, value_rect, spec) + self._btn_rects = {} # name -> rect + + self.frame = 0 + pygame.mouse.set_visible(True) + pygame.key.set_repeat(350, 40) + + # ------------------------------------------------------------------ # + # drawing + # ------------------------------------------------------------------ # + def paint(self, screen): + screen.blit(self.bkgr, (0, 0)) + + # --- Title -------------------------------------------------------- + _outline_text( + screen, + self.font_title, + (self.r_title.x, self.r_title.y), + C_TITLE, + "Options", + border=2, + ) + + # --- Tabs --------------------------------------------------------- + self._draw_tabs(screen) + + # --- Option panel ------------------------------------------------- + _panel(screen, self.r_opts) + self._draw_options(screen) + + # --- Description panel ------------------------------------------- + _panel(screen, self.r_desc) + self._draw_description(screen) + + # --- Buttons ------------------------------------------------------ + self._draw_buttons(screen) + + # --- Flash / status ---------------------------------------------- + if self.flash and self.flash_timer > 0: + _outline_text( + screen, + self.font_small, + (self.r_buttons.x + 8, self.r_buttons.y - 22), + C_ACCENT, + self.flash, + ) + + # --- Key‑rebind overlay ------------------------------------------ + if self.rebinding: + self._draw_rebind_overlay(screen) + + pygame.display.flip() + + def update(self, screen): + self.paint(screen) + + def loop(self): + self.frame += 1 + if self.flash_timer > 0: + self.flash_timer -= 1 + + # .................................................................. # + # sub‑drawers + # .................................................................. # + def _draw_tabs(self, screen): + self._tab_rects = [] + x = self.r_tabs.x + y = self.r_tabs.y + gap = 14 + for i, sec in enumerate(self.tabs): + label = sec.title() + w, h = _text_size(self.font_tab, label) + pad = 14 + r = pygame.Rect(x, y, w + pad * 2, h + 10) + active = i == self.tab_idx + + # tab background + fill = C_ACCENT_DARK if active else C_BUTTON + _round_rect(screen, fill, r, radius=8) + if active: + _round_rect(screen, C_ACCENT, r, radius=8, width=2) + # little brass underline pointing to the panel + pygame.draw.line( + screen, + C_ACCENT, + (r.x + 6, r.bottom), + (r.right - 6, r.bottom), + 3, + ) + + col = C_TITLE if active else C_TEXT_DIM + _outline_text( + screen, self.font_tab, (r.x + pad, r.y + 4), col, label, border=1 + ) + + self._tab_rects.append((i, r)) + x = r.right + gap + + def _draw_options(self, screen): + """Render visible options in the active section (scrolling).""" + sec = self.tabs[self.tab_idx] + opts = settings.options(sec) + + self._opt_rects = [] + x0 = self.r_opts.x + 16 + top = self.r_opts.y + 14 + row_h = max(32, self.font_opt.get_height() + 10) + label_w = int(self.r_opts.w * 0.50) + visible = max(1, (self.r_opts.h - 28) // row_h) + + # Clamp / auto-scroll so the selected row is always on screen. + self.scroll = max(0, min(self.scroll, max(0, len(opts) - visible))) + if self.sel_idx < self.scroll: + self.scroll = self.sel_idx + elif self.sel_idx >= self.scroll + visible: + self.scroll = self.sel_idx - visible + 1 + + # Clip to the panel so overflowing text never bleeds. + old_clip = screen.get_clip() + clip = pygame.Rect( + self.r_opts.x + 4, self.r_opts.y + 4, + self.r_opts.w - 8, self.r_opts.h - 8, + ) + screen.set_clip(clip) + + y = top + first, last = self.scroll, min(len(opts), self.scroll + visible) + for i in range(first, last): + key = opts[i] + dotted = "{}.{}".format(sec, key) + spec = settings.spec(dotted) + val = settings.get(dotted) + + sel = i == self.sel_idx + hov = i == self.hover_idx + + r_row = pygame.Rect(x0 - 6, y - 4, self.r_opts.w - 20, row_h) + if sel: + hl = pygame.Surface(r_row.size, pygame.SRCALPHA) + hl.fill((255, 200, 120, 36)) + screen.blit(hl, r_row.topleft) + _round_rect(screen, C_ACCENT, r_row, radius=6, width=1) + elif hov: + hl = pygame.Surface(r_row.size, pygame.SRCALPHA) + hl.fill((255, 255, 255, 14)) + screen.blit(hl, r_row.topleft) + + # label -------------------------------------------------- + label_col = C_TEXT if (sel or hov) else C_TEXT_DIM + lab = self._ellipsize(spec.label, self.font_opt, label_w - 10) + _outline_text( + screen, self.font_opt, (x0, y), label_col, lab, border=1 + ) + if spec.restart: + _outline_text( + screen, + self.font_small, + (x0 + _text_size(self.font_opt, lab)[0] + 6, y), + C_RESTART, + "*", + border=1, + ) + + # value widget ------------------------------------------ + r_val = pygame.Rect( + x0 + label_w, y - 2, + self.r_opts.w - label_w - 30, row_h - 6, + ) + self._draw_value(screen, dotted, spec, val, r_val, sel) + + self._opt_rects.append((dotted, r_row, r_val, spec, i)) + y += row_h + + screen.set_clip(old_clip) + + # Scroll arrows --------------------------------------------- + if first > 0: + self._draw_scroll_hint(screen, self.r_opts.centerx, self.r_opts.y + 6, up=True) + if last < len(opts): + self._draw_scroll_hint(screen, self.r_opts.centerx, self.r_opts.bottom - 6, up=False) + + @staticmethod + def _ellipsize(text, font, max_w): + """Truncate *text* with a trailing ellipsis until it fits in *max_w*.""" + if font.size(text)[0] <= max_w: + return text + ell = ".." + out = text + while out and font.size(out + ell)[0] > max_w: + out = out[:-1] + return (out + ell) if out else ell + + @staticmethod + def _draw_scroll_hint(screen, cx, cy, up): + """Tiny chevron indicating more content above/below.""" + d = -1 if up else 1 + pts = [(cx - 7, cy - 4 * d), (cx + 7, cy - 4 * d), (cx, cy + 4 * d)] + pygame.draw.polygon(screen, C_ACCENT, pts) + pygame.draw.polygon(screen, (0, 0, 0), pts, 1) + + + def _draw_value(self, screen, dotted, spec, val, rect, selected): + """Render the right‑hand side widget for one option.""" + kind = spec.kind + + if kind == "toggle": + # pill‑style switch + pill = pygame.Rect(rect.x, rect.y + rect.h // 2 - 10, 46, 20) + col = C_TOGGLE_ON if val else C_TOGGLE_OFF + _round_rect(screen, col, pill, radius=10) + knob_x = pill.right - 12 if val else pill.x + 12 + pygame.draw.circle(screen, C_SLIDER_KNOB, (knob_x, pill.centery), 9) + txt = "On" if val else "Off" + _outline_text( + screen, + self.font_small, + (pill.right + 10, pill.y), + C_TEXT, + txt, + border=1, + ) + + elif kind == "slider": + track = pygame.Rect(rect.x, rect.centery - 4, rect.w - 60, 8) + _round_rect(screen, C_SLIDER_TRACK, track, radius=4) + rng = (spec.vmax - spec.vmin) or 1.0 + pct = (float(val) - spec.vmin) / rng + pct = max(0.0, min(1.0, pct)) + fill = pygame.Rect(track.x, track.y, int(track.w * pct), track.h) + _round_rect(screen, C_SLIDER_FILL, fill, radius=4) + knob_x = track.x + int(track.w * pct) + pygame.draw.circle(screen, C_SLIDER_KNOB, (knob_x, track.centery), 8) + pygame.draw.circle(screen, C_ACCENT_DARK, (knob_x, track.centery), 8, 1) + txt = "{:.0%}".format(pct) if spec.vmax == 1.0 else str(val) + _outline_text( + screen, + self.font_small, + (track.right + 10, track.y - 4), + C_TEXT, + txt, + border=1, + ) + # stash track for drag handling + rect.w = track.w # shrink hit‑rect to the track itself + rect.x = track.x + rect.h = 18 + rect.y = track.y - 5 + + elif kind == "choice": + # < Value > stepper with arrow markers on each side when selected + s_val = self._fmt_choice(val) + if selected: + s_val = "< {} >".format(s_val) + _outline_text( + screen, self.font_opt, (rect.x, rect.y), C_ACCENT, s_val, border=1 + ) + + elif kind == "int": + s_val = str(val) + if selected: + s_val = "< {} >".format(s_val) + _outline_text( + screen, self.font_opt, (rect.x, rect.y), C_ACCENT, s_val, border=1 + ) + + elif kind == "key": + action = dotted.split(".", 1)[1] + s_val = settings.key_label(action) + # Long multi-bindings ("Space / F / Left Ctrl / Right Ctrl") + # would spill past the panel – trim with an ellipsis. + s_val = self._ellipsize(s_val, self.font_opt, rect.w) + col = C_ACCENT if selected else C_TEXT + _outline_text(screen, self.font_opt, (rect.x, rect.y), col, s_val, border=1) + + else: + _outline_text( + screen, self.font_opt, (rect.x, rect.y), C_TEXT, str(val), border=1 + ) + + @staticmethod + def _fmt_choice(val): + if isinstance(val, (list, tuple)) and len(val) == 2: + return "{} x {}".format(val[0], val[1]) + return str(val) + + def _draw_description(self, screen): + sec = self.tabs[self.tab_idx] + opts = settings.options(sec) + if not opts: + return + idx = max(0, min(self.sel_idx, len(opts) - 1)) + dotted = "{}.{}".format(sec, opts[idx]) + spec = settings.spec(dotted) + + x = self.r_desc.x + 14 + y = self.r_desc.y + 12 + _outline_text(screen, self.font_opt, (x, y), C_TITLE, spec.label, border=1) + y += self.font_opt.get_height() + 8 + + # word‑wrapped description + desc = spec.desc or "" + max_w = self.r_desc.w - 28 + for line in self._wrap(desc, self.font_small, max_w): + _outline_text(screen, self.font_small, (x, y), C_TEXT, line, border=1) + y += self.font_small.get_height() + 2 + + if spec.restart: + y += 10 + _outline_text( + screen, + self.font_small, + (x, y), + C_RESTART, + "* restart required", + border=1, + ) + + # -- footer: config file location + y = self.r_desc.bottom - 60 + _outline_text( + screen, + self.font_small, + (x, y), + C_TEXT_DIM, + "Config file:", + border=1, + ) + # cut off long paths gracefully + path = settings._instance.config_path + short = path + if len(short) > 48: + short = "…" + short[-46:] + _outline_text( + screen, + self.font_small, + (x, y + self.font_small.get_height() + 2), + C_TEXT_DIM, + short, + border=1, + ) + + @staticmethod + def _wrap(text, font, max_w): + if not text: + return [] + words = text.split(" ") + out, line = [], "" + for w in words: + test = (line + " " + w).strip() + if font.size(test)[0] <= max_w: + line = test + else: + if line: + out.append(line) + line = w + if line: + out.append(line) + return out + + def _draw_buttons(self, screen): + self._btn_rects = {} + labels = [ + ("save", "Save"), + ("reset_sec", "Reset Tab"), + ("defaults", "Defaults"), + ("back", "Back"), + ] + n = len(labels) + gap = 12 + bw = (self.r_buttons.w - gap * (n - 1)) // n + bh = self.r_buttons.h - 12 + x = self.r_buttons.x + y = self.r_buttons.y + 6 + + mouse = pygame.mouse.get_pos() + for key, text in labels: + r = pygame.Rect(x, y, bw, bh) + hov = r.collidepoint(mouse) + fill = C_BUTTON_HOVER if hov else C_BUTTON + _round_rect(screen, fill, r, radius=8) + border_col = C_ACCENT if hov else C_PANEL_BORDER + _round_rect(screen, border_col, r, radius=8, width=2) + tw, th = _text_size(self.font_opt, text) + _outline_text( + screen, + self.font_opt, + (r.centerx - tw // 2, r.centery - th // 2), + C_TEXT, + text, + border=1, + ) + self._btn_rects[key] = r + x += bw + gap + + # Restart‑required badge + if settings.restart_required(): + msg = "Some changes take effect after restart" + tw, th = self.font_small.size(msg) + _outline_text( + screen, + self.font_small, + (self.r_buttons.right - tw - 4, self.r_buttons.y - th - 4), + C_RESTART, + msg, + border=1, + ) + + def _draw_rebind_overlay(self, screen): + overlay = pygame.Surface((SW, SH), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 170)) + screen.blit(overlay, (0, 0)) + + box_w, box_h = int(SW * 0.6), 120 + r = pygame.Rect((SW - box_w) // 2, (SH - box_h) // 2, box_w, box_h) + _panel(screen, r, radius=12) + + action = self.rebinding + spec = settings.spec("controls.{}".format(action)) + title = "Rebind: {}".format(spec.label if spec else action) + tw, _ = self.font_opt.size(title) + _outline_text( + screen, + self.font_opt, + (r.centerx - tw // 2, r.y + 16), + C_TITLE, + title, + border=1, + ) + sub = "Press any key… (Esc to cancel · Backspace to clear)" + sw, _ = self.font_small.size(sub) + _outline_text( + screen, + self.font_small, + (r.centerx - sw // 2, r.y + 56), + C_TEXT, + sub, + border=1, + ) + pygame.display.flip() + + # ------------------------------------------------------------------ # + # events + # ------------------------------------------------------------------ # + def event(self, e): + # ------------------------------------------------ rebind capture + if self.rebinding: + if e.type == KEYDOWN: + if e.key == K_ESCAPE: + self.rebinding = None + elif e.key in (K_BACKSPACE, K_DELETE): + settings.set("controls.{}".format(self.rebinding), []) + self._flash("Cleared binding for {}".format(self.rebinding)) + self.rebinding = None + else: + settings.bind_key(self.rebinding, e.key, replace_primary=True) + self._flash( + "Bound {} → {}".format( + self.rebinding, pygame.key.name(e.key) + ) + ) + self.rebinding = None + return # eat everything else while rebinding + + # ------------------------------------------------ mouse + if e.type == MOUSEMOTION: + self._update_hover(e.pos) + if self.slider_drag: + self._drag_slider(e.pos) + + elif e.type == MOUSEBUTTONDOWN and e.button == 1: + return self._handle_click(e.pos) + + elif e.type == MOUSEBUTTONDOWN and e.button == 4: + # mouse wheel up – scroll the options list + self.scroll = max(0, self.scroll - 1) + + elif e.type == MOUSEBUTTONDOWN and e.button == 5: + # mouse wheel down + self.scroll += 1 # clamped in _draw_options + + elif e.type == MOUSEBUTTONUP and e.button == 1: + self.slider_drag = None + + # ------------------------------------------------ keyboard + elif e.type == KEYDOWN: + if e.key == K_ESCAPE: + return self._go_back() + + if e.key == K_TAB: + delta = -1 if (e.mod & KMOD_SHIFT) else 1 + self._switch_tab(delta) + + elif e.key == K_UP: + self._move_selection(-1) + + elif e.key == K_DOWN: + self._move_selection(+1) + + elif e.key == K_LEFT: + self._adjust(-1) + + elif e.key == K_RIGHT: + self._adjust(+1) + + elif e.key in (K_RETURN, K_SPACE): + self._activate() + + # .................................................................. # + # event helpers + # .................................................................. # + def _update_hover(self, pos): + self.hover_idx = None + for (dotted, row, rv, spec, idx) in self._opt_rects: + if row.collidepoint(pos): + self.hover_idx = idx + break + + def _handle_click(self, pos): + # tabs + for i, r in self._tab_rects: + if r.collidepoint(pos): + self.tab_idx = i + self.sel_idx = 0 + self.hover_idx = None + self.scroll = 0 + return + + # option rows + for (dotted, row, rv, spec, idx) in self._opt_rects: + if row.collidepoint(pos): + was_sel = self.sel_idx == idx + self.sel_idx = idx + if spec.kind == "slider" and rv.collidepoint(pos): + self.slider_drag = (dotted, rv) + self._drag_slider(pos) + return + if was_sel or rv.collidepoint(pos): + self._activate() + return + + # buttons + for name, r in self._btn_rects.items(): + if r.collidepoint(pos): + return self._button_action(name) + + def _drag_slider(self, pos): + dotted, rv = self.slider_drag + spec = settings.spec(dotted) + rng = (spec.vmax - spec.vmin) or 1.0 + pct = (pos[0] - rv.x) / max(1, rv.w) + pct = max(0.0, min(1.0, pct)) + val = spec.vmin + pct * rng + if spec.step: + val = round(val / spec.step) * spec.step + settings.set(dotted, val) + + def _button_action(self, name): + if name == "save": + settings.save() + self._snapshot = settings.export_copy() + self._flash("Settings saved.") + elif name == "reset_sec": + settings.reset_section(self.tabs[self.tab_idx]) + self._flash("Tab reset to defaults.") + elif name == "defaults": + settings.reset_all() + self._flash("All settings reset to defaults.") + elif name == "back": + return self._go_back() + + def _go_back(self): + pygame.key.set_repeat() # turn off key repeat outside the menu + # Auto‑save on exit so players aren't surprised by lost changes. + if settings.dirty(): + settings.save() + self._flash("Settings saved.") + from . import title + return title.Title(self.game) + + def _switch_tab(self, delta): + self.tab_idx = (self.tab_idx + delta) % len(self.tabs) + self.sel_idx = 0 + self.hover_idx = None + self.scroll = 0 + + def _move_selection(self, delta): + n = len(settings.options(self.tabs[self.tab_idx])) + if n: + self.sel_idx = (self.sel_idx + delta) % n + + # .................................................................. # + # value editing + # .................................................................. # + def _current(self): + sec = self.tabs[self.tab_idx] + opts = settings.options(sec) + if not opts: + return None, None + key = opts[max(0, min(self.sel_idx, len(opts) - 1))] + dotted = "{}.{}".format(sec, key) + return dotted, settings.spec(dotted) + + def _adjust(self, delta): + dotted, spec = self._current() + if spec is None: + return + val = settings.get(dotted) + + if spec.kind == "toggle": + settings.set(dotted, not val) + + elif spec.kind == "slider": + step = spec.step or 0.05 + settings.set(dotted, val + step * delta) + + elif spec.kind == "int": + step = spec.step or 1 + settings.set(dotted, val + step * delta) + + elif spec.kind == "choice": + choices = spec.choices or [val] + try: + idx = 0 + for n, c in enumerate(choices): + if ( + val == c + or list(val) == list(c) + or tuple(val) == tuple(c) + ): + idx = n + break + except Exception: + idx = 0 + idx = (idx + delta) % len(choices) + settings.set(dotted, choices[idx]) + + elif spec.kind == "key": + # keys are edited via Enter → rebind overlay + pass + + def _activate(self): + """Primary action on Enter / Space / mouse click.""" + dotted, spec = self._current() + if spec is None: + return + if spec.kind == "toggle": + settings.set(dotted, not settings.get(dotted)) + elif spec.kind == "key": + self.rebinding = dotted.split(".", 1)[1] + elif spec.kind in ("choice", "int", "slider"): + self._adjust(+1) + + def _flash(self, msg): + self.flash = msg + self.flash_timer = 90 # ~3 s at 30 fps diff --git a/zanthor/settings.py b/zanthor/settings.py new file mode 100644 index 0000000..201744e --- /dev/null +++ b/zanthor/settings.py @@ -0,0 +1,772 @@ +""" +zanthor.settings +================ + +A simple, modular configuration system for Zanthor. + +Design goals +------------ +* **Single source of truth** – one `Settings` object, imported as + ``from zanthor import settings`` anywhere in the code base. +* **Human‑editable** – persisted as pretty‑printed JSON in the user's + config directory (``~/.zanthor/settings.json`` or the platform + equivalent). Users may hand‑edit the file; unknown keys are ignored + and missing keys fall back to schema defaults. +* **Schema‑driven validation** – every option is described by a + :class:`Spec` which provides the default value, an optional + ``choices`` list or ``(min, max)`` range, a widget hint and a + human‑readable label / description. The Options UI is generated + directly from the schema so adding a new setting is a one‑line change. +* **Key‑binding support** – actions are stored as lists of *pygame + key names* (``"w"``, ``"left ctrl"`` …) so the JSON stays portable + across pygame / SDL versions. Helper :func:`Settings.keys` returns + the resolved key‑codes for fast event comparison. +* **Live apply** – whenever a value changes, registered listeners are + notified. Audio‑volume changes for example take effect immediately + while the user is still dragging the slider. + +Usage +----- +>>> from zanthor import settings +>>> settings.get("display.resolution") # -> (640, 480) +>>> settings.get("audio.music_volume") # -> 0.8 +>>> settings.keys("move_up") # -> [K_UP, K_w] +>>> settings.set("audio.music_volume", 0.5) +>>> settings.save() + +Adding a new option is literally one entry in :data:`SCHEMA`; the +Options screen picks it up automatically. +""" + +from __future__ import annotations + +import copy +import json +import os +import sys +import traceback + +# NOTE: pygame is imported lazily inside the methods that need it so that +# importing ``zanthor.settings`` never triggers a pygame.init(). +# This keeps start‑up fast and allows the module to be used in +# headless tools (e.g. a future CLI settings dump). + + +# --------------------------------------------------------------------------- +# Schema description +# --------------------------------------------------------------------------- + +class Spec: + """Describes a single configurable value. + + Parameters + ---------- + default : Any + Fallback value when the key is absent or invalid. + label : str + Human‑friendly name shown in the Options screen. + kind : str + Widget hint. One of: + ``"toggle"``, ``"choice"``, ``"slider"``, ``"int"``, ``"key"``. + choices : list, optional + Permitted values for ``"choice"`` kind (also used to cycle + resolutions). + vmin, vmax : number, optional + Inclusive range for ``"slider"`` / ``"int"`` kinds. + step : number, optional + Increment used by the UI when adjusting via keyboard. + desc : str, optional + Longer description / tooltip text. + restart : bool, optional + When True the UI displays a *"requires restart"* badge. + """ + + __slots__ = ( + "default", + "label", + "kind", + "choices", + "vmin", + "vmax", + "step", + "desc", + "restart", + ) + + def __init__( + self, + default, + label, + kind, + choices=None, + vmin=None, + vmax=None, + step=None, + desc="", + restart=False, + ): + self.default = default + self.label = label + self.kind = kind + self.choices = choices + self.vmin = vmin + self.vmax = vmax + self.step = step + self.desc = desc + self.restart = restart + + # -- validation ----------------------------------------------------- + def coerce(self, value): + """Return *value* coerced / clamped to this spec. + + Invalid input silently falls back to ``self.default`` – we never + want a corrupt settings file to crash the game. + """ + try: + if self.kind == "toggle": + return bool(value) + + if self.kind == "choice": + # Choices may be scalars (ints / strings) OR sequences + # like resolution tuples. Sequence values are stored + # as JSON lists so we compare against both list and + # tuple forms. Scalar choices just use plain ==. + if self.choices: + for c in self.choices: + if value == c: + return c + if isinstance(c, (list, tuple)): + try: + if list(value) == list(c): + return tuple(c) + except TypeError: + pass + return copy.deepcopy(self.default) + + if self.kind == "slider": + v = float(value) + if self.vmin is not None: + v = max(self.vmin, v) + if self.vmax is not None: + v = min(self.vmax, v) + return round(v, 3) + + if self.kind == "int": + v = int(value) + if self.vmin is not None: + v = max(self.vmin, v) + if self.vmax is not None: + v = min(self.vmax, v) + return v + + if self.kind == "key": + # Expect a list of pygame *key names* (strings). + if isinstance(value, (list, tuple)): + out = [str(x) for x in value if isinstance(x, (str, int))] + return out or list(self.default) + return list(self.default) + + return value # unknown kind – pass through + except Exception: + return copy.deepcopy(self.default) + + +# --------------------------------------------------------------------------- +# The Schema +# --------------------------------------------------------------------------- +# The schema is a dict‑of‑dicts: section -> option -> Spec. +# Sections map naturally to tabs in the Options UI. + +_RESOLUTIONS = [ + (640, 480), + (800, 600), + (1024, 768), + (1280, 720), + (1280, 800), + (1366, 768), + (1600, 900), + (1920, 1080), +] + +SCHEMA = { + # ----------------------------------------------------------------- # + "display": { + "resolution": Spec( + (640, 480), + "Resolution", + "choice", + choices=_RESOLUTIONS, + desc="Window size. The game scales its layout automatically.", + restart=True, + ), + "fullscreen": Spec( + False, + "Fullscreen", + "toggle", + desc="Run the game in fullscreen mode.", + restart=True, + ), + "fps_cap": Spec( + 30, + "FPS Cap", + "int", + vmin=20, + vmax=120, + step=5, + desc="Maximum frames per second.", + restart=True, + ), + "skip_intro": Spec( + False, + "Skip Intro", + "toggle", + desc="Skip the opening cutscene and jump straight to the title.", + ), + }, + # ----------------------------------------------------------------- # + "audio": { + "music_volume": Spec( + 0.8, + "Music Volume", + "slider", + vmin=0.0, + vmax=1.0, + step=0.05, + desc="Background music loudness.", + ), + "sfx_volume": Spec( + 1.0, + "Effects Volume", + "slider", + vmin=0.0, + vmax=1.0, + step=0.05, + desc="Sound‑effect loudness.", + ), + "mute": Spec( + False, + "Mute All", + "toggle", + desc="Silence every channel.", + ), + }, + # ----------------------------------------------------------------- # + "controls": { + # Key bindings are stored as *key‑name* lists so the JSON stays + # readable and portable across SDL versions. + "move_up": Spec(["up", "w"], "Move Up", "key"), + "move_down": Spec(["down", "s"], "Move Down", "key"), + "move_left": Spec(["left", "a"], "Move Left", "key"), + "move_right": Spec(["right", "d"], "Move Right", "key"), + "fire": Spec(["space", "f", "left ctrl", "right ctrl"], "Fire", "key"), + "pause": Spec(["p", "return", "h"], "Pause / Help", "key"), + "toggle_mouselook": Spec(["m"], "Toggle Look", "key"), + "mouse_look": Spec( + True, + "Mouse-Look", + "toggle", + desc="Scroll the view when the cursor approaches the edge.", + ), + "joy_fire_button": Spec( + 1, + "Joy Fire Btn", + "int", + vmin=0, + vmax=15, + step=1, + desc="Joystick button index used to fire.", + ), + "joy_pickup_button": Spec( + 2, + "Joy Pickup Btn", + "int", + vmin=0, + vmax=15, + step=1, + desc="Joystick button index used to pick items up.", + ), + }, + # ----------------------------------------------------------------- # + "gameplay": { + "auto_pickup": Spec( + True, + "Auto-Pickup", + "toggle", + desc="Automatically collect coal, water and parts on contact.", + ), + "holes_are_tiles": Spec( + 0, + "Cannon Craters", + "choice", + choices=[0, 1, 2], + desc="0 = cosmetic, 1 = solid, 2 = big solid craters.", + ), + "debug_upgrades": Spec( + False, + "Debug Upgrades", + "toggle", + desc="Allow the 1-8 number keys to grant free upgrades.", + ), + }, +} + + +# Flat look‑up of "section.option" -> Spec for convenience. +_FLAT = { + "{}.{}".format(sec, key): spec + for sec, opts in SCHEMA.items() + for key, spec in opts.items() +} + + +# --------------------------------------------------------------------------- +# Storage location helpers +# --------------------------------------------------------------------------- + +def _user_config_dir(app_name="zanthor"): + """Return a per‑user, per‑platform directory for config files. + + Creates the directory if it does not yet exist. If that fails + (read‑only home, sandboxed env …) we silently fall back to the + *current working directory* so the game still runs. + """ + # Allow packagers / power users to override the location completely. + override = os.environ.get("ZANTHOR_CONFIG_DIR") + if override: + path = os.path.expanduser(override) + elif sys.platform.startswith("win"): + base = os.environ.get("APPDATA") or os.path.expanduser("~") + path = os.path.join(base, app_name) + elif sys.platform == "darwin": + path = os.path.expanduser( + os.path.join("~", "Library", "Application Support", app_name) + ) + else: + base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + path = os.path.join(base, app_name) + + try: + os.makedirs(path, exist_ok=True) + except Exception: + path = os.getcwd() + return path + + +_CONFIG_FILE = os.path.join(_user_config_dir(), "settings.json") + + +# --------------------------------------------------------------------------- +# The Settings object +# --------------------------------------------------------------------------- + +class Settings: + """In‑memory view of the user's configuration. + + The object behaves like a small dict keyed by dotted paths + (``"display.resolution"``). Change listeners can be registered with + :meth:`subscribe` – this is how the Options UI makes audio sliders + feel *live*. + """ + + def __init__(self): + self._data = self._defaults() + self._listeners = [] # list of callables(key, value) + self._key_cache = {} # action -> [key_codes] + self._dirty_restart = False # True once any restart‑only value changed + self._loaded_snapshot = None # values as last saved/loaded for dirty‑check + self.config_path = _CONFIG_FILE + + # ------------------------------------------------------------------ + # construction helpers + # ------------------------------------------------------------------ + @staticmethod + def _defaults(): + out = {} + for sec, opts in SCHEMA.items(): + out[sec] = {} + for key, spec in opts.items(): + out[sec][key] = copy.deepcopy(spec.default) + return out + + # ------------------------------------------------------------------ + # persistence + # ------------------------------------------------------------------ + def load(self, path=None): + """Load from *path* (defaults to the standard config file). + + Never raises – any error simply leaves the defaults in place and + is logged to stderr. A fresh file is written on first run so + users can discover where the config lives. + """ + path = path or self.config_path + self.config_path = path + + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as fh: + raw = json.load(fh) + self._merge(raw) + print("[settings] loaded {}".format(path)) + except Exception: + print("[settings] failed to parse {} – using defaults".format(path)) + traceback.print_exc() + else: + # Seed the file so users can find it & tweak by hand. + self.save() + print("[settings] created default config at {}".format(path)) + + self._loaded_snapshot = json.dumps(self._data, sort_keys=True) + self._key_cache.clear() + self._apply_live_audio() + return self + + def save(self, path=None): + path = path or self.config_path + try: + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(self._data, fh, indent=2, sort_keys=True) + os.replace(tmp, path) # atomic on POSIX, best‑effort on Windows + self._loaded_snapshot = json.dumps(self._data, sort_keys=True) + print("[settings] saved {}".format(path)) + except Exception: + print("[settings] could not write {}".format(path)) + traceback.print_exc() + return self + + def _merge(self, raw): + """Merge a raw dict (from JSON) into ``self._data`` with coercion.""" + if not isinstance(raw, dict): + return + for sec, opts in SCHEMA.items(): + src = raw.get(sec, {}) + if not isinstance(src, dict): + continue + for key, spec in opts.items(): + if key in src: + self._data[sec][key] = spec.coerce(src[key]) + + # ------------------------------------------------------------------ + # basic accessors + # ------------------------------------------------------------------ + def get(self, dotted, default=None): + sec, _, key = dotted.partition(".") + try: + return self._data[sec][key] + except KeyError: + return default + + def set(self, dotted, value, notify=True): + spec = _FLAT.get(dotted) + sec, _, key = dotted.partition(".") + if spec is None or sec not in self._data: + raise KeyError(dotted) + + old = self._data[sec].get(key) + new = spec.coerce(value) + self._data[sec][key] = new + + if old != new: + if spec.restart: + self._dirty_restart = True + self._key_cache.clear() + if notify: + for cb in list(self._listeners): + try: + cb(dotted, new) + except Exception: + traceback.print_exc() + self._on_change(dotted, new) + + return new + + def spec(self, dotted): + return _FLAT.get(dotted) + + def sections(self): + return list(SCHEMA.keys()) + + def options(self, section): + return list(SCHEMA.get(section, {}).keys()) + + def reset_all(self): + self._data = self._defaults() + self._key_cache.clear() + self._dirty_restart = True + for cb in list(self._listeners): + try: + cb("*", None) + except Exception: + traceback.print_exc() + self._apply_live_audio() + + def reset_section(self, section): + if section not in SCHEMA: + return + for key, spec in SCHEMA[section].items(): + self.set("{}.{}".format(section, key), copy.deepcopy(spec.default)) + + # ------------------------------------------------------------------ + # dirty / restart tracking + # ------------------------------------------------------------------ + @property + def dirty(self): + """True when in‑memory values differ from the on‑disk snapshot.""" + return json.dumps(self._data, sort_keys=True) != self._loaded_snapshot + + @property + def restart_required(self): + return self._dirty_restart + + def clear_restart_flag(self): + self._dirty_restart = False + + # ------------------------------------------------------------------ + # listeners + # ------------------------------------------------------------------ + def subscribe(self, fn): + """Register *fn(key, value)* to be called whenever a value changes.""" + self._listeners.append(fn) + return fn + + def unsubscribe(self, fn): + try: + self._listeners.remove(fn) + except ValueError: + pass + + # ------------------------------------------------------------------ + # key‑binding helpers + # ------------------------------------------------------------------ + def keys(self, action): + """Return the pygame key‑codes bound to *action* (e.g. ``"move_up"``). + + The result is cached until any binding changes. Unknown key + names are silently skipped so a hand‑edited config with a typo + never crashes event handling. + """ + if action in self._key_cache: + return self._key_cache[action] + + import pygame # local import keeps settings importable w/o pygame init + + names = self._data.get("controls", {}).get(action, []) + codes = [] + for n in names: + try: + if isinstance(n, int): + codes.append(int(n)) + else: + codes.append(pygame.key.key_code(str(n))) + except Exception: + pass + self._key_cache[action] = codes + return codes + + def bind_key(self, action, key_code, replace_primary=True): + """Bind *key_code* (pygame int) to *action*. + + When *replace_primary* is True the new key becomes the single + primary binding; otherwise it is appended as a secondary one. + The storage format stays key‑*names* so the JSON stays readable. + """ + import pygame + + try: + name = pygame.key.name(int(key_code)) + except Exception: + name = str(key_code) + + dotted = "controls.{}".format(action) + spec = _FLAT.get(dotted) + if spec is None or spec.kind != "key": + raise KeyError("{} is not a key binding".format(action)) + + current = list(self._data["controls"].get(action, [])) + if replace_primary: + current = [name] + elif name not in current: + current.append(name) + + self.set(dotted, current) + + def key_label(self, action): + """Human‑readable string for a binding (``"W / Up"``).""" + names = self._data.get("controls", {}).get(action, []) + pretty = [str(n).replace("_", " ").title() for n in names] + return " / ".join(pretty) if pretty else "— unbound —" + + # ------------------------------------------------------------------ + # live‑apply hooks + # ------------------------------------------------------------------ + def _on_change(self, dotted, value): + """Immediate side‑effects for settings that support live update.""" + if dotted.startswith("audio.") or dotted == "*": + self._apply_live_audio() + if dotted == "controls.mouse_look": + try: + from . import const + const.DISABLE_MOUSE_LOOK = 0 if value else 1 + except Exception: + pass + + def _apply_live_audio(self): + """Push current audio levels to pygame's mixer if it's alive.""" + try: + import pygame + if not pygame.mixer or not pygame.mixer.get_init(): + return + mute = bool(self.get("audio.mute", False)) + music = 0.0 if mute else float(self.get("audio.music_volume", 0.8)) + try: + pygame.mixer.music.set_volume(music) + except Exception: + pass + except Exception: + pass + + # ------------------------------------------------------------------ + # convenience high‑level getters (used by const.py / main.py) + # ------------------------------------------------------------------ + def resolution(self): + r = self.get("display.resolution", (640, 480)) + try: + w, h = int(r[0]), int(r[1]) + return max(320, w), max(240, h) + except Exception: + return 640, 480 + + def fps_cap(self): + return int(self.get("display.fps_cap", 30) or 30) + + def fullscreen(self): + return bool(self.get("display.fullscreen", False)) + + def skip_intro(self): + return bool(self.get("display.skip_intro", False)) + + def music_volume(self): + if self.get("audio.mute", False): + return 0.0 + return float(self.get("audio.music_volume", 0.8)) + + def sfx_volume(self): + if self.get("audio.mute", False): + return 0.0 + return float(self.get("audio.sfx_volume", 1.0)) + + # ------------------------------------------------------------------ + # export / import + # ------------------------------------------------------------------ + def export_copy(self): + """Deep‑copy the current values (useful for a *Cancel* button).""" + return copy.deepcopy(self._data) + + def import_copy(self, data): + """Restore values from a previous :meth:`export_copy` result.""" + self._merge(data) + self._key_cache.clear() + self._apply_live_audio() + + +# --------------------------------------------------------------------------- +# module‑level singleton + thin module‑function façade +# --------------------------------------------------------------------------- + +_instance = Settings().load() + + +def get(dotted, default=None): + return _instance.get(dotted, default) + + +def set(dotted, value): # noqa: A001 (intentional shadow of builtin) + return _instance.set(dotted, value) + + +def save(): + return _instance.save() + + +def load(): + return _instance.load() + + +def reset_all(): + return _instance.reset_all() + + +def keys(action): + return _instance.keys(action) + + +def bind_key(action, key_code, replace_primary=True): + return _instance.bind_key(action, key_code, replace_primary) + + +def key_label(action): + return _instance.key_label(action) + + +def spec(dotted): + return _instance.spec(dotted) + + +def sections(): + return _instance.sections() + + +def options(section): + return _instance.options(section) + + +def subscribe(fn): + return _instance.subscribe(fn) + + +def unsubscribe(fn): + return _instance.unsubscribe(fn) + + +def resolution(): + return _instance.resolution() + + +def fps_cap(): + return _instance.fps_cap() + + +def fullscreen(): + return _instance.fullscreen() + + +def skip_intro(): + return _instance.skip_intro() + + +def music_volume(): + return _instance.music_volume() + + +def sfx_volume(): + return _instance.sfx_volume() + + +def export_copy(): + return _instance.export_copy() + + +def import_copy(data): + return _instance.import_copy(data) + + +def restart_required(): + return _instance.restart_required + + +def clear_restart_flag(): + return _instance.clear_restart_flag() + + +def dirty(): + return _instance.dirty diff --git a/zanthor/sounds.py b/zanthor/sounds.py index 0e6cb76..27d8a27 100644 --- a/zanthor/sounds.py +++ b/zanthor/sounds.py @@ -17,6 +17,8 @@ from pygame.locals import * from .const import data_dir +# User-configurable master volume multipliers (music / sfx / mute). +from . import settings as _settings SOUND_PATH = data_dir("sounds") @@ -188,7 +190,11 @@ def Play(self, name, volume=[1.0, 1.0], wait=0, loop=0): elif self.chans[name]: # self.chans[name].set_volume(vol_l, vol_r) - self.chans[name].set_volume(vol_l) + # Scale by the user-configured SFX master volume so + # the in-game Options slider affects every effect + # without having to hunt down every .Play() call. + master = _settings.sfx_volume() + self.chans[name].set_volume(vol_l * master) def Update(self, elapsed_time): """ """ @@ -232,7 +238,8 @@ def PlayMusic(self, musicname): return music.play(-1) - music.set_volume(1.0) + # Honour the user-configured music volume (and mute flag). + music.set_volume(_settings.music_volume()) def PauseMusic(self): self._unp_PauseMusic(1) diff --git a/zanthor/states.py b/zanthor/states.py index b59553e..20f0fdd 100644 --- a/zanthor/states.py +++ b/zanthor/states.py @@ -199,6 +199,12 @@ def init(self): if pygame.mixer: pygame.mixer.music.load(data_dir("intro", "grass.ogg")) pygame.mixer.music.play(-1) + # Honour the user-configured music volume. + try: + from . import settings + pygame.mixer.music.set_volume(settings.music_volume()) + except Exception: + pass def paint(self, screen): # screen.blit(self.bkgr,(0,0)) diff --git a/zanthor/title.py b/zanthor/title.py index 2f69b5b..f097497 100644 --- a/zanthor/title.py +++ b/zanthor/title.py @@ -21,6 +21,7 @@ def init(self): # ('Load','load'), # ('Save','save'), ("Help", Help), + ("Options", "options"), ("Credits", Credits), ("Quit", engine.Quit), ] @@ -29,6 +30,12 @@ def init(self): if pygame.mixer: pygame.mixer.music.load(data_dir("intro", "zanthor.ogg")) pygame.mixer.music.play(-1) + # Honour the user-configured music volume. + try: + from . import settings + pygame.mixer.music.set_volume(settings.music_volume()) + except Exception: + pass self.font_title = [] for size in (160, 165, 170, 175, 180): @@ -150,6 +157,11 @@ def event(self, e): from . import menu return menu.Menu(self.game) + elif action == "options": + # Launch the Options screen. We do NOT stop the title + # music so the user hears volume-slider changes live. + from . import options + return options.Options(self.game) else: return action(self.game)