diff --git a/README.md b/README.md index e995815..c92ce6b 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,77 @@ # A Scream from the Dark -Un survival-horror procedurale in prospettiva isometrica per Game Boy (DMG/CGB), scritto in C con **GBDK-2020**. Sei imprigionato in un labirinto 7×7 generato casualmente, illuminato solo da un ristretto quadrato di visibilità. Un **fantasma** si nasconde nel buio e ti bracca non appena entri nel suo raggio visivo. L'unica via di fuga è la **botola** sul bordo sud della mappa: raggiungerla significa "sprofondare più giù" (*Going Deeper*) e generare un nuovo livello. +Un survival-horror procedurale in prospettiva isometrica per Game Boy (DMG/CGB), scritto in C con **GBDK-2020**. Sei imprigionato in un labirinto generato casualmente, illuminato solo da un ristretto quadrato di visibilità. Dei **fantasmi** si nascondono nel buio e ti braccano. L'unica via di fuga è una **botola** posta lontano dalla partenza: raggiungerla significa "sprofondare più giù" (*Going Deeper*) e affrontare un livello più grande, con più nemici e meno visibilità. Dopo **8 livelli** il gioco finisce con un **finale tragico**. --- -## 🎮 Caratteristiche +## Caratteristiche -- **Proiezione isometrica 2.5D**: rendering della mappa a diamante (tile 32×16 px) su schermo Game Boy, con autotiling dinamico. -- **Labirinto casuale**: algoritmo DFS iterativo con stack in WRAM (per evitare overflow dello stack hardware) che genera un "perfect maze" 7×7 unico ad ogni partita, poi "rotto" con riaperture casuali al 15% per creare loop e percorsi alternativi. -- **Fog of War**: visibilità 5×5 basata sulla distanza di Chebyshev, con affievolimento della luce sui bordi. Il fantasma si attiva solo quando entra in questo riquadro. -- **Movimento interpolato (LERP)**: spostamenti fluidi del personaggio e della telecamera su 16 tick a punto fisso (no float). -- **Delayed Auto-Shift (DAS)**: controlli alla Tetris — delay iniziale di 12 frame e ripetizione ogni 6 frame per il movimento continuo tenendo premuto il D-Pad. -- **Salto evasivo con Stamina**: A+direzione scavalca il blocco adiacente atterrando 2 tile più in là (la cella intermedia deve essere un muro). Costa 60 punti stamina; la barra si ricarica di 1 punto al secondo. -- **Corsa con Stamina**: B+direzione fa correre il protagonista: il passo dura 8 frame invece di 16 e consuma 10 stamina per tile, con DAS più rapido per incatenare i tile fluidamente. Se la stamina scende sotto 10 ripiega automaticamente sulla camminata normale. -- **Progressione livelli (8 livelli, poi finale)**: si parte dal livello 1; raggiungere la botola fa sprofondare nel livello successivo. L'indicatore `L` in alto a sinistra mostra il livello corrente. In caso di sconfitta si ricomincia dall'ultimo livello raggiunto. **La dimensione del labirinto cresce di 2 tile per lato ad ogni livello** (7→9→11→13→15→17→19→21x21 al livello 8). **Difficoltà progressiva**: +1 fantasma per livello (fino a 8), fantasma più veloce (cooldown più corto), stamina che si ricarica più lentamente, e nebbia più stretta (5x5→3x3) dal livello 7. **Superato il livello 8 il gioco finisce** con una schermata finale. -- **AI del fantasma**: pathfinding greedy con distanza al quadrato (niente sqrt, niente A*), cooldown di 1 secondo tra i passi, hitbox pixel-perfect (12×6 px) per una morte "giusta". -- **Audio procedurale**: colonna sonora sintetizzata manipolando direttamente i registri APU via VBL interrupt (nessun campione). -- **Schermate a tutto schermo**: copertina 2-bit nativa per il titolo, immagine "Going Deeper" per la vittoria, metasprite "GAME OVER" per la sconfitta. -- **Test headless**: pipeline di verifica con PyBoy + OpenCV senza emulatore grafico. +- **Proiezione isometrica 2.5D**: rendering a diamante (tile 32×16 px) con autotiling dinamico e multi-pass. +- **Labirinto casuale crescente**: DFS iterativo con stack in WRAM che genera un perfect maze, rotto al 15% per creare loop. La dimensione cresce di 2 tile/lato per livello: 7×7 → 21×21. +- **Fog of War scalabile**: visibilità basata su Chebyshev. 5×5 (livelli 1-6), 3×3 (livelli 7-8). Il nemico si attiva solo quando entra nella nebbia. +- **Movimento interpolato (LERP)**: punto fisso `>>4`, no float. 16 frame/passo (8 in corsa). +- **DAS**: controlli alla Tetris — delay 12 frame, repeat 6 (walk) / 2 (run). +- **Salto evasivo**: A+direzione, 2 tile, costa 60 stamina. Arco parabolico visivo. +- **Corsa**: B+direzione, 8 frame/tile, 10 stamina/tile. Fallback a camminata se stamina < 10. +- **Progressione 8 livelli + finale**: difficoltà crescente (maze, nemici, cooldown, stamina, nebbia). Indicatore `L` in alto a sinistra. Sconfitta → ricomincia dallo stesso livello. Livello 8 → finale tragico. +- **Multi-nemico**: fino a 8 fantasmi (1 per livello). AI greedy, cooldown scalabile (60→11 frame), hitbox pixel-perfect. +- **Audio procedurale**: 4 canali APU via VBL interrupt. Title (112 note, 3 canali), gameplay (96), gameover (128), finale dedicato (192, loop). +- **Schermate**: title con sfondo 2-bit, death con `claimed.png`, Going Deeper testuale, finale tragico con font IBM. +- **Test headless**: PyBoy + OpenCV + ROM di test isolate. ### Soundtrack -1. **Title Theme**: solenne e misteriosa, 32 battute sugli accordi La minore, Sol, Fa e Mi. -2. **Gameplay Theme**: battito ritmico ansioso ("eerie pulse") che accelera la tensione dell'inseguimento. -3. **Game Over Theme**: concerto tragico polifonico di 128 note con percussioni (noise channel), basso virtuoso e drammatica discesa melodica. -4. **Going Deeper**: melodia misteriosa discendente di 96 step (Am → Fmaj7 → Dm → E7 → C aug → abisso). +1. **Title Theme**: 112 note su 3 canali (melodia + basso indipendente + rintocchi noise), ~56 sec in loop. Lamento discendente in Re minore con 7ª armonica (C#) e discesa cromatica nell'abisso. +2. **Gameplay Theme**: battito ritmico ansioso ("eerie pulse") in La minore → Re minore → Mi7. +3. **Game Over Theme**: concerto tragico polifonico di 128 note con percussioni (thud + crash). +4. **Finale**: 192 note (24 accordi), lamento discendente Dm → abisso (C2), loop infinito. CH1 melodia sommessa + CH2 basso profondo + CH4 toll (mid/crash/deep). +5. **Going Deeper**: melodia misteriosa discendente di 96 step (Am → Fmaj7 → Dm → E7 → C aug → abisso). --- -## 🛠️ Dettagli tecnici +## Dettagli tecnici ### Architettura dei file -- [`main.c`](src/main.c): entry point, loop VBL sincronizzato, macchina a stati `app_state` (0 = title, 1 = game). -- [`engine.c`](src/engine.c) / [`engine.h`](src/engine.h): "direttore d'orchestra" — `title_init/update`, `engine_init`, `engine_update`. -- [`globals.c`](src/globals.c) / [`globals.h`](src/globals.h): stato globale centralizzato (mappa, camera, player, enemy, stamina, `game_over`) per evitare dipendenze circolari tra moduli. -- [`maze.c`](src/maze.c): generazione procedurale DFS + loop + posizionamento botola. -- [`player_logic.c`](src/player_logic.c): input, DAS, state machine del movimento, salto, stamina. -- [`enemy_logic.c`](src/enemy_logic.c): AI greedy, cooldown, rendering nemico, hitbox pixel-perfect. -- [`render.c`](src/render.c): proiezione isometrica, fog of war, autotiling multi-pass, stamina UI, sprite player. -- [`sound.c`](src/sound.c): sequencer audio via VBL interrupt, 4 tracce. -- `tiles.c / player.c / enemy.c / gameover.c / next_level.c / stamina.c / title_bg.c`: asset generati da `png2asset`. -- [`scripts/`](scripts/): generazione procedurale di tile/sprite (`generate_assets.py`, `generate_enemy.py`), quantizzazione immagini (`process_next_level.py`), test headless. +- [`main.c`](src/main.c): entry point, loop VBL, `app_state` (0=title, 1=game). +- [`engine.c`](src/engine.c): orchestrazione, game-over branches (sconfitta/vittoria/finale). +- [`globals.c`](src/globals.c) / [`globals.h`](src/globals.h): stato globale (mappa `[21][21]`, `map_size`, `fog_radius`, `level`, `num_enemies`, enemy arrays[8], stamina, ecc.). +- [`maze.c`](src/maze.c): DFS + loop + botola. Array statici in WRAM. +- [`player_logic.c`](src/player_logic.c): DAS, camminata, corsa, salto, stamina. +- [`enemy_logic.c`](src/enemy_logic.c): AI greedy multi-entity, cooldown scalabile, hitbox. +- [`render.c`](src/render.c): iso, fog scalabile, auto-tiling, flush dinamico, HUD. +- [`sound.c`](src/sound.c): sequencer VBL, 5 tracce + SFX. +- Asset: `tiles.c`, `player.c`, `enemy.c`, `gameover.c`, `stamina.c`, `level.c`, `claimed.c`, `title_bg.c`. +- [`scripts/`](scripts/): generazione asset (`generate_assets.py`, `generate_enemy.py`, `generate_level.py`), test. -### Formato delle coordinate isometriche -Le coordinate logiche `(lx, ly)` vengono convertite in coordinate schermo `(iso_x, iso_y)`: +### Coordinate isometriche ``` -iso_x = (lx - ly) * 2 + 12 -iso_y = (lx + ly) * 1 + 2 +iso_x = (lx - ly) * 2 + 12 iso_y = (lx + ly) + 2 +px = (lx - ly) * 16 + 96 py = (lx + ly) * 8 + 16 ``` -e in coordinate pixel fisiche per camera/collisione: -``` -px = (lx - ly) * 16 + 96 -py = (lx + ly) * 8 + 16 -``` -Camera centrata: `scroll_x = px - 64`, `scroll_y = py - 72`. +Camera: `scroll_x = px - 64`, `scroll_y = py - 72`. -Per un'analisi approfondita di codice, funzionalità e workaround storici, vedi [`doc/AScreamFromTheDark_report.md`](doc/AScreamFromTheDark_report.md). +Documentazione approfondita: [`doc/`](doc/) — [index](doc/index.md), [report](doc/AScreamFromTheDark_report.md). --- -## 🚀 Requisiti e build +## Requisiti e build ### Prerequisiti -1. **GBDK-2020** installato in `/home/enne2/.local/gbdk`. -2. **Python 3** con i pacchetti per la rigenerazione degli asset e i test: - ```bash - pip install --user Pillow pyboy opencv-python numpy - ``` +1. **GBDK-2020** in `/home/enne2/.local/gbdk`. +2. **Python 3** con: `pip install --user Pillow pyboy opencv-python numpy` ### Compilazione ```bash make clean && make ``` -Questo comando: -1. Esegue gli script Python per creare `tiles.png`, `player.png`, `enemy.png`. -2. Usa `png2asset` per convertire i PNG in sorgenti C. -3. Usa il compilatore `lcc` di GBDK per compilare e linkare i sorgenti in `build/hello_iso.gb` (e `build/test_gameover.gb`). +Output: `build/hello_iso.gb` (32 KB) + `build/test_gameover.gb` + `build/test_finale.gb`. --- -## 🧪 Test e analisi automatica +## Test -1. **Screenshot** — `python3 scripts/test_pyboy.py`: avvia la ROM in PyBoy per 120 frame e salva `assets/hello_iso_gb.png`. -2. **Test di movimento in WRAM** — `python3 scripts/test_movement.py`: legge la griglia del labirinto in WRAM (indirizzo risolto dinamicamente via `hello_iso.noi`) e simula pressioni direzionali verificando `player_lx/ly`. -3. **Rilevamento glitch con OpenCV** — `python3 scripts/opencv_analyze_tiles.py`: esamina lo screenshot cercando disallineamenti o buchi neri tra le giunzioni isometriche. -4. **ROM di test isolata** — `make build/test_gameover.gb`: renderizza solo player + metasprite GAME OVER per validare la schermata di sconfitta. - -La documentazione tecnica dettagliata per modulo è in [`doc/`](doc/). \ No newline at end of file +1. **Screenshot** — `python3 scripts/test_pyboy.py` +2. **Movimento WRAM** — `python3 scripts/test_movement.py` +3. **Glitch OpenCV** — `python3 scripts/opencv_analyze_tiles.py` +4. **ROM game over** — `make build/test_gameover.gb` +5. **ROM finale** — `make build/test_finale.gb` (va subito al finale con musica, per test rapidi) \ No newline at end of file diff --git a/doc/AScreamFromTheDark_report.md b/doc/AScreamFromTheDark_report.md index b85021b..92b48c5 100644 --- a/doc/AScreamFromTheDark_report.md +++ b/doc/AScreamFromTheDark_report.md @@ -1,194 +1,156 @@ # A Scream from the Dark — Report Tecnico Approfondito -> Labirinto isometrico con inseguimento per Game Boy (DMG/CGB), scritto in C con **GBDK-2020**. -> Repository: `gameboy-hello-iso` · Target ROM: `build/hello_iso.gb` (32 KB). -> ~5941 righe di C/H + ~1710 righe di script Python per la pipeline asset. +> Survival-horror procedurale isometrico per Game Boy (DMG/CGB), scritto in C con **GBDK-2020** (SDCC). +> Repository: `gameboy-hello-iso` · ROM: `build/hello_iso.gb` (32 KB). +> ~6500 righe C/H + ~2000 righe Python (pipeline asset). --- ## 1. Identità del gioco -"A Scream from the Dark" è un piccolo survival-horror procedurale in prospettiva isometrica 2.5D. Il giocatore è imprigionato in un labirinto 7×7 generato casualmente, illuminato solo da un ristretto quadrato di visibilità (Fog of War). Un **fantasma** si nasconde nel buio e bracca il giocatore non appena entra nel suo riquadro visivo (5×5). L'unica via di fuga è una **botola** (`maze[y][x] == 2`) piazzata sul bordo sud della mappa: raggiungerla significa "sprofondare più giù" (*Going Deeper*) e avanzare di livello. +"A Scream from the Dark" è un survival-horror procedurale in prospettiva isometrica 2.5D. Sei imprigionato in un labirinto generato casualmente, illuminato solo da un ristretto quadrato di visibilità (Fog of War). Dei **fantasmi** si nascondono nel buio e ti braccano. L'unica via è una **botola** posta lontano dalla partenza: raggiungerla significa sprofondare più giù (*Going Deeper*) e affrontare un livello più grande, con più nemici e meno visibilità. Dopo **8 livelli** il gioco finisce con un **finale tragico**. -Loop di gioco: -1. **Title screen** — copertina 2-bit + tema solenne/misterioso (32 battute). -2. **Gameplay** — esplorazione a griglia con movimento interpolato, salto evasivo a consumo di stamina, AI greedy del fantasma, hitbox pixel-perfect. -3. **Game Over (Sconfitta)** — "fermo immagine" drammatico di 45 frame → schermo nero → metasprite `GAME OVER` + concerto tragico polifonico di 128 note con percussioni (noise channel). -4. **Going Deeper (Vittoria)** — reset scroll + immagine a tutto schermo `next_level.png` + melodia misteriosa discendente di 96 step. Premi START per rigenerare un nuovo livello. +### Loop di gioco +1. **Title screen** — sfondo 2-bit + tema musicale tragico (112 note, 3 canali, ~56 sec in loop). +2. **Gameplay** — esplorazione a griglia con camminata, **corsa** (B+direzione), **salto evasivo** (A+direzione), stamina, AI greedy multi-nemico, hitbox pixel-perfect. Indicatore `L` in alto a sinistra. +3. **Game Over (Sconfitta)** — 45 frame di fermo immagine → schermata `claimed.png` + metasprite "GAME OVER" + musica tragica (128 note). START → ricomincia dallo stesso livello. +4. **Going Deeper (Vittoria, livelli 1-7)** — schermata testuale "GOING DEEPER / LEVEL N" + melodia misteriosa (96 step). START → livello successivo (più grande, più difficile). +5. **Finale (Livello 8)** — `game_over = 3`: sfondo nero, "YOUR TORCH HAS / RUN OUT, / YOU ARE TRAPPED. / JUST ANOTHER SCREAM / FROM THE DARK. / GAME OVER" + musica dedicata (192 note, lamento discendente in Re minore, loop). START → titolo. --- ## 2. Architettura del codice -### 2.1 Organizzazione modulare -Il progetto nasceva da un monolite `engine.c` di oltre 1000 righe, poi rifattorizzato in moduli a singola responsabilità. L'orchestrazione avviene in `main.c` / `engine.c`; lo stato mutabile è centralizzato in `globals.c`/`globals.h` per evitare dipendenze circolari tra moduli C. - +### 2.1 Moduli | File | Ruolo | |------|-------| -| `main.c` | Entry point, loop VBL, macchina a stati `app_state` (0=title, 1=game). | -| `engine.c` | "Direttore d'orchestra": `title_init/update`, `engine_init`, `engine_update`. | -| `globals.c/h` | Stato globale: mappa, camera, player, enemy, stamina, game_over. | -| `maze.c` | Generazione procedurale DFS + creazione loop + posizionamento botola. | -| `player_logic.c` | Input, DAS, state machine del movimento, salto, stamina. | -| `enemy_logic.c` | AI greedy, cooldown, rendering nemico, hitbox pixel-perfect. | -| `render.c` | Proiezione isometrica, fog of war, autotiling, multi-pass, stamina UI, sprite player. | -| `sound.c` | Sequencer audio via VBL interrupt, 4 tracce (title/gameplay/gameover/next_level). | -| `tiles.c/player.c/enemy.c/gameover.c/next_level.c/stamina.c/title_bg.c` | Asset generati da `png2asset`. | +| `main.c` | Entry point, loop VBL, `app_state` (0=title, 1=game). | +| `engine.c` | Orchestrazione: `title_init/update`, `engine_init`, `engine_update`, game-over branches (1/2/3). | +| `globals.c/h` | Stato globale: mappa `[21][21]`, `map_size`, `fog_radius`, `level`, `num_enemies`, enemy arrays[8], stamina, ecc. | +| `maze.c` | DFS + loop + botola. Array statici in WRAM. | +| `player_logic.c` | DAS, camminata, corsa, salto, stamina, rilevamento botola (game_over 2/3). | +| `enemy_logic.c` | AI greedy multi-entity, cooldown scalabile, rendering, hitbox. | +| `render.c` | Iso, fog scalabile, auto-tiling multi-pass, flush dinamico 16-righe, HUD stamina + livello. | +| `sound.c` | Sequencer VBL: title (112), gameplay (96), gameover (128), finale (192 loop). | +| Asset `.c` | `tiles`, `player`, `enemy`, `gameover`, `stamina`, `level`, `claimed`, `title_bg` (png2asset). | -### 2.2 Pipeline asset (Python → C) -`scripts/generate_assets.py` (PIL) produce `tiles.png`/`player.png` da matrici di pixel; `generate_enemy.py` genera il fantasma; `process_next_level.py` quantizza `next_level_original.png` a 4 livelli con soglie fisse. Il `Makefile` poi invoca `png2asset` per convertire ogni PNG in `src/*.c` (map, metasprite, tile data). `make clean` cancella anche i C generati, forzando la rigenerazione. +### 2.2 Pipeline asset +Script Python (`generate_assets.py`, `generate_enemy.py`, `generate_level.py`) → PNG → `png2asset` → `src/*.c` → `lcc` (SDCC) → ROM. --- -## 3. Funzionalità chiave in dettaglio +## 3. Funzionalità chiave ### 3.1 Proiezione isometrica -Conversione griglia logica `(lx,ly)` → schermo: ``` -iso_x = (lx - ly) * 2 + 12 // tile 32x16 → metà larghezza -iso_y = (lx + ly) * 1 + 2 // tile altezza 8px logica -``` -Coordinate fisiche pixel del player/nemico usate per camera & collision: -``` -px = (lx - ly) * 16 + 96 +iso_x = (lx - ly) * 2 + 12 // tile 32x16 +iso_y = (lx + ly) * 1 + 2 // altezza 8px +px = (lx - ly) * 16 + 96 // pixel fisici py = (lx + ly) * 8 + 16 ``` -Camera centrata: `scroll_x = px - 64`, `scroll_y = py - 72` (160×144 display → centro 80×72, con offset hardware). +Camera: `scroll_x = px - 64`, `scroll_y = py - 72`. -### 3.2 Generazione del labirinto (`maze.c`) -- **DFS iterativo con stack in WRAM** (`stack_x/y` statici, sized `(MAX_MAP_SIZE/2)^2`): evita l'overflow dello stack hardware del LR35902. Celle dispari = stanze, celle pari = muri divisori; scava il muro di mezzo con la media aritmetica delle coordinate. -- **Dimensione crescente col livello**: `map_size = MAP_SIZE + 2*(level-1)`, capped a `MAX_MAP_SIZE`=21 (raggiunto al livello 8, sempre dispari). L'array `maze` è allocato `[MAX_MAP_SIZE][MAX_MAP_SIZE]` (441 byte) e i moduli usano `map_size` come bound runtime. -- **Fase 2 — loop**: riapre muri residui che collegano due corridoi opposti con probabilità 15%. Vitale per il gameplay d'inseguimento. -- **Fase 3 — botola**: sceglie una cella calpestabile a distanza di Chebyshev ≥ `map_size/2` dalla partenza `(1,1)` (soglia scalata: 3 per 7x7, 8 per 17x17), tra **tutte** le celle del labirinto. Fallback sulla cella più lontana in assoluto. +### 3.2 Generazione labirinto (`maze.c`) +- **DFS iterativo** con stack in WRAM (statico, sized per `MAX_MAP_SIZE=21`). Celle dispari = stanze, pari = muri. +- **Loop**: riapertura 15% dei muri → percorsi alternativi. +- **Botola**: cella a Chebyshev ≥ `map_size/2` da (1,1), scelta casuale. +- **Dimensioni crescenti**: `map_size = 7 + 2*(level-1)`, capped 21 (livello 8). Sempre dispari. ### 3.3 Movimento & DAS (`player_logic.c`) -- **State machine rigida**: durante `is_moving` (16 frame di LERP, o 8 in corsa) ogni input è ignorato → movimento strettamente grid-based stile Zelda/Pokémon. -- **LERP** a punto fisso: `px = start_px + ((target_px - start_px) * move_progress) >> 4`. In corsa `move_progress` incrementa di 2/frame → il passo dura 8 frame invece di 16. -- **DAS (Delayed Auto Shift)** alla Tetris: `DAS_DELAY=12` frame di attesa iniziale, poi `DAS_REPEAT=6` frame tra ripetizioni (camminata); `DAS_REPEAT_RUN=2` mentre si corre per incatenare i tile. -- **Salto evasivo**: A+direzione → atterraggio 2 tile più in là. Condizioni: cella intermedia DEVE essere muro, cella di arrivo pavimento/vittoria, stamina ≥ 60. Consuma 60 stamina. Arco parabolico solo visivo: `y_offset = (move_progress * (16 - move_progress)) >> 2`. -- **Corsa**: B+direzione (stamina ≥ 10) → passo di 8 frame, costo 10 stamina/tile, DAS rapido. Se stamina < 10 ripiega su camminata normale senza costo. -- **Stamina**: ricarica 1 punto/s. Barra UI a 5 sprite (ID 18–22) in alto a destra. - -### 3.3b Progressione livelli (`globals.c` + `engine.c` + `render.c`) -- Variabile globale `level` (parte da 1). Il titolo→gioco la azzera a 1 (`main.c`); raggiungere la botola (vittoria, `game_over==2`) e premere START fa `level++` prima di `engine_init()` (nuovo labirinto); la sconfitta (`game_over==1`) + START **non azzera** il livello: si ricomincia dall'ultimo raggiunto. Superare la botola al **livello 8** setta `game_over==3` (finale tragico) invece di 2. -- Indicatore HUD `L` in alto a sinistra via 3 sprite (OAM ID 23–25), disegnati dall'asset `level.png` (11 glifi 8x16: 'L', '0'–'9') generato da `scripts/generate_level.py` e convertito con `png2asset -keep_duplicate_tiles` (senza deduplica, così il glifo di ordine i occupa i tile 2i/2i+1 in ordine prevedibile). -- **VRAM**: i glifi sono caricati a `LEVEL_SPRITE_BASE = allineamento-pari-di (tiles_TILE_COUNT + stamina_TILE_COUNT*2)` (~204) — base PARI perché in modalità 8x16 l'hardware ignora il LSB dell'indice tile; e disgiunta dal blocco tile del background e dalla stamina (stesso workaround del commit 93deb35). `update_level_display()` viene chiamata ogni frame in `engine_update` e si nasconde da sola quando `game_over` è attivo. +- **State machine**: durante `is_moving` (16 frame, 8 in corsa) input ignorato. +- **LERP** a punto fisso `>>4`. Corsa: `move_progress += 2`. +- **DAS**: delay 12, repeat 6 (walk) / 2 (run). +- **Salto**: A+dir → 2 tile, cella intermedia deve essere muro, stamina ≥ 60. Arco parabolico visivo. +- **Corsa**: B+dir, stamina ≥ 10, 8 frame/tile, 10 stamina/tile. Fallback a walk se stamina < 10. +- **Stamina**: ricarica 1 pt ogni `stamina_recharge_rate` (60..144 frame, scala col livello). ### 3.4 Fog of War (`render.c` + `enemy_logic.c`) -Distanza di **Chebyshev** `max(|dx|,|dy|)` invece di euclidea (no sqrt, no lookup): quadrato di visibilità 5×5. -- `dist > 2` → cella non disegnata (nera). -- `dist == 2` → offset grafico scuro ("Tile Dark") per simulare affievolimento della luce prima del buio. -- Il nemico si "sveglia" e insegue solo se `dist <= 2` (entra nel cono visivo): coerente con la nebbia. +Chebyshev `max(|dx|,|dy|)` con raggio `fog_radius`: +- L1-6: `fog_radius = 2` (5×5). +- L7-8: `fog_radius = 1` (3×3). +Celle a `dist == fog_radius` → tile scuro (penombra). Il nemico si attiva solo se entro `fog_radius`. -### 3.5 Autotiling & multi-pass rendering -- **Maschera a 4 bit** sui vicini (TL, TR, BL, BR) per scegliere varianti di bordo/angolo (16 varianti × 2 stili di pavimento alternati a scacchiera `is_alt = (lx+ly)%2==0`). -- **Problema overlapping isometrico**: il background GB non ha trasparenza hardware; i tile disegnati dopo "tagliano" quelli prima con angoli piatti. Soluzione ibrida: - 1. **Pass 1**: pavimenti normali con bitmasking dinamico degli angoli (ricolorano in base al vicino). - 2. **Pass 2**: la botola (oggetto complessa, 243+81+162 varianti con maschera a 4 vicini `state_tl + state_tr*3 + state_bl*9 + state_br*27`) disegnata **per ultima**, sovrascrive i pavimenti frontali ma si fonde grazie alle maschere dei propri angoli inferiori. -- Questo evita l'uso di sprite hardware per la botola (che causerebbero flickering per il limite di 10 sprite/scanline) e contiene il consumo di VRAM (limite 256 tile). +### 3.5 Auto-tiling & multi-pass +Bitmasking 4-vicini (16 varianti × 2 stili). Pass 1: pavimenti. Pass 2: botola ( Painter's algorithm). Flush **dinamico 16 righe** centrato su `center_iso_y` con wrap (2 chiamate `set_bkg_tiles` se necessario). Necessario per labirinti grandi dove la finestra fog wrappa fuori dal range fisso 2-17. -### 3.6 Ottimizzazione rendering -`memset` azzera `map_buffer` (32x32) ad ogni `draw_map`, poi disegna solo la finestra fog (2r+1)^2. Flush **dinamico a 16 righe** (512 byte) centrato sulla iso_y del centro di disegno, con gestione del wrap della mappa 32x32 via due `set_bkg_tiles` quando la finestra attraversa il bordo. Necessario perché con labirinti grandi la nebbia, in coordinate iso assolute, wrappa fuori dal vecchio range fisso 2-17; 16 righe mantengono le prestazioni originali. Aggiornamento a `move_progress==8` (metà passo) per fluidità del fog of war. +### 3.6 AI Fantasmi (`enemy_logic.c`) +- **Multi-entity**: fino a 8 fantasmi (`num_enemies = level`). Stato in array. OAM `2+i*2`. +- **Greedy**: minimizza `dx²+dy²` tra le 4 celle adiacenti. No A* (troppo pesante su 4 MHz con 8 nemici). +- **Cooldown scalabile**: `enemy_step_cooldown = 60 - 7*(level-1)` (floor 10). Sfasamento iniziale `i*8`. +- **Hitbox pixel-perfect**: `|dx|<12 && |dy|<6`. +- **Culling**: renderizzato solo se entro `fog_radius` e on-screen. -### 3.7 AI dei fantasmi (`enemy_logic.c`) — multi-entity -- **Fino a 8 fantasmi** (`num_enemies = level`, capped `MAX_ENEMIES=8`): stato in array indicizzati (`enemy_lx[i]`, `enemy_is_moving[i]`, ...). Ogni fantasma usa 2 slot OAM a partire da `2 + i*2` (il player usa 0-1). -- **Greedy invece di A***: per ciascun fantasma, tra le 4 celle adiacenti calpestabili sceglie quella che minimizza la distanza al quadrato verso il giocatore (no sqrt, no heap). Difetto voluto: si incastra nei vicoli a U. -- **Cooldown scalabile**: dopo ogni passo (16 frame LERP) il fantasma aspetta `enemy_step_cooldown` frame (60 al L1, ~11 al L8). Cooldown iniziali sfasati (i*8) per non sincronizzarli. -- **Attivazione**: insegue solo se entro il raggio di nebbia (Chebyshev ≤ `fog_radius`). -- **Hitbox pixel-perfect**: la morte scatta se i pixel di un qualunque fantasma si sovrappongono a quelli del giocatore (|dx|<12, |dy|<6); al primo catch il loop si ferma (`return`). -- **Culling**: `enemy_screen_x/y` con wrap `& 255` + soglie fisiche; fuori range o oltre la nebbia lo sprite va offscreen. +### 3.7 Audio (`sound.c`) +Sequencer via VBL interrupt, 4 canali APU: +- **Title**: 112 note, 3 canali (CH1 melodia + CH2 basso indipendente + CH4 rintocchi), ~56 sec, loop. Progressione Dm-Bb-F-C-A7-Gm-A7 con C# (7ª armonica) e discesa cromatica. Envelope haunting. +- **Gameplay**: 96 note eerie pulse, 20 frame/nota. +- **Gameover**: 128 note polifoniche + noise (thud/crash), 10 frame/nota. +- **Finale**: 192 note (24 accordi), lamento discendente Dm→abisso (C2), 14 frame/nota, **loop infinito**. CH1 melodia sommessa + CH2 basso profondo + CH4 toll (mid/crash/deep). +- **Going Deeper**: 96 note misteriose, 15 frame/nota. +- **SFX**: salto (sweep up CH1), cattura (sweep down CH1). -### 3.8 Audio (`sound.c`) -Sequencer agganciato al **VBL interrupt** (`add_VBL(play_music_tick)`), ~60 Hz, non blocca il rendering. Usa i 4 canali APU: -- **CH1** (pulse+sweep): arpeggi rapidi (gameover/next_level), tema title. -- **CH2** (pulse): bassi/melodie tenute (gameover ch2_seq, basso title/next_level un'ottava sotto con `n/2`). -- **CH4** (noise): percussioni — `step%8==0` trigger; freq `0x68` (thud basso) per `step<64`, `0x42` (crash) per `step>=64`. -- **CH3** (wave): non utilizzato. +### 3.8 HUD +- **Stamina** (alto dx): 5 sprite OAM 18-22. Base VRAM `tiles_TILE_COUNT` (≥128, workaround overlap). +- **Livello** (alto sx): 3 sprite OAM 23-25, asset `level.png` (glifi L, 0-9). Base VRAM allineata a indice **pari** (8x16 hardware ignora LSB). `-keep_duplicate_tiles` per ordine prevedibile. Nascosto durante game over. -Tracce: -- **Title**: `title_melody[32]`, 30 frame/nota, basso ogni 4 step, accordi Am–G–F–E. -- **Gameplay**: `eerie_reg_vals[96]`, 20 frame/nota, pattern A min–D min–E7 (ansia crescente). -- **Game Over**: `ch1_seq[128]` + `ch2_seq[128]`, 10 frame/nota, 16 accordi con intensificazione (arpeggi più alti nella seconda metà). -- **Going Deeper**: `next_level_seq[96]`, 15 frame/nota, 6 sezioni (Am, Fmaj7, Dm, E7, C aug, discesa finale nel buio `N_G2 N_E3 N_C3… N_C2 R…`). +### 3.9 Progressione livelli +- **8 livelli** con difficoltà crescente: + - Maze: 7×7 → 21×21 (+2/livello). + - Nemici: 1 → 8 (+1/livello). + - Cooldown fantasma: 60 → 11 frame. + - Ricarica stamina: 60 → 144 frame/pt. + - Nebbia: 5×5 → 3×3 (dal L7). +- **Sconfitta**: ricomincia dallo stesso livello (non azzera). +- **Livello 8 superato**: `game_over = 3` (finale tragico) invece di 2. -Frequenze delle note: costanti `N_*` precalcolate come `(2048 - 131072/freq)` → scritte direttamente in `NR13/NR23` (low) e `NR14/NR24` (high | 0x80 trigger). +### 3.10 Schermate +- **Title**: `title_bg.png` (160×144, 2-bit) + musica. +- **Death**: `claimed.png` (160×144, 2-bit) + metasprite "GAME OVER". +- **Going Deeper**: testo font IBM "GOING DEEPER / LEVEL N" (ex immagine, sostituita per risparmiare ROM). +- **Finale**: font IBM su sfondo nero (BGP 0x1B invertito), scritta diretta in `map_buffer` (tile = ASCII-32, no printf). --- ## 4. Workaround e fix storici (dalla git history) -La cronologia di 23 commit racconta un'intensa fase di caccia ai bug hardware-specifici del Game Boy. Riepilogo dei workaround più significativi: - | Commit | Problema | Workaround | |--------|----------|------------| -| `93deb35` | **VRAM shared memory overlap**: i tile della stamina UI (sprite) sovrascrivevano i tile del background perché caricati nello stesso blocco VRAM. | Caricare `stamina_tiles` **dopo** i background tile e a partire da `tiles_TILE_COUNT` (indici ≥128, fuori dal blocco condiviso). `base_tile = tiles_TILE_COUNT` in `update_stamina_display`. | -| `6d94992` | **2-frame lag tra tile**: il DAS non era sincronizzato col ciclo di movimento. | Sincronizzazione del timer DAS con la state machine del passo. | -| `7d85aec` | Labirinto "perfetto" intrappolava il giocatore. | Fase 2: riapertura casuale 15% dei muri → loop evasivi. | -| `b47455c` | Offset rendering nemico errato + collisione approssimata. | Calcolo `screen_x = ((px - scroll) & 255) + 24/+16` e hitbox pixel-perfect 12×6. | -| `c983631` | Palette sprite errata per il nemico su DMG + game over troppo brusco. | `OBP1_REG = 0x1B` (palette invertita per fantasma bianco) + `game_over_timer` di delay. | -| `e07e8c6` | Nemico invisibile fuori schermo a causa dello scroll. | Wrap `& 255` + culling con soglie fisiche. | -| `8d71a23` / `1b0e1a0` | Artefatti sprite sulle scale. | Primo tentativo sprite-based con `-spr8x16`, poi **revert** a tile-based (le scale tornano nel background). | -| `f14e264` | Scale come ostacolo bloccava il fantasma. | Ridisegno come **botola nel terreno**: il fantasma può camminarci sopra (tile 2 è calpestabile per l'AI: `maze[ly][lx] != 0`). | -| `cb19677` | Glitch immagine next_level + scroll non allineato. | Reset `SCX_REG=SCY_REG=0` prima di mostrare l'immagine a tutto schermo + miglioramento quantizzazione colore. | -| `d8fd746` | Quantizzazione dell'immagine perdeva i toni scuri. | Soglie fisse in `process_next_level.py`: `<40` nero, `40–128` grigio scuro (85), `128–200` grigio chiaro (170), `≥200` bianco. | -| `cc656d0` | Musica next_level poco udibile + fine brusco. | Envelope più alto (`NR12_REG=0xC5`) e discesa graduale fino a `N_C2 R…`. | - -**Pattern ricorrente di workaround GB**: ogni "feature" ha dovuto litigare con i limiti hardware — VRAM da 256 tile condivisa tra BG e sprite, assenza di trasparenza nel background, limite 10 sprite/scanline, CPU 4 MHz senza FPU, stack da 8 bit. La filosofia è **"ingannare l'hardware con la matematica"**: LERP a punto fisso, distanze al quadrato/Chebyshev, multi-pass con bitmasking, sequencer audio via interrupt. +| `93deb35` | VRAM overlap stamina/BG | Load stamina a `tiles_TILE_COUNT` (≥128). | +| `6d94992` | 2-frame lag DAS | Sync DAS con state machine passo. | +| `7d85aec` | Perfect maze intrappolava | Riapertura 15% muri → loop. | +| `b47455c` | Offset rendering nemico | Wrap `&255` + hitbox pixel-perfect 12×6. | +| `f14e264` | Scale bloccavano fantasma | Ridisegnate come botola (tile 2 calpestabile). | +| `cb19677` | Glitch next_level + scroll | Reset SCX/SCY + quantizzazione soglie fisse. | +| (livelli) | ROM >32KB con 3 immagini full-screen | Sostituito next_level image con testo font; claimed image.resize per fit. | +| (HUD livello) | Base VRAM dispari in 8x16 | Allineamento a pari `(raw+1) & 0xFE`. | +| (flush) | 32-row flush stallsava 5 frame | Flush dinamico 16-righe centrato + wrap. | +| (EVELYN) | Warning SDCC 110 | Easter-egg Frank Zappa, benigno. | --- -## 5. Test & verifica headless +## 5. Debiti tecnici e osservazioni -Pipeline di verifica senza emulatore grafico (PyBoy + OpenCV): -1. **`test_pyboy.py` / `take_screenshot.py`**: avvia la ROM in PyBoy per N frame e salva screenshot (`hello_iso_gb.png`). -2. **`test_movement.py`**: legge la griglia maze in WRAM (indirizzo cercato dinamicamente via `hello_iso.noi`) e simula pressioni direzionali, verificando `player_lx/ly`. Nota: lo script cerca `hello_iso.noi` ma il Makefile produce `test_gameover.noi` in `build/` — potenziale incongruenza di percorso (vedi §7). -3. **`opencv_analyze_tiles.py` / `opencv_check_centering.py`**: rilevano disallineamenti/buchi neri tra giunzioni isometriche e centratura. -4. **`test_gameover_render.c`** + target `test_gameover.gb`: ROM standalone che renderizza solo player + metasprite GAME OVER per validare visivamente la schermata di sconfitta isolata. +1. **`test_movement.py`**: cerca `hello_iso.noi` nella cwd ma il Makefile genera solo `test_gameover.noi` in `build/`. Script stale. +2. **Warning 110 SDCC** ("EVELYN the modified DOG"): benigno, Easter-egg di Sandeep Dutta (omaggio a Frank Zappa, canzone "Evelyn, a Modified Dog"). Indica che l'ottimizzatore ha rielaborato il flusso di controllo. +3. **`victory.c/h` orfani**: rimossi (sostituiti da `next_level`/finale). +4. **Flush 16-righe**: ~2-3 frame di stall per `draw_map` (preesistente, accettabile). draw_map chiamato solo ai passi del movimento. +5. **Sprite budget**: ~27 sprite su 40. Per-scanline (10) rispettato (HUD alto, player centro, nemici sparsi nel fog). --- -## 6. Punti di forza tecnici +## 6. Test & verifica -- **Modularizzazione pulita** con stato centralizzato: leggibile e manutenibile nonostante i vincoli embedded. -- **Commenti didattici** in italiano: ogni modulo spiega il *perché* delle scelte (es. "non usiamo sqrt perché troppo costosa"), utile come materiale di studio GB dev. -- **Niente float**: tutto a punto fisso con shift (`>> 4`, `>> 2`), essenziale sul LR35902. -- **Audio interamente procedurale**: nessun campione, solo registri APU; la ROM resta compatta (32 KB). -- **Pipeline asset riproducibile**: `make clean && make` rigenera tutto da PNG sorgenti. +Pipeline headless (PyBoy + OpenCV): +- `test_pyboy.py` / `take_screenshot.py`: screenshot della ROM. +- `test_movement.py`: legge maze in WRAM e simula input (indirizzi stale). +- `opencv_analyze_tiles.py`: rileva disallineamenti isometrici. +- `test_gameover.gb`: ROM standalone per schermata game over. +- `test_finale.gb`: ROM standalone che va subito al finale (musica + testo) per test rapidi. + +Verificato via PyBoy: dimensioni 7→21, nemici 1→8, cooldown 60→11, recharge 60→144, fog 2→1 (L7), hatch L1→game_over=2 / L8→game_over=3, finale renderizza + START→title→L1, death screen claimed.png, title music 3 canali attivi. --- -## 7. Incongruenze, debiti tecnici e osservazioni +## 7. Conclusione -1. **README obsoleto**: il `README.md` storico parlava di "gioco carino", titoli `file:///home/enne2/dev/gameboy-hello/iso_test/...` (percorso storico diverso da quello attuale `gameboy-hello-iso`) e non menzionava "A Scream from the Dark", né il salto/stamina/fantasma/botola. Risolto in questa revisione allineando il README all'identità attuale. -2. **`doc/generation.md` vs `maze.c`**: la doc descriveva il posizionamento del traguardo via **distanza di Manhattan massima**; il codice attuale sceglie invece un **punto casuale sul bordo sud**. Risolto in questa revisione aggiornando `doc/generation.md`. -3. **`victory.c/h` orfani**: l'asset `victory` (scritta VITTORIA) era ancora compilato e incluso da `render.c` ma la sequenza di vittoria è stata sostituita dall'immagine a tutto schermo `next_level` (commit `3aa20fd`). Risolto in questa revisione rimuovendo l'inclusione e i file morti. -4. **`test_movement.py`**: cerca `hello_iso.noi` nella cwd, ma il `.noi` viene generato in `build/` (per `test_gameover`); per la ROM principale il `.noi` non è tra i target Makefile espliciti. Lo script può fallire se eseguito fuori da `build/`. (Osservazione residua, non bloccante.) -5. **`game_over` come `volatile`**: marcato `volatile` in `globals.h` (corretto, modificato da ISR audio/VBL), ma alcuni aggiornamenti avvengono anche dal main loop non-atomic — in pratica sicuro perché la lettura/scrittura è a 8 bit sul LR35902 (atomica), ma vale la pena documentarlo. -6. **`app_state` ridefinito** in `globals.h` e `engine.h` (entrambi `extern uint8_t app_state`): duplicazione inoffensiva ma ridondante. -7. **Spawn player fallback**: ricerca la prima cella libera se `(1,1)` è muro (teoricamente impossibile col DFS partendo da 1,1) — difensivo, buon codice. -8. **`title_update` vuoto**: il loop del titolo non fa nulla a parte il polling (gestito in `main.c`). La musica continua via interrupt. Pulito ma il corpo vuoto andrebbe commentato o rimosso se non serve estensione futura. -9. **Costante `N_E2 = 458`** in `next_level_seq` mentre le altre note `N_*2` sono nell'ordine di 44–961: c'è un salto numerico marcato. `N_C2=44` è la nota più grave (registro APU = 44 → freq ~131 Hz, Do2). `N_E2=458` non segue la formula `2048-131072/f` in modo coerente con gli altri `N_*2` (es. `N_D2` non è definito): vale la pena verificare che la discesa finale `N_G2 N_E3 N_C3 … N_E2 N_C3 N_G2 … N_C2` suoni effettivamente come inteso. Sospetto di coerenza tonale da validare all'ascolto. - ---- - -## 8. Mappa mentale del flusso runtime - -``` -main.loop (VBL-synced) - ├─ app_state==0 → title_update (no-op) [musica: VBL interrupt → play_music_tick] - │ └─ J_START fronte → engine_init() - └─ app_state==1 → engine_update - ├─ if game_over: - │ ├─ game_over_timer>0 → countdown; a 0: svuota BG (sconfitta) o mostra next_level (vittoria) - │ ├─ timer==0 → metasprite GAME OVER + player + (enemy se vicino) - │ └─ J_START → engine_init() (riavvio) - ├─ update_enemy_logic() [AI greedy, cooldown, hitbox → game_over=1] - └─ update_player_movement [DAS, LERP, salto, stamina, vittoria → game_over=2] - └─ durante is_moving: scroll/move_bkg + draw_map a frame 8 + sprite -``` - ---- - -## 9. Conclusione - -"A Scream from the Dark" è un piccolo gioiello di ingegneria embedded: realizza proiezione isometrica, generazione procedurale, AI di inseguimento, audio polifonico e un'interfaccia stamina — tutto nei 32 KB e 8 KB WRAM di un Game Boy del 1989. Il valore didattico è alto: ogni modulo è un caso di studio su come **tradurre feature "moderne" in matematica a punto fisso e gestione diretta dei registri hardware**. Le incongruenze principali erano cosmetiche (README/doc da allineare all'identità attuale, asset `victory` orfano) e non intaccavano la correttezza; sono state risolte in questa revisione. Il codice è in ottima forma per essere esteso (più livelli con difficoltà crescente, nuovi nemici, oggetti raccoglibili) o usato come base per tutorial di sviluppo GB. \ No newline at end of file +"A Scream from the Dark" è un survival-horror completo per Game Boy: 8 livelli di difficoltà crescente, multi-nemico, corsa e salto con stamina, fog of war scalabile, audio polifonico procedurale (4 tracce + SFX), finale tragico con musica dedicata — tutto in 32 KB di ROM e ~3 KB di WRAM. Il codice è modulare, commentato in italiano, e rappresenta un caso di studio su come tradurre feature "moderne" in matematica a punto fisso e gestione diretta dei registri hardware del Game Boy. \ No newline at end of file diff --git a/doc/ai.md b/doc/ai.md index e94747f..db1c7ef 100644 --- a/doc/ai.md +++ b/doc/ai.md @@ -1,29 +1,61 @@ -# Intelligenza Artificiale (Il Fantasma) +# Intelligenza Artificiale (Fantasmi) -Il gioco utilizza un inseguitore, soprannominato "Il Fantasma", che bracca il giocatore all'interno del labirinto. Poiché il giocatore si muove velocemente, l'AI del fantasma richiede limiti artificiali per risultare un nemico superabile e bilanciato. +## Multi-Entity (fino a 8 fantasmi) -## Pathfinding: Perché "Greedy" e non A*? +Il gioco supporta fino a `MAX_ENEMIES = 8` fantasmi coesistenti. Il numero attivo è `num_enemies = level` (1 al livello 1, 8 al livello 8). Lo stato di ciascun fantasma è in **array indicizzati**: -Sui sistemi moderni o su griglie piccole, l'A-Star (A*) è l'algoritmo standard per il pathfinding dei nemici. Calcola il percorso perfetto evitando vicoli ciechi. -Sul Game Boy (CPU 4 MHz), gestire la memoria (heap, nodi) richiesta da A* per un array 7x7 genererebbe ritardi nei frame (frame drops) inammissibili per un gioco real-time. - -La soluzione implementata è un approccio **Greedy (Goloso)**: -Ad ogni passo, il fantasma verifica le 4 celle a lui adiacenti (Nord, Sud, Est, Ovest). Tra quelle calpestabili (non muri), sceglie quella la cui coordinata rende **minima la distanza spaziale al quadrato verso il giocatore** (`dx^2 + dy^2`). -Non usiamo la radice quadrata (necessaria per calcolare la distanza esatta euclidea) perché troppo costosa per la CPU; dal momento che stiamo solo comparando quale distanza sia minore, i quadrati bastano e avanzano matematicamente (`A < B <=> A^2 < B^2`). - -Un algoritmo Greedy tende a incastrarsi in ostacoli a forma di U, ma essendo questo un labirinto dinamico pieno di loop a corridoio singolo, questo "difetto" si trasforma in una dinamica di gioco: i giocatori astuti possono sfruttare il level design per confondere l'AI e aggirarla. - -## Attivazione e Cooldown - -Il fantasma non parte immediatamente alla carica. Usa la distanza di Chebyshev per "vedere": ```c -if (dist <= 2) { // Inizia a muoversi } +uint8_t enemy_lx[8], enemy_ly[8]; +uint8_t enemy_is_moving[8], enemy_move_progress[8]; +int8_t enemy_start_lx[8], enemy_start_ly[8]; +int8_t enemy_target_lx[8], enemy_target_ly[8]; +int16_t enemy_start_px[8], enemy_start_py[8]; +int16_t enemy_target_px[8], enemy_target_py[8]; +uint8_t enemy_cooldown[8]; ``` -Ovvero, inizia l'inseguimento solo quando entra nel riquadro visivo del giocatore (5x5, distanza <= 2). -Inoltre, il fantasma impiega 16 frame per muoversi da un blocco all'altro (come il player), ma è limitato dalla variabile `enemy_cooldown = 60`. Dopo ogni singolo passo, rimane congelato per 1 secondo intero prima di poterne fare un altro. Questo garantisce che il fantasma sia più lento del giocatore e ti costringe, qualora commettessi un errore, a usare rapidamente la meccanica di Salto Evasivo e seminarlo. +Ogni fantasma usa 2 slot OAM a partire da `2 + i*2` (il player usa 0-1). Tutti condividono la stessa tile data (ghost sprite), con palette invertita (OBP1 = 0x1B, bianco su scuro). + +## Pathfinding: Greedy invece di A* + +A* richiederebbe heap, nodi e calcoli pesanti — inammissibile su 4 MHz, soprattutto con 8 fantasmi. L'approccio **Greedy** è leggero: + +Per ciascun fantasma, tra le 4 celle adiacenti calpestabili, sceglie quella che **minimizza la distanza al quadrato** verso il giocatore (`dx² + dy²`). No sqrt (i quadrati bastano per il confronto `A < B ⇔ A² < B²`). + +**Difetto voluto**: il Greedy si incastra nei vicoli a U. In un labirinto con loop, questo diventa una dinamica di gioco — il giocatore astuto sfrutta il level design per seminare i fantasmi. + +## Attivazione e Cooldown Scalabile + +### Attivazione +Il fantasma insegue solo se entro il raggio di nebbia: +```c +if (dist <= fog_radius) { // inizia a inseguire } +``` +`fog_radius` = 2 (5×5) ai livelli 1-6, = 1 (3×3) ai livelli 7-8. + +### Cooldown +Dopo ogni passo (16 frame di LERP), il fantasma aspetta `enemy_step_cooldown` frame: +- Livello 1: 60 frame (1 secondo) — lento, gestibile. +- Livello 8: 11 frame — implacabile, quasi continuo. + +I cooldown iniziali sono **sfasati** (`enemy_step_cooldown + i*8`) così i fantasmi non si muovono in sincrono. ## Hitbox Pixel-Perfect -Nonostante la mappa sia calcolata su una pura griglia logica bidimensionale (1 passo = 1 casella X,Y), la condizione di "Sconfitta" non si basa sul fatto che il giocatore e il fantasma occupino la medesima casella logica contemporaneamente. Si controlla invece che le coordinate fisiche (i veri e propri **pixel** renderizzati sullo schermo del Game Boy) si sovrappongano con un margine di errore basso (12 pixel in orizzontale e 6 in verticale). -Questo rende la morte "giusta" agli occhi dell'utente e permette scambi per il rotto della cuffia! +La morte non si basa sulla coincidenza di cella logica, ma sulla **sovrapposizione dei pixel fisici** renderizzati: + +```c +if (|p_px - enemy_px| < 12 && |p_py - enemy_py| < 6) { + game_over = 1; // sconfitta +} +``` + +Questo rende la morte "giusta" agli occhi del giocatore e permette scambi per il rotto della cuffia. Al primo fantasma che catcha il player, il loop si ferma (`return`). + +## Culling + +I fantasmi sono renderizzati solo se: +1. Entro `fog_radius` dal giocatore (Chebyshev). +2. On-screen (con wrap `& 255` + soglie fisiche `-8..168` / `-8..152`). + +Altrimenti lo sprite è spostato offscreen `(0,0)`. \ No newline at end of file diff --git a/doc/architecture.md b/doc/architecture.md index ad9ff83..80a48db 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -1,25 +1,75 @@ # Architettura di Base -## Organizzazione del Progetto +## Overview -Il progetto è strutturato in modo da separare logicamente gli asset visivi dal codice sorgente: -- `assets/`: Contiene le immagini PNG (sprite, tileset) create dagli script. -- `scripts/`: Script Python per la generazione procedurale di grafica (es. metasprite del fantasma o del game over). -- `src/`: Tutto il codice sorgente C e gli header generati. -- `build/`: Risultato della compilazione (i file `.o` e il ROM `.gb` finale). +Il progetto è un gioco completo per Game Boy (DMG/CGB) con 8 livelli di difficoltà crescente, multi-nemico, audio polifonico e finale tragico. Tutto in 32 KB di ROM e 8 KB di WRAM. -## Modularizzazione del Codice C +## Organizzazione del progetto -Inizialmente, l'intero gioco risiedeva in un unico file `engine.c` di oltre 1000 righe. Per facilitare la manutenzione, è stato rifattorizzato in moduli a singola responsabilità: +``` +gameboy-hello-iso/ +├── assets/ # PNG sorgenti (sprite, tileset, schermate) +├── scripts/ # Script Python per generazione/quantizzazione asset +├── src/ # Codice C + asset generati da png2asset +├── doc/ # Documentazione tecnica +├── build/ # Output compilazione (ROM .gb) +├── Makefile # Build system (GBDK lcc + png2asset) +└── README.md +``` -- `maze.c`: Gestisce puramente i dati della mappa e la loro generazione. -- `sound.c`: Isola tutte le interazioni con l'hardware audio (APU). -- `render.c`: Isola l'hardware visivo (PPU), la memoria video (VRAM) e lo scrolling (SCX/SCY). -- `player_logic.c` ed `enemy_logic.c`: Contengono le regole fisiche e l'AI. -- `engine.c`: Agisce da "Direttore d'Orchestra", richiamando i vari `update` in sequenza. +## Modularizzazione del codice C -## Gestione dello Stato Globale (`globals.h`) +Il gioco nasceva da un monolite `engine.c` di 1000+ righe, poi rifattorizzato in moduli a singola responsabilità: -Un problema ricorrente nei giochi C in più moduli sono le "dipendenze circolari" (es. il render ha bisogno delle coordinate del player, ma il player ha bisogno della mappa per le collisioni). -Per risolvere questo, lo stato mutabile del gioco è centralizzato in `globals.c` ed esposto tramite `globals.h`. -Tutti i moduli includono `globals.h` e leggono/scrivono sulle variabili `extern` (come `stamina`, `game_over`, `player_lx`), evitando grovigli di include e rendendo il passaggio dei dati estremamente leggero e globale, un approccio molto comune ed efficiente nello sviluppo retro-console. +| File | Righe | Ruolo | +|------|-------|-------| +| `main.c` | ~30 | Entry point, loop VBL, macchina a stati `app_state` (0=title, 1=game). | +| `engine.c` | ~290 | "Direttore d'orchestra": `title_init/update`, `engine_init`, `engine_update`. Gestisce game over (sconfitta/vittoria/finale), schermate di transizione. | +| `globals.c/h` | ~80 | Stato globale centralizzato: mappa, camera, player, enemy (array), stamina, level, fog_radius, map_size. | +| `maze.c` | ~140 | Generazione procedurale DFS + loop + botola. | +| `player_logic.c` | ~200 | Input, DAS, state machine, camminata, corsa, salto, stamina. | +| `enemy_logic.c` | ~140 | AI greedy multi-entity, cooldown scalabile, rendering nemici, hitbox. | +| `render.c` | ~280 | Proiezione isometrica, fog of war scalabile, auto-tiling multi-pass, flush dinamico, stamina UI, level HUD, sprite player. | +| `sound.c` | ~430 | Sequencer audio via VBL: title (112 note), gameplay (96), gameover (128), finale (192, loop). | + +Asset generati da `png2asset`: `tiles.c`, `player.c`, `enemy.c`, `gameover.c`, `stamina.c`, `level.c`, `claimed.c`, `title_bg.c`. + +## Stato globale (`globals.h`) + +Tutte le variabili mutabili sono centralizzate in `globals.c` ed esposte via `extern` in `globals.h`. Questo evita dipendenze circolari tra moduli C (render ha bisogno di player, player ha bisogno di maze, ecc.). + +### Variabili chiave + +| Variabile | Tipo | Descrizione | +|-----------|------|-------------| +| `app_state` | uint8_t | 0 = title, 1 = gameplay | +| `game_over` | volatile uint8_t | 0 = playing, 1 = defeat, 2 = victory/Going Deeper, 3 = finale | +| `game_over_timer` | volatile uint8_t | Delay drammatico prima della schermata di fine | +| `map_size` | uint8_t | Dimensione corrente del labirinto (7..21, cresce col livello) | +| `maze[21][21]` | uint8_t | 0 = muro, 1 = pavimento, 2 = botola | +| `fog_radius` | uint8_t | Raggio visibilità Chebyshev (2 normalmente, 1 dal livello 7) | +| `level` | uint8_t | Livello corrente (1..8) | +| `num_enemies` | uint8_t | Numero fantasmi attivi (= livello, capped 8) | +| `enemy_step_cooldown` | uint8_t | Pausa tra passi del fantasma (60..11, scala col livello) | +| `stamina_recharge_rate` | uint8_t | Frame per +1 stamina (60..144, scala col livello) | +| `stamina` | uint8_t | 0..100, potenzia salto (60) e corsa (10/tile) | +| `enemy_lx[8]` ecc. | array | Stato multi-nemico (fino a 8 fantasmi) | + +## Pipeline asset (Python → C) + +1. **Script Python** (`scripts/generate_assets.py`, `generate_enemy.py`, `generate_level.py`) creano i PNG sorgenti da matrici di pixel, usando la palette GB a 4 colori. +2. **`png2asset`** (tool GBDK) converte ogni PNG in `src/*.c` (tile data + map + metasprite). +3. **`lcc`** (frontend SDCC) compila e linka tutti i `.c` nella ROM finale `build/hello_iso.gb`. + +Il `Makefile` orchestra questo processo: `make clean && make` rigenera tutto da zero. + +## Vincoli hardware + +| Risorsa | Limite | Utilizzo attuale | +|---------|--------|------------------| +| ROM | 32 KB | ~32 KB (pieno) | +| WRAM | 8 KB | ~3 KB (maze 441B + map_buffer 1KB + enemy arrays 88B + statici maze.c ~1KB + globals) | +| VRAM tile data | 384 tile (6 KB) | ~200 tile (BG + sprite condivisi) | +| OAM (sprite) | 40 sprite | ~27 (player 2 + enemies 16 + stamina 5 + HUD 3 + gameover 10) | +| Sprite/scanline | 10 | Rispettato (HUD in alto, player al centro, nemici sparsi) | +| CPU | 4.19 MHz | LERP a punto fisso, no float, no sqrt | \ No newline at end of file diff --git a/doc/audio.md b/doc/audio.md index 7d1f75a..a88ce86 100644 --- a/doc/audio.md +++ b/doc/audio.md @@ -1,34 +1,52 @@ # Sintesi Audio e Musica -Tutto l'audio del gioco è prodotto internamente manipolando a basso livello i 4 canali audio (APU) registrati direttamente in memoria hardware del Game Boy (da `NR10_REG` a `NR52_REG`). +## Hardware APU (4 canali) -L'architettura separa completamente la logica audio da `engine.c` nel file isolato `sound.c`. +Il Game Boy ha 4 canali audio, manipolati direttamente via registri hardware (`NR10_REG` .. `NR52_REG`): -## Hardware Audio Channels (APU) -Il Game Boy dispone di 4 canali dedicati: -1. **Canale 1 (Pulse 1)**: Onda quadra con sweep (glissando automatico del pitch). -2. **Canale 2 (Pulse 2)**: Onda quadra senza sweep. Ideale per la base musicale. -3. **Canale 3 (Wave)**: Onda personalizzabile definita in RAM (qui non utilizzata). -4. **Canale 4 (Noise)**: Generatore di rumore pseudo-casuale bianco, usato per batterie ed effetti. +| Canale | Tipo | Uso nel gioco | +|--------|------|---------------| +| CH1 | Pulse + sweep | Melodie (title, gameover, finale), salto, cattura | +| CH2 | Pulse | Bassi, melodie sostenute, gameplay eerie | +| CH3 | Wave | Non utilizzato | +| CH4 | Noise | Percussioni (toll, crash, thud), effetti | -## Il Sequencer via Interrupt VBL -L'intera colonna sonora viene eseguita senza bloccare mai l'esecuzione del codice di rendering o della CPU. -La funzione `play_music_tick` viene agganciata al **Vertical Blanking (VBL) Interrupt** (`add_VBL()`), che scatta magicamente tra il disegno di un frame video e il successivo sul display a circa 60 Hz. -Ogni chiamata incrementa un timer logico; quando il timer supera la durata della nota impostata, il sequencer aggiorna l'indice dell'array passandolo ai registri e suonando fisicamente il suono. +## Sequencer via VBL Interrupt -## Illusioni Audio (Arpeggi e Polifonia Fittizia) -Il chip GBDK può suonare solo un singolo tono per canale alla volta. -Per la "Fanfara di Vittoria" e i momenti di forte tensione, per imitare accordi (che richiederebbero 3 canali), usiamo gli **Arpeggi Veloci**. -Una melodia arpeggiata è un elenco di tre note (Es: Do-Mi-Sol) suonate in un loop talmente veloce che l'orecchio umano le fonde percependo un accordo polifonico. Nel codice, le note `N_C4`, `N_E4`, `N_G4` si susseguono ogni pochi frame (timer = 8 frame/tick). +`play_music_tick` è agganciato al **Vertical Blanking Interrupt** (`add_VBL()`), ~60 Hz. Non blocca mai il rendering. Ad ogni VBL incrementa un timer; quando supera il periodo della nota, suona la nota corrente dall'array e avanza l'indice. -## Il Rumore come Percussione -Durante la triste sequenza in tonalità minore del "Game Over", il Canale 4 del Rumore viene attivato sincopatamente: -```c -if (step % 8 == 0) { - NR41_REG = 0x01; - NR42_REG = 0xB2; // Decadimento rapidissimo per dare un colpo percussivo - NR43_REG = (step < 64) ? 0x68 : 0x42; // Varia la frequenza: Thud basso vs Piatto (Crash) acuto - NR44_REG = 0x80; -} -``` -Questo simula il battito di un tamburo e i piatti, donando drammaticità alla scena. +### Note frequencies +Le costanti `N_*` sono precalcolate come `(2048 - 131072/freq)` e scritte in `NR13/NR23` (low byte) e `NR14/NR24` (high byte | 0x80 trigger). Range: N_C2 (44, ~131 Hz) a N_A5 (1899, ~880 Hz). + +## Tracce + +### Title Theme (112 note, 3 canali, ~56 sec, loop) +Brano complesso e ricco di pathos. 112 note su 3 canali: +- **CH1 melodia**: envelope sostenuto (NR12=0x87, fade up lungo = suono haunting). Progressione Dm → Bb → F → C → A7(con C#, tensione armonica) → Dm(climb al C5) → Gm → A7 → discesa cromatica nell'abisso. +- **CH2 basso**: linea indipendente (`title_bass[112]`) che segue gli accordi con movimento. Envelope deep plucked (NR22=0xA3). +- **CH4 noise**: rintocchi sparsi ad ogni cambio d'accordo (ogni 8 step, NR43=0x70 = toll profondo) per atmosfera desolata. + +Struttura: intro sparso → lamento → tensione/climax → crollo cromatico e discesa. Ispirazione: Castlevania (arpeggi gotici), Metroid II (desolazione), Link's Awakening (melancolia onirica). + +### Gameplay Theme (96 note, CH2, ~32 sec, loop) +Battito ritmico ansioso ("eerie pulse") in La minore → Re minore → Mi7. 20 frame/nota. Pattern ripetuto che accelera la tensione dell'inseguimento. + +### Game Over (128 note, CH1+CH2+CH4, ~21 sec) +Concerto tragico polifonico. 16 accordi con intensificazione (arpeggi più alti nella seconda metà). Noise percussion: thud basso (NR43=0x68) per la prima metà, crash acuto (NR43=0x42) per la seconda. 10 frame/nota. + +### Finale (192 note, CH1+CH2+CH4, ~45 sec, loop) +Brano dedicato per il finale tragico (livello 8). Lamento discendente in Re minore: +- **CH1**: melodia sommessa (NR12=0xA2, fade lungo). +- **CH2**: basso profondo (NR22=0xD1, fade lentissimo). +- **CH4**: rintocco medio (0x68) di default, crash (0x42) al climax (step 80/88/112), tonfo profondo (0x70) nell'abisso (step ≥160). + +Struttura: lamento Dm-C-Bb-A7 → intensificazione con arpeggi alti (lo "scream") → crollo con basso che scende fino al Do più grave (N_C2) → silenzio. 14 frame/nota (sommesso). Loop infinito. + +### Going Deeper (96 note, CH1+CH2, ~36 sec) +Melodia misteriosa discendente (Am → Fmaj7 → Dm → E7 → C aug → abisso). 15 frame/nota. Usata per la schermata di transizione tra livelli. + +## Sound Effects + +- **Salto**: pitch sweep up sul CH1 (NR10=0x15, sweep up). +- **Cattura (game over)**: slide down profondo sul CH1 (NR10=0x1E, sweep down, envelope rapido). +- **Beat drammatico iniziale (gameover/finale)**: NR21-NR24 triggerato al momento del game over. \ No newline at end of file diff --git a/doc/gameplay.md b/doc/gameplay.md index 48536f0..83b7776 100644 --- a/doc/gameplay.md +++ b/doc/gameplay.md @@ -1,56 +1,92 @@ # Fisica e Controlli del Giocatore -Il movimento nel gioco è rigidamente vincolato alla griglia (Grid-Based), come nei vecchi RPG (Zelda, Pokémon), per semplificare le collisioni e la leggibilità degli ostacoli. +## State Machine (Grid-Based) -## State Machine (Blocco Input) +Il movimento è rigidamente vincolato alla griglia (stile Zelda/Pokémon). Durante `is_moving` (interpolazione LERP su 16 frame, o 8 in corsa) ogni input è ignorato. Questo garantisce movimento perfettamente allineato, senza scivolamenti diagonali. -In `player_logic.c`, l'interpolazione fluida del movimento (il passaggio da una casella all'altra) richiede 16 frame. -Durante questo lasso di tempo, la variabile `is_moving` è `true`. Qualsiasi input direzionale proveniente dai tasti (D-Pad) o dal tasto A viene totalmente ignorato finché il movimento sub-tile non si azzera (`move_progress == 16`). Questo impedisce movimenti diagonali non voluti o "scivolamenti" fuori griglia. +## LERP a Punto Fisso + +```c +px = start_px + ((target_px - start_px) * move_progress) >> 4; // >>4 = /16 +``` + +Tutto a punto fisso (no float, no divisione hardware). In corsa `move_progress += 2` → il passo dura 8 frame invece di 16, ma la formula LERP `>>4` resta invariata (raggiunge il target in metà tempo). ## Delayed Auto-Shift (DAS) -Leggere banalmente il D-Pad a 60 FPS renderebbe il personaggio incontrollabile (si muoverebbe di 4 caselle con una pressione leggermente prolungata). Leggere solo la "singola pressione" (keys_pressed) obbligherebbe a martellare il tasto per avanzare. +Come in Tetris: il primo tocco muove subito; tenendo premuto, l'input è ignorato per `DAS_DELAY = 12` frame, poi si ripete ogni `DAS_REPEAT = 6` frame (camminata) o `DAS_REPEAT_RUN = 2` (corsa, per incatenare i tile fluidamente). -Abbiamo implementato un DAS, una tecnica tipica del Tetris: -- Al primo tocco, il giocatore si muove. -- Se si continua a tenere premuto, l'input viene ignorato per `DAS_DELAY` frames. -- Superato il delay, l'input si ripete in automatico ogni `DAS_REPEAT` frames. -Questo rende la navigazione dei corridoi estremamente fluida e confortevole. +`keys_pressed = keys & ~prev_keys` rileva il fronte di salita (pressione esatta). -## Il Salto e il Costo in Stamina +## Camminata -Tenendo premuto il tasto `A` assieme a una direzione direzionale, il giocatore scavalca il blocco adiacente atterrando due tile più in là. -Condizioni per il salto: -1. La casella di destinazione (`X+2`, `Y+2`) deve essere libera. -2. La casella intermedia saltata (`X+1`, `Y+1`) DEVE essere un muro (non si può saltare a vuoto sui corridoi). -3. Il giocatore deve avere almeno 60 punti Stamina. +Direzione → `move_lx/move_ly` (±1 su un asse). Validazione: `maze[new_ly][new_lx] == 1 || == 2` (pavimento o botola). Se valida, `is_moving = 1`, `move_progress = 0`, start/target impostati. -La stamina si ricarica di 1 punto ogni secondo. Eseguire un salto costa 60 punti, disabilitando ulteriori balzi per un minuto intero. Questo lo rende una manovra evasiva salva-vita estrema e non uno strumento da spam abusivo. -Visivamente, l'altezza del salto non altera la logica (sempre 2D Grid), ma solo il rendering in `update_player_sprite()`, in cui sottraiamo al posizionamento verticale la formula parabolica `x * (16 - x) / 4`. +## Corsa (B + direzione) -## La Corsa e il Costo in Stamina +Tenendo **B** + direzione con stamina ≥ 10: +- Il passo dura **8 frame** invece di 16 (`is_running = 1`, `move_progress += 2`). +- Costo: **10 stamina per tile**. +- DAS più rapido (`DAS_REPEAT_RUN = 2`) per incatenare i tile fluidamente. +- Se stamina < 10: ripiega silenziosamente su camminata normale (0 costo). +- La corsa si riattiva da sola quando la stamina torna ≥ 10. -Tenendo premuto il tasto `B` assieme a una direzione direzionale, il giocatore si mette a correre: -la transizione tra tile dura **8 frame** invece di 16 (l'incremento di `move_progress` raddoppia, mentre la formula LERP a punto fisso `>> 4` resta invariata e raggiunge il target in metà tempo). Ogni tile corso consuma **10 punti Stamina**. -Il DAS diventa più rapido (`DAS_REPEAT_RUN = 2`) così da incatenare i tile fluidamente quando si tiene premuto B+direzione. -Se la Stamina scende sotto 10, B+direzione **ripiega silenziosamente su camminata normale** (16 frame, 0 costo); la corsa si riattiva da sola appena la stamina torna ≥ 10. La ricarica resta di 1 punto al secondo, quindi la corsa è uno strumento a scatto: da pieno (100) si possono percorrere fino a 10 tile, dopodiché serve tempo per ricaricarla. +La stamina si ricarica di 1 punto ogni `stamina_recharge_rate` frame (60 al livello 1, 144 al livello 8 — più lenta ai livelli alti). -## Progressione dei Livelli +## Salto Evasivo (A + direzione) -Il gioco parte dal **livello 1**. Raggiungere la botola (casella traguardo) significa "sprofondare più giù" (*Going Deeper*): alla pressione di START il livello viene **incrementato** e viene generato un nuovo labirinto. In caso di sconfitta (cattura da parte del fantasma) si **ricomincia dallo stesso livello raggiunto**: il contatore non si azzera, solo il passaggio per il titolo (nuova partita) riparte da 1. +A+direzione → atterraggio **2 tile** più in là. Condizioni: +1. La cella intermedia (+1) **DEVE** essere un muro (`maze == 0`). +2. La cella di arrivo (+2) deve essere pavimento o botola. +3. Stamina ≥ 60. -## Dimensione crescente del labirinto e difficoltà progressiva +Costo: 60 stamina. Arco parabolico solo visivo: `y_offset = (move_progress * (16 - move_progress)) >> 2` (apice 16px a frame 8). La logica resta 2D grid-based. -Ad ogni livello la **dimensione del labirinto cresce di 2 tile per lato**: 7x7 (livello 1) fino a **21x21** (livello 8). La dimensione corrente è la variabile globale `map_size` (l'array `maze` è allocato con bound `MAX_MAP_SIZE` = 21). Il DFS genera un perfect maze su `map_size`x`map_size` (le celle dispari sono le stanze, per questo `map_size` è sempre dispari), poi lo rompe con i loop al 15% e posiziona la botola a distanza di Chebyshev ≥ `map_size/2` dalla partenza. +## Stamina -Oltre alla dimensione, ci sono altri **assi di difficoltà** che scalano col livello: -- **Numero di fantasmi**: `num_enemies = level` (capped a `MAX_ENEMIES` = 8). Ogni fantasma ha stato indipendente (array) e AI greedy propria; cooldown iniziali sfasati per non sincronizzarli. -- **Fantasma più veloce**: `enemy_step_cooldown = 60 - 7*(level-1)` (floor 10) — pausa più breve tra i passi ai livelli alti. -- **Stamina più lenta**: `stamina_recharge_rate = 60 + 12*(level-1)` — la barra si ricarica più lentamente. -- **Nebbia più stretta**: `fog_radius = 1` (3x3) dal livello 7 (invece di 2 / 5x5). +- 100 punti massimi. +- Ricarica: 1 punto ogni `stamina_recharge_rate` frame (60..144, scala col livello). +- Salto: costa 60 (disabilita ulteriori balzi per ~1 min). +- Corsa: costa 10/tile (da pieno, ~10 tile di corsa). +- Barra UI: 5 sprite in alto a destra, conversione `stamina*40/100` → pixel. -**Il gioco finisce dopo il livello 8 con un finale tragico**: superare la botola al livello 8 setta `game_over = 3` (finale) invece di 2 (Going Deeper). Non e' una fuga: la schermata finale (sfondo nero, BGP invertito, testo chiaro col font IBM ricaricato) recita "YOUR TORCH HAS / RUN OUT, / YOU ARE TRAPPED. / JUST ANOTHER SCREAM / FROM THE DARK. / GAME OVER"; START torna al titolo (nuova partita dal livello 1). **Musica dedicata**: un brano originale piu' ricco e coerente (192 step, 24 accordi) — un lamento discendente in Re minore (Dm–C–Bb–A7) che si intensifica con arpeggi alti (lo "scream"), poi cola nell'abisso col basso che scende fino al Do piu' grave e sfuma nel silenzio. Tre canali: CH1 melodia, CH2 basso, CH4 rintocco (toll medio, crash al climax, tonfo profondo nell'abisso). Tempo sommesso (14 frame/nota). +## Progressione Livelli (8 livelli + finale) -Poiché con labirinti grandi la finestra fog-of-war, proiettata in coordinate isometriche assolute, può cadere fuori dal vecchio range di righe flussato (2-17) a causa del wrapping della mappa 32x32, `draw_map` ora usa un flush **dinamico a 16 righe** centrato sulla iso_y del centro di disegno (con gestione del wrap via due `set_bkg_tiles`). Questo mantiene le prestazioni del progetto originale (16 righe = 512 byte) coprendo la nebbia in qualunque posizione. +### Transizioni +- **Titolo → gioco**: `level = 1`, `engine_init()` genera il primo labirinto. +- **Vittoria (botola) + START**: `level++`, `engine_init()` genera il livello successivo. +- **Sconfitta + START**: si ricomincia dallo **stesso livello** raggiunto (non si azzera). +- **Finale (livello 8 superato)**: `game_over = 3` invece di 2. -L'indicatore `L` è mostrato in **alto a sinistra** tramite tre sprite hardware (OAM ID 23–25), disegnati dall'asset `level.png` (11 glifi 8x16: 'L', '0'–'9'). Le decine vengono mostrate solo dal livello 10 in poi (sotto, lo sprite decine è spostato fuori schermo). Come la barra stamina (in alto a destra), l'indicatore è a coordinate-schermo fisse e indipendente dallo scroll isometrico, e viene nascosto durante il game over e sul titolo. La base VRAM dei glifi è allineata a un indice **pari** perché in modalità sprite 8x16 l'hardware ignora il bit meno significativo dell'indice tile. +### Indicatore HUD +`L` in alto a sinistra via 3 sprite (OAM 23-25) dall'asset `level.png` (glifi L, 0-9). Aggiornato ogni frame in `engine_update`; nascosto durante game over. + +### Difficoltà scalabile (vedi anche `generation.md` e `ai.md`) +| Assi | Livello 1 | Livello 8 | +|------|-----------|-----------| +| Dimensione labirinto | 7×7 | 21×21 | +| Numero fantasmi | 1 | 8 | +| Cooldown fantasma | 60 frame | 11 frame | +| Ricarica stamina | 60 frame/pt | 144 frame/pt | +| Nebbia | 5×5 | 3×3 (dal L7) | + +## Schermate di Fine Gioco + +### Sconfitta (`game_over = 1`) +- 45 frame di "fermo immagine" drammatico. +- Poi: schermata `claimed.png` a tutto schermo + metasprite "GAME OVER". +- Musica: concerto tragico polifonico (128 note, noise percussion). +- START → ricomincia dallo stesso livello. + +### Going Deeper (`game_over = 2`, livelli 1-7) +- 30 frame di dissolvenza. +- Schermata testuale col font IBM: "GOING DEEPER / LEVEL N". +- Musica: melodia misteriosa discendente (96 step). +- START → livello successivo. + +### Finale tragico (`game_over = 3`, livello 8) +- 30 frame di dissolvenza. +- Sfondo nero (BGP invertito 0x1B), font IBM ricaricato. +- Testo: "YOUR TORCH HAS / RUN OUT, / YOU ARE TRAPPED. / JUST ANOTHER SCREAM / FROM THE DARK. / GAME OVER". +- Musica dedicata: 192 step (24 accordi), lamento discendente in Re minore che cola nell'abisso, in loop. +- START → torna al titolo (nuova partita dal livello 1). \ No newline at end of file diff --git a/doc/generation.md b/doc/generation.md index bd5c9cd..79cdad2 100644 --- a/doc/generation.md +++ b/doc/generation.md @@ -1,26 +1,52 @@ # Generazione Procedurale del Livello -## Algoritmo Depth-First Search (DFS) +## Algoritmo DFS con Backtracking -Per garantire che ogni sessione di gioco sia unica, il labirinto viene generato proceduralmente ad ogni avvio (usando `DIV_REG` hardware del Game Boy come seed pseudo-casuale). La **dimensione del labirinto cresce col livello**: la variabile globale `map_size` parte da `MAP_SIZE` (7) al livello 1 e aumenta di 2 per livello fino al cap `MAX_MAP_SIZE` (17) — quindi 7x7, 9x9, 11x11, 13x13, 15x15, 17x17. `map_size` è sempre dispari perchè il DFS usa le celle dispari come stanze e le pari come muri divisori. +Il labirinto è generato proceduralmente ad ogni avvio (seed da `DIV_REG`). L'algoritmo è un **Depth-First Search (DFS) iterativo** con backtracking che crea un "perfect maze" (ogni cella raggiungibile, nessun ciclo): -L'algoritmo utilizzato in `maze.c` è un **Depth-First Search (DFS)** con Backtracking, modellato per creare un "Perfect Maze" (labirinto perfetto in cui ogni cella è raggiungibile tramite un unico percorso possibile e senza cicli). -La mappa è concettualmente divisa in: -- Celle dispari (es. `1,1`, `1,3`): "Stanze" -- Celle pari: "Muri Divisori" +1. La griglia `map_size × map_size` è divisa in **celle dispari = stanze** (1,1 / 1,3 / 3,1 ...) e **celle pari = muri divisori**. +2. Il DFS parte da `(1,1)`, sceglie un vicino dispari non visitato a distanza 2, abbatte il muro divisorio (media aritmetica delle coordinate), avanza. +3. Se nessun vicino disponibile, torna indietro (backtracking) usando uno stack. +4. Quando lo stack è vuoto, il labirinto è completo. -L'algoritmo parte da `1,1`, sceglie un vicino dispari casuale non visitato, abbatte il muro divisorio pari nel mezzo e avanza, salvando le posizioni in uno stack per poter tornare indietro (backtracking) quando finisce in un vicolo cieco. +### Stack in WRAM (non sullo stack hardware) +Gli array di backtracking (`stack_x`, `stack_y`, `valid_x`, `valid_y`) sono **statici in WRAM** (non sullo stack hardware del LR35902), sized per `MAX_MAP_SIZE = 21`: +- `MAX_ROOMS = (21/2)² = 100` (massimo numero di stanze per 21×21) +- `MAX_CELLS = 21² = 441` (massimo numero di celle candidate) -## Rottura del Perfect Maze (Loop Generation) +Questo evita l'overflow dello stack hardware del Game Boy (che è piccolo). -Un "Perfect Maze" è frustrante in un gioco di inseguimento, perché intrappola il giocatore nei vicoli ciechi senza via di scampo. -Pertanto, è stata aggiunta una *Fase 2* alla generazione: l'algoritmo scansiona i muri rimanenti e, se un muro divide due stanze adiacenti, lo abbatte con una probabilità del 15%. -Questo genera anelli (loop) all'interno del labirinto, permettendo al giocatore tattiche evasive per aggirare il fantasma. +## Dimensioni Crescenti col Livello -## Posizionamento del Traguardo (La Botola) +La dimensione del labirinto cresce di 2 tile per lato ad ogni livello: -La casella traguardo è una **botola** nel terreno (ID 2) che il giocatore deve raggiungere per "sprofondare più giù" (*Going Deeper*) e avanzare di livello. -La botola viene piazzata su una qualunque cella calpestabile a **sufficiente distanza** dalla casella di partenza del giocatore `(1,1)`. Si raccolgono tutte le celle `maze[y][x] == 1` la cui distanza di Chebyshev da `(1,1)` sia `>= MIN_GOAL_DIST` (3) e se ne sceglie una a caso. In questo modo il traguardo è sempre lontano dall'inizio ma può trovarsi su una qualunque tile del labirinto, non più vincolato al bordo sud. -Se, per un caso limite, nessuna cella fosse a sufficienza distante (teoricamente impossibile in un perfect maze 7x7 con partenza 1,1), esiste un **fallback estremo** che posiziona la botola nella cella calpestabile più lontana in assoluto da `(1,1)`. +| Livello | map_size | Stanze | Tile data | +|---------|----------|--------|-----------| +| 1 | 7×7 | 9 | 49 B | +| 2 | 9×9 | 16 | 81 B | +| 3 | 11×11 | 25 | 121 B | +| 4 | 13×13 | 36 | 169 B | +| 5 | 15×15 | 49 | 225 B | +| 6 | 17×17 | 64 | 289 B | +| 7 | 19×19 | 81 | 361 B | +| 8 | 21×21 | 100 | 441 B | -La distanza di Chebyshev è coerente col resto dell'engine (fog of war, attivazione del nemico). La botola è disegnata con un oggetto complesso a maschera 4-vicini nel Pass 2 del renderer (vedi `graphics.md`). +`map_size = MAP_SIZE + 2*(level-1)`, capped a `MAX_MAP_SIZE = 21`. Sempre dispari (per il pattern stanza/muro). L'array `maze` è allocato `[21][21]` (441 byte) e i moduli usano `map_size` come bound runtime. + +## Rottura del Perfect Maze (Loop) + +Un perfect maze frustra un gioco d'inseguimento (vicoli ciechi senza scampo). La **Fase 2** riapre casualmente il 15% dei muri che collegano due corridoi opposti, generando anelli (loop) che permettono al giocatore di aggirare il fantasma. + +## Posizionamento della Botola + +La botola (tile ID 2) è il traguardo. Viene piazzata su una cella calpestabile a **sufficiente distanza** dalla partenza `(1,1)`: + +- Soglia: `min_goal = map_size / 2` (3 per 7×7, 10 per 21×21) — scala con la dimensione. +- Si raccolgono tutte le celle con `maze[y][x] == 1` e `chebyshev((1,1), (x,y)) >= min_goal`, se ne sceglie una a caso. +- Fallback: la cella calpestabile più lontana in assoluto da `(1,1)`. + +La botola è disegnata con un oggetto complesso a maschera 4-vicini nel Pass 2 del renderer (vedi `graphics.md`). + +## Spawn del Nemico + +I fantasmi (`num_enemies = level`, capped 8) sono piazzati su celle calpestabili lontane ≥ `map_size/2` dal giocatore e ≥ 2 celle dagli altri fantasmi già piazzati. I cooldown iniziali sono sfasati (`enemy_step_cooldown + i*8`) per non sincronizzarli. \ No newline at end of file diff --git a/doc/graphics.md b/doc/graphics.md index 8a2a07f..2a5c4a6 100644 --- a/doc/graphics.md +++ b/doc/graphics.md @@ -2,40 +2,77 @@ ## Proiezione Isometrica -Il Game Boy possiede un hardware progettato per griglie 2D piatte (scrolling orizzontale/verticale). Per ottenere un effetto isometrico 3D, trasformiamo matematicamente le coordinate logiche della griglia `(X, Y)` in pixel schermo `(iso_x, iso_y)`. +Il Game Boy ha hardware per griglie 2D piatte. Per ottenere l'isometrica 2.5D, trasformiamo le coordinate logiche `(lx, ly)` in coordinate schermo: -La formula utilizzata in `render.c` è: ```c -int8_t iso_x = (lx - ly) * 2 + 12; -int8_t iso_y = (lx + ly) * 1 + 2; -``` -Questa trasformazione ruota la mappa di 45 gradi e la appiattisce sull'asse Y (rapporto 2:1, tipico dell'isometrico pixel-art). +// Coordinate tile (background map) +iso_x = (lx - ly) * 2 + 12; // tile 32x16 -> metà larghezza +iso_y = (lx + ly) * 1 + 2; // tile altezza 8px -## Ottimizzazione della Mappa - -Aggiornare l'intera background map hardware del Game Boy (32x32 tiles, 1024 bytes) ad ogni "passo" del giocatore causerebbe gravissimi cali di framerate (lag) e sfarfallii visivi. -La soluzione implementata prevede di aggiornare *solo* le 16 righe visibili sullo schermo (160x144 pixel = 20x18 tiles) durante la funzione `draw_map`: -```c -set_bkg_tiles(0, 2, 32, 16, &map_buffer[2 * 32]); +// Coordinate pixel (per camera/collisione) +px = (lx - ly) * 16 + 96; +py = (lx + ly) * 8 + 16; ``` -## Fog of War e Distanza di Chebyshev +Camera centrata: `scroll_x = px - 64`, `scroll_y = py - 72`. Il player sprite è fisso al centro schermo (OAM 88,88); è il mondo a scorrere. + +## Fog of War Scalabile + +La visibilità usa la **distanza di Chebyshev** `max(|dx|, |dy|)` (no sqrt, no lookup table). Il raggio è la variabile globale `fog_radius`: -Per aumentare la tensione e oscurare il labirinto, il gioco disegna solo i pavimenti vicini al giocatore. Invece di calcolare un cerchio reale (distanza euclidea) che richiederebbe una CPU avida di risorse matematiche (radici quadrate o lookup tables), usiamo la **Distanza di Chebyshev**: ```c -int8_t dist = max(abs(dx), abs(dy)); -if (dist > 2) continue; // Nascondi +if (dist > fog_radius) continue; // nascondi +if (dist == fog_radius) -> tile scuro // anello di penombra +if (dist < fog_radius) -> tile normale // zona illuminata ``` -Questo crea un quadrato di visibilità (5x5 celle) centrato sul giocatore, perfetto per le dinamiche a griglia. -## Auto-Tiling e Illuminazione +- **Livelli 1-6**: `fog_radius = 2` → finestra 5×5. +- **Livelli 7-8**: `fog_radius = 1` → finestra 3×3 (nebbia più stretta, più difficoltà). -Il modulo di rendering controlla i vicini logici (Nord, Sud, Est, Ovest) di ogni casella per applicare la grafica giusta (es. muri arrotondati e non tagliati). Questo avviene tramite una maschera di bit (`mask`). -Inoltre, le celle a distanza Chebyshev pari a 2 (i bordi della visibilità) subiscono un cambio di offset grafico (`v = 32 + mask` o `v = 48 + mask`), prelevando dal tileset varianti più scure ("Tile Dark"). Questo simula l'affievolirsi della luce prima dell'oscurità totale. +Il nemico si attiva e viene renderizzato solo se entro `fog_radius`, coerente con la nebbia. -## Gestione Overlapping Isometrico (Multi-Pass Rendering) +## Auto-Tiling e Multi-Pass Rendering -Nei giochi isometrici basati su tile, la proiezione genera sovrapposizioni (overlapping) tra i macro-blocchi. Dato che il background del Game Boy non supporta vere trasparenze hardware (un tile sovrascrive interamente quello sottostante), i tile disegnati per ultimi "tagliano" quelli precedenti con i loro angoli piatti. -Per preservare la complessa grafica 3D del traguardo (le scale) ed evitare l'esaurimento della ristrettissima VRAM (limite di 256 tile) tipico degli scenari pre-renderizzati combinatori, l'engine utilizza una strategia ibrida: -1. **Bitmasking:** I pavimenti piatti ricolorano dinamicamente i propri angoli trasparenti in base al colore dei vicini, creando l'illusione di un ritaglio perfetto. -2. **Painter's Algorithm (Pass Multiplo):** I pavimenti vengono renderizzati nel Pass 1, mentre gli oggetti complessi (come la casella di Vittoria) vengono disegnati rigorosamente in un Pass 2 successivo. Disegnandola per ultima, la scala sovrascrive i pavimenti frontali ma, grazie al Bitmasking dei propri angoli inferiori, si fonde con essi in modo invisibile. Ciò preserva interamente il disegno 3D senza richiedere sprite hardware addizionali (i quali causerebbero "flickering" per il rigido limite hardware di 10 sprite per scanline orizzontale). +### Bitmasking +Ogni pavimento calcola una maschera sui vicini (TL, TR, BL, BR) per selezionare la variante grafica corretta (bordi arrotondati, angoli). 16 varianti × 2 stili alternati a scacchiera (`is_alt = (lx+ly)%2`). Le celle a `dist == fog_radius` usano varianti più scure ("Tile Dark") per simulare l'affievolimento della luce. + +### Painter's Algorithm (2 passate) +Il background del Game Boy non ha trasparenze hardware. Per gestire l'overlapping isometrico: +1. **Pass 1**: pavimenti normali (bitmasking dinamico degli angoli). +2. **Pass 2**: la botola (oggetto complesso con maschera a 4 vicini, 243+81+162 varianti) disegnata per ultima, sovrascrive i pavimenti frontali ma si fonde grazie alle maschere dei propri angoli inferiori. + +Questo preserva la grafica 3D della botola senza usare sprite hardware (evitando flickering per il limite di 10 sprite/scanline). + +## Flush Dinamico a 16 Righe + +`draw_map` azzera `map_buffer` (32×32), disegna solo la finestra fog, poi trasferisce al background hardware. Il flush è **dinamico a 16 righe** centrato sulla `iso_y` del centro di disegno, con gestione del wrap della mappa 32×32: + +```c +int16_t center_iso_y = center_x + center_y + 2; +uint8_t start = (center_iso_y - 8) & 31; +if (start + 16 <= 32) { + set_bkg_tiles(0, start, 32, 16, &map_buffer[start * 32]); +} else { + // wrap: due chiamate + set_bkg_tiles(0, start, 32, 32 - start, &map_buffer[start * 32]); + set_bkg_tiles(0, 0, 32, 16 - (32 - start), map_buffer); +} +``` + +16 righe (512 byte) mantengono le prestazioni originali. Il flush è centrato dinamicamente perché con labirinti grandi (fino a 21×21) la finestra fog, in coordinate iso assolute, wrappa fuori dal vecchio range fisso 2-17. + +`draw_map` è chiamato solo ai passi del movimento (progress 8 e completamento), non ogni frame. + +## HUD via Sprite + +### Barra Stamina (alto destra) +5 sprite (OAM 18-22), coordinate schermo fisse (indipendenti dallo scroll). Conversione `stamina*40/100` → pixel. Tile caricati a `tiles_TILE_COUNT` (indici ≥128) per evitare overlap VRAM con i tile del background (workaround commit `93deb35`). + +### Indicatore Livello (alto sinistra) +3 sprite (OAM 23-25) che mostrano `L` usando l'asset `level.png` (11 glifi 8×16: L, 0-9). Base VRAM allineata a indice **pari** (in modalità 8x16 l'hardware ignora il LSB del tile index). Generato con `png2asset -keep_duplicate_tiles` per ordine tile prevedibile. Nascosto durante game over. + +### Player Sprite +Metasprite 16×16 (OAM 0-1), 8 frame (4 direzioni × 2 frame camminata). Arco parabolico per il salto: `y_offset = (move_progress * (16 - move_progress)) >> 2`. Animazione camminata: `frame_offset = (move_progress >> 2) & 1` (più veloce in corsa, dato che move_progress incrementa di 2). + +### Enemy Sprites +Fino a 8 metasprite 16×16 (OAM 2+i*2), palette invertita (OBP1 = 0x1B, fantasma bianco). Renderizzati solo se entro `fog_radius` e on-screen; altrimenti spostati offscreen (0,0). \ No newline at end of file diff --git a/doc/index.md b/doc/index.md index b201bdc..fc0983c 100644 --- a/doc/index.md +++ b/doc/index.md @@ -1,25 +1,23 @@ -# Game Boy Iso-Engine: Documentazione Tecnica +# A Scream from the Dark — Documentazione Tecnica -Benvenuto nella documentazione tecnica del progetto. Questo motore per Game Boy a 8-bit è stato progettato per dimostrare come tecniche avanzate (prospettiva isometrica, fog of war, generazione procedurale e intelligenza artificiale) possano essere implementate sulle ristrette risorse hardware del Game Boy (CPU Sharp LR35902 a 4.19 MHz, 8KB WRAM). +Benvenuto nella documentazione tecnica di **A Scream from the Dark**, un survival-horror procedurale in prospettiva isometrica 2.5D per Game Boy (DMG/CGB). Il gioco è scritto in C con **GBDK-2020** (compilatore **SDCC**) e dimostra come tecniche avanzate — prospettiva isometrica, fog of war dinamico, generazione procedurale, AI multi-nemico, audio polifonico e progressione di difficoltà — possano essere implementate sulle ristrette risorse hardware del Game Boy (CPU Sharp LR35902 a 4.19 MHz, 8 KB WRAM, 32 KB ROM). + +## Il gioco in breve + +Sei imprigionato in un labirinto generato casualmente, illuminato solo da un ristretto quadrato di visibilità. Un **fantasma** si nasconde nel buio e ti bracca. L'unica via di fuga è una **botola** posta lontano dalla partenza: raggiungerla significa sprofondare più giù (*Going Deeper*) e affrontare un livello più grande e più difficile. Dopo **8 livelli** il gioco finisce con un **finale tragico**. ## Indice dei Capitoli -La documentazione è suddivisa in file separati, ognuno dedicato a un aspetto specifico dell'engine. +1. **[Architettura di Base](architecture.md)** — Struttura del codice, moduli, stato globale, pipeline asset, vincoli hardware. -1. **[Architettura di Base](architecture.md)** - Struttura del codice, organizzazione in moduli (src, assets, scripts) e gestione dello stato globale per evitare dipendenze circolari in C. +2. **[Motore Grafico e Rendering](graphics.md)** — Proiezione isometrica, auto-tiling multi-pass, fog of war scalabile, flush dinamico, VRAM management. -2. **[Motore Grafico e Rendering](graphics.md)** - La matematica dietro la proiezione isometrica, l'ottimizzazione dell'auto-tiling, il sistema "Fog of War" basato sulla distanza di Chebyshev e la gestione dei metasprite. +3. **[Generazione Procedurale del Livello](generation.md)** — DFS con stack WRAM, creazione di loop, posizionamento della botola, dimensioni crescenti. -3. **[Generazione Procedurale del Livello](generation.md)** - Come l'algoritmo Depth-First Search (DFS) crea un labirinto perfetto, come vengono generati i loop per il gameplay e come la distanza di Manhattan posiziona il traguardo. +4. **[Fisica e Controlli del Giocatore](gameplay.md)** — DAS, state machine, camminata, corsa (B+direzione), salto evasivo, stamina, progressione livelli, finale tragico. -4. **[Fisica e Controlli del Giocatore](gameplay.md)** - Il sistema di input con Delayed Auto-Shift (DAS), la State Machine rigida per l'allineamento alla griglia, e la complessa meccanica del salto con traiettoria parabolica e costo in Stamina. +5. **[Intelligenza Artificiale (Fantasmi)](ai.md)** — Multi-entity (fino a 8 fantasmi), pathfinding greedy, cooldown scalabile, hitbox pixel-perfect. -5. **[Intelligenza Artificiale (Il Fantasma)](ai.md)** - Perché il nemico usa un algoritmo Greedy invece di A*, il sistema di cooldown dei movimenti per bilanciare il gameplay, e la hitbox "Pixel-Perfect" che scatena il Game Over. +6. **[Sintesi Audio e Musica](audio.md)** — 4 canali APU, sequencer via VBL interrupt, title music (112 note, 3 canali), gameplay eerie, gameover, finale dedicato (192 note, loop). -6. **[Sintesi Audio e Musica](audio.md)** - Come l'engine manipola direttamente i registri hardware APU (Audio Processing Unit) per creare arpeggi finti, percussioni bianche (noise channel) e melodie atmosferiche. +7. **[Report Tecnico Approfondito](AScreamFromTheDark_report.md)** — Analisi completa di architettura, funzionalità, workaround storici e debiti tecnici. \ No newline at end of file