27 Commits
Author SHA1 Message Date
Matteo Benedetto e76c6f665a Revert "Redraw wall/bush maze tiles with richer pixel-art detail"
The user asked to go back to the pre-commit assets. The original
BMP_{1..4}_*.png tiles have been restored. The generator script
introduced in that commit is also removed.
2026-06-27 20:21:05 +02:00
Matteo Benedetto 0f14ae3376 Redraw wall/bush maze tiles with richer pixel-art detail
The original BMP_{1..4}_{N,S,E,W,NE,NW,SE,SW,EN,ES,WN,WS}.png tiles were
very noisy low-quality checkerboard patterns. They are now redrawn as
hand-styled pixel-art bush/hedge tiles:

- 5-color per-theme palette (deep shadow, dark, mid, light, highlight)
- irregular wall/passage boundary with natural erosion
- shaded interior (darker in the center = depth)
- scattered leaf clusters and highlights
- kept 32x32 resolution and engine transparent-gray convention

A new generator tool is added at tools/redraw_walls.py. Run it again
with python3 tools/redraw_walls.py to regenerate the tiles if you want
to iterate on the style or palettes.
2026-06-27 20:19:41 +02:00
Matteo Benedetto 5ce084f04d Fix missing state_machine import in controls.py
Resolves NameError when toggling pause (and any other control path
referencing state_machine.GameState). The module was referenced but
never imported after the recent cheat/controls edits.
2026-06-27 20:18:04 +02:00
Matteo Benedetto 4bab8d1a79 Scale gas spread, weapon refill, litter size, and post-mating stop with difficulty
Four gameplay parameters are now driven by the selected difficulty,
following the existing 'hard = harder for the player' philosophy:

  param                       easy   normal   hard
  gas_spread_speed            40     50       90     (higher = slower gas)
  weapon_refill_multiplier    1.3    1.0      0.6    (less ammo on hard)
  max_babies                  2      3        5      (more pups on hard)
  mate_stop_male / female     120/240 100/200 60/120 (shorter stop on hard)

Implementation:
- engine/config.py: new keys on each DIFFICULTY_OPTIONS entry.
- rats.py: _apply_difficulty() loads them onto the game (with __init__
  defaults for safety); normal keeps the previous hardcoded values.
- units/gas.py: Gas.speed reads game.gas_spread_speed (fallback 50).
- engine/unit_manager.py: refill_ammo() scales per-frame refill chances
  by game.weapon_refill_multiplier (fallback 1.0).
- units/rat.py: Male.fuck() uses game.mate_stop_male/mate_stop_female
  and random.randint(1, game.max_babies) (fallbacks to old values).

All reads use getattr with the previous constant as fallback, so older
configs / saved state keep working.
2026-06-27 19:52:27 +02:00
Matteo Benedetto a09b9f0878 Add Ctrl+Return cheat to instantly win the current level
During gameplay, pressing Ctrl+Return triggers an immediate level clear:
the current level is marked as won (level_clear, or run_complete on the
last DAT level) and the state transitions to VICTORY, showing the
normal 'Level Clear!' dialog so the player can then advance with Return.

Implementation:
- engine/sdl2.py: mainloop detects Ctrl+Return via SDLK_RETURN + KMOD_CTRL
  and dispatches a 'cheat_win_level' action (bypassing keybindings).
- engine/controls.py: trigger() now also accepts direct action names that
  are registered in the dispatcher (not key-event names), and a new
  cheat_win_level handler forwards to the game.
- rats.py: MiceMaze.cheat_win_level() performs the win, guarded so it only
  fires while a level is actively being played.

No keybinding files need editing; the cheat is wired through the engine
layer directly.
2026-06-27 19:04:37 +02:00
Matteo Benedetto c09840099e Draw rat normally at tunnel entrances
Rats are now drawn fully (single animation frame) regardless of whether
they are on an open cell, a tunnel entrance, or an internal tunnel
passage. The only case where the rat is not drawn is when its center is
inside a wall (non-empty, non-tunnel cell).

Removed the now-unused _get_tunnel_entrance_direction helper.
2026-06-27 18:56:04 +02:00
Matteo Benedetto 705f3ba260 Remove _draw_partially_hidden_in_tunnel clipping
The clipping logic that sliced the rat sprite when entering/leaving a
single-entrance tunnel produced a jarring half-rat visual. Removed it
entirely: a rat on a single-entrance tunnel cell is now simply not
drawn (it disappears into the tunnel) instead of being partially
clipped. Internal tunnel passages are unchanged and still render the
rat normally.
2026-06-27 18:54:46 +02:00
Matteo Benedetto 1627f58103 Fix rat sprite strip artifact inside internal tunnel cells
After merging new-newnassets, the rat sprite assets are now horizontal
4-frame sprite sheets (e.g. BMP_MALE_UP.png is 80x40 instead of 20x40).
The drawing path for rats inside internal tunnel passages still called
draw_image() without source_rect, so the engine rendered all 4 frames
side by side as a single wide strip (the visual artifact shown in
/tmp/pi-clipboard-...png).

This brings the tunnel-internal rendering in line with the non-tunnel
path, which already used source_rect = (frame * w, 0, w, h).
2026-06-27 18:42:46 +02:00
Matteo Benedetto f348d7e0d9 Merge branch 'merge-newassets-clean' into master
Integrates new LPC-style rat assets and animations from origin/new-newnassets.

Summary:
- 32 new LPC-style sprites (assets/Rat_LPC_Style/)
- 4 animation sprite sheets (assets/Rat_Animated/) for animated rats
- 64 updated Rat sprites in assets/Rat/ with backup originals
- engine/sdl2.py: added 'scale=True' param to load_image()
- engine/graphics.py: rat textures loaded with scale=False
- units/rat.py: animation frame computed from partial_move * 4
- 22 final-version generator scripts in tools/lpc_extract/
- 3 cherry-picked commits from origin/new-newnassets

Excluded from upstream (working artifacts, not appropriate for prod repo):
- ~25 PNG in repo root (male_rats*.png, spritesheet*.png, etc.)
- ~416 tile extracts in tools/lpc_extract/grid/
- scattered debug PNGs (bushes, preview, montages)
- early-iteration scripts (animate_rat v1-v4, restyle v2-v10, etc.)
2026-06-27 17:56:16 +02:00
Matteo Benedetto e26464d843 Merge origin/new-newnassets: new LPC rat assets and animations
Cherry-picked from new-newnassets (3 commits):
- bdf5a8d Add new start animation asset for game launch
- 5f2d09f Fix rat animations and restore map tiles
- 2e2c5bb feat: add LPC-style rat assets, sprite generation scripts, and grid extraction tools

Conflicts resolved:
- engine/sdl2.py: kept 'if scale:' wrapper (compatible, default scale=True)
- units/rat.py: kept animation frame computation (partial_move * 4)
- 12 PNG files (BMP_BABY/MALE/FEMALE_*): kept new-newnassets versions

Cleanup applied: removed ~480 working artifacts not appropriate for the
production repo:
- 25 PNG in repo root (male_rats*.png, spritesheet*.png)
- 416 tile extracts in tools/lpc_extract/grid/
- scattered debug PNGs (bushes, preview, montages, etc.)
- early iteration scripts (animate_rat v1-v4, restyle v2-v10, etc.)

Kept: 32 new LPC-style sprites in assets/Rat_LPC_Style/, 4 animation
sprite sheets in assets/Rat_Animated/, 64 updated Rat sprites, 22
final-version generator scripts, plus all .py/.md updates.
2026-06-27 17:53:45 +02:00
Matteo Benedetto c1662ecf29 Add regression test for gray-strip artifact in asset rendering
Tests/test_gray_strip_artifact.py renders lose.png on a white panel
both with and without the near-white normalization, saves the
results to /tmp/test_loss_before.png and /tmp/test_loss_after.png,
and counts near-white (RGB 240-254, alpha>200) pixels which are the
direct cause of the gray seam.

With the fix the near-white pixel count drops from 3714 to 133
(reduction of 96%), confirming the normalization removes the
artifact.
2026-06-17 11:46:01 +02:00
Matteo Benedetto 2ad6a082b3 Normalize near-white pixels to pure white in all loaded assets
Assets like lose.png contain thousands of (254,254,254) pixels on a
background that is also white. When drawn on a white panel these
near-white pixels are slightly darker, producing a visible gray seam.

Apply a global normalization in load_image(): any opaque pixel with
R=G=B >= 250 is clamped to (255,255,255). Applied to every asset, not
only those with transparent_color, so the background is always pure
white regardless of off-by-one in the source PNG.
2026-06-17 11:13:40 +02:00
Matteo Benedetto 319801d6e5 Fix rat reproduction collision check
Replace the brittle 'self.position == other.position_before' check
with a symmetric cell-intersection test: two rats are considered
colliding if any of their current/previous cells overlap. This handles
both 'both rats in same cell' and 'one rat enters the cell the other
just left'.

Also remove the erroneous hasattr(other_unit, 'fuck') guard that
prevented Male.fuck() from being called on Female (Female has no fuck
method, but only Male should initiate reproduction).

Add tests/test_rat_reproduction.py with 8 scenarios covering overlapping
rats, baby rats, already-pregnant females, far-apart rats, female
self-initiation, sound playback, and procreate interval spawning.
2026-06-17 10:29:26 +02:00
Matteo Benedetto 2eccf504f5 Add semi-transparent tunnel cover overlay for internal passages
- Add sdl2.create_overlay_texture() and draw_overlay_texture(alpha) for
  transparent full-map overlays built from sub-tile surface blits.
- Add Graphics.regenerate_tunnel_cover() which builds an overlay of
  4 random grass sub-tiles (20x20) for every internal tunnel cell,
  defined as a tunnel cell surrounded by occupied cells (wall or
  tunnel) on all four sides. Cells with at least one open side are
  handled by cave_foreground and skipped here.
- Draw the tunnel cover in the game loop after top-layer units/effects
  but before cave_foreground and points, at 95% opacity (alpha=242)
  so the unit and effect passing underneath is just barely visible.
2026-06-16 23:06:17 +02:00
Matteo Benedetto 62a65f599d Draw explosions and gas inside tunnel cells
Remove the is_hidden_in_tunnel() checks from Explosion.draw() and
Gas.draw() so explosions and gas clouds remain visible when their center
falls inside a tunnel cell. Logic already spawned them in tunnel cells,
but they were rendered invisible because the draw path returned early.
2026-06-16 21:56:22 +02:00
Matteo Benedetto 5afdf3705b Render rats inside internal tunnel passages
Previously Rat.draw() hid any rat whose center fell inside a tunnel.
The clipping helper only handled single-entrance cells and returned None
for internal passages/crossroads, causing rats to vanish entirely.

Now internal tunnel cells (those with 0 or 2+ open sides) draw the rat
normally so it remains visible while walking through the tunnel.
Single-entrance cells keep the partial clip effect.
Also fix the visible-ratio math for DOWN/RIGHT clipping: the old
formulas subtracted cell_size from a local coordinate, producing
zero or negative visibility.
2026-06-16 20:37:46 +02:00
Matteo Benedetto b1e9770991 Use wall-only neighbor checks for wall border rendering
Previously regenerate_background() used occupied() (wall or tunnel) to
choose wall border/corner tiles. This caused walls adjacent to tunnels
to render with inner corners as if the tunnel were solid ground.

Now wall border logic uses is_wall() so only actual wall neighbors
influence the shape. Tunnel neighbor checks for flower-suppression are
kept explicit.
2026-06-16 20:24:33 +02:00
Matteo Benedetto 2f8e3e8b28 Fix gas poisoning, explosion hit detection and render order
- Move gas poisoning from Gas.move() to Gas.collisions() so all rats are
  registered in the collision system before the poison query runs.
- Shrink gas and explosion bboxes so rats must be well inside the tile to
  be poisoned/killed.
- Use AABB overlap instead of partial_move threshold for gas poisoning.
- Draw mobile units first, then top-layer effects (gas, mines, bombs,
  explosions) so rats appear under the gas.
- Add draw_on_top hint to Unit base class and top-layer units.
2026-06-16 19:32:50 +02:00
Matteo Benedetto 3e8cd97fda Fix bomb explosions not killing rats and remove tracked pycache
- Move Timer explosion from move() to collisions() so all units are
  registered in the collision system before the kill query runs.
- Explosion units now set a bbox and kill rats that touch them.
- Guard Rat.draw() so dead rats are not drawn.
- Remove units/__pycache__ files from tracking.
2026-06-16 19:15:35 +02:00
John Doe c7ed24483d Add comprehensive test suite for game mechanics and level handling
- Introduced `test_final_level_flow.py` to validate final level transitions and game end scenarios.
- Created `test_game_over_flow.py` to ensure game over conditions trigger correctly based on rat counts.
- Implemented `test_keybindings.py` to verify keybinding configurations and their context-specific actions.
- Developed `test_level_editor.py` to assess level editor functionalities and layout computations.
- Added `test_level_io.py` for testing level data serialization and deserialization.
- Established `test_loop_logic_parity.py` to ensure consistent game state across multiple simulation runs.
- Created `test_non_regression.py` to simulate game behavior and capture states for future verification.
- Implemented `test_verify.py` to compare current game states against a golden master for regression detection.
2026-05-19 22:18:43 +02:00
John Doe 486cd6b7c5 Add non-regression test states and Bluetooth diagnostic script
- Introduced a new JSON file containing non-regression test states with detailed unit information, including positions, ages, and movement directions across multiple frames.
- Added a shell script for Bluetooth diagnostics that checks system information, Bluetooth binaries, running processes, D-Bus status, Bluetooth controller details, and audio stack status, providing a comprehensive overview for troubleshooting.
2026-05-17 23:36:24 +02:00
John Doe dd82ccc087 Add launcher script for microphone visualizer with gamepad support 2026-05-09 17:57:57 +02:00
John Doe 7868d83ced Add microphone visualizer tool using SDL2 for audio input visualization
- Implemented a new Python script `mic_visualizer.py` that captures audio from a microphone and visualizes it in real-time.
- Utilized SDL2 for audio capture and rendering, allowing users to see waveform and spectrum representations of the audio input.
- Added command-line arguments for listing devices, selecting a capture device, and configuring window size and audio settings.
- Included functionality for displaying audio levels and peaks, enhancing user experience with visual feedback.
2026-05-09 17:55:29 +02:00
John Doe d4c73a344b Add Mice! game box art image to MUOS catalogue 2026-05-09 17:27:26 +02:00
John Doe 703e4717e4 Add metadata files for Mice! game in muOS catalogue 2026-05-09 17:22:55 +02:00
John Doe b849e16f69 Add new start animation asset for game launch 2026-05-09 17:08:53 +02:00
John Doe c7ff5ae4cf Refactor code structure for improved readability and maintainability 2026-05-09 16:35:25 +02:00
606 changed files with 13508 additions and 6470 deletions
-76
View File
@@ -1,76 +0,0 @@
# Piano di distribuzione ARM con AppImage
Questo repository ora e pronto per essere portato dentro un bundle AppImage senza dipendere dalla directory corrente e senza scrivere nel filesystem montato in sola lettura dell'AppImage.
## Stato attuale
- Le risorse di runtime vengono risolte a partire dal root del progetto tramite `MICE_PROJECT_ROOT`.
- I dati persistenti (`scores.txt`, `user_profiles.json`) vengono scritti in una directory utente persistente:
- `MICE_DATA_DIR`, se impostata.
- altrimenti `${XDG_DATA_HOME}/mice`.
- fallback: `~/.local/share/mice`.
- E presente uno scaffold di packaging in `packaging/`.
## Strategia consigliata
1. Costruire l'AppImage su una macchina `aarch64` reale o in una chroot/container ARM.
2. Creare dentro `AppDir` un ambiente Python copiato localmente con `python -m venv --copies`.
3. Installare le dipendenze Python da `requirements.txt` dentro quel Python locale.
4. Copiare il gioco e gli asset in `AppDir/usr/share/mice`.
5. Bundlare le librerie native richieste da SDL2 e dai wheel Python dentro `AppDir/usr/lib`.
6. Usare `AppRun` per esportare `LD_LIBRARY_PATH`, `MICE_PROJECT_ROOT` e `MICE_DATA_DIR` prima del lancio di `rats.py`.
7. Generare il file finale con `appimagetool`.
## Perche costruire nativamente su ARM
- Un AppImage deve contenere binari della stessa architettura del target.
- `PySDL2`, `numpy` e `Pillow` portano con se librerie native o dipendenze native.
- Il cross-build da `x86_64` a `aarch64` e possibile, ma aumenta molto il rischio di incompatibilita su `glibc`, `libSDL2` e wheel Python.
## Comando di build
Da una macchina Linux `aarch64` con `python3`, `rsync`, `ldd`, `ldconfig` e `appimagetool` disponibili:
```bash
./packaging/build_appimage_aarch64.sh
```
Output previsto:
- `dist/AppDir`
- `dist/Mice-aarch64.AppImage`
## Dipendenze host richieste al builder ARM
Serve un sistema di build ARM con almeno:
- `python3`
- `python3-venv`
- `rsync`
- `glibc` userland standard
- `appimagetool`
- librerie di sviluppo/runtime installate sul builder, in particolare:
- `libSDL2`
- `libSDL2_ttf`
## Test minimi da fare sul target ARM
1. Avvio del gioco da shell.
2. Caricamento font e immagini.
3. Riproduzione audio WAV.
4. Salvataggio punteggi in `~/.local/share/mice/scores.txt`.
5. Creazione e lettura profili in `~/.local/share/mice/user_profiles.json`.
6. Cambio livello da `assets/Rat/level.dat`.
## Rischi residui
- La relocazione di un venv copiato dentro AppImage e pratica, ma va verificata sul target reale.
- Se il target ARM ha un userland molto vecchio, conviene costruire l'AppImage su una distro ARM con `glibc` piu vecchia del target.
- Se emergono problemi di relocazione del Python del venv, il passo successivo corretto e passare a un Python relocatable tipo `python-build-standalone` mantenendo invariato il launcher.
## File introdotti
- `runtime_paths.py`
- `packaging/appimage/AppRun`
- `packaging/appimage/mice.desktop`
- `packaging/build_appimage_aarch64.sh`
-221
View File
@@ -1,221 +0,0 @@
# Ottimizzazione Sistema di Collisioni con NumPy
## Sommario
Il sistema di collisioni del gioco è stato ottimizzato per gestire **oltre 200 unità simultanee** mantenendo performance elevate (50+ FPS).
## Problema Originale
### Analisi del Vecchio Sistema
1. **Metodo Rat.collisions()**: O(n²) nel caso peggiore
- Ogni ratto controllava tutte le unità nelle sue celle
- Controllo AABB manuale per ogni coppia
- Con molti ratti nella stessa cella, diventava O(n²)
2. **Calcoli bbox ridondanti**
- bbox calcolata in `draw()` ma usata anche in `collisions()`
- Nessun caching
3. **Esplosioni bombe**: Iterazioni multiple sulle stesse posizioni
- Loop annidati per ogni direzione dell'esplosione
- Controllo manuale di `unit_positions` e `unit_positions_before`
4. **Gas**: Controllo vittime a ogni frame anche quando non necessario
## Soluzione Implementata
### Nuovo Sistema: CollisionSystem (engine/collision_system.py)
#### Caratteristiche Principali
1. **Approccio Ibrido**
- < 10 candidati: Metodo semplice senza overhead NumPy
- ≥ 10 candidati: Operazioni vettorizzate con NumPy
- Ottimale per tutti gli scenari
2. **Spatial Hashing**
- Dizionari `spatial_grid` e `spatial_grid_before`
- Lookup O(1) per posizioni
- Solo candidati nella stessa cella vengono controllati
3. **Pre-allocazione Array NumPy**
- Arrays pre-allocati con capacità iniziale di 100
- Raddoppio dinamico quando necessario
- Riduce overhead di `vstack`/`append`
4. **Collision Layers**
- Matrice di collisione 6x6 per filtrare interazioni non necessarie
- Layers: RAT, BOMB, GAS, MINE, POINT, EXPLOSION
- Controllo O(1) se due layer possono collidere
5. **AABB Vettorizzato**
- Controllo collisioni bbox per N unità in una sola operazione
- Broadcasting NumPy per calcoli paralleli
### Struttura del Sistema
```python
class CollisionSystem:
- register_unit() # Registra unità nel frame corrente
- get_collisions_for_unit() # Trova tutte le collisioni per un'unità
- get_units_in_area() # Ottiene unità in più celle (esplosioni)
- check_aabb_collision_vectorized() # AABB vettorizzato
- _simple_collision_check() # Metodo semplice per pochi candidati
```
### Modifiche alle Unità
#### 1. Unit (units/unit.py)
- Aggiunto attributo `collision_layer`
- Inizializzazione con layer specifico
#### 2. Rat (units/rat.py)
- Usa `CollisionSystem.get_collisions_for_unit()`
- Eliminati loop manuali
- Tolleranza AABB gestita dal sistema
#### 3. Bomb (units/bomb.py)
- Esplosioni usano `get_units_in_area()`
- Raccolta posizioni esplosione → query batch
- Singola operazione per trovare tutte le vittime
#### 4. Gas (units/gas.py)
- Usa `get_units_in_cell()` per trovare vittime
- Separazione tra position e position_before
#### 5. Mine (units/mine.py)
- Controllo trigger con `get_units_in_cell()`
- Layer-based detection
### Integrazione nel Game Loop (rats.py)
```python
# Inizializzazione
self.collision_system = CollisionSystem(
self.cell_size, self.map.width, self.map.height
)
# Update loop (3 passaggi)
1. Move: Tutte le unità si muovono
2. Register: Registrazione nel collision system + backward compatibility
3. Collisions + Draw: Controllo collisioni e rendering
```
## Performance
### Test Results (250 unità su griglia 30x30)
**Stress Test - 100 frames:**
```
Total time: 332.41ms
Average per frame: 3.32ms
FPS capacity: 300.8 FPS
Target (50 FPS): ✓ PASS
```
### Confronto Scenari Reali
| Numero Unità | Frame Time | FPS Capacity |
|--------------|------------|--------------|
| 50 | ~0.5ms | 2000 FPS |
| 100 | ~1.3ms | 769 FPS |
| 200 | ~2.5ms | 400 FPS |
| 250 | ~3.3ms | 300 FPS |
| 300 | ~4.0ms | 250 FPS |
**Conclusione**: Il sistema mantiene **performance eccellenti** anche con 300+ unità, ben oltre il target di 50 FPS.
### Vantaggi per Scenari Specifici
1. **Molti ratti in poche celle**:
- Vecchio: O(n²) per celle dense
- Nuovo: O(n) con spatial hashing
2. **Esplosioni bombe**:
- Vecchio: Loop annidati per ogni direzione
- Nuovo: Singola query batch per tutte le posizioni
3. **Scalabilità**:
- Vecchio: Degrada linearmente con numero unità
- Nuovo: Performance costante grazie a spatial hashing
## Compatibilità
- **Backward compatible**: Mantiene `unit_positions` e `unit_positions_before`
- **Rimozione futura**: Questi dizionari possono essere rimossi dopo test estesi
- **Nessuna breaking change**: API delle unità invariata
## File Modificati
1. ✅ `requirements.txt` - Aggiunto numpy
2. ✅ `engine/collision_system.py` - Nuovo sistema (370 righe)
3. ✅ `units/unit.py` - Aggiunto collision_layer
4. ✅ `units/rat.py` - Ottimizzato collisions()
5. ✅ `units/bomb.py` - Esplosioni vettorizzate
6. ✅ `units/gas.py` - Query ottimizzate
7. ✅ `units/mine.py` - Detection ottimizzata
8. ✅ `units/points.py` - Aggiunto collision_layer
9. ✅ `rats.py` - Integrato CollisionSystem nel game loop
10. ✅ `test_collision_performance.py` - Benchmark suite
## Prossimi Passi (Opzionali)
1. **Rimozione backward compatibility**: Eliminare `unit_positions`/`unit_positions_before`
2. **Profiling avanzato**: Identificare ulteriori bottleneck
3. **Spatial grid gerarchico**: Per mappe molto grandi (>100x100)
4. **Caching bbox**: Se le unità non si muovono ogni frame
## Installazione
```bash
cd /home/enne2/Sviluppo/mice
source .venv/bin/activate
pip install numpy
```
## Testing
```bash
# Benchmark completo
python test_collision_performance.py
# Gioco normale
./mice.sh
```
## Note Tecniche
### Approccio Ibrido Spiegato
Il sistema usa un **threshold di 10 candidati** per decidere quando usare NumPy:
- **< 10 candidati**: Loop Python semplice (no overhead numpy)
- **≥ 10 candidati**: Operazioni vettorizzate NumPy
Questo è ottimale perché:
- Con pochi candidati, l'overhead di creare array NumPy supera i benefici
- Con molti candidati, la vettorizzazione compensa l'overhead iniziale
### Memory Layout
```
Arrays NumPy (pre-allocati):
- bboxes: (capacity, 4) float32 → ~1.6KB per 100 unità
- positions: (capacity, 2) int32 → ~800B per 100 unità
- layers: (capacity,) int8 → ~100B per 100 unità
Total: ~2.5KB per 100 unità (trascurabile)
```
## Conclusioni
L'ottimizzazione con NumPy è **altamente efficace** per il caso d'uso di Mice! con 200+ unità:
✅ Performance eccellenti (300+ FPS con 250 unità)
✅ Scalabilità lineare grazie a spatial hashing
✅ Backward compatible
✅ Approccio ibrido ottimale per tutti gli scenari
✅ Memory footprint minimo
Il sistema è **pronto per la produzione**.
-773
View File
@@ -1,773 +0,0 @@
# NumPy Tutorial: Dal Tuo Sistema di Collisioni al Codice Ottimizzato
Questo documento spiega NumPy usando come esempio reale il sistema di collisioni di Mice!, confrontando il tuo approccio originale con la versione ottimizzata.
## Indice
1. [Introduzione: Il Problema delle Performance](#1-introduzione-il-problema-delle-performance)
2. [Cos'è NumPy e Perché Serve](#2-cosè-numpy-e-perché-serve)
3. [Concetti Base di NumPy](#3-concetti-base-di-numpy)
4. [Dal Tuo Codice a NumPy: Caso Pratico](#4-dal-tuo-codice-a-numpy-caso-pratico)
5. [Spatial Hashing: L'Algoritmo Intelligente](#5-spatial-hashing-lalgoritmo-intelligente)
6. [Operazioni Vettoriali in NumPy](#6-operazioni-vettoriali-in-numpy)
7. [Best Practices e Pitfalls](#7-best-practices-e-pitfalls)
---
## 1. Introduzione: Il Problema delle Performance
### Il Tuo Sistema Originale (Funzionava Bene!)
```python
# rats.py - Il tuo approccio originale
def update_maze(self):
# Popolava dizionari con le posizioni delle unità
self.unit_positions = {}
self.unit_positions_before = {}
for unit in self.units.values():
unit.move()
# Raggruppa unità per posizione
self.unit_positions.setdefault(unit.position, []).append(unit)
self.unit_positions_before.setdefault(unit.position_before, []).append(unit)
for unit in self.units.values():
unit.collisions() # Ogni unità controlla le proprie collisioni
```
### Il Problema con 200+ Unità
Con 5-10 topi: **funziona perfetto** ✅
Con 200+ topi: **FPS crollano** ❌
**Perché?**
- Ogni topo controlla collisioni con TUTTI gli altri topi
- 200 topi = 200 × 200 = **40,000 controlli per frame!**
- Complessità: **O(n²)** - cresce in modo quadratico
---
## 2. Cos'è NumPy e Perché Serve
### NumPy in 3 Parole
**Array multidimensionali ottimizzati**
### Perché è Veloce?
```python
# Python puro (lento ❌)
distances = []
for i in range(1000):
for j in range(1000):
dx = x[i] - y[j]
dy = x[i] - y[j]
distances.append((dx**2 + dy**2)**0.5)
# Tempo: ~500ms con 1 milione di operazioni
# NumPy (veloce ✅)
import numpy as np
distances = np.sqrt((x[:, None] - y[None, :])**2 + (x[:, None] - y[None, :])**2)
# Tempo: ~5ms - 100 volte più veloce!
```
### Perché la Differenza?
1. **Codice C Compilato**: NumPy è scritto in C/C++, non Python interpretato
2. **Operazioni Vettoriali**: Calcola migliaia di valori in parallelo
3. **Memoria Contigua**: Dati organizzati efficientemente in RAM
4. **CPU SIMD**: Usa istruzioni speciali della CPU per parallelismo hardware
---
## 3. Concetti Base di NumPy
### Array vs Liste Python
```python
# Lista Python (flessibile ma lenta)
lista = [1, 2, 3, 4, 5]
lista.append("sei") # OK - tipi misti
lista[0] = "uno" # OK - cambio tipo
# Array NumPy (veloce ma rigido)
import numpy as np
array = np.array([1, 2, 3, 4, 5])
# array[0] = "uno" # ERRORE! Tipo fisso: int64
```
**Regola**: NumPy sacrifica flessibilità per velocità
### Operazioni Elemento per Elemento
```python
# Python puro
lista_a = [1, 2, 3]
lista_b = [4, 5, 6]
risultato = []
for a, b in zip(lista_a, lista_b):
risultato.append(a + b)
# risultato = [5, 7, 9]
# NumPy (broadcasting)
array_a = np.array([1, 2, 3])
array_b = np.array([4, 5, 6])
risultato = array_a + array_b # [5, 7, 9] - automatico!
```
### Broadcasting: Operazioni su Array di Dimensioni Diverse
```python
# Esempio reale dal tuo gioco: calcolare distanze
unit_positions = np.array([[10, 20], [30, 40], [50, 60]]) # 3 unità
target = np.array([25, 35]) # 1 bersaglio
# Vogliamo: distanza di ogni unità dal bersaglio
# Senza broadcasting (noioso):
distances = []
for pos in unit_positions:
dx = pos[0] - target[0]
dy = pos[1] - target[1]
distances.append(np.sqrt(dx**2 + dy**2))
# Con broadcasting (elegante):
diff = unit_positions - target # NumPy espande target automaticamente
distances = np.sqrt((diff**2).sum(axis=1))
# Output: [18.03, 7.07, 28.28]
```
**Come funziona?**
```
unit_positions: [[10, 20], target: [25, 35]
[30, 40],
[50, 60]] Broadcasting lo espande a:
[[25, 35],
[25, 35],
[25, 35]]
```
---
## 4. Dal Tuo Codice a NumPy: Caso Pratico
### Fase 1: Il Tuo Approccio con i Dizionari
```python
# units/rat.py - Il tuo codice originale
def collisions(self):
# Prende unità nella stessa cella
units_here = self.game.unit_positions.get(self.position, [])
units_before = self.game.unit_positions_before.get(self.position_before, [])
# Controlla ogni unità
for other_unit in units_here + units_before:
if other_unit.id == self.id:
continue
# Logica di collisione...
if self.sex == other_unit.sex and self.fight:
self.die(other_unit)
elif self.sex != other_unit.sex:
self.fuck(other_unit)
```
**Pro del Tuo Approccio:**
- ✅ Semplice e leggibile
- ✅ Usa dizionari Python nativi
- ✅ Funziona perfettamente con poche unità
**Problema con 200+ Unità:**
- ❌ Ogni topo itera su liste di Python
- ❌ Controlli ripetuti (topo A controlla B, poi B controlla A)
- ❌ Nessuna ottimizzazione per distanze
### Fase 2: Spatial Hashing (L'Idea Geniale)
Prima di NumPy, serve un algoritmo migliore: **Spatial Hashing**
```python
# Concetto: dividi il mondo in "celle" (griglia)
# Ogni cella contiene solo le unità al suo interno
# Mondo di gioco:
# 0 1 2 3
# 0 [ ] [ ] [ ] [ ]
# 1 [ ] [A] [B] [ ]
# 2 [ ] [ ] [C] [ ]
# 3 [ ] [ ] [ ] [ ]
# Dizionario spatial hash:
spatial_grid = {
(1, 1): [unit_A],
(2, 1): [unit_B],
(2, 2): [unit_C]
}
# Quando unit_A cerca collisioni:
# Controlla SOLO celle (1,1) e adiacenti (0,0), (0,1), (0,2), (1,0), (1,2), (2,0), (2,1), (2,2)
# Non controlla unit_C a (2,2) - troppo lontano!
```
**Vantaggio**: Da O(n²) a O(n)!
- 200 unità: da 40,000 controlli a ~1,800 controlli (celle adiacenti)
### Fase 3: NumPy per Calcoli Massivi
```python
# engine/collision_system.py - Il nuovo approccio
class CollisionSystem:
def __init__(self, cell_size=32):
# Pre-allocazione: prepara spazio per array NumPy
self.unit_ids = np.zeros(100, dtype=np.int64) # Array di ID
self.bboxes = np.zeros((100, 4), dtype=np.float32) # Array di bounding box
self.positions = np.zeros((100, 2), dtype=np.int32) # Array di posizioni
self.current_size = 0 # Quante unità registrate
self.capacity = 100 # Capacità massima prima di resize
```
**Perché Pre-allocazione?**
```python
# Cattivo: crescita lenta ❌
array = np.array([])
for i in range(1000):
array = np.append(array, i) # Crea NUOVO array ogni volta!
# Tempo: ~200ms
# Buono: pre-allocazione ✅
array = np.zeros(1000)
for i in range(1000):
array[i] = i # Modifica array esistente
# Tempo: ~2ms
```
### Fase 4: Registrazione Unità
```python
def register_unit(self, unit_id, bbox, position, position_before, collision_layer):
"""Registra un'unità nel sistema di collisione"""
# Se array pieno, raddoppia capacità
if self.current_size >= self.capacity:
self._resize_arrays(self.capacity * 2)
idx = self.current_size
# Inserisci dati negli array NumPy
self.unit_ids[idx] = unit_id
self.bboxes[idx] = bbox # [x1, y1, x2, y2]
self.positions[idx] = position
self.position_before[idx] = position_before
self.layers[idx] = collision_layer.value
# Spatial hashing: aggiungi a griglia
cell = (position[0], position[1])
self.spatial_grid[cell].append(idx) # Salva INDICE, non unità
self.current_size += 1
```
**Nota Importante**: Salviamo **indici** negli array, non oggetti Python!
- `spatial_grid[(5, 10)] = [0, 3, 7]` → Unità agli indici 0, 3, 7 degli array NumPy
- Accesso veloce: `self.bboxes[0]`, `self.bboxes[3]`, `self.bboxes[7]`
### Fase 5: Collisioni Vettoriali con NumPy
```python
def get_collisions_for_unit(self, unit_id, bbox, collision_layer):
"""Trova tutte le collisioni per un'unità"""
# 1. Trova celle da controllare (spatial hashing)
x, y = bbox[0] // self.cell_size, bbox[1] // self.cell_size
cells_to_check = [
(x-1, y-1), (x, y-1), (x+1, y-1),
(x-1, y), (x, y), (x+1, y),
(x-1, y+1), (x, y+1), (x+1, y+1)
]
# 2. Raccogli candidati da celle adiacenti
candidates = []
for cell in cells_to_check:
candidates.extend(self.spatial_grid.get(cell, []))
if len(candidates) < 10:
# POCHI candidati: usa Python normale
collisions = []
for idx in candidates:
if self.unit_ids[idx] == unit_id:
continue
if self._check_bbox_collision(bbox, self.bboxes[idx]):
collisions.append((self.layers[idx], self.unit_ids[idx]))
return collisions
else:
# MOLTI candidati: USA NUMPY! ✨
return self._vectorized_collision_check(unit_id, bbox, candidates)
```
### Fase 6: La Magia di NumPy - Vectorized Collision Check
```python
def _vectorized_collision_check(self, unit_id, bbox, candidate_indices):
"""Controlla collisioni usando NumPy per massima velocità"""
# Converti candidati in array NumPy
candidate_indices = np.array(candidate_indices, dtype=np.int32)
# Filtra l'unità stessa (non collidere con se stessi)
mask = self.unit_ids[candidate_indices] != unit_id
candidate_indices = candidate_indices[mask]
if len(candidate_indices) == 0:
return []
# Estrai bounding box di TUTTI i candidati in un colpo solo
candidate_bboxes = self.bboxes[candidate_indices] # Shape: (N, 4)
# candidate_bboxes = [[x1, y1, x2, y2], # candidato 0
# [x1, y1, x2, y2], # candidato 1
# ...]
# Controllo collisione AABB (Axis-Aligned Bounding Box)
# Due rettangoli collidono se:
# - bbox.x1 < other.x2 AND
# - bbox.x2 > other.x1 AND
# - bbox.y1 < other.y2 AND
# - bbox.y2 > other.y1
# NumPy calcola TUTTE le collisioni contemporaneamente! 🚀
colliding_mask = (
(bbox[0] < candidate_bboxes[:, 2]) & # bbox.x1 < others.x2
(bbox[2] > candidate_bboxes[:, 0]) & # bbox.x2 > others.x1
(bbox[1] < candidate_bboxes[:, 3]) & # bbox.y1 < others.y2
(bbox[3] > candidate_bboxes[:, 1]) # bbox.y2 > others.y1
)
# colliding_mask = [True, False, True, False, True, ...]
# Filtra solo unità che collidono
colliding_indices = candidate_indices[colliding_mask]
# Restituisci coppie (layer, unit_id)
return list(zip(
self.layers[colliding_indices],
self.unit_ids[colliding_indices]
))
```
**Spiegazione Dettagliata del Codice NumPy:**
```python
# Esempio concreto con 3 candidati
bbox = [10, 20, 30, 40] # Nostro topo: x1=10, y1=20, x2=30, y2=40
candidate_bboxes = np.array([
[5, 15, 25, 35], # Candidato 0
[50, 60, 70, 80], # Candidato 1 (lontano)
[15, 25, 35, 45] # Candidato 2
])
# Controllo bbox[0] < candidate_bboxes[:, 2]
# bbox[0] = 10
# candidate_bboxes[:, 2] = [25, 70, 35] # Colonna x2 di tutti i candidati
# 10 < [25, 70, 35] = [True, True, True]
# Controllo bbox[2] > candidate_bboxes[:, 0]
# bbox[2] = 30
# candidate_bboxes[:, 0] = [5, 50, 15] # Colonna x1
# 30 > [5, 50, 15] = [True, False, True]
# ... altri controlli ...
# Combinazione finale (AND logico):
colliding_mask = [True, False, True] # Solo 0 e 2 collidono!
```
---
## 5. Spatial Hashing: L'Algoritmo Intelligente
### Visualizzazione Pratica
```
Mondo di gioco 640x480, cell_size=32
Griglia spaziale:
0 1 2 3 4 5 ... 19
┌────┬────┬────┬────┬────┬────┬────┬────┐
0 │ │ │ │ │ │ │ │ │
├────┼────┼────┼────┼────┼────┼────┼────┤
1 │ │ R1 │ R2 │ │ │ │ │ │ R = Rat
├────┼────┼────┼────┼────┼────┼────┼────┤ B = Bomb
2 │ │ R3 │ B1 │ R4 │ │ │ │ │ M = Mine
├────┼────┼────┼────┼────┼────┼────┼────┤
3 │ │ │ M1 │ │ │ │ │ │
└────┴────┴────┴────┴────┴────┴────┴────┘
```
### Come Funziona il Lookup
```python
# R1 cerca collisioni da cella (1, 1)
def get_collisions_for_unit(self, unit_id, bbox):
x, y = 1, 1 # Posizione R1
# Controlla 9 celle (3x3 centrato su R1):
cells = [
(0,0), (1,0), (2,0), # Riga sopra
(0,1), (1,1), (2,1), # Riga centrale (include R1)
(0,2), (1,2), (2,2) # Riga sotto
]
# spatial_grid è un dizionario:
# {
# (1, 1): [idx_R1],
# (2, 1): [idx_R2],
# (1, 2): [idx_R3],
# (2, 2): [idx_B1, idx_R4],
# (2, 3): [idx_M1]
# }
candidates = []
for cell in cells:
candidates.extend(self.spatial_grid.get(cell, []))
# candidates = [idx_R1, idx_R2, idx_R3, idx_B1, idx_R4]
# NON include idx_M1 perché (2,3) è fuori dal range 3x3!
```
### Benefici Misurabili
```python
# SENZA spatial hashing (O(n²)):
# 200 unità → 200 × 200 = 40,000 controlli
# CON spatial hashing (O(n)):
# 200 unità, distribuite su 20×15=300 celle
# Media 0.67 unità per cella
# Ogni unità controlla 9 celle × 0.67 = ~6 candidati
# 200 unità × 6 candidati = 1,200 controlli
#
# Miglioramento: 40,000 → 1,200 = 33x più veloce! 🚀
```
---
## 6. Operazioni Vettoriali in NumPy
### Broadcasting Avanzato: Esplosioni
```python
def get_units_in_area(self, positions, layer_filter=None):
"""Trova unità in un'area (es. esplosione bomba)"""
# positions: lista di posizioni esplose
# es. [(10, 10), (10, 11), (11, 10), (11, 11)] # Esplosione 2x2
if self.current_size == 0:
return []
# Converti in array NumPy
area_positions = np.array(positions, dtype=np.int32) # Shape: (4, 2)
# Prendi posizioni di TUTTE le unità
all_positions = self.positions[:self.current_size] # Shape: (N, 2)
# es. all_positions = [[5, 5], [10, 10], [15, 15], [10, 11], ...]
# Broadcasting trick per confrontare OGNI posizione esplosione con OGNI unità
# area_positions[:, None, :] → Shape: (4, 1, 2)
# all_positions[None, :, :] → Shape: (1, N, 2)
# Risultato → Shape: (4, N, 2) - tutte le combinazioni!
matches = (area_positions[:, None, :] == all_positions[None, :, :]).all(axis=2)
# matches[i, j] = True se esplosione i colpisce unità j
# any(axis=0): almeno una posizione esplosione colpisce quella unità?
unit_hit_mask = matches.any(axis=0) # Shape: (N,)
# Filtra per layer se richiesto
if layer_filter:
valid_layers = self.layers[:self.current_size] == layer_filter.value
unit_hit_mask = unit_hit_mask & valid_layers
# Restituisci ID delle unità colpite
hit_indices = np.where(unit_hit_mask)[0]
return self.unit_ids[hit_indices].tolist()
```
**Spiegazione con Esempio Concreto:**
```python
# Bomba esplode creando 4 celle di fuoco
area_positions = np.array([[10, 10], [10, 11], [11, 10], [11, 11]])
# Ci sono 3 topi nel gioco
all_positions = np.array([[5, 5], [10, 10], [10, 11]])
# Broadcasting:
area_positions[:, None, :].shape # (4, 1, 2)
all_positions[None, :, :].shape # (1, 3, 2)
# Confronto elemento per elemento:
matches = (area_positions[:, None, :] == all_positions[None, :, :]).all(axis=2)
# matches = [
# [False, False, False], # Esplosione (10,10) vs [(5,5), (10,10), (10,11)]
# [False, True, False], # Esplosione (10,11) vs ...
# [False, False, True ], # Esplosione (11,10) vs ...
# [False, False, False] # Esplosione (11,11) vs ...
# ]
# Collassa su asse 0 (almeno UNA esplosione colpisce?)
unit_hit_mask = matches.any(axis=0) # [False, True, True]
# Topo 0 (5,5): NON colpito
# Topo 1 (10,10): COLPITO (da esplosione 0)
# Topo 2 (10,11): COLPITO (da esplosione 1)
```
### Calcolo Distanze Vettoriale
```python
# Esempio: trovare tutti i topi entro raggio 50 pixel da una bomba
bomb_position = np.array([100, 100]) # Posizione bomba
# Posizioni di tutti i topi (array NumPy)
rat_positions = self.positions[:self.current_size] # Shape: (N, 2)
# Calcolo distanze usando broadcasting
diff = rat_positions - bomb_position # Shape: (N, 2)
# diff[i] = [rat_x - bomb_x, rat_y - bomb_y]
distances = np.sqrt((diff ** 2).sum(axis=1)) # Shape: (N,)
# distances[i] = sqrt((dx)^2 + (dy)^2)
# Trova topi entro raggio
within_radius = distances < 50 # Boolean mask
hit_rat_indices = np.where(within_radius)[0]
# Esempio output:
# rat_positions = [[90, 90], [110, 110], [200, 200]]
# diff = [[-10, -10], [10, 10], [100, 100]]
# distances = [14.14, 14.14, 141.42]
# within_radius = [True, True, False]
# hit_rat_indices = [0, 1] # Primi due topi colpiti!
```
---
## 7. Best Practices e Pitfalls
### ✅ Quando Usare NumPy
```python
# BUONO: Operazioni su molti dati
positions = np.array([[...] for _ in range(1000)])
distances = np.sqrt(((positions - target)**2).sum(axis=1))
# CATTIVO: Operazioni su pochi dati (overhead NumPy!)
positions = np.array([[10, 20], [30, 40]]) # Solo 2 elementi
distances = np.sqrt(((positions - target)**2).sum(axis=1))
# Più lento di un semplice loop Python!
```
**Regola nel tuo codice:**
```python
if len(candidates) < 10:
# Usa Python normale
for idx in candidates:
...
else:
# Usa NumPy
self._vectorized_collision_check(...)
```
### ✅ Pre-allocazione vs Append
```python
# CATTIVO ❌ (lento con array grandi)
array = np.array([])
for i in range(10000):
array = np.append(array, i) # O(n) ad ogni append!
# BUONO ✅ (veloce)
array = np.zeros(10000)
for i in range(10000):
array[i] = i # O(1) ad ogni assegnazione
# MIGLIORE ✅ (senza loop!)
array = np.arange(10000) # Operazione vettoriale nativa
```
### ✅ Memory Layout e Performance
```python
# Row-major (C order) - default NumPy
array = np.zeros((1000, 3), order='C')
# Memoria: [x0, y0, z0, x1, y1, z1, ...]
# Veloce per accesso righe: array[i, :]
# Column-major (Fortran order)
array = np.zeros((1000, 3), order='F')
# Memoria: [x0, x1, ..., y0, y1, ..., z0, z1, ...]
# Veloce per accesso colonne: array[:, j]
# Nel tuo caso (posizioni):
self.positions = np.zeros((100, 2)) # Row-major è perfetto
# Accesso frequente: self.positions[idx] → [x, y] di un'unità
```
### ⚠️ Pitfall Comuni
#### 1. Copy vs View
```python
# View (condivide memoria)
a = np.array([1, 2, 3])
b = a[:] # b è una VIEW di a
b[0] = 999
print(a) # [999, 2, 3] - modificato anche a!
# Copy (memoria separata)
a = np.array([1, 2, 3])
b = a.copy()
b[0] = 999
print(a) # [1, 2, 3] - a è immutato
```
**Nel tuo codice:**
```python
def get_collisions_for_unit(self, ...):
# Filtriamo candidati
candidate_indices = candidate_indices[mask] # Crea VIEW
# Se modifichi candidate_indices dopo, potresti modificare l'originale!
# Soluzione: .copy() se necessario
```
#### 2. Broadcasting Inatteso
```python
a = np.array([1, 2, 3])
b = np.array([[1], [2], [3]])
# Cosa succede?
result = a + b
# Broadcasting espande a:
# [[1, 2, 3], [[1], [1], [1]] [[2, 3, 4],
# [1, 2, 3], + [2], [2], [2]] = [3, 4, 5],
# [1, 2, 3]] [3], [3], [3]] [4, 5, 6]]
```
**Verifica sempre le shape:**
```python
print(f"Shape: {array.shape}") # Sempre prima di operazioni complesse!
```
#### 3. Integer Overflow
```python
# ATTENZIONE con dtype piccoli!
a = np.array([250], dtype=np.uint8) # Max 255
b = a + 10 # 260, ma uint8 wrap: diventa 4!
# Soluzione: usa dtype appropriati
a = np.array([250], dtype=np.int32) # Max ~2 miliardi
b = a + 10 # 260 ✅
```
---
## Confronto Finale: Prima vs Dopo
### Codice Originale (Il Tuo)
```python
# rats.py
def update_maze(self):
self.unit_positions = {}
self.unit_positions_before = {}
for unit in self.units.values():
unit.move()
self.unit_positions.setdefault(unit.position, []).append(unit)
self.unit_positions_before.setdefault(unit.position_before, []).append(unit)
for unit in self.units.values():
unit.collisions()
# units/rat.py
def collisions(self):
for other_unit in self.game.unit_positions.get(self.position, []):
if other_unit.id == self.id:
continue
# Controlla collisione...
```
**Complessità**: O(n²) nel caso peggiore
**Performance**: ~50ms con 200 unità
**Memoria**: Dizionari Python + liste di oggetti
### Codice Ottimizzato (NumPy)
```python
# rats.py - 4-pass loop
def update_maze(self):
self.collision_system.clear()
# Pass 1: Pre-registra posizioni
for unit in self.units.values():
self.collision_system.register_unit(unit.id, unit.bbox, ...)
# Pass 2: Movimento
for unit in self.units.values():
unit.move()
# Pass 3: Ri-registra dopo movimento
self.collision_system.clear()
for unit in self.units.values():
self.collision_system.register_unit(unit.id, unit.bbox, ...)
# Pass 4: Collisioni
for unit in self.units.values():
unit.collisions()
# units/rat.py
def collisions(self):
collisions = self.game.collision_system.get_collisions_for_unit(
self.id, self.bbox, self.collision_layer
)
for _, other_id in collisions:
other_unit = self.game.get_unit_by_id(other_id)
if isinstance(other_unit, Rat):
# Controlla collisione...
```
**Complessità**: O(n) con spatial hashing + NumPy
**Performance**: ~3ms con 250 unità (16x più veloce!)
**Memoria**: Array NumPy pre-allocati (più efficiente)
---
## Conclusione
### Cosa Hai Imparato
1. **NumPy**: Array veloci per operazioni matematiche massive
2. **Broadcasting**: Operazioni automatiche su array di dimensioni diverse
3. **Spatial Hashing**: Ridurre O(n²) a O(n) con partizionamento spaziale
4. **Vectorization**: Sostituire loop Python con operazioni NumPy parallele
5. **Pre-allocazione**: Evitare allocazioni ripetute per velocità
### Quando Applicare Queste Tecniche
- ✅ **Usa NumPy quando**: Hai 50+ elementi da processare con operazioni matematiche
- ✅ **Usa Spatial Hashing quando**: Controlli collisioni/prossimità in spazio 2D/3D
- ✅ **Usa Vectorization quando**: Stesso calcolo ripetuto su molti dati
- ❌ **Non usare quando**: Pochi elementi (< 10) o logica complessa non matematica
### Risorse per Approfondire
- **NumPy Documentation**: https://numpy.org/doc/stable/
- **NumPy Quickstart**: https://numpy.org/doc/stable/user/quickstart.html
- **Broadcasting**: https://numpy.org/doc/stable/user/basics.broadcasting.html
- **Performance Tips**: https://numpy.org/doc/stable/user/c-info.performance.html
---
**Il tuo codice originale era ottimo per il caso d'uso iniziale.** L'ottimizzazione con NumPy è stata necessaria solo quando hai scalato a 200+ unità. Questo è un esempio perfetto di "ottimizza quando serve", non prematuramente! 🎯
+11
View File
@@ -15,6 +15,17 @@ Mice! is a strategic game where players must kill rats with bombs before they re
- **Scoring**: Points system to track player progress.
- **Performance**: Optimized collision detection system supporting 200+ simultaneous units using NumPy vectorization.
## Utilities
### Microphone Visualizer
A small SDL2 microphone visualizer is available in `tools/mic_visualizer.py`.
- List capture devices: `python tools/mic_visualizer.py --list-devices`
- Open the default microphone: `python tools/mic_visualizer.py`
- Open a specific input: `python tools/mic_visualizer.py --device-index 1`
- On muOS, use `mice_mic.sh` as a launcher in `ROMS/Ports` and it will run fullscreen with gamepad quit support.
## Engine Architecture
The Mice! game engine is built on a modular architecture designed for flexibility and maintainability. The engine follows a component-based design pattern where different systems handle specific aspects of the game.
-308
View File
@@ -1,308 +0,0 @@
# Game Profile Manager
A PySDL2-based user profile management system designed for gamepad-only control with virtual keyboard input. This system allows players to create, edit, delete, and select user profiles for games using only gamepad inputs or directional keys, with no need for physical keyboard text input.
## Features
- **640x480 Resolution**: Optimized for retro gaming systems and handheld devices
- **Create New Profiles**: Add new user profiles with custom names using virtual keyboard
- **Profile Selection**: Browse and select active profiles
- **Edit Settings**: Modify profile settings including difficulty, volume levels, and preferences
- **Delete Profiles**: Remove unwanted profiles
- **Gamepad/Directional Navigation**: Full control using only gamepad/joystick inputs or arrow keys
- **Virtual Keyboard**: Text input using directional controls - no physical keyboard typing required
- **JSON Storage**: Profiles stored in human-readable JSON format
- **Persistent Settings**: All changes automatically saved
## Installation
### Requirements
- Python 3.6+
- PySDL2
- SDL2 library
### Setup
```bash
# Install required Python packages
pip install pysdl2
# For Ubuntu/Debian users, you may also need:
sudo apt-get install libsdl2-dev libsdl2-ttf-dev
# Make launcher executable
chmod +x launch_profile_manager.sh
```
## Usage
### Running the Profile Manager
```bash
# Method 1: Use the launcher script
./launch_profile_manager.sh
# Method 2: Run directly with Python
python3 profile_manager.py
```
### Gamepad Controls
#### Standard Gamepad Layout (Xbox/PlayStation compatible)
- **D-Pad/Hat**: Navigate menus up/down/left/right, control virtual keyboard cursor
- **Button 0 (A/X)**: Confirm selection, enter menus, select virtual keyboard characters
- **Button 1 (B/Circle)**: Go back, cancel action
- **Button 2 (X/Square)**: Delete profile, backspace in virtual keyboard
- **Button 3 (Y/Triangle)**: Reserved for future features
#### Keyboard Controls (Alternative)
- **Arrow Keys**: Navigate menus and virtual keyboard cursor
- **Enter/Space**: Confirm selection, select virtual keyboard characters
- **Escape**: Go back, cancel action
- **Delete/Backspace**: Delete profile, backspace in virtual keyboard
- **Tab**: Reserved for future features
#### Virtual Keyboard Text Input
When creating or editing profile names:
1. **Navigate**: Use D-Pad/Arrow Keys to move cursor over virtual keyboard
2. **Select Character**: Press A/Enter to add character to profile name
3. **Backspace**: Press X/Delete to remove last character
4. **Complete**: Navigate to "DONE" and press A/Enter to finish input
5. **Cancel**: Navigate to "CANCEL" and press A/Enter to abort
#### Navigation Flow
1. **Main Menu**: Create Profile → Select Profile → Edit Settings → Exit
2. **Profile List**: Choose from existing profiles, or go back
3. **Create Profile**: Use virtual keyboard to enter name, confirm with directional controls
4. **Edit Profile**: Adjust settings using left/right navigation
### Display Specifications
- **Resolution**: 640x480 pixels (4:3 aspect ratio)
- **Optimized for**: Retro gaming systems, handheld devices, embedded systems
- **Font Scaling**: Adaptive font sizes for optimal readability at low resolution
### Profile Structure
Profiles are stored in `user_profiles.json` with the following structure:
```json
{
"profiles": {
"PlayerName": {
"name": "PlayerName",
"created_date": "2024-01-15T10:30:00",
"last_played": "2024-01-20T14:45:00",
"games_played": 25,
"total_score": 15420,
"best_score": 980,
"settings": {
"difficulty": "normal",
"sound_volume": 75,
"music_volume": 60,
"screen_shake": true,
"auto_save": true
},
"achievements": [
"first_win",
"score_500"
]
}
},
"active_profile": "PlayerName"
}
```
## Integration with Games
### Loading Active Profile
```python
import json
def load_active_profile():
try:
with open('user_profiles.json', 'r') as f:
data = json.load(f)
active_name = data.get('active_profile')
if active_name and active_name in data['profiles']:
return data['profiles'][active_name]
except (FileNotFoundError, json.JSONDecodeError):
pass
return None
# Usage in your game
profile = load_active_profile()
if profile:
difficulty = profile['settings']['difficulty']
sound_volume = profile['settings']['sound_volume']
```
### Updating Profile Stats
```python
def update_profile_stats(score, game_completed=True):
try:
with open('user_profiles.json', 'r') as f:
data = json.load(f)
active_name = data.get('active_profile')
if active_name and active_name in data['profiles']:
profile = data['profiles'][active_name]
if game_completed:
profile['games_played'] += 1
profile['total_score'] += score
profile['best_score'] = max(profile['best_score'], score)
profile['last_played'] = datetime.now().isoformat()
with open('user_profiles.json', 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error updating profile: {e}")
```
## Customization
### Adding New Settings
Edit the `UserProfile` dataclass and the settings adjustment methods:
```python
# In profile_manager.py, modify the UserProfile.__post_init__ method
def __post_init__(self):
if self.settings is None:
self.settings = {
"difficulty": "normal",
"sound_volume": 50,
"music_volume": 50,
"screen_shake": True,
"auto_save": True,
"your_new_setting": "default_value" # Add here
}
```
### Custom Font
Place your font file in the `assets/` directory and update the font path:
```python
font_path = "assets/your_font.ttf"
```
### Screen Resolution
The application is optimized for 640x480 resolution. To change resolution, modify the window size in the init_sdl method:
```python
self.window = sdl2.ext.Window(
title="Profile Manager",
size=(your_width, your_height) # Change from (640, 480)
)
```
### Virtual Keyboard Layout
Customize the virtual keyboard characters by modifying the keyboard_chars list:
```python
self.keyboard_chars = [
['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'],
['K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T'],
['U', 'V', 'W', 'X', 'Y', 'Z', '1', '2', '3', '4'],
['5', '6', '7', '8', '9', '0', '_', '-', ' ', '<'],
['DONE', 'CANCEL', '', '', '', '', '', '', '', '']
]
```
## Troubleshooting
### No Gamepad Detected
- Ensure your gamepad is connected before starting the application
- Try different USB ports
- Check if your gamepad is recognized by your system
- The application will show "No gamepad detected - using keyboard fallback"
- Virtual keyboard works with both gamepad and keyboard controls
### Font Issues
- Ensure the font file exists in the assets directory
- The system will fall back to default font if custom font is not found
- Supported font formats: TTF, OTF
- Font sizes are automatically scaled for 640x480 resolution
### Virtual Keyboard Not Responding
- Ensure you're in text input mode (creating/editing profile names)
- Use arrow keys or D-Pad to navigate the virtual keyboard cursor
- Press Enter/A button to select characters
- The virtual keyboard cursor should be visible as a highlighted character
### Profile Not Saving
- Check file permissions in the application directory
- Ensure sufficient disk space
- Verify JSON format is not corrupted
### Resolution Issues
- The application is designed for 640x480 resolution
- On higher resolution displays, the window may appear small
- This is intentional for compatibility with retro gaming systems
- Content is optimized and readable at this resolution
## File Structure
```
project_directory/
├── profile_manager.py # Main application (640x480, virtual keyboard)
├── launch_profile_manager.sh # Launcher script
├── user_profiles.json # Profile data storage
├── test_profile_manager.py # Test suite for core functions
├── game_profile_integration.py # Example game integration
├── assets/
│ └── decterm.ttf # Font file (optional)
└── README_PROFILE_MANAGER.md # This documentation
```
## Development Notes
### Virtual Keyboard Implementation
The virtual keyboard is implemented as a 2D grid of characters:
- Cursor position tracked with (keyboard_cursor_x, keyboard_cursor_y)
- Character selection adds to input_text string
- Special functions: DONE (confirm), CANCEL (abort), < (backspace)
- Fully navigable with directional controls only
### Screen Layout for 640x480
- Header area: 0-80px (titles, status)
- Content area: 80-400px (main UI elements)
- Controls area: 400-480px (help text, instructions)
- All elements scaled and positioned for optimal readability
### Adding New Screens
1. Add screen name to `current_screen` handling
2. Create render method (e.g., `render_new_screen()`)
3. Add navigation logic in input handlers
4. Update screen transitions in confirm/back handlers
### Gamepad Button Mapping
The application uses SDL2's joystick interface. Button numbers may vary by controller:
- Most modern controllers follow the Xbox layout
- PlayStation controllers map similarly but may have different button numbers
- Test with your specific controller and adjust mappings if needed
### Performance Considerations
- Rendering is capped at 60 FPS for smooth operation
- Input debouncing prevents accidental rapid inputs
- JSON operations are minimized and occur only when necessary
- Virtual keyboard rendering optimized for 640x480 resolution
- Font scaling automatically adjusted for readability
### Adding Support for Different Resolutions
To support different screen resolutions, modify these key areas:
1. Window initialization in `init_sdl()`
2. Panel and button positioning in render methods
3. Font size scaling factors
4. Virtual keyboard grid positioning
### Gamepad Integration Notes
- Uses SDL2's joystick interface for maximum compatibility
- Button mapping follows standard Xbox controller layout
- Hat/D-Pad input prioritized over analog sticks for precision
- Input timing designed for responsive but not accidental activation
## Target Platforms
This profile manager is specifically designed for:
- **Handheld Gaming Devices**: Steam Deck, ROG Ally, etc.
- **Retro Gaming Systems**: RetroPie, Batocera, etc.
- **Embedded Gaming Systems**: Custom arcade cabinets, portable devices
- **Low-Resolution Displays**: 640x480, 800x600, and similar resolutions
- **Gamepad-Only Environments**: Systems without keyboard access
## License
This profile manager is provided as-is for educational and personal use. Designed for integration with retro and handheld gaming systems.
-419
View File
@@ -1,419 +0,0 @@
# regenerate_background() Analysis And Refactor Plan
## Scope
This document analyzes `Graphics.regenerate_background()` and proposes a staged refactor plan.
Relevant code paths:
- `engine/graphics.py#L146` `draw_maze()` lazily triggers background generation.
- `engine/graphics.py#L155` `draw_cave_foreground()` consumes cave overlay metadata produced during regeneration.
- `engine/graphics.py#L175` `regenerate_background()` builds the static background texture and cave overlay placements.
- `engine/graphics.py#L311` `add_blood_stain()` confirms that blood is intentionally excluded from the background texture and rendered as a separate overlay.
- `engine/sdl2.py#L98` `create_texture()` composites surface tiles into one SDL texture.
- `engine/sdl2.py#L118` `load_image()` explains why the code keeps both surfaces and textures for the same themed assets.
- `engine/maze.py#L13-L15` define `MAP_EMPTY`, `MAP_WALL`, and `MAP_TUNNEL`.
- `rats.py#L79`, `rats.py#L117-L121`, and `rats.py#L163-L165` show where background state is invalidated.
- `rats.py#L335` shows cave foreground rendering happens after the background draw and before units are drawn.
## What The Method Actually Does
`regenerate_background()` is doing more than the name suggests. It is not only “regenerating a background”; it is handling five separate concerns in one place:
1. It walks the logical map cell by cell.
2. It analyzes neighborhood topology around each wall or tunnel cell.
3. It chooses visual variants, including random grass and flower decoration.
4. It builds cave foreground overlay metadata for later explosion-aware rendering.
5. It commits the accumulated surfaces into a single SDL background texture.
That makes it both a planner and a renderer.
## Current Inputs, Outputs, And Side Effects
### Inputs read from `self`
- `self.map.tiles`, `self.map.width`, `self.map.height`
- `self.cell_size`
- `self.grasses`, `self.grass_textures`
- `self.flowers`, `self.flower_textures`
- `self.edges`, `self.corners`, `self.inner_corners`
- `self.caves`
- `self.render_engine`
### Derived helpers inside the method
- `occupied(x, y)` treats every non-empty cell as occupied, so both walls and tunnels count as solid neighbors for topology decisions.
- `is_tunnel(x, y)` is used only for the flower suppression logic in the bottom-right quadrant.
- `draw(...)` appends background surface tiles.
- `draw_cave(...)` appends cave overlay tuples in the format consumed later by `draw_cave_foreground()`.
- `random_wall()`, `random_wall_texture()`, `random_flower()`, `random_flower_texture()` embed random selection directly in the traversal logic.
### Outputs and side effects
- Resets `self.cave_foreground_tiles`
- Builds a local `texture_tiles` list
- Sets `self.background_texture`
- Does not return a value
This means the method is hard to test in isolation because the real output is split across mutable instance state and SDL object creation.
## Functional Walkthrough
### 1. Initialization
The method creates:
- `texture_tiles`: a list of `(surface, x, y)` tuples for the static background
- `self.cave_foreground_tiles`: a list of `(cell_x, cell_y, direction, surface, x, y)` tuples for overlay rendering
- `half_cell`: used to place quarter-cell tiles at 20 px offsets when `cell_size` is 40
This immediately shows a hidden design choice: one map cell can emit up to four quarter tiles rather than a single full-tile sprite.
### 2. Cell iteration
The outer loop traverses every cell of `self.map.tiles`.
- `MAP_EMPTY`: skipped completely
- `MAP_WALL`: potentially emits several quarter tiles
- `MAP_TUNNEL`: emits cave overlays and sometimes grass filler tiles
### 3. Wall rendering logic
For `MAP_WALL`, the method evaluates the four quadrants independently.
#### Top-left quadrant
If the north-west corner is exposed, it chooses among:
- `inner_corners["WN"]`
- `edges["W"]`
- `edges["N"]`
- `corners["NW"]`
based on whether the north and west neighbors are occupied.
#### Bottom-right quadrant
This is the densest branch. It checks south, east, and south-east occupancy.
- If all three are occupied, it usually draws a random grass tile.
- With a 10% chance, it draws a flower instead, but only if the cell is not near the border and none of the neighboring cells involved are tunnels.
- If only south or east are occupied, it chooses `inner_corners["ES"]`, `edges["E"]`, or `edges["S"]`.
- Otherwise it uses `corners["SE"]`.
This branch mixes topology, decoration policy, border constraints, and tunnel suppression all in one nested block.
#### Top-right quadrant
Mirrors the top-left logic using north and east occupancy:
- `inner_corners["EN"]`
- `edges["E"]`
- `edges["N"]`
- `corners["NE"]`
#### Bottom-left quadrant
Mirrors the same pattern using south and west occupancy:
- `inner_corners["WS"]`
- `edges["W"]`
- `edges["S"]`
- `corners["SW"]`
### 4. Tunnel rendering logic
For `MAP_TUNNEL`, the method checks `above`, `below`, `left`, and `right` occupancy and chooses cave overlay sprites.
Observed behavior:
- If there is no occupied tile above, it always draws a grass filler in the bottom-right quarter and uses the `UP` cave sprite.
- If there is an occupied tile above but not below, it uses the `DOWN` cave sprite.
- If both above and below are occupied and the left side is blocked, it may use a full-quarter wall/flower texture in the cave list.
- If both above and below are occupied and the left side is open, it draws a grass filler plus the `LEFT` cave sprite.
- If above and below are occupied, left is blocked, and right is open, it uses the `RIGHT` cave sprite.
This logic appears tuned to the current level topology and asset set rather than representing a complete, explicit rule system for all tunnel neighbor combinations.
### 5. Commit phase
After traversal, the method calls `render_engine.create_texture(texture_tiles, fill_color=(128, 128, 128))` to compose one static SDL texture for the entire maze background.
This is the correct optimization boundary for the current architecture, but it also means SDL concerns leak directly into the generation logic.
## Why The Method Feels Complex
The complexity is not only “too many lines”. It comes from multiple kinds of coupling.
### 1. Mixed responsibilities
The method mixes:
- map analysis
- rule selection
- random decoration
- cave overlay planning
- final rendering commit
Each of these changes for different reasons, so they should not live in the same function.
### 2. Repeated neighborhood queries
Neighbor checks like `occupied(x, y - 1)` and `occupied(x + 1, y)` are recomputed many times, often inside overlapping branches. That makes the code noisy and increases the chance of introducing asymmetric bugs during edits.
### 3. Hidden representation mismatch
Background composition uses SDL surfaces, while cave overlays use textures. That is why the code has parallel helpers like `random_wall()` and `random_wall_texture()`. The behavior is valid, but the representation split is leaking into every branch.
### 4. Randomness is embedded in rule logic
The function directly calls global `random` during traversal. That makes visual behavior hard to snapshot-test or compare before and after a refactor.
### 5. Side effects are scattered across the class lifecycle
Invalidation is controlled elsewhere in `rats.py`, where the code manually clears:
- `self.background_texture`
- `self.blood_layer_sprites`
- `self.cave_foreground_tiles`
This is correct today, but it creates a fragile contract between game flow code and rendering code.
### 6. Tunnel rules are implicit
The tunnel branch contains nested assumptions that are hard to verify by inspection. It is not obvious whether the logic is exhaustive, map-specific, or intentionally asymmetric.
## Important Invariants To Preserve
Any refactor must keep these behaviors unless you explicitly choose to change them:
1. Blood stains remain outside the static background texture.
2. `draw_cave_foreground()` must still be able to swap cave sprites for explosion sprites at runtime.
3. Quarter-tile placement and offsets must remain visually identical.
4. Random flower placement must preserve the current frequency and tunnel/border exclusions, or the change must be documented as a visual redesign.
5. Theme asset selection must keep using surfaces for background composition and textures for runtime overlays unless the render-engine API changes.
## Refactor Goals
The target should be:
- easier to read
- behaviorally stable
- testable without SDL
- explicit about map-topology rules
- easy to extend with new wall or tunnel tile rules
## Recommended Refactor Direction
The safest path is not a full rewrite. It is a staged extraction toward a pure planning layer.
### Stage 1: Name The Concepts
Extract small private helpers without changing data structures yet.
Suggested helpers:
- `_is_occupied(x, y)`
- `_is_tunnel(x, y)`
- `_make_cell_context(x, y)`
- `_append_background_tile(surface, x, y, texture_tiles)`
- `_append_cave_tile(surface, x, y, direction)`
- `_choose_wall_fill(x, y, allow_flower)`
This alone will remove repeated neighbor reads and make the current logic easier to reason about.
### Stage 2: Introduce A Pure Planning Model
Create lightweight data containers, for example:
```python
from dataclasses import dataclass
@dataclass(frozen=True)
class TilePlacement:
surface: object
x: int
y: int
@dataclass(frozen=True)
class CavePlacement:
cell_x: int
cell_y: int
direction: str | None
sprite: object
x: int
y: int
@dataclass(frozen=True)
class CellContext:
x: int
y: int
cell: int
north: bool
south: bool
east: bool
west: bool
north_west: bool
north_east: bool
south_west: bool
south_east: bool
```
Then split the method into:
- `_build_background_plan()`
- `_plan_wall_cell(context, plan)`
- `_plan_tunnel_cell(context, plan)`
- `_commit_background_plan(plan)`
The important shift is this: planning should produce plain Python data first, and SDL texture creation should happen only in the commit step.
### Stage 3: Replace Nested Branches With Rule Helpers
The wall logic is currently “four quadrants, each with a small rule tree”. Keep that structure, but make it explicit.
Suggested helpers:
- `_plan_wall_nw(context, px, py, plan)`
- `_plan_wall_ne(context, px, py, half_cell, plan)`
- `_plan_wall_sw(context, px, py, half_cell, plan)`
- `_plan_wall_se(context, px, py, half_cell, plan)`
This sounds verbose, but it is much easier to review because each helper owns one visual quadrant and one set of rules.
### Stage 4: Isolate Decoration Policy
The flower rule is currently buried inside the `SE` branch. Extract it into a dedicated function such as:
```python
def _should_place_flower(self, x, y, context) -> bool:
...
```
That function should own:
- the 10% probability
- border exclusions
- tunnel exclusions
This makes visual tuning possible without reopening the topology logic.
### Stage 5: Make Tunnel Rules Explicit
Tunnel behavior needs a named rule function with documented cases.
For example:
- `_classify_tunnel(context) -> TunnelPattern`
- `_plan_tunnel_pattern(pattern, px, py, plan)`
Even if the final logic stays the same, naming the tunnel patterns will expose whether the code is intentionally map-specific or accidentally incomplete.
### Stage 6: Centralize Invalidation
Introduce one method such as:
```python
def invalidate_background(self):
self.background_texture = None
self.cave_foreground_tiles.clear()
```
Then use that method from lifecycle points in `rats.py`.
This reduces the chance of future bugs where one part of the cached rendering state is reset and another is forgotten.
## Suggested Final Shape
The long-term shape can stay inside `Graphics` and still be much cleaner:
```python
def regenerate_background(self):
plan = self._build_background_plan()
self._commit_background_plan(plan)
def _build_background_plan(self):
...
def _plan_wall_cell(self, context, plan):
...
def _plan_tunnel_cell(self, context, plan):
...
def _commit_background_plan(self, plan):
self.cave_foreground_tiles = plan.cave_tiles
self.background_texture = self.render_engine.create_texture(
plan.background_tiles,
fill_color=(128, 128, 128),
)
```
This would preserve the current class boundaries while making the core algorithm testable.
## Test Strategy Before Refactoring
Because the function is visual and randomized, refactoring without a guardrail is risky.
Recommended safety steps:
1. Introduce a seeded RNG path so map generation can be deterministic during tests.
2. Add a small test map fixture that exercises walls, corners, borders, and tunnels.
3. Snapshot the produced tile plan, not the SDL texture object.
4. Verify cave overlay tuples are identical before and after the extraction.
5. Add a smoke test for `draw_cave_foreground()` with an explosion unit to ensure cave sprite replacement still works.
## Proposed Implementation Order
### Phase 0: Freeze Current Behavior
- Add a deterministic RNG entry point or injectable random source.
- Capture the current background plan for one or two representative maps.
### Phase 1: Extract Context And Emit Helpers
- Remove repeated `occupied(...)` calls.
- Keep current tuple outputs and current SDL commit behavior.
### Phase 2: Split Wall And Tunnel Planning
- Move wall rules into quadrant helpers.
- Move tunnel rules into a dedicated planner.
### Phase 3: Introduce A `BackgroundPlan`
- Return plain data from planning.
- Keep SDL texture creation in one place.
### Phase 4: Centralize Cache Invalidation
- Replace direct state resets with a single background invalidation method.
### Phase 5: Optional Optimization Pass
- Consider caching immutable plans by `(level_index, theme_index)` if needed.
- Consider precomputing per-cell contexts if profiling shows the planner is still hot.
## Refactor Risks And Questions
These should be clarified before implementation:
1. Are tunnel patterns guaranteed by the level data, or should the code become exhaustive for arbitrary maps?
2. Is `occupied()` intentionally treating tunnels as “solid” for wall topology, or is that only a rendering shortcut?
3. Is the flower placement rule part of the visual identity, or can it be simplified?
4. Do we want to keep both surfaces and textures in the theme cache, or would a render-engine API change be acceptable later?
## Recommended First Refactor PR
The lowest-risk first PR would do only this:
1. Extract `CellContext` creation.
2. Extract the four wall-quadrant planners.
3. Extract tunnel planning into one helper.
4. Leave the tuple formats and SDL commit step unchanged.
That PR would reduce complexity sharply while keeping the visual output almost certainly identical.
## Summary
`regenerate_background()` is complex because it is simultaneously a topology analyzer, decoration policy engine, cave overlay planner, and SDL background composer. The safest refactor is to separate planning from rendering, then isolate wall rules, tunnel rules, and decoration policy into named helpers with deterministic test coverage.
-466
View File
@@ -1,466 +0,0 @@
# Analisi Performance Rendering SDL2 - Mice!
## Sommario Esecutivo
Il sistema di rendering presenta **diverse criticità** che possono causare cali di FPS con molte unità (200+). Ho identificato 7 problemi principali e relative soluzioni.
---
## 🔴 CRITICITÀ IDENTIFICATE
### 1. **Controllo Visibilità Inefficiente** ⚠️ ALTA PRIORITÀ
**Problema:**
```python
def is_in_visible_area(self, x, y):
return (-self.w_offset - self.cell_size <= x <= self.width - self.w_offset and
-self.h_offset - self.cell_size <= y <= self.height - self.h_offset)
```
Ogni `draw_image()` chiama `is_in_visible_area()` che fa **4 confronti** per ogni sprite.
**Impatto con 250 unità:**
- 250 unità × 4 confronti = **1000 operazioni per frame**
- Molte unità potrebbero essere fuori schermo ma vengono controllate comunque
**Soluzione:**
```python
# Opzione A: Culling a livello di game loop (CONSIGLIATA)
# Filtra unità PRIMA del draw usando spatial grid
visible_cells = get_visible_cells(w_offset, h_offset, viewport_width, viewport_height)
for unit in units:
if unit.position in visible_cells or unit.position_before in visible_cells:
unit.draw()
# Opzione B: Cache dei bounds
class GameWindow:
def update_viewport_bounds(self):
self.visible_x_min = -self.w_offset - self.cell_size
self.visible_x_max = self.width - self.w_offset
self.visible_y_min = -self.h_offset - self.cell_size
self.visible_y_max = self.height - self.h_offset
def is_in_visible_area(self, x, y):
return (self.visible_x_min <= x <= self.visible_x_max and
self.visible_y_min <= y <= self.visible_y_max)
```
**Guadagno stimato:** 10-15% con 200+ unità
---
### 2. **Chiamate renderer.copy() Non Batch** ⚠️ ALTA PRIORITÀ
**Problema:**
```python
# Ogni unità chiama renderer.copy() individualmente
def draw_image(self, x, y, sprite, tag=None, anchor="nw"):
if not self.is_in_visible_area(x, y):
return
sprite.position = (x + self.w_offset, y + self.w_offset)
self.renderer.copy(sprite, dstrect=sprite.position) # ← Singola chiamata SDL
```
**Impatto:**
- 250 unità = **250 chiamate individuali a SDL2**
- Ogni chiamata ha overhead di context switch
- Non sfrutta batching hardware
**Soluzione - Sprite Batching:**
```python
class GameWindow:
def __init__(self, ...):
self.sprite_batch = [] # Accumula sprite da disegnare
def queue_sprite(self, x, y, sprite):
"""Accoda sprite invece di disegnarlo subito"""
if self.is_in_visible_area(x, y):
self.sprite_batch.append((sprite, x + self.w_offset, y + self.h_offset))
def flush_sprites(self):
"""Disegna tutti gli sprite in batch"""
for sprite, x, y in self.sprite_batch:
sprite.position = (x, y)
self.renderer.copy(sprite, dstrect=sprite.position)
self.sprite_batch.clear()
# Nel game loop
for unit in units:
unit.draw() # Ora usa queue_sprite invece di draw_image
renderer.flush_sprites() # Singolo flush alla fine
```
**Guadagno stimato:** 15-25% con 200+ unità
---
### 3. **Calcolo Posizioni Ridondante** ⚠️ MEDIA PRIORITÀ
**Problema in Rat.draw():**
```python
def draw(self):
start_perf = self.game.render_engine.get_perf_counter() # ← Non utilizzato!
direction = self.calculate_rat_direction() # ← Già calcolato in move()
# Calcolo partial_x/y ripetuto per ogni frame
if direction in ["UP", "DOWN"]:
partial_y = self.partial_move * self.game.cell_size * (1 if direction == "DOWN" else -1)
else:
partial_x = self.partial_move * self.game.cell_size * (1 if direction == "RIGHT" else -1)
x_pos = self.position_before[0] * self.game.cell_size + ...
y_pos = self.position_before[1] * self.game.cell_size + ...
# get_image_size() chiamato ogni frame
image_size = self.game.render_engine.get_image_size(image)
```
**Impatto:**
- `calculate_rat_direction()`: già calcolato in `move()` → **250 chiamate duplicate**
- `get_image_size()`: dimensioni statiche, non cambiano → **250 lookups inutili**
- Calcoli aritmetici ripetuti
**Soluzione - Cache in Unit:**
```python
class Rat(Unit):
def move(self):
# ... existing move logic ...
self.direction = self.calculate_rat_direction() # Cache direction
# Pre-calcola render_position durante move
self._update_render_position()
def _update_render_position(self):
"""Pre-calcola posizione di rendering"""
if self.direction in ["UP", "DOWN"]:
partial_y = self.partial_move * self.game.cell_size * (1 if self.direction == "DOWN" else -1)
partial_x = 0
else:
partial_x = self.partial_move * self.game.cell_size * (1 if self.direction == "RIGHT" else -1)
partial_y = 0
image_size = self.game.rat_image_sizes[self.sex if self.age > AGE_THRESHOLD else "BABY"][self.direction]
self.render_x = self.position_before[0] * self.game.cell_size + (self.game.cell_size - image_size[0]) // 2 + partial_x
self.render_y = self.position_before[1] * self.game.cell_size + (self.game.cell_size - image_size[1]) // 2 + partial_y
self.bbox = (self.render_x, self.render_y, self.render_x + image_size[0], self.render_y + image_size[1])
def draw(self):
sex = self.sex if self.age > AGE_THRESHOLD else "BABY"
image = self.game.rat_assets_textures[sex][self.direction]
self.game.render_engine.draw_image(self.render_x, self.render_y, image, tag="unit")
```
**Pre-cache dimensioni immagini in Graphics:**
```python
class Graphics:
def load_assets(self):
# ... existing code ...
# Pre-cache image sizes
self.rat_image_sizes = {}
for sex in ["MALE", "FEMALE", "BABY"]:
self.rat_image_sizes[sex] = {}
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]:
texture = self.rat_assets_textures[sex][direction]
self.rat_image_sizes[sex][direction] = texture.size
```
**Guadagno stimato:** 5-10% con 200+ unità
---
### 4. **Tag System Inutilizzato** ⚠️ BASSA PRIORITÀ
**Problema:**
```python
def delete_tag(self, tag):
"""Placeholder for tag deletion (not implemented)"""
pass
# Ogni draw passa tag="unit" ma non viene mai usato
unit.draw() # → draw_image(..., tag="unit")
```
**Impatto:**
- Overhead minimo di passaggio parametro inutile
- 250 unità × parametro = spreco memoria call stack
**Soluzione:**
Rimuovere parametro `tag` da `draw_image()` e tutte le chiamate.
**Guadagno stimato:** 1-2%
---
### 5. **Generazione Blood Stains Costosa** ⚠️ MEDIA PRIORITÀ
**Problema:**
```python
def add_blood_stain(self, position):
# Genera nuova surface SDL con pixel manipulation
new_blood_surface = self.render_engine.generate_blood_surface() # LENTO
if position in self.blood_stains:
# Combina surfaces con pixel blending
combined_surface = self.render_engine.combine_blood_surfaces(...) # MOLTO LENTO
# WORST: Rigenera TUTTO il background
self.background_texture = None # ← Forza rigenerazione completa
```
**Impatto:**
- Ogni morte di ratto → rigenerazione background completo
- 200 morti = **200 rigenerazioni** di texture enorme
- `generate_blood_surface()`: loop pixel-by-pixel
- `combine_blood_surfaces()`: blending manuale RGBA
**Soluzione - Pre-generazione + Overlay Layer:**
```python
class Graphics:
def load_assets(self):
# Pre-genera 10 varianti di blood stains
self.blood_stain_pool = [
self.render_engine.generate_blood_surface()
for _ in range(10)
]
self.blood_stain_textures = [
self.render_engine.factory.from_surface(surface)
for surface in self.blood_stain_pool
]
# Layer separato per blood
self.blood_layer_sprites = []
def add_blood_stain(self, position):
"""Aggiunge blood come sprite invece che rigenerare background"""
import random
blood_texture = random.choice(self.blood_stain_textures)
x = position[0] * self.cell_size
y = position[1] * self.cell_size
self.blood_layer_sprites.append((blood_texture, x, y))
def draw_blood_layer(self):
"""Disegna tutti i blood stains come sprites"""
for texture, x, y in self.blood_layer_sprites:
self.render_engine.draw_image(x, y, texture, tag="blood")
# Nel game loop
self.draw_maze() # Background statico (UNA SOLA VOLTA)
self.draw_blood_layer() # Blood stains come sprites
# ... draw units ...
```
**Guadagno stimato:** 20-30% durante scenari con molte morti
---
### 6. **Font Manager Creazione Inefficiente** ⚠️ BASSA PRIORITÀ
**Problema:**
```python
def generate_fonts(self, font_file):
fonts = {}
for i in range(10, 70, 1): # 60 font managers!
fonts.update({i: sdl2.ext.FontManager(font_path=font_file, size=i)})
return fonts
```
**Impatto:**
- 60 FontManager creati all'avvio
- Usa solo 3-4 dimensioni durante il gioco
- Memoria sprecata: ~60 × FontManager overhead
**Soluzione - Lazy Loading:**
```python
def generate_fonts(self, font_file):
self.font_file = font_file
self.fonts = {}
# Pre-carica solo dimensioni comuni
common_sizes = [20, 35, 45]
for size in common_sizes:
self.fonts[size] = sdl2.ext.FontManager(font_path=font_file, size=size)
def get_font(self, size):
"""Lazy load font se non esiste"""
if size not in self.fonts:
self.fonts[size] = sdl2.ext.FontManager(font_path=self.font_file, size=size)
return self.fonts[size]
```
**Guadagno:** Startup time: -200ms, Memoria: -5MB
---
### 7. **Performance Counter Inutilizzato** ⚠️ MINIMA PRIORITÀ
**Problema in Rat.draw():**
```python
def draw(self):
start_perf = self.game.render_engine.get_perf_counter() # Mai usato!
# ... resto del codice ...
```
**Impatto:**
- 250 chiamate a `SDL_GetPerformanceCounter()` per niente
- Overhead chiamata: ~0.001ms × 250 = 0.25ms/frame
**Soluzione:**
Rimuovere la riga o usarla per profiling reale.
---
## 📊 IMPATTO TOTALE STIMATO
### Performance Attuali (Stimate)
Con 250 unità:
- Collision detection: ~3.3ms (✅ ottimizzato)
- Rendering: **~10-15ms** (🔴 collo di bottiglia)
- Game logic: ~2ms
- **TOTALE: ~15-20ms/frame** (50-65 FPS)
### Performance Post-Ottimizzazione
Con 250 unità:
- Collision detection: ~3.3ms
- Rendering: **~4-6ms** (✅ migliorato 2.5x)
- Game logic: ~2ms
- **TOTALE: ~9-11ms/frame** (90-110 FPS)
---
## 🎯 PIANO DI IMPLEMENTAZIONE CONSIGLIATO
### Priority 1 - Quick Wins (1-2 ore)
1. ✅ **Viewport culling** (soluzione A - spatial grid)
2. ✅ **Cache render positions** in Rat
3. ✅ **Pre-cache image sizes**
4. ✅ **Rimuovi tag parameter**
**Guadagno atteso: 20-30%**
### Priority 2 - Medium Effort (2-3 ore)
5. ✅ **Blood stain overlay layer** (invece di rigenerazione)
6. ✅ **Sprite batching** (queue + flush)
**Guadagno atteso: +30-40% cumulativo = 50-70% totale**
### Priority 3 - Optional (1 ora)
7. ✅ **Lazy font loading**
8. ✅ **Rimuovi performance counter inutilizzato**
**Guadagno atteso: marginale ma cleanup code**
---
## 🔧 OTTIMIZZAZIONI AVANZATE (Opzionali)
### A. Texture Atlas per Rat Sprites
**Problema:** 250 ratti = 250 texture bind per frame
**Soluzione:**
```python
# Combina tutti i rat sprites in una singola texture
# Usa source rectangles per selezionare sprite specifici
rat_atlas = create_texture_atlas(all_rat_sprites)
renderer.copy(rat_atlas, srcrect=sprite_rect, dstrect=screen_rect)
```
**Guadagno:** +10-20% con 200+ unità
### B. Dirty Rectangle Tracking
**Problema:** Ridisegna tutto il background ogni frame
**Soluzione:**
```python
# Traccia solo le aree che sono cambiate
dirty_rects = []
for unit in units:
if unit.moved:
dirty_rects.append(unit.previous_rect)
dirty_rects.append(unit.current_rect)
# Ridisegna solo dirty rects
for rect in dirty_rects:
redraw_region(rect)
```
**Guadagno:** +30-50% su mappe grandi
### C. Multi-threaded Rendering
**Problema:** Single-threaded rendering
**Soluzione:**
```python
# Thread 1: Game logic + collision
# Thread 2: Preparazione sprite (calcolo posizioni, culling)
# Main thread: Solo rendering SDL
```
**Guadagno:** +40-60% su CPU multi-core
---
## 📈 METRICHE DI SUCCESSO
Dopo le ottimizzazioni Priority 1 e 2:
| Unità | FPS Attuale | FPS Target | FPS Atteso |
|-------|-------------|------------|------------|
| 50 | ~60 | 60 | 60+ |
| 100 | ~55 | 60 | 60+ |
| 200 | ~45 | 50 | 70-80 |
| 250 | ~35-40 | 50 | 60-70 |
| 300 | ~30 | 50 | 50-60 |
---
## 🧪 STRUMENTI DI PROFILING
### Script di Benchmark Rendering
```python
# test_rendering_performance.py
import time
from rats import MiceMaze
def benchmark_rendering():
game = MiceMaze('maze.json')
# Spawna 250 ratti
for _ in range(250):
game.spawn_rat()
# Misura 100 frame
render_times = []
for _ in range(100):
start = time.perf_counter()
# Solo rendering (no game logic)
game.draw_maze()
for unit in game.units.values():
unit.draw()
game.renderer.present()
render_times.append((time.perf_counter() - start) * 1000)
print(f"Avg render time: {sum(render_times)/len(render_times):.2f}ms")
print(f"Min: {min(render_times):.2f}ms, Max: {max(render_times):.2f}ms")
```
---
## 💡 CONCLUSIONI
Il rendering è **il principale bottleneck** con 200+ unità, non le collisioni.
**Ottimizzazioni critiche:**
1. Viewport culling (15% gain)
2. Sprite batching (25% gain)
3. Blood stain overlay (30% gain in scenari con morti)
4. Cache render positions (10% gain)
**Implementando Priority 1 + 2 si ottiene ~2.5x speedup sul rendering**, portando il gioco da ~40 FPS a ~70-80 FPS con 250 unità.
Il sistema di collisioni NumPy è già ottimizzato (3.3ms), quindi il focus deve essere sul rendering SDL2.
-259
View File
@@ -1,259 +0,0 @@
# Ottimizzazioni Rendering Implementate
## ✅ Completato - 24 Ottobre 2025
### Modifiche Implementate
#### 1. **Cache Viewport Bounds** ✅ (+15% performance)
**File:** `engine/sdl2.py`
**Problema:** `is_in_visible_area()` ricalcolava i bounds ogni chiamata (4 confronti × 250 unità = 1000 operazioni/frame)
**Soluzione:**
```python
def _update_viewport_bounds(self):
"""Update cached viewport bounds for fast visibility checks"""
self.visible_x_min = -self.w_offset - self.cell_size
self.visible_x_max = self.width - self.w_offset
self.visible_y_min = -self.h_offset - self.cell_size
self.visible_y_max = self.height - self.h_offset
def is_in_visible_area(self, x, y):
"""Ottimizzato con cached bounds"""
return (self.visible_x_min <= x <= self.visible_x_max and
self.visible_y_min <= y <= self.visible_y_max)
```
I bounds vengono aggiornati solo quando cambia il viewport (scroll), non a ogni check.
---
#### 2. **Pre-cache Image Sizes** ✅ (+5% performance)
**File:** `engine/graphics.py`
**Problema:** `get_image_size()` chiamato 250 volte/frame anche se le dimensioni sono statiche
**Soluzione:**
```python
# All'avvio, memorizza tutte le dimensioni
self.rat_image_sizes = {}
for sex in ["MALE", "FEMALE", "BABY"]:
self.rat_image_sizes[sex] = {}
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]:
texture = self.rat_assets_textures[sex][direction]
self.rat_image_sizes[sex][direction] = texture.size # Cache!
```
Le dimensioni vengono lette una sola volta all'avvio, non ogni frame.
---
#### 3. **Cache Render Positions in Rat** ✅ (+10% performance)
**File:** `units/rat.py`
**Problema:**
- `calculate_rat_direction()` chiamato sia in `move()` che in `draw()` → duplicato
- Calcoli aritmetici (partial_x, partial_y, x_pos, y_pos) ripetuti ogni frame
- `get_image_size()` chiamato ogni frame (ora risolto con cache)
**Soluzione:**
```python
def move(self):
# ... movimento ...
self.direction = self.calculate_rat_direction()
self._update_render_position() # Pre-calcola per draw()
def _update_render_position(self):
"""Pre-calcola posizione di rendering durante move()"""
sex = self.sex if self.age > AGE_THRESHOLD else "BABY"
image_size = self.game.rat_image_sizes[sex][self.direction] # Cache!
# Calcola una sola volta
if self.direction in ["UP", "DOWN"]:
partial_x = 0
partial_y = self.partial_move * self.game.cell_size * (1 if self.direction == "DOWN" else -1)
else:
partial_x = self.partial_move * self.game.cell_size * (1 if self.direction == "RIGHT" else -1)
partial_y = 0
self.render_x = self.position_before[0] * self.game.cell_size + (self.game.cell_size - image_size[0]) // 2 + partial_x
self.render_y = self.position_before[1] * self.game.cell_size + (self.game.cell_size - image_size[1]) // 2 + partial_y
self.bbox = (self.render_x, self.render_y, self.render_x + image_size[0], self.render_y + image_size[1])
def draw(self):
"""Semplicissimo - usa solo valori pre-calcolati"""
sex = self.sex if self.age > AGE_THRESHOLD else "BABY"
image = self.game.rat_assets_textures[sex][self.direction]
self.game.render_engine.draw_image(self.render_x, self.render_y, image, tag="unit")
```
**Benefici:**
- Nessun calcolo duplicato
- `draw()` diventa semplicissimo
- `bbox` aggiornato automaticamente per collision system
---
#### 4. **Blood Stains come Overlay Layer** ✅ (+30% in scenari con morti)
**File:** `engine/graphics.py`
**Problema:**
- Ogni morte di ratto → `generate_blood_surface()` (pixel-by-pixel loop)
- Poi → `combine_blood_surfaces()` (blending RGBA manuale)
- Infine → `self.background_texture = None` → **rigenerazione completa background**
- Con 200 morti = 200 rigenerazioni di texture enorme!
**Soluzione:**
**A) Pre-generazione pool all'avvio:**
```python
def load_assets(self):
# ...
# Pre-genera 10 varianti di blood stains
self.blood_stain_textures = []
for _ in range(10):
blood_surface = self.render_engine.generate_blood_surface()
blood_texture = self.render_engine.draw_blood_surface(blood_surface, (0, 0))
if blood_texture:
self.blood_stain_textures.append(blood_texture)
self.blood_layer_sprites = [] # Lista di blood sprites
```
**B) Blood come sprites overlay:**
```python
def add_blood_stain(self, position):
"""Aggiunge blood come sprite - NESSUNA rigenerazione background!"""
import random
blood_texture = random.choice(self.blood_stain_textures)
x = position[0] * self.cell_size
y = position[1] * self.cell_size
# Aggiungi alla lista invece di rigenerare
self.blood_layer_sprites.append((blood_texture, x, y))
def draw_blood_layer(self):
"""Disegna tutti i blood stains come sprites"""
for blood_texture, x, y in self.blood_layer_sprites:
self.render_engine.draw_image(x, y, blood_texture, tag="blood")
```
**C) Background statico:**
```python
def draw_maze(self):
if self.background_texture is None:
self.regenerate_background()
self.render_engine.draw_background(self.background_texture)
self.draw_blood_layer() # Blood come overlay separato
```
**Benefici:**
- Background generato UNA SOLA VOLTA (all'inizio)
- Blood stains: pre-generati → nessun costo runtime
- Nessuna rigenerazione costosa
- 10 varianti casuali per varietà visiva
---
### Performance Stimate
#### Prima delle Ottimizzazioni
Con 250 unità:
```
Frame breakdown:
- Collision detection: 3.3ms (già ottimizzato con NumPy)
- Rendering: 10-15ms
- draw_image checks: ~2ms (visibility checks)
- get_image_size calls: ~1ms
- Render calculations: ~2ms
- Blood regenerations: ~3-5ms (picchi)
- SDL copy calls: ~4ms
- Game logic: 2ms
TOTALE: ~15-20ms → 50-65 FPS
```
#### Dopo le Ottimizzazioni
Con 250 unità:
```
Frame breakdown:
- Collision detection: 3.3ms (invariato)
- Rendering: 5-7ms ✅
- draw_image checks: ~0.5ms (cached bounds)
- get_image_size calls: 0ms (pre-cached)
- Render calculations: ~0.5ms (pre-calcolati in move)
- Blood regenerations: 0ms (overlay sprites)
- SDL copy calls: ~4ms (invariato)
- Game logic: 2ms
TOTALE: ~10-12ms → 80-100 FPS
```
**Miglioramento: ~2x più veloce nel rendering**
---
### Metriche di Successo
| Unità | FPS Prima | FPS Dopo | Miglioramento |
|-------|-----------|----------|---------------|
| 50 | ~60 | 60+ | Stabile |
| 100 | ~55 | 60+ | +9% |
| 200 | ~45 | 75-85 | +67-89% |
| 250 | ~35-40 | 60-70 | +71-100% |
| 300 | ~30 | 55-65 | +83-117% |
---
### File Modificati
1. ✅ `engine/sdl2.py` - Cache viewport bounds
2. ✅ `engine/graphics.py` - Pre-cache sizes + blood overlay
3. ✅ `units/rat.py` - Cache render positions
**Linee di codice modificate:** ~120 linee
**Tempo implementazione:** ~2 ore
**Performance gain:** 2x rendering, 1.5-2x FPS totale con 200+ unità
---
### Ottimizzazioni Future (Opzionali)
#### Non Implementate (basso impatto):
- ❌ Rimozione tag parameter (1-2% gain)
- ❌ Sprite batching (complesso, 15-25% gain ma richiede refactor)
- ❌ Texture atlas (10-20% gain ma richiede asset rebuild)
#### Motivo:
Le ottimizzazioni implementate hanno già raggiunto l'obiettivo di 60+ FPS con 250 unità. Le ulteriori ottimizzazioni avrebbero costo/beneficio sfavorevole.
---
### Testing
**Come testare i miglioramenti:**
1. Avvia il gioco: `./mice.sh`
2. Spawna molti ratti (usa keybinding per spawn)
3. Osserva FPS counter in alto a sinistra
4. Usa bombe per uccidere ratti → osserva che NON ci sono lag durante morti multiple
**Risultati attesi:**
- Con 200+ ratti: FPS stabile 70-85
- Durante esplosioni multiple: nessun lag
- Blood stains appaiono istantaneamente
---
### Conclusioni
✅ **Obiettivo raggiunto**: Da ~40 FPS a ~70-80 FPS con 250 unità
Le ottimizzazioni si concentrano sui bottleneck reali:
1. **Viewport checks** erano costosi → ora cached
2. **Image sizes** venivano riletti → ora cached
3. **Render calculations** erano duplicati → ora pre-calcolati
4. **Blood stains** rigeneravano tutto → ora overlay
Il sistema ora scala bene fino a 300+ unità mantenendo 50+ FPS.
Il rendering SDL2 è ora **2x più veloce** e combinato con il collision system NumPy già ottimizzato, il gioco può gestire scenari con centinaia di unità senza problemi di performance.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 954 B

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 296 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 B

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 614 B

After

Width:  |  Height:  |  Size: 189 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 B

After

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 519 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 543 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 543 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 400 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 627 B

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 B

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 B

After

Width:  |  Height:  |  Size: 193 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 B

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 B

After

Width:  |  Height:  |  Size: 194 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 627 B

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 B

After

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 B

After

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 608 B

After

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 771 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 775 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 773 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+24
View File
@@ -27,6 +27,30 @@
"keydown_M": "toggle_audio",
"keydown_F": "toggle_full_screen"
},
"keybinding_level_intro": {
"keydown_Return": "reset_game",
"keydown_Escape": "quit_game",
"keydown_M": "toggle_audio",
"keydown_F": "toggle_full_screen"
},
"keybinding_level_clear": {
"keydown_Return": "reset_game",
"keydown_Escape": "quit_game",
"keydown_M": "toggle_audio",
"keydown_F": "toggle_full_screen"
},
"keybinding_defeat": {
"keydown_Return": "reset_game",
"keydown_Escape": "quit_game",
"keydown_M": "toggle_audio",
"keydown_F": "toggle_full_screen"
},
"keybinding_run_complete": {
"keydown_Return": "reset_game",
"keydown_Escape": "quit_game",
"keydown_M": "toggle_audio",
"keydown_F": "toggle_full_screen"
},
"keybinding_paused": {
"keydown_Return": "reset_game",
"keydown_Escape": "quit_game",
+24
View File
@@ -25,6 +25,30 @@ keybinding_start_menu:
controllerbuttondown_misc1: quit_game
controllerbuttondown_guide: quit_game
keybinding_level_intro:
controllerbuttondown_a: reset_game
controllerbuttondown_start: reset_game
controllerbuttondown_misc1: quit_game
controllerbuttondown_guide: quit_game
keybinding_level_clear:
controllerbuttondown_a: reset_game
controllerbuttondown_start: reset_game
controllerbuttondown_misc1: quit_game
controllerbuttondown_guide: quit_game
keybinding_defeat:
controllerbuttondown_a: reset_game
controllerbuttondown_start: reset_game
controllerbuttondown_misc1: quit_game
controllerbuttondown_guide: quit_game
keybinding_run_complete:
controllerbuttondown_a: reset_game
controllerbuttondown_start: reset_game
controllerbuttondown_misc1: quit_game
controllerbuttondown_guide: quit_game
keybinding_paused:
controllerbuttondown_start: toggle_pause
controllerbuttondown_dpad_up: menu_up
+24
View File
@@ -26,6 +26,30 @@ keybinding_start_menu:
keydown_M: toggle_audio
keydown_F: toggle_full_screen
keybinding_level_intro:
keydown_Return: reset_game
keydown_Escape: quit_game
keydown_M: toggle_audio
keydown_F: toggle_full_screen
keybinding_level_clear:
keydown_Return: reset_game
keydown_Escape: quit_game
keydown_M: toggle_audio
keydown_F: toggle_full_screen
keybinding_defeat:
keydown_Return: reset_game
keydown_Escape: quit_game
keydown_M: toggle_audio
keydown_F: toggle_full_screen
keybinding_run_complete:
keydown_Return: reset_game
keydown_Escape: quit_game
keydown_M: toggle_audio
keydown_F: toggle_full_screen
keybinding_paused:
keydown_Return: reset_game
keydown_Escape: quit_game
+16
View File
@@ -24,6 +24,22 @@ keybinding_start_menu:
joybuttondown_16: toggle_pause
joybuttondown_12: quit_game
keybinding_level_intro:
joybuttondown_13: reset_game
joybuttondown_12: quit_game
keybinding_level_clear:
joybuttondown_13: reset_game
joybuttondown_12: quit_game
keybinding_defeat:
joybuttondown_13: reset_game
joybuttondown_12: quit_game
keybinding_run_complete:
joybuttondown_13: reset_game
joybuttondown_12: quit_game
keybinding_paused:
joybuttondown_13: reset_game
joybuttondown_16: toggle_pause
+16
View File
@@ -20,6 +20,22 @@ keybinding_start_menu:
joybuttondown_10: toggle_pause
joybuttondown_11: quit_game
keybinding_level_intro:
joybuttondown_9: reset_game
joybuttondown_11: quit_game
keybinding_level_clear:
joybuttondown_9: reset_game
joybuttondown_11: quit_game
keybinding_defeat:
joybuttondown_9: reset_game
joybuttondown_11: quit_game
keybinding_run_complete:
joybuttondown_9: reset_game
joybuttondown_11: quit_game
keybinding_paused:
joybuttondown_9: reset_game
joybuttondown_10: toggle_pause
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 683 KiB

+109 -326
View File
@@ -1,25 +1,25 @@
"""
Optimized collision detection system using NumPy for vectorized operations.
Native Python collision detection system using Spatial Hashing.
This module provides efficient collision detection for games with many entities (200+).
Uses AABB (Axis-Aligned Bounding Box) collision detection with numpy vectorization.
HYBRID APPROACH:
- For < 50 units: Uses simple dictionary-based approach (low overhead)
- For >= 50 units: Uses NumPy vectorization (scales better)
Performance improvements:
- O(n²) → O(n) for spatial queries using grid-based hashing
- Vectorized AABB checks for large unit counts
- Minimal overhead for small unit counts
This module provides efficient collision detection without NumPy.
It uses a grid-based approach (buckets) to ensure O(1) or O(n) complexity.
This structure is designed to be easily portable to Nim.
"""
import numpy as np
from typing import Dict, List, Tuple, Set
from dataclasses import dataclass
# Threshold for switching to NumPy mode
NUMPY_THRESHOLD = 50
def shrink_bbox(bbox: Tuple[float, float, float, float], ratio: float) -> Tuple[float, float, float, float]:
"""Return a smaller bbox centered on the original one.
ratio is the fraction of width/height removed from each side.
E.g. ratio=0.25 keeps the central 50% of the original area.
"""
x1, y1, x2, y2 = bbox
dx = (x2 - x1) * ratio / 2
dy = (y2 - y1) * ratio / 2
return (x1 + dx, y1 + dy, x2 - dx, y2 - dy)
@dataclass
@@ -35,16 +35,7 @@ class CollisionLayer:
class CollisionSystem:
"""
Manages collision detection for all game units using NumPy vectorization.
Attributes
----------
cell_size : int
Size of each grid cell in pixels
grid_width : int
Number of cells in grid width
grid_height : int
Number of cells in grid height
Manages collision detection using a Spatial Grid.
"""
def __init__(self, cell_size: int, grid_width: int, grid_height: int):
@@ -52,350 +43,142 @@ class CollisionSystem:
self.grid_width = grid_width
self.grid_height = grid_height
# Spatial grid for fast lookups
self.spatial_grid: Dict[Tuple[int, int], List] = {}
self.spatial_grid_before: Dict[Tuple[int, int], List] = {}
# Grid: Maps (x, y) coordinates to list of Unit objects/IDs
self.grid: Dict[Tuple[int, int], List[int]] = {}
self.grid_before: Dict[Tuple[int, int], List[int]] = {}
# Arrays for vectorized operations
self.unit_ids = []
self.bboxes = np.array([], dtype=np.float32).reshape(0, 4) # (x1, y1, x2, y2)
self.positions = np.array([], dtype=np.int32).reshape(0, 2) # (x, y)
self.positions_before = np.array([], dtype=np.int32).reshape(0, 2)
self.layers = np.array([], dtype=np.int8)
# Unit storage
self.units_data: Dict[int, dict] = {}
self.unit_ids: List[int] = [] # Stable list of IDs for parity
# Pre-allocation tracking
self._capacity = 0
self._size = 0
# Collision matrix: which layers collide with which
self.collision_matrix = np.zeros((6, 6), dtype=bool)
# Collision matrix (Native Python dict of sets for speed)
self._setup_collision_matrix()
def _setup_collision_matrix(self):
"""Define which collision layers interact with each other."""
L = CollisionLayer
# Interaction rules: layer -> set of target layers
self.interaction_map = {
L.RAT: {L.RAT, L.GAS, L.MINE, L.POINT, L.EXPLOSION},
L.GAS: {L.RAT},
L.MINE: {L.RAT},
L.POINT: {L.RAT},
L.EXPLOSION: {L.RAT},
L.BOMB: set() # Bombs are passive until they explode
}
# Rats collide with: Rats, Bombs, Gas, Mines, Points
self.collision_matrix[L.RAT, L.RAT] = True
self.collision_matrix[L.RAT, L.BOMB] = False # Bombs don't kill on contact
self.collision_matrix[L.RAT, L.GAS] = True
self.collision_matrix[L.RAT, L.MINE] = True
self.collision_matrix[L.RAT, L.POINT] = True
self.collision_matrix[L.RAT, L.EXPLOSION] = True
# Gas affects rats
self.collision_matrix[L.GAS, L.RAT] = True
# Mines trigger on rats
self.collision_matrix[L.MINE, L.RAT] = True
# Points collected by rats (handled in point logic)
self.collision_matrix[L.POINT, L.RAT] = True
# Explosions kill rats
self.collision_matrix[L.EXPLOSION, L.RAT] = True
# Make matrix symmetric
self.collision_matrix = np.logical_or(self.collision_matrix,
self.collision_matrix.T)
def clear(self):
"""Clear all collision data for new frame."""
self.spatial_grid.clear()
self.spatial_grid_before.clear()
self.unit_ids = []
self.bboxes = np.array([], dtype=np.float32).reshape(0, 4)
self.positions = np.array([], dtype=np.int32).reshape(0, 2)
self.positions_before = np.array([], dtype=np.int32).reshape(0, 2)
self.layers = np.array([], dtype=np.int8)
self.grid.clear()
self.grid_before.clear()
self.units_data.clear()
self.unit_ids.clear()
def register_unit(self, unit_id, bbox: Tuple[float, float, float, float],
position: Tuple[int, int], position_before: Tuple[int, int],
layer: int):
"""
Register a unit for collision detection this frame.
Parameters
----------
unit_id : UUID
Unique identifier for the unit
bbox : tuple
Bounding box (x1, y1, x2, y2)
position : tuple
Current grid position (x, y)
position_before : tuple
Previous grid position (x, y)
layer : int
Collision layer (from CollisionLayer enum)
Register a unit in the spatial grid.
"""
idx = len(self.unit_ids)
self.unit_ids.append(unit_id)
self.units_data[unit_id] = {
"bbox": bbox,
"pos": position,
"pos_before": position_before,
"layer": layer
}
# Pre-allocate arrays in batches to reduce overhead
if len(self.bboxes) == 0:
# Initialize with reasonable capacity
self.bboxes = np.empty((100, 4), dtype=np.float32)
self.positions = np.empty((100, 2), dtype=np.int32)
self.positions_before = np.empty((100, 2), dtype=np.int32)
self.layers = np.empty(100, dtype=np.int8)
self._capacity = 100
self._size = 0
elif self._size >= self._capacity:
# Expand capacity
new_capacity = self._capacity * 2
self.bboxes = np.resize(self.bboxes, (new_capacity, 4))
self.positions = np.resize(self.positions, (new_capacity, 2))
self.positions_before = np.resize(self.positions_before, (new_capacity, 2))
self.layers = np.resize(self.layers, new_capacity)
self._capacity = new_capacity
# Add to spatial buckets
if position not in self.grid:
self.grid[position] = []
self.grid[position].append(unit_id)
# Add data
self.bboxes[self._size] = bbox
self.positions[self._size] = position
self.positions_before[self._size] = position_before
self.layers[self._size] = layer
self._size += 1
# Add to spatial grids
self.spatial_grid.setdefault(position, []).append(idx)
self.spatial_grid_before.setdefault(position_before, []).append(idx)
def check_aabb_collision(self, idx1: int, idx2: int, tolerance: int = 0) -> bool:
"""
Check AABB collision between two units.
Parameters
----------
idx1, idx2 : int
Indices in the arrays
tolerance : int
Overlap tolerance in pixels (reduces detection zone)
Returns
-------
bool
True if bounding boxes overlap
"""
bbox1 = self.bboxes[idx1]
bbox2 = self.bboxes[idx2]
return (bbox1[0] < bbox2[2] - tolerance and
bbox1[2] > bbox2[0] + tolerance and
bbox1[1] < bbox2[3] - tolerance and
bbox1[3] > bbox2[1] + tolerance)
def check_aabb_collision_vectorized(self, idx: int, indices: np.ndarray,
tolerance: int = 0) -> np.ndarray:
"""
Vectorized AABB collision check between one unit and many others.
Parameters
----------
idx : int
Index of the unit to check
indices : ndarray
Array of indices to check against
tolerance : int
Overlap tolerance in pixels
Returns
-------
ndarray
Boolean array indicating collisions
"""
if len(indices) == 0:
return np.array([], dtype=bool)
# Slice actual data size, not full capacity
bbox = self.bboxes[idx]
other_bboxes = self.bboxes[indices]
# Vectorized AABB check
collisions = (
(bbox[0] < other_bboxes[:, 2] - tolerance) &
(bbox[2] > other_bboxes[:, 0] + tolerance) &
(bbox[1] < other_bboxes[:, 3] - tolerance) &
(bbox[3] > other_bboxes[:, 1] + tolerance)
)
return collisions
if position_before not in self.grid_before:
self.grid_before[position_before] = []
self.grid_before[position_before].append(unit_id)
def get_collisions_for_unit(self, unit_id, layer: int,
tolerance: int = 0) -> List[Tuple[int, any]]:
"""
Get all units colliding with the specified unit.
Uses hybrid approach: simple method for few units, numpy for many.
Parameters
----------
unit_id : UUID
ID of the unit to check
layer : int
Collision layer of the unit
tolerance : int
Overlap tolerance
Returns
-------
list
List of tuples (index, unit_id) for colliding units
Get all units colliding with the specified unit using grid lookup.
"""
if unit_id not in self.unit_ids:
if unit_id not in self.units_data:
return []
data = self.units_data[unit_id]
bbox = data["bbox"]
pos = data["pos"]
pos_before = data["pos_before"]
idx = self.unit_ids.index(unit_id)
position = tuple(self.positions[idx])
position_before = tuple(self.positions_before[idx])
colliding_units = []
target_layers = self.interaction_map.get(layer, set())
# Get candidate indices from spatial grid
# Candidate search: look in current and previous grid buckets
# This covers units that moved into our space or were there before
candidates = set()
for pos in [position, position_before]:
candidates.update(self.spatial_grid.get(pos, []))
candidates.update(self.spatial_grid_before.get(pos, []))
for p in [pos, pos_before]:
if p in self.grid:
candidates.update(self.grid[p])
if p in self.grid_before:
candidates.update(self.grid_before[p])
# Remove self and out-of-bounds indices
candidates.discard(idx)
candidates = {c for c in candidates if c < self._size}
candidates.discard(unit_id)
if not candidates:
return []
# HYBRID APPROACH: Use simple method for few candidates
if len(candidates) < 10:
return self._simple_collision_check(idx, candidates, layer, tolerance)
# NumPy vectorized approach for many candidates
candidates_array = np.array(list(candidates), dtype=np.int32)
candidate_layers = self.layers[candidates_array]
# Check collision matrix
can_collide = self.collision_matrix[layer, candidate_layers]
valid_candidates = candidates_array[can_collide]
if len(valid_candidates) == 0:
return []
# Vectorized AABB check
collisions = self.check_aabb_collision_vectorized(idx, valid_candidates, tolerance)
colliding_indices = valid_candidates[collisions]
# Return list of (index, unit_id) pairs
return [(int(i), self.unit_ids[i]) for i in colliding_indices]
def _simple_collision_check(self, idx: int, candidates: set, layer: int,
tolerance: int) -> List[Tuple[int, any]]:
"""
Simple collision check without numpy overhead.
Used when there are few candidates.
"""
results = []
bbox = self.bboxes[idx]
for other_idx in candidates:
# Check collision layer
if not self.collision_matrix[layer, self.layers[other_idx]]:
continue
for other_id in candidates:
other_data = self.units_data[other_id]
# AABB check
other_bbox = self.bboxes[other_idx]
# 1. Filter by layer
if other_data["layer"] not in target_layers:
continue
# 2. AABB Check
other_bbox = other_data["bbox"]
if (bbox[0] < other_bbox[2] - tolerance and
bbox[2] > other_bbox[0] + tolerance and
bbox[1] < other_bbox[3] - tolerance and
bbox[3] > other_bbox[1] + tolerance):
results.append((int(other_idx), self.unit_ids[other_idx]))
return results
# Return dummy index (for parity) and ID
colliding_units.append((0, other_id))
return colliding_units
def get_units_in_cell(self, position: Tuple[int, int],
use_before: bool = False) -> List[any]:
"""
Get all unit IDs in a specific grid cell.
Parameters
----------
position : tuple
Grid position (x, y)
use_before : bool
If True, use position_before instead of position
Returns
-------
list
List of unit IDs in that cell
"""
grid = self.spatial_grid_before if use_before else self.spatial_grid
indices = grid.get(position, [])
return [self.unit_ids[i] for i in indices]
"""Get all unit IDs in a specific grid cell."""
target_grid = self.grid_before if use_before else self.grid
return target_grid.get(position, [])
def get_units_in_area(self, positions: List[Tuple[int, int]],
layer_filter: int = None) -> Set[any]:
"""
Get all units in multiple grid cells (useful for explosions).
Parameters
----------
positions : list
List of grid positions to check
layer_filter : int, optional
If provided, only return units of this layer
Returns
-------
set
Set of unique unit IDs in the area
"""
unit_set = set()
"""Get all units in multiple grid cells (vectorized lookup replacement)."""
found = set()
for pos in positions:
# Check both current and previous positions
for grid in [self.spatial_grid, self.spatial_grid_before]:
indices = grid.get(pos, [])
for idx in indices:
if layer_filter is None or self.layers[idx] == layer_filter:
unit_set.add(self.unit_ids[idx])
return unit_set
# Check current grid
if pos in self.grid:
for uid in self.grid[pos]:
if layer_filter is None or self.units_data[uid]["layer"] == layer_filter:
found.add(uid)
# Check previous grid
if pos in self.grid_before:
for uid in self.grid_before[pos]:
if layer_filter is None or self.units_data[uid]["layer"] == layer_filter:
found.add(uid)
return found
def check_partial_move_collision(self, unit_id, partial_move: float,
threshold: float = 0.5) -> List[any]:
"""
Check collisions considering partial movement progress.
For units moving between cells, checks if they should be considered
in current or previous cell based on movement progress.
Parameters
----------
unit_id : UUID
Unit to check
partial_move : float
Movement progress (0.0 to 1.0)
threshold : float
Movement threshold for position consideration
Returns
-------
list
List of unit IDs in collision
"""
if unit_id not in self.unit_ids:
"""Collision check considering movement progress."""
if unit_id not in self.units_data:
return []
data = self.units_data[unit_id]
pos = data["pos"] if partial_move >= threshold else data["pos_before"]
idx = self.unit_ids.index(unit_id)
# Choose position based on partial move
if partial_move >= threshold:
position = tuple(self.positions[idx])
else:
position = tuple(self.positions_before[idx])
# Get units in that position
indices = self.spatial_grid.get(position, []) + \
self.spatial_grid_before.get(position, [])
# Remove duplicates and self
indices = list(set(indices))
if idx in indices:
indices.remove(idx)
return [self.unit_ids[i] for i in indices]
found = set()
if pos in self.grid:
found.update(self.grid[pos])
if pos in self.grid_before:
found.update(self.grid_before[pos])
found.discard(unit_id)
return list(found)
+92
View File
@@ -0,0 +1,92 @@
# Game constants and configuration
LEVEL_MUSIC_CONFIG = "level_music"
START_MENU_MUSIC = "High_Score_Garden.mp3"
RUN_COMPLETE_MUSIC = "Sunset_At_Pixel_Gardens.mp3"
START_MENU_ANIMATION = "anim/start_mice.gif"
SUPPORTED_MUSIC_EXTENSIONS = {".mp3", ".ogg", ".wav"}
BASE_INITIAL_RATS = 5
DEFAULT_DIFFICULTY = "easy"
DIFFICULTY_ALIASES = {
"medium": "normal",
"normale": "normal",
}
START_MENU_AUDIO_OPTIONS = (
("sound_volume", "Suono"),
("music_volume", "Musica"),
)
VOLUME_STEP = 5
GAME_END_LEVEL_CLEAR = "level_clear"
GAME_END_DEFEAT = "defeat"
GAME_END_RUN_COMPLETE = "run_complete"
DIFFICULTY_OPTIONS = (
{
"key": "easy",
"label": "Easy",
"starting_rats_multiplier": 1,
"speed_multiplier": 1.0,
# Gas spread tick interval (higher = slower spread = less effective weapon).
"gas_spread_speed": 40,
# Multiplier applied to per-frame weapon refill probabilities.
"weapon_refill_multiplier": 1.3,
# Upper bound for babies per litter (random.randint(1, max_babies)).
"max_babies": 2,
# Stop ticks after mating for the male / the pregnant female.
"mate_stop_male": 120,
"mate_stop_female": 240,
"fill": (235, 246, 234),
"accent": (88, 148, 82),
},
{
"key": "normal",
"label": "Normal",
"starting_rats_multiplier": 2,
"speed_multiplier": 1.5,
"gas_spread_speed": 50,
"weapon_refill_multiplier": 1.0,
"max_babies": 3,
"mate_stop_male": 100,
"mate_stop_female": 200,
"fill": (252, 242, 223),
"accent": (204, 146, 44),
},
{
"key": "hard",
"label": "Hard",
"starting_rats_multiplier": 3,
"speed_multiplier": 2.0,
"gas_spread_speed": 90,
"weapon_refill_multiplier": 0.6,
"max_babies": 5,
"mate_stop_male": 60,
"mate_stop_female": 120,
"fill": (251, 229, 229),
"accent": (188, 68, 68),
},
)
DIFFICULTY_OPTIONS_BY_KEY = {option["key"]: option for option in DIFFICULTY_OPTIONS}
START_MENU_COLORS = {
"panel_fill": (255, 255, 255),
"panel_border": (52, 52, 52),
"header_fill": (255, 255, 255),
"text": (24, 24, 24),
"muted": (82, 82, 82),
"hint_fill": (255, 255, 255),
"track_fill": (212, 215, 216),
"card_fill": (255, 255, 255),
}
START_MENU_AUDIO_STYLES = {
"sound_volume": {
"accent": (214, 146, 62),
"fill": (251, 241, 225),
},
"music_volume": {
"accent": (91, 122, 208),
"fill": (230, 235, 248),
},
}
+123 -51
View File
@@ -8,6 +8,7 @@ from pathlib import Path
import yaml
from runtime_paths import resolve_bundle_path
from engine import state_machine
DEFAULT_KEYBINDINGS_PROFILE = "pc"
@@ -196,18 +197,65 @@ def resolve_keybindings(preferred_profile=None, preferred_file=None, render_engi
class KeyBindings:
def __init__(self, game):
self.game = game
self.bindings = {}
# Explicit action mapping for static-friendly dispatch (Nim-ready)
self.action_dispatcher = {
"spawn_rat": self.spawn_rat,
"spawn_new_bomb": self.spawn_new_bomb,
"spawn_new_mine": self.spawn_new_mine,
"spawn_new_nuclear_bomb": self.spawn_new_nuclear_bomb,
"spawn_new_gas": self.spawn_new_gas,
"toggle_audio": self.toggle_audio,
"toggle_pause": self.toggle_pause,
"toggle_full_screen": self.toggle_full_screen,
"quit_game": self.quit_game,
"cheat_win_level": self.cheat_win_level,
"menu_up": self.game.menu_up,
"menu_down": self.game.menu_down,
"menu_left": self.game.menu_left,
"menu_right": self.game.menu_right,
"reset_game": self.game.reset_game,
"start_scrolling": self.start_scrolling,
"stop_scrolling": self.stop_scrolling,
}
def _binding_sections_for_action(self):
game_end_active, game_end_reason = getattr(self.game, "game_end", (False, None))
if game_end_active:
if game_end_reason == "level_clear":
return ["keybinding_level_clear", "keybinding_paused"]
if game_end_reason == "defeat":
return ["keybinding_defeat", "keybinding_paused"]
if game_end_reason == "run_complete":
return ["keybinding_run_complete", "keybinding_paused"]
return ["keybinding_paused"]
if getattr(self.game, "game_status", None) == "start_menu":
if getattr(self.game, "menu_screen", None) == "level_intro":
return ["keybinding_level_intro", "keybinding_start_menu"]
return ["keybinding_start_menu"]
status = getattr(self.game, "game_status", None)
if status:
return [f"keybinding_{status}"]
return []
def initialize_keybindings(self):
preferred_profile = None
preferred_file = None
if hasattr(self, "profile_integration") and self.profile_integration:
preferred_profile = self.profile_integration.get_setting("keybindings_profile")
preferred_file = self.profile_integration.get_setting("keybindings_file")
if hasattr(self.game, "profile_integration") and self.game.profile_integration:
preferred_profile = self.game.profile_integration.get_setting("keybindings_profile")
preferred_file = self.game.profile_integration.get_setting("keybindings_file")
bindings, source_path, profile_name, reason = resolve_keybindings(
preferred_profile=preferred_profile,
preferred_file=preferred_file,
render_engine=getattr(self, "render_engine", None),
render_engine=getattr(self.game, "render_engine", None),
)
self.bindings = self._validate_bindings(bindings, source_path)
@@ -233,15 +281,14 @@ class KeyBindings:
continue
method_name = value.split("|", 1)[0]
method = getattr(self, method_name, None)
if callable(method):
if method_name in self.action_dispatcher:
validated[section_name][action] = value
continue
invalid_bindings += 1
print(
f"[input] ignoring binding {section_name}.{action} -> {value}: "
f"missing method {method_name}"
f"missing method {method_name} in dispatcher"
)
if invalid_bindings:
@@ -250,86 +297,111 @@ class KeyBindings:
return validated
def trigger(self, action):
if not hasattr(self, "bindings"):
if not self.bindings:
self.initialize_keybindings()
value = self.bindings.get(f"keybinding_{self.game_status}", {}).get(action)
# Direct action dispatch: if the action name itself is a registered
# dispatcher entry (e.g. cheat actions injected by the engine layer),
# invoke it without requiring a keybinding entry.
direct_method = self.action_dispatcher.get(action)
if direct_method is not None and action not in ("spawn_rat",):
# Only treat as direct when the action isn't also a normal key event
# name. Key events look like "keydown_*" / "keyup_*" / etc.
if not action.startswith(("keydown_", "keyup_", "joybutton", "joyhat", "controller", "mousemove")):
direct_method()
return None
value = None
for section_name in self._binding_sections_for_action():
value = self.bindings.get(section_name, {}).get(action)
if value:
break
if not value:
return None
if "|" in value:
method_name, *args = value.split("|")
method = getattr(self, method_name, None)
if callable(method):
method = self.action_dispatcher.get(method_name)
if method:
method(*args)
return None
method = getattr(self, value, None)
if callable(method):
method = self.action_dispatcher.get(value)
if method:
method()
return None
def cheat_win_level(self):
"""Cheat handler: win the current level instantly (Ctrl+Return)."""
if hasattr(self.game, "cheat_win_level"):
self.game.cheat_win_level()
def spawn_rat(self):
self.game.unit_manager.spawn_rat()
def spawn_new_bomb(self):
self.spawn_bomb(self.pointer)
self.game.unit_manager.spawn_bomb(self.game.pointer)
def spawn_new_mine(self):
self.spawn_mine(self.pointer)
self.game.unit_manager.spawn_mine(self.game.pointer)
def spawn_new_nuclear_bomb(self):
self.spawn_nuclear_bomb(self.pointer)
self.game.unit_manager.spawn_nuclear_bomb(self.game.pointer)
def spawn_new_gas(self):
self.spawn_gas()
self.game.unit_manager.spawn_gas()
def toggle_audio(self):
self.render_engine.audio = not self.render_engine.audio
self.audio = self.render_engine.audio
if hasattr(self, "profile_integration") and self.profile_integration:
self.profile_integration.set_setting("sound_enabled", self.audio)
if not self.render_engine.audio:
self.render_engine.stop_sound()
self.game.render_engine.audio = not self.game.render_engine.audio
self.game.audio = self.game.render_engine.audio
if hasattr(self.game, "profile_integration") and self.game.profile_integration:
self.game.profile_integration.set_setting("sound_enabled", self.game.audio)
if not self.game.render_engine.audio:
self.game.render_engine.stop_sound()
def toggle_pause(self):
if getattr(self, "game_end", (False, None))[0]:
if getattr(self.game, "game_end", (False, None))[0]:
return
if self.game_status == "game":
self.game_status = "paused"
if self.game.state_machine.current_state == state_machine.GameState.PLAYING:
self.game.state_machine.transition_to(state_machine.GameState.PAUSED)
return
if self.game_status == "paused":
self.game_status = "game"
if self.game.state_machine.current_state == state_machine.GameState.PAUSED:
self.game.state_machine.transition_to(state_machine.GameState.PLAYING)
return
if self.game_status == "start_menu" and getattr(self, "menu_screen", None) == "start":
self.reset_game()
if self.game.state_machine.current_state == state_machine.GameState.START_MENU:
self.game.reset_game()
def toggle_full_screen(self):
self.full_screen = not self.full_screen
self.render_engine.full_screen(self.full_screen)
self.game.full_screen = not self.game.full_screen
self.game.render_engine.full_screen(self.game.full_screen)
def quit_game(self):
self.render_engine.close()
self.game.render_engine.close()
def start_scrolling(self, direction):
self.scrolling_direction = direction
if not self.scrolling:
self.scrolling = 1
self.game.scrolling_direction = direction
if not self.game.scrolling:
self.game.scrolling = 1
def stop_scrolling(self):
self.scrolling = 0
self.game.scrolling = 0
def scroll(self):
if self.scrolling:
if not self.scrolling % 5:
if self.scrolling_direction == "Up":
self.scroll_cursor(y=-1)
elif self.scrolling_direction == "Down":
self.scroll_cursor(y=1)
elif self.scrolling_direction == "Left":
self.scroll_cursor(x=-1)
elif self.scrolling_direction == "Right":
self.scroll_cursor(x=1)
self.scrolling += 1
if self.game.scrolling:
if not self.game.scrolling % 5:
if self.game.scrolling_direction == "Up":
self.game.graphics.scroll_cursor(y=-1)
elif self.game.scrolling_direction == "Down":
self.game.graphics.scroll_cursor(y=1)
elif self.game.scrolling_direction == "Left":
self.game.graphics.scroll_cursor(x=-1)
elif self.game.scrolling_direction == "Right":
self.game.graphics.scroll_cursor(x=1)
self.game.scrolling += 1
def axis_scroll(self, x, y):
self.scroll_cursor(1 if x > 0 else -1, 1 if y > 0 else -1)
self.game.graphics.scroll_cursor(1 if x > 0 else -1, 1 if y > 0 else -1)
+470 -100
View File
@@ -1,16 +1,21 @@
import os
import random
import time
from engine import maze
from engine import maze, config
from engine.collision_system import CollisionLayer
from runtime_paths import bundle_path
class Graphics():
class Graphics:
def __init__(self, game):
self.game = game
self.loaded_theme_index = None
def load_assets(self):
theme_index = self.get_theme_index()
print(f"[gfx] load_assets requested: level={self.current_level + 1} theme={theme_index}")
if getattr(self, "startup_loading_active", False):
self._update_startup_loading(
print(f"[gfx] load_assets requested: level={self.game.current_level + 1} theme={theme_index}")
if getattr(self.game, "startup_loading_active", False):
self.game._update_startup_loading(
"Loading graphics",
detail=f"Preparing theme {theme_index}",
progress=0.3,
@@ -21,11 +26,13 @@ class Graphics():
self.blood_layer_sprites = []
if not hasattr(self, "cave_foreground_tiles"):
self.cave_foreground_tiles = []
if not hasattr(self, "tunnel_cover_texture"):
self.tunnel_cover_texture = None
if not getattr(self, "common_assets_loaded", False):
print("Loading graphics assets...")
if getattr(self, "startup_loading_active", False):
self._update_startup_loading(
if getattr(self.game, "startup_loading_active", False):
self.game._update_startup_loading(
"Loading graphics",
detail="Decoding sprites and tiles",
progress=0.4,
@@ -41,12 +48,12 @@ class Graphics():
self.rat_assets_textures[sex] = {}
self.rat_image_sizes[sex] = {}
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]:
self.rat_assets[sex][direction] = self.render_engine.load_image(
self.rat_assets[sex][direction] = self.game.render_engine.load_image(
f"Rat/BMP_{sex}_{direction}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
scale=False,
)
texture = self.render_engine.load_image(
texture = self.game.render_engine.load_image(
f"Rat/BMP_{sex}_{direction}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
surface=False,
@@ -56,30 +63,36 @@ class Graphics():
self.rat_image_sizes[sex][direction] = (texture.size[0] // 4, texture.size[1])
for n in range(5):
self.bomb_assets[n] = self.render_engine.load_image(
self.bomb_assets[n] = self.game.render_engine.load_image(
f"Rat/BMP_BOMB{n}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
)
rat_asset_dir = bundle_path("assets", "Rat")
for file in os.listdir(rat_asset_dir):
if file.endswith(".png"):
self.assets[file[:-4]] = self.render_engine.load_image(
f"Rat/{file}",
transparent_color=((125, 125, 125), (128, 128, 128)),
)
for file in sorted(os.listdir(rat_asset_dir)):
if file.endswith(".png") and not file.startswith("."):
# Check if it's one of our expected BMP files or other known assets
# to avoid loading temporary or irrelevant PNGs
file_key = file[:-4]
try:
self.assets[file_key] = self.game.render_engine.load_image(
f"Rat/{file}",
transparent_color=((125, 125, 125), (128, 128, 128)),
)
except (FileNotFoundError, IOError) as e:
print(f"Warning: Could not load asset {file}: {e}")
print("Pre-generating blood stain pool...")
if getattr(self, "startup_loading_active", False):
self._update_startup_loading(
if getattr(self.game, "startup_loading_active", False):
self.game._update_startup_loading(
"Loading graphics",
detail="Generating blood pool",
progress=0.58,
)
self.blood_stain_textures = []
for _ in range(10):
blood_surface = self.render_engine.generate_blood_surface()
blood_texture = self.render_engine.draw_blood_surface(blood_surface, (0, 0))
blood_surface = self.game.render_engine.generate_blood_surface()
blood_texture = self.game.render_engine.draw_blood_surface(blood_surface, (0, 0))
if blood_texture:
self.blood_stain_textures.append(blood_texture)
@@ -90,33 +103,33 @@ class Graphics():
if theme_index not in self.theme_assets_cache:
print(f"Loading theme assets {theme_index}...")
if getattr(self, "startup_loading_active", False):
self._update_startup_loading(
if getattr(self.game, "startup_loading_active", False):
self.game._update_startup_loading(
"Loading graphics",
detail=f"Loading theme {theme_index} art",
progress=0.74,
)
self.theme_assets_cache[theme_index] = {
"floor_tile": self.render_engine.create_color_surface((128, 128, 128)),
"tunnel": self.render_engine.load_image("Rat/BMP_TUNNEL.png"),
"floor_tile": self.game.render_engine.create_color_surface((128, 128, 128)),
"tunnel": self.game.render_engine.load_image("Rat/BMP_TUNNEL.png"),
"grasses": [
self.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png", surface=True)
self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png", surface=True)
for i in range(4)
],
"grass_textures": [
self.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png")
self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png")
for i in range(4)
],
"flowers": [
self.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png", surface=True)
self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png", surface=True)
for i in range(4)
],
"flower_textures": [
self.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png")
self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png")
for i in range(4)
],
"caves": {
direction: self.render_engine.load_image(
direction: self.game.render_engine.load_image(
f"Rat/BMP_{theme_index}_CAVE_{direction}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
surface=False,
@@ -124,7 +137,7 @@ class Graphics():
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]
},
"explosions": {
direction: self.render_engine.load_image(
direction: self.game.render_engine.load_image(
f"Rat/BMP_{theme_index}_EXPLOSION_{direction}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
surface=False,
@@ -132,15 +145,15 @@ class Graphics():
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]
},
"edges": {
direction: self.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
direction: self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
for direction in ["N", "S", "E", "W"]
},
"corners": {
direction: self.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
direction: self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
for direction in ["NE", "NW", "SE", "SW"]
},
"inner_corners": {
direction: self.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
direction: self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
for direction in ["EN", "ES", "WN", "WS"]
},
}
@@ -148,8 +161,8 @@ class Graphics():
else:
print(f"[gfx] theme cache hit -> reusing theme {theme_index}")
if getattr(self, "startup_loading_active", False):
self._update_startup_loading(
if getattr(self.game, "startup_loading_active", False):
self.game._update_startup_loading(
"Loading graphics",
detail="Finishing render setup",
progress=0.84,
@@ -170,27 +183,66 @@ class Graphics():
self.inner_corners = theme_assets["inner_corners"]
def get_theme_index(self):
return self.current_level % 32 // 8 + 1
return self.game.current_level % 32 // 8 + 1
# ==================== RENDERING ====================
def draw_maze(self):
if self.background_texture is None:
print(f"[gfx] generating background texture for level={self.current_level + 1} theme={self.loaded_theme_index}")
if self.game.background_texture is None:
print(f"[gfx] generating background texture for level={self.game.current_level + 1} theme={self.loaded_theme_index}")
self.regenerate_background()
self.render_engine.draw_background(self.background_texture)
self.game.render_engine.draw_background(self.game.background_texture)
# Draw blood layer as sprites (optimized - no background regeneration)
self.draw_blood_layer()
def regenerate_tunnel_cover(self):
"""Generate an overlay texture that covers internal tunnel passages with grass sub-tiles.
A tunnel cell is considered an internal passage only when it is surrounded
by occupied cells (walls or tunnels) on all four sides. In that case there is
no cave entrance drawn by cave_foreground, so we cover it here.
"""
tunnel_tiles = []
half_cell = self.game.cell_size // 2
def occupied(x, y):
return self.game.map.in_bounds(x, y) and self.game.map.get_cell(x, y) != maze.MAP_EMPTY
for y, row in enumerate(self.game.map.tiles):
for x, cell in enumerate(row):
if cell != maze.MAP_TUNNEL:
continue
above = occupied(x, y - 1)
below = occupied(x, y + 1)
left = occupied(x - 1, y)
right = occupied(x + 1, y)
if not (above and below and left and right):
continue
px = x * self.game.cell_size
py = y * self.game.cell_size
for qx, qy, sx, sy in [
(0, 0, 0, 0),
(half_cell, 0, half_cell, 0),
(0, half_cell, 0, half_cell),
(half_cell, half_cell, half_cell, half_cell),
]:
grass = random.choice(self.grasses)
tunnel_tiles.append((grass, sx, sy, half_cell, half_cell, px + qx, py + qy))
self.tunnel_cover_texture = self.game.render_engine.create_overlay_texture(tunnel_tiles)
def draw_cave_foreground(self):
active_cave_explosions = {}
for unit in self.units.values():
for unit in self.game.units.values():
if unit.collision_layer != CollisionLayer.EXPLOSION:
continue
if not self.map.is_tunnel(*unit.position):
if not self.game.map.is_tunnel(*unit.position):
continue
active_cave_explosions[unit.position] = getattr(unit, "cave_direction", None)
@@ -198,30 +250,35 @@ class Graphics():
if (cell_x, cell_y) in active_cave_explosions:
explosion_direction = active_cave_explosions[(cell_x, cell_y)] or direction
surface = self.explosions.get(explosion_direction, surface)
self.render_engine.draw_image(x, y, surface, anchor="nw", tag="cave")
self.game.render_engine.draw_image(x, y, surface, anchor="nw", tag="cave")
def draw_blood_layer(self):
"""Draw all blood stains as sprites overlay (optimized)"""
for blood_texture, x, y in self.blood_layer_sprites:
self.render_engine.draw_image(x, y, blood_texture, tag="blood")
self.game.render_engine.draw_image(x, y, blood_texture, tag="blood")
def regenerate_background(self):
"""Generate or regenerate the background texture (static - no blood stains)"""
texture_tiles = []
self.cave_foreground_tiles = []
half_cell = self.cell_size // 2
self.regenerate_tunnel_cover()
half_cell = self.game.cell_size // 2
def draw(surface, x, y):
texture_tiles.append((surface, x, y))
return True # allow callers to count successful draws
def draw_cave(surface, x, y, direction):
self.cave_foreground_tiles.append((x // self.cell_size, y // self.cell_size, direction, surface, x, y))
self.cave_foreground_tiles.append((x // self.game.cell_size, y // self.game.cell_size, direction, surface, x, y))
def occupied(x, y):
return self.map.in_bounds(x, y) and self.map.get_cell(x, y) != maze.MAP_EMPTY
return self.game.map.in_bounds(x, y) and self.game.map.get_cell(x, y) != maze.MAP_EMPTY
def is_wall(x, y):
return self.game.map.in_bounds(x, y) and self.game.map.get_cell(x, y) == maze.MAP_WALL
def is_tunnel(x, y):
return self.map.in_bounds(x, y) and self.map.get_cell(x, y) == maze.MAP_TUNNEL
return self.game.map.in_bounds(x, y) and self.game.map.get_cell(x, y) == maze.MAP_TUNNEL
def random_wall():
return random.choice(self.grasses)
@@ -234,84 +291,95 @@ class Graphics():
def random_flower_texture():
return random.choice(self.flower_textures)
for y, row in enumerate(self.map.tiles):
for y, row in enumerate(self.game.map.tiles):
for x, cell in enumerate(row):
px = x * self.cell_size
py = y * self.cell_size
px = x * self.game.cell_size
py = y * self.game.cell_size
if cell == maze.MAP_EMPTY:
continue
if cell == maze.MAP_WALL:
if x == 0 or y == 0 or x == self.map.width - 1 or y == self.map.height - 1:
draw(random_wall(), px, py)
wall_tiles_drawn = 0
if x > 0 and y > 0 and (not occupied(x - 1, y - 1) or not occupied(x, y - 1) or not occupied(x - 1, y)):
north = occupied(x, y - 1)
west = occupied(x - 1, y)
def draw_wall(surface, x, y):
nonlocal wall_tiles_drawn
draw(surface, x, y)
wall_tiles_drawn += 1
if x == 0 or y == 0 or x == self.game.map.width - 1 or y == self.game.map.height - 1:
draw_wall(random_wall(), px, py)
if x > 0 and y > 0 and (not is_wall(x - 1, y - 1) or not is_wall(x, y - 1) or not is_wall(x - 1, y)):
north = is_wall(x, y - 1)
west = is_wall(x - 1, y)
if north or west:
if north and west:
draw(self.inner_corners["WN"], px, py)
draw_wall(self.inner_corners["WN"], px, py)
elif north and not west:
draw(self.edges["W"], px, py)
draw_wall(self.edges["W"], px, py)
else:
draw(self.edges["N"], px, py)
draw_wall(self.edges["N"], px, py)
else:
draw(self.corners["NW"], px, py)
draw_wall(self.corners["NW"], px, py)
if y < self.map.height - 1 and x < self.map.width - 1:
south = occupied(x, y + 1)
east = occupied(x + 1, y)
southeast = occupied(x + 1, y + 1)
if y < self.game.map.height - 1 and x < self.game.map.width - 1:
south = is_wall(x, y + 1)
east = is_wall(x + 1, y)
southeast = is_wall(x + 1, y + 1)
if southeast and south and east:
if (
random.randrange(10) != 0
or x == 0
or y == 0
or x == self.map.width - 2
or y == self.map.height - 2
or x == self.game.map.width - 2
or y == self.game.map.height - 2
or is_tunnel(x + 1, y)
or is_tunnel(x, y + 1)
or is_tunnel(x + 1, y + 1)
):
draw(random_wall(), px + half_cell, py + half_cell)
draw_wall(random_wall(), px + half_cell, py + half_cell)
else:
draw(random_flower(), px + half_cell, py + half_cell)
draw_wall(random_flower(), px + half_cell, py + half_cell)
elif south or east:
if south and east:
draw(self.inner_corners["ES"], px + half_cell, py + half_cell)
draw_wall(self.inner_corners["ES"], px + half_cell, py + half_cell)
elif south and not east:
draw(self.edges["E"], px + half_cell, py + half_cell)
draw_wall(self.edges["E"], px + half_cell, py + half_cell)
else:
draw(self.edges["S"], px + half_cell, py + half_cell)
draw_wall(self.edges["S"], px + half_cell, py + half_cell)
else:
draw(self.corners["SE"], px + half_cell, py + half_cell)
draw_wall(self.corners["SE"], px + half_cell, py + half_cell)
if y > 0 and x < self.map.width - 1 and (not occupied(x + 1, y - 1) or not occupied(x, y - 1) or not occupied(x + 1, y)):
north = occupied(x, y - 1)
east = occupied(x + 1, y)
if y > 0 and x < self.game.map.width - 1 and (not is_wall(x + 1, y - 1) or not is_wall(x, y - 1) or not is_wall(x + 1, y)):
north = is_wall(x, y - 1)
east = is_wall(x + 1, y)
if north or east:
if north and east:
draw(self.inner_corners["EN"], px + half_cell, py)
draw_wall(self.inner_corners["EN"], px + half_cell, py)
elif north and not east:
draw(self.edges["E"], px + half_cell, py)
draw_wall(self.edges["E"], px + half_cell, py)
else:
draw(self.edges["N"], px + half_cell, py)
draw_wall(self.edges["N"], px + half_cell, py)
else:
draw(self.corners["NE"], px + half_cell, py)
draw_wall(self.corners["NE"], px + half_cell, py)
if y < self.map.height - 1 and x > 0 and (not occupied(x - 1, y + 1) or not occupied(x, y + 1) or not occupied(x - 1, y)):
south = occupied(x, y + 1)
west = occupied(x - 1, y)
if y < self.game.map.height - 1 and x > 0 and (not is_wall(x - 1, y + 1) or not is_wall(x, y + 1) or not is_wall(x - 1, y)):
south = is_wall(x, y + 1)
west = is_wall(x - 1, y)
if south or west:
if south and west:
draw(self.inner_corners["WS"], px, py + half_cell)
draw_wall(self.inner_corners["WS"], px, py + half_cell)
elif south and not west:
draw(self.edges["W"], px, py + half_cell)
draw_wall(self.edges["W"], px, py + half_cell)
else:
draw(self.edges["S"], px, py + half_cell)
draw_wall(self.edges["S"], px, py + half_cell)
else:
draw(self.corners["SW"], px, py + half_cell)
draw_wall(self.corners["SW"], px, py + half_cell)
# Fallback: isolated/surrounded wall cells must not show background color
if wall_tiles_drawn == 0:
draw(random_wall(), px, py)
elif cell == maze.MAP_TUNNEL:
above = occupied(x, y - 1)
@@ -323,43 +391,345 @@ class Graphics():
if below:
if left:
if right:
if random.randrange(10) != 0:
draw_cave(random_wall_texture(), px + half_cell, py + half_cell, None)
else:
draw_cave(random_flower_texture(), px + half_cell, py + half_cell, None)
# Internal tunnel passage: leave it empty so it uses
# the background fill color and stays visually open.
pass
else:
draw_cave(self.caves["RIGHT"], px, py, "RIGHT")
else:
draw(self.grasses[0], px + half_cell, py + half_cell)
draw_cave(self.caves["LEFT"], px, py, "LEFT")
else:
draw_cave(self.caves["DOWN"], px, py, "DOWN")
else:
draw(self.grasses[0], px + half_cell, py + half_cell)
draw_cave(self.caves["UP"], px, py, "UP")
# Blood stains now handled separately as overlay layer
self.background_texture = self.render_engine.create_texture(texture_tiles, fill_color=(128, 128, 128))
self.game.background_texture = self.game.render_engine.create_texture(texture_tiles, fill_color=(128, 128, 128))
def add_blood_stain(self, position):
"""Add a blood stain as sprite overlay (opti mized - no background regeneration)"""
"""Add a blood stain as sprite overlay (optimized - no background regeneration)"""
# Pick random blood texture from pre-generated pool
if not self.blood_stain_textures:
return
blood_texture = random.choice(self.blood_stain_textures)
x = position[0] * self.cell_size
y = position[1] * self.cell_size
x = position[0] * self.game.cell_size
y = position[1] * self.game.cell_size
# Add to blood layer sprites instead of regenerating background
self.blood_layer_sprites.append((blood_texture, x, y))
def scroll_cursor(self, x=0, y=0):
if self.pointer[0] + x > self.map.width or self.pointer[1] + y > self.map.height:
if self.game.pointer[0] + x > self.game.map.width or self.game.pointer[1] + y > self.game.map.height:
return
self.pointer = (
max(1, min(self.map.width-2, self.pointer[0] + x)),
max(1, min(self.map.height-2, self.pointer[1] + y))
self.game.pointer = (
max(1, min(self.game.map.width-2, self.game.pointer[0] + x)),
max(1, min(self.game.map.height-2, self.game.pointer[1] + y))
)
self.game.render_engine.scroll_view(self.game.pointer)
# ==================== MENU RENDERING ====================
def _menu_font(self, size):
clamped = max(10, min(69, int(size)))
return self.game.render_engine.fonts[clamped]
def _draw_start_menu_difficulty_selector(self, x, y, width, height):
colors = config.START_MENU_COLORS
difficulty_config = self.game._difficulty_config()
accent = difficulty_config["accent"]
render_engine = self.game.render_engine
center_x = x + width // 2
compact_selector = height <= 72
title_font = self._menu_font(render_engine.target_size[1] // 38)
value_font = self._menu_font(render_engine.target_size[1] // 23)
arrow_font = value_font
section_gap = 4 if compact_selector else 6
title_line_height = max(14, render_engine.target_size[1] // 32)
value_line_height = max(18, render_engine.target_size[1] // 24)
current_y = y + 2
render_engine.draw_text("Difficulty", title_font, ("center", current_y), colors["muted"])
current_y += title_line_height + section_gap
arrow_offset = max(34, min(52, width // 7))
arrow_y = current_y - (1 if compact_selector else 0)
render_engine.draw_text("<", arrow_font, (center_x - arrow_offset, arrow_y), colors["muted"])
render_engine.draw_text(">", arrow_font, (center_x + arrow_offset, arrow_y), colors["muted"])
render_engine.draw_text(
difficulty_config["label"],
value_font,
("center", current_y),
accent,
)
current_y += value_line_height
def _draw_start_menu_slider(self, x, y, width, height, setting_name, label, value, selected):
colors = config.START_MENU_COLORS
style = config.START_MENU_AUDIO_STYLES[setting_name]
accent = style["accent"]
fill_color = style["fill"] if selected else colors["card_fill"]
border_color = accent if selected else (156, 156, 156)
text_color = colors["text"]
render_engine = self.game.render_engine
render_engine.draw_rectangle(x, y, width, height, "start_menu_slider", filling=fill_color)
render_engine.draw_rectangle(x, y, width, height, "start_menu_slider", outline=border_color)
if selected:
render_engine.draw_rectangle(x + 12, y + 10, 8, height - 20, "start_menu_slider", filling=accent)
title_y = y + 10
render_engine.draw_text(label, self._menu_font(render_engine.target_size[1] // 32), (x + 34, title_y), text_color)
render_engine.draw_text(f"{value}%", self._menu_font(render_engine.target_size[1] // 34), (x + width - 84, title_y + 2), text_color)
track_x = x + 34
track_y = y + height - 26
track_width = width - 68
track_height = 14
filled_width = int(track_width * value / 100)
knob_width = 14
knob_x = track_x + int((track_width - knob_width) * value / 100)
render_engine.draw_rectangle(track_x, track_y, track_width, track_height, "start_menu_slider", filling=colors["track_fill"])
render_engine.draw_rectangle(track_x, track_y, track_width, track_height, "start_menu_slider", outline=(128, 128, 128))
if filled_width > 0:
render_engine.draw_rectangle(track_x, track_y, filled_width, track_height, "start_menu_slider", filling=accent)
render_engine.draw_rectangle(knob_x, track_y - 4, knob_width, track_height + 8, "start_menu_slider", filling=(255, 255, 255))
render_engine.draw_rectangle(knob_x, track_y - 4, knob_width, track_height + 8, "start_menu_slider", outline=accent)
def _current_start_menu_animation_frame(self):
animation = getattr(self.game, "start_menu_animation", None)
if not animation or not animation["frames"]:
return None, (0, 0)
if len(animation["frames"]) == 1 or animation["total_duration"] <= 0:
return animation["frames"][0], animation["size"]
elapsed_ms = int((time.monotonic() - self.game.start_menu_animation_started_at) * 1000)
current_offset = elapsed_ms % animation["total_duration"]
accumulated = 0
for index, duration in enumerate(animation["durations"]):
accumulated += duration
if current_offset < accumulated:
return animation["frames"][index], animation["size"]
return animation["frames"][-1], animation["size"]
def _render_audio_menu(self, title, subtitle_lines, primary_action_text, hint_text, image_name="BMP_WEWIN"):
colors = config.START_MENU_COLORS
render_engine = self.game.render_engine
target_width, target_height = render_engine.target_size
compact_menu = target_height <= 540
panel_x = max(48, target_width // 12)
panel_y = max(34, target_height // 18)
panel_width = target_width - panel_x * 2
panel_height = target_height - panel_y * 2
header_height = max(44, target_height // 13)
render_engine.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "start_menu", filling=colors["panel_fill"])
render_engine.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "start_menu", outline=colors["panel_border"])
render_engine.draw_rectangle(panel_x, panel_y, panel_width, header_height, "start_menu", filling=colors["header_fill"])
render_engine.draw_text(
title,
self._menu_font(target_height // 20),
("center", panel_y + 16),
colors["text"],
)
image = self.assets[image_name]
image_width, image_height = render_engine.get_image_size(image)
image_y = panel_y + header_height + (14 if compact_menu else 18)
render_engine.draw_image(
target_width // 2 - image_width // 2 - render_engine.w_offset,
image_y - render_engine.h_offset,
image,
"start_menu",
)
info_y = image_y + image_height + 18
line_gap = 18 if compact_menu else max(20, target_height // 34)
for index, line in enumerate(subtitle_lines):
render_engine.draw_text(
line,
self._menu_font(target_height // (34 if index == 0 else 36)),
("center", info_y + line_gap * index),
colors["text"] if index == 0 else colors["muted"],
)
cta_y = info_y + line_gap * len(subtitle_lines) + 8
render_engine.draw_text(
primary_action_text,
self._menu_font(target_height // 31),
("center", cta_y),
colors["text"],
)
card_width = max(320, min(panel_width - 160, 720))
card_height = 60 if compact_menu else max(68, target_height // 10)
card_x = target_width // 2 - card_width // 2
cards_y = cta_y + (16 if compact_menu else 26)
card_gap = 12 if compact_menu else 16
for index, (setting_name, label) in enumerate(config.START_MENU_AUDIO_OPTIONS):
card_y = cards_y + index * (card_height + card_gap)
self._draw_start_menu_slider(
card_x,
card_y,
card_width,
card_height,
setting_name,
label,
getattr(self.game, setting_name),
index == self.game.start_menu_selection,
)
cards_bottom = cards_y + len(config.START_MENU_AUDIO_OPTIONS) * card_height + (len(config.START_MENU_AUDIO_OPTIONS) - 1) * card_gap
if compact_menu:
hint_y = min(cards_bottom + 8, panel_y + panel_height - 28)
render_engine.draw_text(
hint_text,
self._menu_font(target_height // 42),
("center", hint_y),
colors["muted"],
)
else:
hint_y = cards_bottom + 10
hint_height = max(34, target_height // 18)
hint_width = card_width
hint_x = card_x
render_engine.draw_rectangle(hint_x, hint_y, hint_width, hint_height, "start_menu", filling=colors["hint_fill"])
render_engine.draw_rectangle(hint_x, hint_y, hint_width, hint_height, "start_menu", outline=(170, 170, 170))
render_engine.draw_text(
hint_text,
self._menu_font(target_height // 40),
("center", hint_y + 10),
colors["muted"],
)
def render_start_menu(self):
colors = config.START_MENU_COLORS
render_engine = self.game.render_engine
target_width, target_height = render_engine.target_size
compact_menu = target_height <= 540
panel_x = max(48, target_width // 12)
panel_y = max(34, target_height // 18)
panel_width = target_width - panel_x * 2
panel_height = target_height - panel_y * 2
header_height = max(58, target_height // 10)
title_font = self._menu_font(target_height // 15)
body_font = self._menu_font(target_height // 28)
meta_font = self._menu_font(target_height // 30)
cta_font = self._menu_font(target_height // 22)
hint_font = self._menu_font(target_height // 32)
line_gap = 22 if compact_menu else max(26, target_height // 26)
render_engine.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "start_menu", filling=colors["panel_fill"])
render_engine.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "start_menu", outline=colors["panel_border"])
render_engine.draw_rectangle(panel_x, panel_y, panel_width, header_height, "start_menu", filling=colors["header_fill"])
render_engine.draw_text(
f"Welcome to Mice, {self.game.profile_integration.get_profile_name()}!",
title_font,
("center", panel_y + max(12, header_height // 5)),
colors["text"],
)
animation_frame, animation_size = self._current_start_menu_animation_frame()
animation_bottom = panel_y + header_height + 18
if animation_frame is not None:
max_animation_width = panel_width - (70 if compact_menu else 110)
display_width = min(animation_size[0], max_animation_width)
scale_factor = display_width / animation_size[0]
display_height = max(1, int(animation_size[1] * scale_factor))
animation_x = target_width // 2 - display_width // 2
animation_y = panel_y + header_height + (18 if compact_menu else 26)
render_engine.draw_image(
animation_x - render_engine.w_offset,
animation_y - render_engine.h_offset,
animation_frame,
"start_menu",
source_rect=(0, 0, animation_size[0], animation_size[1]),
dest_size=(display_width, display_height),
)
animation_bottom = animation_y + display_height
player_profile = self.game.profile_integration.current_profile
device_id = self.game.profile_integration.get_device_id()
subtitle_lines = ["A game by Matteo, because he was bored."]
if player_profile:
if compact_menu:
subtitle_lines.append(
f"Best: {player_profile['best_score']} | Games: {player_profile['games_played']}"
)
else:
subtitle_lines.append(f"Device: {device_id}")
subtitle_lines.append(
f"Best Score: {player_profile['best_score']} | Games: {player_profile['games_played']}"
)
elif compact_menu:
subtitle_lines.append(f"Guest profile | {device_id}")
else:
subtitle_lines.append(f"Device: {device_id}")
subtitle_lines.append("No profile loaded - playing as guest")
info_y = animation_bottom + (18 if compact_menu else 22)
for index, line in enumerate(subtitle_lines):
render_engine.draw_text(
line,
body_font if index == 0 else meta_font,
("center", info_y + line_gap * index),
colors["text"] if index == 0 else colors["muted"],
)
difficulty_width = max(360, min(panel_width - 160, 760))
difficulty_height = 54 if compact_menu else max(72, target_height // 9)
difficulty_x = target_width // 2 - difficulty_width // 2
difficulty_y = info_y + line_gap * len(subtitle_lines) + (12 if compact_menu else 18)
self._draw_start_menu_difficulty_selector(
difficulty_x,
difficulty_y,
difficulty_width,
difficulty_height,
)
cta_y = difficulty_y + difficulty_height + (12 if compact_menu else 24)
render_engine.draw_text(
"Press Return to start",
cta_font,
("center", cta_y),
colors["text"],
)
if compact_menu:
render_engine.draw_text(
"Arrows change difficulty",
hint_font,
("center", cta_y + line_gap),
colors["muted"],
)
render_engine.draw_text(
"Esc quits M toggles audio",
hint_font,
("center", cta_y + line_gap + 18),
colors["muted"],
)
else:
render_engine.draw_text(
"Arrows change difficulty Esc quits M toggles audio",
hint_font,
("center", cta_y + line_gap + 8),
colors["muted"],
)
def render_pause_menu(self):
subtitle_lines = [
f"Level {self.game.current_level + 1} | Points: {self.game.points}",
f"Rats in maze: {self.game.unit_manager.count_rats()}",
]
self._render_audio_menu(
title="Pause",
subtitle_lines=subtitle_lines,
primary_action_text="Press Return to resume",
hint_text="Up/Down select Left/Right adjust Esc quits",
image_name="BMP_PAUSE" if "BMP_PAUSE" in self.assets else "BMP_WEWIN",
)
self.render_engine.scroll_view(self.pointer)
+13 -10
View File
@@ -8,19 +8,22 @@ SCORES_FILE = persistent_data_path("scores.txt", default_text="")
class Scoring:
def __init__(self, game):
self.game = game
# ==================== SCORING ====================
def save_score(self):
# Save to traditional scores.txt file
with SCORES_FILE.open("a", encoding="utf-8") as f:
player_name = getattr(self, 'profile_integration', None)
if player_name and hasattr(player_name, 'get_profile_name'):
name = player_name.get_profile_name()
device_id = player_name.get_device_id()
f.write(f"{datetime.datetime.now()} - {self.points} - {name} - {device_id}\n")
profile_integration = getattr(self.game, 'profile_integration', None)
if profile_integration and hasattr(profile_integration, 'get_profile_name'):
name = profile_integration.get_profile_name()
device_id = profile_integration.get_device_id()
f.write(f"{datetime.datetime.now()} - {self.game.points} - {name} - {device_id}\n")
else:
f.write(f"{datetime.datetime.now()} - {self.points} - Guest\n")
f.write(f"{datetime.datetime.now()} - {self.game.points} - Guest\n")
def read_score(self):
table = []
try:
@@ -40,6 +43,6 @@ class Scoring:
except FileNotFoundError:
pass
return table[:5] # Return top 5 scores instead of 3
def add_point(self, value):
self.points += value
self.game.points += value
+102 -8
View File
@@ -236,6 +236,61 @@ class GameWindow:
sdl2.SDL_FreeSurface(bg_surface)
return bg_texture
def create_overlay_texture(self, tiles: list):
"""Create a transparent RGBA texture from sub-tile surface blits.
Each tile is a tuple (surface, src_x, src_y, width, height, dst_x, dst_y).
Source surfaces are converted to RGBA so the resulting texture has an
alpha channel and supports global alpha modulation.
"""
bg_surface = sdl2.SDL_CreateRGBSurface(
0, self.width, self.height, 32,
0x00FF0000, 0x0000FF00, 0x000000FF, 0xFF000000
)
sdl2.SDL_FillRect(bg_surface, None, 0)
sdl2.SDL_SetSurfaceBlendMode(bg_surface, sdl2.SDL_BLENDMODE_BLEND)
rgba_format = sdl2.SDL_PIXELFORMAT_RGBA8888
for surface, src_x, src_y, width, height, dst_x, dst_y in tiles:
src_fmt = sdl2.SDL_PIXELFORMAT_UNKNOWN
try:
src_fmt = surface.format.format
except AttributeError:
src_fmt = sdl2.SDL_PIXELFORMAT_UNKNOWN
srcrect = sdl2.SDL_Rect(int(src_x), int(src_y), int(width), int(height))
dstrect = sdl2.SDL_Rect(int(dst_x), int(dst_y), int(width), int(height))
if src_fmt == rgba_format:
sdl2.SDL_BlitSurface(surface, srcrect, bg_surface, dstrect)
else:
converted = sdl2.SDL_ConvertSurfaceFormat(surface, rgba_format, 0)
if converted:
sdl2.SDL_BlitSurface(converted, srcrect, bg_surface, dstrect)
sdl2.SDL_FreeSurface(converted)
else:
sdl2.SDL_BlitSurface(surface, srcrect, bg_surface, dstrect)
bg_texture = self.factory.from_surface(bg_surface)
try:
sdl2.SDL_SetTextureBlendMode(bg_texture.texture, sdl2.SDL_BLENDMODE_BLEND)
except Exception:
pass
sdl2.SDL_FreeSurface(bg_surface)
return bg_texture
def draw_overlay_texture(self, texture, alpha=255):
"""Draw a full-map transparent overlay texture with current view offset."""
if texture is None:
return
raw_texture = getattr(texture, "texture", texture)
if alpha < 255:
try:
sdl2.SDL_SetTextureAlphaMod(raw_texture, int(alpha))
except Exception:
pass
try:
sdl2.SDL_SetTextureBlendMode(raw_texture, sdl2.SDL_BLENDMODE_BLEND)
except Exception:
pass
self.renderer.copy(texture, dstrect=sdl2.SDL_Rect(self.w_offset, self.h_offset, self.width, self.height))
def create_color_surface(self, color, width=None, height=None):
"""Create a solid color surface matching the current cell size by default."""
width = width or self.cell_size
@@ -247,7 +302,7 @@ class GameWindow:
"""Load and process an image with optional transparency and scaling"""
image_path = resolve_bundle_path(os.path.join("assets", path))
image = Image.open(image_path)
# Handle transparency
if transparent_color:
image = image.convert("RGBA")
@@ -262,11 +317,26 @@ class GameWindow:
for item in datas
]
image.putdata(new_data)
# Normalize near-white background pixels to pure white to avoid
# dark seams when the texture is drawn on a white panel. Applied
# to every asset, not only those with transparent_color, so the
# background is always pure white.
if image.mode != "RGBA":
image = image.convert("RGBA")
datas = image.getdata()
normalized = [
(255, 255, 255, item[3]) if item[3] > 0
and item[0] >= 250 and item[1] >= 250 and item[2] >= 250
and item[0] == item[1] == item[2]
else item
for item in datas
]
image.putdata(normalized)
# Scale image: tiles are now 64px (was 20px), multiply by 5/8 to reach cell_size (40px)
if scale:
image = image.resize((image.width * 5 // 8, image.height * 5 // 8), Image.NEAREST)
if surface:
return sdl2.ext.pillow_to_surface(image)
temp_surface = sdl2.ext.pillow_to_surface(image)
@@ -495,9 +565,29 @@ class GameWindow:
if image := kwargs.get("image"):
image_width, image_height = self.get_image_size(image)
image_x = self.target_size[0] // 2 - image_width // 2
self.draw_image(image_x - self.w_offset, content_y - self.h_offset, image, "win")
content_y += image_height + max(14, panel_height // 30)
image_scale = kwargs.get("image_scale")
image_max_width = kwargs.get("image_max_width")
image_max_height = kwargs.get("image_max_height")
scale_limit = 1.0
if image_scale is not None:
scale_limit = min(scale_limit, max(0.01, float(image_scale)))
if image_max_width:
scale_limit = min(scale_limit, float(image_max_width) / image_width)
if image_max_height:
scale_limit = min(scale_limit, float(image_max_height) / image_height)
render_width = max(1, int(round(image_width * scale_limit)))
render_height = max(1, int(round(image_height * scale_limit)))
image_x = self.target_size[0] // 2 - render_width // 2
self.draw_image(
image_x - self.w_offset,
content_y - self.h_offset,
image,
"win",
dest_size=(render_width, render_height),
)
content_y += render_height + max(14, panel_height // 30)
if subtitle := kwargs.get("subtitle"):
subtitle_sprites = [
@@ -859,8 +949,12 @@ class GameWindow:
keycode = event.key.keysym.sym
key = sdl2.SDL_GetKeyName(event.key.keysym.sym).decode('utf-8')
key = key.replace(" ", "_")
# Check for Right Ctrl key to trigger white flash
self.trigger(f"keydown_{key}")
# Cheat: Ctrl+Return instantly wins the current level.
if keycode == sdl2.SDLK_RETURN and (event.key.keysym.mod & sdl2.KMOD_CTRL):
self.trigger("cheat_win_level")
else:
# Check for Right Ctrl key to trigger white flash
self.trigger(f"keydown_{key}")
elif event.type == sdl2.SDL_KEYUP:
key = sdl2.SDL_GetKeyName(event.key.keysym.sym).decode('utf-8')
key = key.replace(" ", "_")
+42
View File
@@ -0,0 +1,42 @@
from enum import Enum, auto
class GameState(Enum):
START_MENU = auto()
PLAYING = auto()
PAUSED = auto()
GAME_OVER = auto()
VICTORY = auto()
class StateMachine:
def __init__(self, game):
self.game = game
self.current_state = GameState.START_MENU
def transition_to(self, new_state):
print(f"[state] Transitioning from {self.current_state} to {new_state}")
self.current_state = new_state
# Sincronizzazione per compatibilità con KeyBindings e logica esistente
if new_state == GameState.PLAYING:
self.game.game_status = "game"
self.game.menu_screen = None
elif new_state == GameState.PAUSED:
self.game.game_status = "paused"
self.game.menu_screen = None
elif new_state == GameState.START_MENU:
self.game.game_status = "start_menu"
self.game.menu_screen = "start"
elif new_state in [GameState.GAME_OVER, GameState.VICTORY]:
self.game.game_status = "paused" # Legacy status used for key handling in end screens
self.game.menu_screen = None
def update(self):
# Dispatch alla logica originale ripristinata in MiceMaze/Graphics
if self.current_state == GameState.START_MENU:
self.game.graphics.render_start_menu()
elif self.current_state == GameState.PAUSED:
self.game.graphics.render_pause_menu()
elif self.current_state in [GameState.GAME_OVER, GameState.VICTORY]:
# Delega al metodo game_over originale che gestisce i dialoghi specifici
self.game.game_over()
+61 -37
View File
@@ -4,35 +4,39 @@ from units import gas, rat, bomb, mine
from units.unit import UnitType
class UnitManager:
def __init__(self, game):
self.game = game
def _spawnable_rat_positions(self):
positions = []
for y in range(1, self.map.height - 1):
for x in range(1, self.map.width - 1):
if not self.map.is_empty(x, y):
for y in range(1, self.game.map.height - 1):
for x in range(1, self.game.map.width - 1):
if not self.game.map.is_empty(x, y):
continue
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx = x + dx
ny = y + dy
if self.map.in_bounds(nx, ny) and self.map.is_empty(nx, ny):
if self.game.map.in_bounds(nx, ny) and self.game.map.is_empty(nx, ny):
positions.append((x, y))
break
return positions
def has_weapon_at(self, position):
"""Check if there's a weapon (bomb, gas, mine) at the given position"""
for unit in self.units.values():
if unit.position == position:
# Check if it's a weapon type (not a rat or points)
if isinstance(unit, (bomb.Timer, bomb.NuclearBomb, gas.Gas, mine.Mine)):
return True
weapon_types = {UnitType.BOMB_TIMER, UnitType.BOMB_NUCLEAR, UnitType.GAS, UnitType.MINE}
for unit in self.game.units.values():
if unit.position == position and unit.type in weapon_types:
return True
return False
def can_place_weapon_at(self, position):
x, y = position
if not self.map.in_bounds(x, y):
if not self.game.map.in_bounds(x, y):
return False
if not self.map.is_empty(x, y):
if not self.game.map.is_empty(x, y):
return False
if self.has_weapon_at(position):
return False
@@ -40,19 +44,39 @@ class UnitManager:
def count_rats(self):
count = 0
for unit in self.units.values():
if isinstance(unit, rat.Rat):
rat_types = {UnitType.RAT_MALE, UnitType.RAT_FEMALE}
for unit in self.game.units.values():
if unit.type in rat_types:
count += 1
return count
def refill_ammo(self):
"""Randomly refill ammo during gameplay.
Per-frame refill probabilities are scaled by the current difficulty's
weapon_refill_multiplier (harder difficulties refill less often).
"""
import random
multiplier = getattr(self.game, "weapon_refill_multiplier", 1.0)
for ammo_type, data in self.game.ammo.items():
if ammo_type == "bomb":
if random.random() < 0.02 * multiplier:
data["count"] = min(data["count"] + 1, data["max"])
elif ammo_type == "mine":
if random.random() < 0.05 * multiplier:
data["count"] = min(data["count"] + 1, data["max"])
elif ammo_type == "gas":
if random.random() < 0.01 * multiplier:
data["count"] = min(data["count"] + 1, data["max"])
def spawn_gas(self, parent_id=None):
if not self.can_place_weapon_at(self.pointer):
if not self.can_place_weapon_at(self.game.pointer):
return
if self.ammo["gas"]["count"] <= 0:
if self.game.ammo["gas"]["count"] <= 0:
return
self.ammo["gas"]["count"] -= 1
self.render_engine.play_sound("GAS.WAV")
self.spawn_unit(gas.Gas, self.pointer, parent_id=parent_id)
self.game.ammo["gas"]["count"] -= 1
self.game.render_engine.play_sound("GAS.WAV")
self.spawn_unit(gas.Gas, self.game.pointer, parent_id=parent_id)
def spawn_rat(self, position=None):
if position is None:
@@ -68,9 +92,9 @@ class UnitManager:
# Try nearby positions
for dx, dy in [(0,1), (1,0), (0,-1), (-1,0), (1,1), (-1,-1), (1,-1), (-1,1)]:
alt_pos = (position[0] + dx, position[1] + dy)
if not self.map.in_bounds(alt_pos[0], alt_pos[1]):
if not self.game.map.in_bounds(alt_pos[0], alt_pos[1]):
continue
if self.map.is_empty(alt_pos[0], alt_pos[1]) and not self.has_weapon_at(alt_pos):
if self.game.map.is_empty(alt_pos[0], alt_pos[1]) and not self.has_weapon_at(alt_pos):
position = alt_pos
break
else:
@@ -84,45 +108,45 @@ class UnitManager:
def spawn_bomb(self, position):
if not self.can_place_weapon_at(position):
return
if self.ammo["bomb"]["count"] <= 0:
if self.game.ammo["bomb"]["count"] <= 0:
return
self.render_engine.play_sound("PUTDOWN.WAV")
self.game.render_engine.play_sound("PUTDOWN.WAV")
self.spawn_unit(bomb.Timer, position)
self.ammo["bomb"]["count"] -= 1
self.game.ammo["bomb"]["count"] -= 1
def spawn_nuclear_bomb(self, position):
"""Spawn a nuclear bomb at the specified position"""
if self.ammo["nuclear"]["count"] <= 0:
if self.game.ammo["nuclear"]["count"] <= 0:
return
if not self.can_place_weapon_at(position):
return
self.render_engine.play_sound("NUCLEAR.WAV")
self.ammo["nuclear"]["count"] -= 1
self.game.render_engine.play_sound("NUCLEAR.WAV")
self.game.ammo["nuclear"]["count"] -= 1
self.spawn_unit(bomb.NuclearBomb, position)
def spawn_mine(self, position):
if self.ammo["mine"]["count"] <= 0:
if self.game.ammo["mine"]["count"] <= 0:
return
if not self.can_place_weapon_at(position):
return
self.render_engine.play_sound("PUTDOWN.WAV")
self.ammo["mine"]["count"] -= 1
self.game.render_engine.play_sound("PUTDOWN.WAV")
self.game.ammo["mine"]["count"] -= 1
self.spawn_unit(mine.Mine, position, on_bottom=True)
def spawn_unit(self, unit, position, on_bottom=False, **kwargs):
id = uuid.uuid4()
if on_bottom:
self.units = {id: unit(self, position, id, **kwargs), **self.units}
self.game.units = {id: unit(self.game, position, id, **kwargs), **self.game.units}
else:
self.units[id] = unit(self, position, id, **kwargs)
self.game.units[id] = unit(self.game, position, id, **kwargs)
def choose_start(self):
if not hasattr(self, '_valid_positions') or self._valid_positions is None:
self._valid_positions = self._spawnable_rat_positions()
print(f"[flow] choose_start computed {len(self._valid_positions)} spawnable cells", flush=True)
if not self._valid_positions:
if not hasattr(self.game, '_valid_positions') or self.game._valid_positions is None:
self.game._valid_positions = self._spawnable_rat_positions()
print(f"[flow] choose_start computed {len(self.game._valid_positions)} spawnable cells", flush=True)
if not self.game._valid_positions:
return None
return random.choice(self._valid_positions)
return random.choice(self.game._valid_positions)
def get_unit_by_id(self, id):
return self.units.get(id) or None
return self.game.units.get(id) or None
+1 -1
View File
@@ -3,7 +3,7 @@
<game>
<path>./mice.sh</path>
<name>Mice!</name>
<desc>Mice! is a strategic single‑player game where you place bombs and mines to exterminate rats before they reproduce out of control. It features randomly generated mazes (DFS), sprite-based graphics, and sound effects. Inspired by the classic Rats! for Windows 95, this version is written in Python and uses a lightweight custom engine with SDL‑style rendering.</desc>
<desc>Mice! is a strategic single-player game where you place bombs and mines to exterminate rats before they reproduce out of control. It features randomly generated mazes (DFS), sprite-based graphics, and sound effects. Inspired by the classic Rats! for Windows 95, this version is written in Python and uses a lightweight custom engine with SDL-style rendering. Created by Matteo Benedetto, a bored engineer.</desc>
<releasedate>20250818T000000</releasedate>
<developer>Matteo Benedetto</developer>
<publisher>Self-published</publisher>
+1
View File
@@ -0,0 +1 @@
print("Hello from Nuitka")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

-82
View File
@@ -1,82 +0,0 @@
#!/usr/bin/env python3
import sys
import os
import sdl2
import sdl2.ext
class KeyLogger:
def __init__(self):
# Initialize SDL2
sdl2.ext.init(joystick=True, video=True, audio=False)
# Initialize joystick support
sdl2.SDL_Init(sdl2.SDL_INIT_JOYSTICK)
sdl2.SDL_JoystickOpen(0)
sdl2.SDL_JoystickOpen(1) # Open the first joystick
sdl2.SDL_JoystickEventState(sdl2.SDL_ENABLE)
self.window = sdl2.ext.Window("Key Logger", size=(640, 480))
self.window.show()
self.running = True
self.key_down = True
self.font = sdl2.ext.FontManager("assets/decterm.ttf", size=24)
def run(self):
# Main loop
while self.running:
# Handle SDL events
events = sdl2.ext.get_events()
for event in events:
self.event = event.type
if event.type == sdl2.SDL_KEYDOWN:
keycode = event.key.keysym.sym
# Log keycode to file
self.message = f"Key pressed: {sdl2.SDL_GetKeyName(keycode).decode('utf-8')}"
elif event.type == sdl2.SDL_KEYUP:
keycode = event.key.keysym.sym
# Log keycode to file
self.message = f"Key released: {sdl2.SDL_GetKeyName(keycode).decode('utf-8')}"
elif event.type == sdl2.SDL_JOYBUTTONDOWN:
button = event.jbutton.button
self.message = f"Joystick button {button} pressed"
if button == 9: # Assuming button 0 is the right trigger
self.running = False
elif event.type == sdl2.SDL_JOYBUTTONUP:
button = event.jbutton.button
self.message = f"Joystick button {button} released"
elif event.type == sdl2.SDL_JOYAXISMOTION:
axis = event.jaxis.axis
value = event.jaxis.value
self.message = f"Joystick axis {axis} moved to {value}"
elif event.type == sdl2.SDL_JOYHATMOTION:
hat = event.jhat.hat
value = event.jhat.value
self.message = f"Joystick hat {hat} moved to {value}"
elif event.type == sdl2.SDL_QUIT:
self.running = False
# Update the window
sdl2.ext.fill(self.window.get_surface(), sdl2.ext.Color(34, 0, 33))
greeting = self.font.render("Press any key...", color=sdl2.ext.Color(255, 255, 255))
sdl2.SDL_BlitSurface(greeting, None, self.window.get_surface(), None)
if hasattr(self, 'message'):
text_surface = self.font.render(self.message, color=sdl2.ext.Color(255, 255, 255))
sdl2.SDL_BlitSurface(text_surface, None, self.window.get_surface(), sdl2.SDL_Rect(0, 30, 640, 480))
if hasattr(self, 'event'):
event_surface = self.font.render(f"Event: {self.event}", color=sdl2.ext.Color(255, 255, 255))
sdl2.SDL_BlitSurface(event_surface, None, self.window.get_surface(), sdl2.SDL_Rect(0, 60, 640, 480))
sdl2.SDL_UpdateWindowSurface(self.window.window)
# Refresh the window
self.window.refresh()
sdl2.SDL_Delay(10)
# Check for quit event
if not self.running:
break
# Cleanup
sdl2.ext.quit()
if __name__ == "__main__":
logger = KeyLogger()
logger.run()
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

-25
View File
@@ -1,25 +0,0 @@
#!/bin/sh
set -eu
APPDIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
VENV_DIR="$APPDIR/usr/opt/python"
GAME_DIR="$APPDIR/usr/share/mice"
if [ -n "${MICE_DATA_DIR:-}" ]; then
DATA_DIR="$MICE_DATA_DIR"
elif [ -n "${XDG_DATA_HOME:-}" ]; then
DATA_DIR="$XDG_DATA_HOME/mice"
else
DATA_DIR="$HOME/.local/share/mice"
fi
mkdir -p "$DATA_DIR"
export PATH="$VENV_DIR/bin:${PATH:-}"
export LD_LIBRARY_PATH="$APPDIR/usr/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export MICE_PROJECT_ROOT="$GAME_DIR"
export MICE_DATA_DIR="$DATA_DIR"
export PYTHONNOUSERSITE=1
cd "$GAME_DIR"
exec "$VENV_DIR/bin/python" rats.py "$@"
-9
View File
@@ -1,9 +0,0 @@
[Desktop Entry]
Type=Application
Name=Mice!
Comment=Strategic rat extermination game built with Python and SDL2
Exec=mice
Icon=mice
Categories=Game;StrategyGame;
Terminal=false
StartupNotify=false
-136
View File
@@ -1,136 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}"
APPDIR="${APPDIR:-$DIST_DIR/AppDir}"
APP_NAME="Mice"
APP_ID="mice"
ARCH_EXPECTED="aarch64"
APPIMAGETOOL_BIN="${APPIMAGETOOL_BIN:-appimagetool}"
PYTHON_BIN="${PYTHON_BIN:-python3}"
OUTPUT_APPIMAGE="${OUTPUT_APPIMAGE:-$DIST_DIR/${APP_NAME}-${ARCH_EXPECTED}.AppImage}"
PYTHON_DIR="$APPDIR/usr/opt/python"
GAME_DIR="$APPDIR/usr/share/mice"
LIB_DIR="$APPDIR/usr/lib"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'Missing required command: %s\n' "$1" >&2
exit 1
fi
}
find_system_library() {
local soname="$1"
ldconfig -p | awk -v target="$soname" '$1 == target { print $NF; exit }'
}
should_bundle_soname() {
case "$1" in
linux-vdso.so.*|libc.so.*|libm.so.*|libpthread.so.*|libdl.so.*|librt.so.*|libutil.so.*|libresolv.so.*|ld-linux*.so.*)
return 1
;;
*)
return 0
;;
esac
}
copy_dependency_tree() {
local binary="$1"
local dep
local soname
while IFS= read -r dep; do
[ -n "$dep" ] || continue
[ -f "$dep" ] || continue
soname=$(basename "$dep")
if ! should_bundle_soname "$soname"; then
continue
fi
if [ ! -e "$LIB_DIR/$soname" ]; then
cp -a "$dep" "$LIB_DIR/$soname"
chmod 755 "$LIB_DIR/$soname" || true
copy_dependency_tree "$dep"
fi
done < <(
ldd "$binary" 2>/dev/null | awk '
/=>/ && $3 ~ /^\// { print $3 }
$1 ~ /^\// { print $1 }
' | sort -u
)
}
copy_system_library() {
local soname="$1"
local path
path=$(find_system_library "$soname")
if [ -z "$path" ]; then
printf 'Unable to locate required system library: %s\n' "$soname" >&2
exit 1
fi
cp -a "$path" "$LIB_DIR/$(basename "$path")"
chmod 755 "$LIB_DIR/$(basename "$path")" || true
copy_dependency_tree "$path"
}
printf '==> Checking build prerequisites\n'
require_command rsync
require_command "$PYTHON_BIN"
require_command "$APPIMAGETOOL_BIN"
require_command ldconfig
require_command ldd
if [ "$(uname -m)" != "$ARCH_EXPECTED" ]; then
printf 'This builder must run on %s. Current architecture: %s\n' "$ARCH_EXPECTED" "$(uname -m)" >&2
exit 1
fi
printf '==> Creating AppDir at %s\n' "$APPDIR"
rm -rf "$APPDIR"
mkdir -p "$DIST_DIR" "$LIB_DIR" "$GAME_DIR"
printf '==> Building bundled Python environment with %s\n' "$PYTHON_BIN"
"$PYTHON_BIN" -m venv --copies "$PYTHON_DIR"
"$PYTHON_DIR/bin/pip" install --upgrade pip setuptools wheel
"$PYTHON_DIR/bin/pip" install -r "$ROOT_DIR/requirements.txt"
printf '==> Syncing game files\n'
rsync -a \
--delete \
--exclude '.git' \
--exclude '.github' \
--exclude '.venv' \
--exclude '__pycache__' \
--exclude '*.pyc' \
--exclude '.mypy_cache' \
--exclude '.pytest_cache' \
--exclude 'build' \
--exclude 'dist' \
--exclude 'packaging' \
"$ROOT_DIR/" "$GAME_DIR/"
rm -f "$GAME_DIR/user_profiles.json" "$GAME_DIR/scores.txt"
printf '==> Installing AppImage metadata\n'
install -Dm755 "$ROOT_DIR/packaging/appimage/AppRun" "$APPDIR/AppRun"
install -Dm644 "$ROOT_DIR/packaging/appimage/mice.desktop" "$APPDIR/$APP_ID.desktop"
install -Dm644 "$ROOT_DIR/packaging/appimage/mice.desktop" "$APPDIR/usr/share/applications/$APP_ID.desktop"
install -Dm644 "$ROOT_DIR/assets/Rat/BMP_WEWIN.png" "$APPDIR/$APP_ID.png"
install -Dm644 "$ROOT_DIR/assets/Rat/BMP_WEWIN.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/$APP_ID.png"
printf '==> Bundling native dependencies\n'
copy_system_library libSDL2-2.0.so.0
copy_system_library libSDL2_ttf-2.0.so.0
copy_dependency_tree "$PYTHON_DIR/bin/python"
while IFS= read -r -d '' candidate; do
copy_dependency_tree "$candidate"
done < <(find "$PYTHON_DIR" -type f \( -name '*.so' -o -name '*.so.*' -o -perm -u+x \) -print0)
printf '==> Building AppImage %s\n' "$OUTPUT_APPIMAGE"
ARCH="$ARCH_EXPECTED" "$APPIMAGETOOL_BIN" --appimage-extract-and-run "$APPDIR" "$OUTPUT_APPIMAGE"
printf 'AppImage created at %s\n' "$OUTPUT_APPIMAGE"
+218 -568
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Some files were not shown because too many files have changed in this diff Show More