docs(arquitectura): afig guia d'arquitectura del projecte

This commit is contained in:
JailDesigner committed 2026-05-29 13:07:23 +02:00
1 parent 3db0ed4457
commit ef496bd971
1 file changed
+907
+907
View File
@@ -0,0 +1,907 @@
# Arquitectura de **AEE Arcade**
> Guía de orientación para un desarrollador nuevo en el proyecto.
>
> Cada afirmación de este documento está anclada a código real: se cita el
> fichero (y, cuando ayuda, la función o el número de línea) que la respalda.
> Donde no he encontrado algo, lo digo explícitamente en lugar de inventarlo.
>
> El proyecto es *"Aventures en Egipte ARCADE"* (AEE), un juego 2D escrito en
> C++ sobre **SDL3**. Los comentarios del código están en valenciano/catalán;
> este documento está en castellano por petición.
---
## Índice
1. [Visión general](#1-visión-general)
2. [Punto de entrada y bucle principal](#2-punto-de-entrada-y-bucle-principal)
3. [Gestión de escenas](#3-gestión-de-escenas)
4. [Renderizado: de la lógica al píxel](#4-renderizado-de-la-lógica-al-píxel)
5. [Entrada](#5-entrada)
6. [Comunicación entre módulos](#6-comunicación-entre-módulos)
7. [Lógica del juego](#7-lógica-del-juego)
8. [Entidades](#8-entidades)
9. [Mapa y niveles](#9-mapa-y-niveles)
10. [Recursos](#10-recursos)
11. [Configuración y constantes](#11-configuración-y-constantes)
12. [Modo demo / IA](#12-modo-demo--ia)
13. [Convenciones y patrones recurrentes](#13-convenciones-y-patrones-recurrentes)
14. [Guía de navegación: "si quieres tocar X, mira Y"](#14-guía-de-navegación-si-quieres-tocar-x-mira-y)
---
## 1. Visión general
AEE Arcade está organizado en **dos capas claramente separadas** dentro de
`source/`:
- **`source/core/`** — el motor genérico, sin conocimiento del juego concreto:
sistema/arranque, rendering, audio, input, recursos, escena base, locale y
UI base.
- **`source/game/`** — el juego concreto: escenas (LOGO, TITLE, INTRO, GAME),
entidades (momias, sarcófagos, puertas…), mapa, progresión de niveles,
editor (solo Debug) y HUD.
La separación no es solo de carpetas: el código de `core/` nunca incluye
cabeceras de `game/`. La conexión va siempre de `game/` hacia `core/`, salvo
algún *hook* genérico (p.ej. el overlay del menú, ver §6).
Filosofía dominante: **data-driven**. Casi todo el comportamiento (geometría
de salas, plantillas de entidades, timings de transición, controles, paletas,
shaders, idioma…) se carga desde ficheros **YAML** en `data/`, con valores de
*fallback* compilados en `source/utils/defaults.hpp`. El parser es
[fkYAML](https://github.com/fktn-k/fkYAML), vendorizado en
`source/external/fkyaml_node.hpp`.
```mermaid
graph TD
SDL[SDL3 callbacks · main.cpp] --> DIR[Director · core/system]
DIR -->|update / render| SC[Scene activa]
DIR --> EVT[EventBuffer]
EVT --> MENU[ServiceMenu] & GI["GlobalInputs (F1–F12)"] & GP[GamepadManager]
SC --> GAME[GameScene · game/scenes]
GAME --> EM[EntityManager] --> BUS[EventBus]
GAME --> TM[Tilemap] & MAP[Map] & LM[LevelManager]
GAME --> PL[Players] -->|polling teclado/mando| INPUT[core/input]
EM --> ENT["Entity + concrete/*"]
GAME -->|blit ARGB| SURF[Surface]
SURF --> CANVAS["Canvas 320×240 ARGB (CPU)"]
CANVAS --> SCREEN[Screen] --> BACKEND["SDL3 GPU + shaders / fallback SDL_Renderer"]
RES[Resource::Cache] -.-> SURF & AUDIO[Audio]
```
Tamaño aproximado: ~188 ficheros C++ y ~30.000 líneas en `source/` (excluyendo
`external/`).
**Dos ideas-fuerza que conviene interiorizar desde el principio**, porque
condicionan todo lo demás:
1. **El render es 100% por CPU sobre un único canvas ARGB de 320×240.** La GPU
solo escala ese canvas a la ventana y aplica post-procesado (CRT). Ver §4.
2. **La paleta es por-`Surface`, pero el canvas final es ARGB de 24 bits.** No
es un juego de paleta global limitada: cada GIF lleva su propia paleta y se
compone sobre un framebuffer de color completo
(`source/core/rendering/surface.hpp:13`).
---
## 2. Punto de entrada y bucle principal
### 2.1. No hay `while` propio: SDL conduce el bucle
`source/main.cpp` define `SDL_MAIN_USE_CALLBACKS` (línea 1). Eso significa que
**no existe un game loop escrito a mano**: es SDL quien llama a cuatro
funciones de *callback*:
| Callback | Qué hace en este proyecto |
|---|---|
| `SDL_AppInit` | Guarda `argv` (para reinicio en caliente), inicia el logger y **crea el `Director`** (`main.cpp:13`). |
| `SDL_AppEvent` | Empuja cada `SDL_Event` a `EventBuffer::events` (`main.cpp:31`). |
| `SDL_AppIterate` | Ejecuta **un frame**: `director->iterate()` (`main.cpp:37`). |
| `SDL_AppQuit` | Destruye el `Director` y, si se pidió, hace `execv` para reiniciar (`main.cpp:42`). |
El `Director` se entrega a SDL como puntero crudo (`appstate`) y se reabsorbe
en `SDL_AppQuit` con un `unique_ptr` para limpieza RAII ordenada.
### 2.2. El `Director`: orquestador del frame
`source/core/system/director.hpp` / `.cpp` es el corazón del sistema.
**Constructor (`director.cpp:47`)** — inicializa *todo* en un orden cuidadoso y
documentado en comentarios. Resumen del orden:
1. `SDL_Init(0)` (sin subsistemas todavía).
2. Sistema de recursos: localiza `resources.pack` junto al binario; en `_DEBUG`
activa *fallback* a disco apuntando al árbol fuente (`AEE_SOURCE_DIR`) para
editar YAML/GIF sin re-empaquetar; en Release el pack es obligatorio
(`director.cpp:52`).
3. `createSystemFolder()` — carpeta de configuración por plataforma (ver §11).
4. `GamepadManager::get().init()` **antes** de cargar opciones (las opciones
re-vinculan mandos a jugadores; `director.cpp:86`).
5. Carga de `config.yaml`, `gamepad_configs.yaml`, `gameplay.yaml`, presets de
shaders (`postfx.yaml`, `crtpi.yaml`).
6. `Screen::get().configure(...)` — crea ventana + backend GPU.
7. `Locale`, `KeyConfig`, `UiConfig`, `Lighting`, `EditorConfig` (Debug).
8. Pre-carga de recursos de UI: paleta, fuentes, sombra compartida, pistas de
música, sonidos de menú.
9. `Audio::init(...)`.
10. Construcción de los *overlays* compartidos (`Notifier`, `RenderInfo`,
`ServiceMenu`) y de las `Surface`/`Text` compartidas entre escenas.
11. Registro del *overlay hook* del menú en `Screen` (`director.cpp:291`).
12. Elección de la escena inicial (ver §3.3) y `last_ticks_ms_ = SDL_GetTicks()`.
**Destructor (`director.cpp:336`)** — el orden de liberación es crítico y está
fuertemente comentado: primero la escena, luego el audio del `Cache` (mientras
SDL sigue vivo), luego `Audio`, `Screen` y por fin `SDL_Quit()`. Se hace
explícitamente (no vía destructores estáticos `atexit`) para evitar crashes en
`vkDestroyInstance` cuando el runtime descarga librerías antes de tiempo.
### 2.3. Gestión del tiempo y orden update/render
El "frame" vive en `Director::iterate()` (`director.cpp:509`). **No hay
timestep fijo**: se usa el delta real entre frames.
```cpp
const std::uint64_t NOW_MS = SDL_GetTicks();
int delta_ms = static_cast<int>(NOW_MS - last_ticks_ms_);
delta_ms = std::max(delta_ms, 0); // nunca negativo
last_ticks_ms_ = NOW_MS;
```
Orden exacto de un frame (`director.cpp:509`–`549`):
```mermaid
sequenceDiagram
participant SDL
participant Dir as Director::iterate
participant Scene
SDL->>Dir: SDL_AppIterate
Note over Dir: Mouse::handleEvent(ev) para todos los eventos
Dir->>Dir: processGamepadEvents() (hot-plug + botón SERVICE)
Dir->>Dir: processMenuInput() (ServiceMenu consume teclas)
Dir->>Dir: processEvents() (detecta SDL_EVENT_QUIT)
Dir->>Dir: delta_ms = now - last
Dir->>Dir: Audio::update()
Dir->>Dir: Mouse::updateCursorVisibility()
Dir->>Dir: ServiceMenu::update(delta_ms)
Dir->>Scene: update(delta_ms)
Dir->>Scene: render()
Dir->>Dir: switchSceneIfRequested()
Dir->>Dir: EventBuffer::events.clear()
```
El orden de procesamiento de eventos es deliberado y está comentado en
`director.cpp:514`: gamepad → menú → quit, para que un `SDL_EVENT_QUIT`
inyectado por el menú ("Salir del juego") se vea en el mismo frame antes de
limpiar el buffer.
El bucle termina (`SDL_APP_SUCCESS`) cuando la escena se reinicia a `nullptr`
(quit) o cuando `current_scene_->wantsQuit()` es `true`.
---
## 3. Gestión de escenas
### 3.1. La base `Scene`
`source/core/scene/scene.hpp` es minimalista:
```cpp
class Scene {
public:
virtual void update(int delta_ms) = 0;
virtual void render() = 0;
auto wantsQuit() const -> bool { return wants_quit_; }
auto takeExitRequest() -> std::any { return std::exchange(exit_request_, {}); }
protected:
bool wants_quit_{false};
std::any exit_request_;
};
```
El detalle clave: la petición de transición viaja dentro de un **`std::any`**.
Así, `core/scene/scene.hpp` **no necesita conocer los tipos del juego**. La
escena deposita un `ExitRequest` (tipo definido en `game/`) y el `Director`
hace el `std::any_cast` cuando le toca.
### 3.2. El flujo de escenas: descentralizado al pedir, centralizado al construir
`source/game/scenes/scene_flow.hpp` define el contrato:
- `enum class SceneId { LOGO, TITLE, INTRO, GAME }`.
- Structs de parámetros por escena (`LogoParams`, `TitleParams`,
`IntroParams`, `GameParams`) reunidos en un `std::variant SceneParams`.
- `struct ExitRequest { SceneId next; SceneParams params; }`.
Una escena **no construye la siguiente**: solo declara a dónde quiere ir. El
único punto que sabe cómo instanciar cada escena concreta es
`Director::buildScene()` (`director.cpp:484`), que tiene a mano todos los
recursos compartidos pre-cargados en el constructor.
```mermaid
graph LR
A[Escena activa] -->|deposita ExitRequest en std::any| B[Director::switchSceneIfRequested]
B -->|std::any_cast| C[Director::buildScene]
C -->|make_unique| D[Nueva escena]
```
Esto desacopla las escenas entre sí: ninguna `#include` a otra. Para **añadir
una escena nueva** (documentado en `scene_flow.hpp:14`): (1) una entrada en el
`enum`, (2) un struct `*Params` si necesita datos, (3) un `case` en
`buildScene`.
> Nota de migración: en `buildScene` se ve que algunas escenas todavía reciben
> recursos directamente del `Director` por el constructor en vez de pasar por
> el canal `ExitRequest`. Es un estado intermedio, comentado en
> `director.cpp:484`.
### 3.3. Flujo del juego y punto de arranque
El flujo "completo" que ve el jugador final es **LOGO → TITLE → INTRO → GAME**.
- **Release**: siempre arranca en `LOGO` (`director.cpp:314`).
- **Debug**: lee `debug.yaml` de la carpeta de sistema; si no existe, lo crea
con `start_scene: GAME` para no tragarse los placeholders en el día a día
(`director.cpp:301`). `debug.yaml` también puede fijar `starting_players` y
`starting_level` (`director.cpp:304`).
Las escenas concretas viven en `source/game/scenes/`:
`logo_scene`, `title_scene`, `intro_scene`, `game_scene`. La transición entre
salas dentro de GAME **no** es un cambio de escena: la `GameScene` persiste y
recarga la sala internamente (ver §7).
---
## 4. Renderizado: de la lógica al píxel
Esta es la sección más importante para entender el proyecto, porque el render
es poco convencional: **todo el dibujo lo hace la CPU sobre un buffer de
píxeles**, y la GPU solo interviene al final.
### 4.1. El canvas: un único framebuffer ARGB en RAM
- La resolución lógica del juego es **320×240** (`source/utils/defines.hpp:10`,
`GameCanvas::WIDTH/HEIGHT`).
- `Screen` mantiene `std::vector<std::uint32_t> canvas_` — un array plano de
píxeles ARGB8888 (`screen.hpp:143`).
- El acceso de los blitters no va por `Screen::get()` directamente, sino por
`Rendering::activeCanvas()` (`source/core/rendering/canvas.hpp:38`), para no
acoplarse al ciclo de vida de `Screen`. También existe un sistema de
**clipping** apilable (`pushClip`/`popClip`, `canvas.hpp:46`).
El bit alfa es siempre opaco: `Rendering::OPAQUE_ALPHA = 0xFF000000`
(`canvas.hpp:15`). El canal alfa no se usa para transparencia del frame final
— la transparencia de sprites se resuelve por *color index transparente*
durante el blit (ver §4.2).
### 4.2. `Surface`: sprites paletizados → píxeles ARGB
`source/core/rendering/surface.hpp` / `.cpp` es el blitter base.
- Una `Surface` guarda los píxeles como **índices de 8 bits** (`Uint8`) más una
**paleta propia** de 256 colores ARGB (`Palette = std::array<Uint32, 256>`).
- Se carga desde un GIF (`Surface(const std::string&)`, `surface.cpp:38`):
decodifica píxeles y paleta con `GIF::Gif` (`core/rendering/gif.*`).
- El índice 0 es transparente por convención de todos los GIF del proyecto
(`surface.hpp:78`); los tilesets que usen otro índice llaman a
`setTransparentColor()`.
El núcleo del dibujo es `renderWithColorReplace()` (`surface.cpp:90`). Para
cada píxel del rectángulo fuente:
1. Comprueba el *clip* y descarta fuera de él.
2. Lee el índice fuente; si es el transparente, **salta** (no escribe).
3. Aplica `flip` horizontal/vertical reindexando la coordenada fuente.
4. Opcionalmente reemplaza un índice por otro (`source_color → target_color`),
usado por `Text` para recolorear glifos (la "tinta" se guarda con un único
índice).
5. Escribe `palette_[remapped]` en el canvas.
`render()` (`surface.cpp:82`) es un caso particular de lo anterior (delega con
`source==target==transparent`, evitando duplicar el bucle).
> Hay además una `SubPalette` (`std::array<Uint8,256>`, identidad por defecto
> vía `std::iota`) que permite remapear índices antes de la paleta; se usa para
> efectos como el parpadeo de inmunidad del Player.
Sobre `Surface` se construyen abstracciones de mayor nivel en
`core/rendering/sprite/`:
- `Sprite` → `AnimatedSprite` → `MovingSprite` (herencia).
- `AnimatedSprite` reproduce tiras de fotogramas; su parser está en
`animated_sprite_parser.cpp`. **El `Player` hereda de `AnimatedSprite`**
(`player.hpp:18`).
### 4.3. El orden de pintado de un frame de juego
`GameScene::render()` (`source/game/scenes/game_scene.cpp:1682`) es la
referencia canónica de cómo se compone una imagen. Orden exacto:
1. **Fondo**: `Screen::fillRect` con el color de fondo de la paleta UI.
2. **Tilemap**: `tilemap_.render()` — copia de la textura prerenderizada
(ver §9.2). Las celdas de sarcófago quedan transparentes.
3. **(Editor Debug)**: si está activo, pinta entidades congeladas y vuelve.
4. **Capa de pintado** (`PaintLayer`): el "rastro" del jugador por el suelo.
5. **Sombras**: `p->renderShadow()` de cada jugador en el aire — por debajo de
todos los actores.
6. **Z-sort de actores**: entidades + jugadores se fusionan en un vector y se
ordenan con `std::ranges::stable_sort` por **`getFeetY()` ascendente**
(`game_scene.cpp:1755`). Quien tiene los pies más arriba se pinta antes (más
al fondo). Esto da la falsa perspectiva 2D.
7. **Pasada de iluminación**: `applyLightingPass()` (ver §4.4).
8. **Fade de sala** (`renderRoomFade`): negro entre actores y HUD.
9. **HUD**: `hud_->render()`.
10. **Texto de transición de sala** y **rótulo de game over**.
11. **Fade final de game over**.
12. `Notifier::render()` y `RenderInfo::render()` (overlays de debug).
13. **`Screen::get().present()`**.
El criterio recurrente está comentado: la UI (HUD, notificaciones, menú) se
pinta **después** de los fades para quedar siempre legible.
### 4.4. Iluminación por CPU
`source/core/rendering/lighting.hpp` / `.cpp`. Es una pasada que **modifica
cada píxel del canvas** después de pintar el mundo. Dos modos seleccionables
por sala (`lighting.mode` en el YAML):
- **SUBTRACTIVE** (histórico, por defecto): `out = src × max(room_light,
max_emitter_intensity)`. Los emisores "rompen" una capa oscura. El color del
emisor se ignora; solo cuenta la intensidad.
- **ADDITIVE**: `out = saturate(src × room_light + Σ emitter_color × intensity)`.
Los emisores suman halo cromático.
Cada emisor (jugadores, antorchas, momias con `light_radius > 0`) proyecta un
círculo radial de 3 anillos configurables en `data/config/lighting.yaml`. Las
antorchas pueden tener *flicker* (`light_flicker`), con parámetros resueltos
por entidad (`Entity::getFlickerParams`, `entity.hpp:306`).
### 4.5. `Screen::present()` y el backend GPU
`Screen::present()` (`source/core/rendering/screen.cpp:306`):
1. Llama al **overlay hook** (menú de servicio) para que pinte *sobre* el
canvas antes de subirlo (`screen.cpp:310`).
2. Actualiza el contador de FPS.
3. Si hay **backend GPU** (`shader_backend_`): `uploadPixels(canvas)` +
`render()`. Este es el camino por defecto.
4. Si no (fallback): `SDL_UpdateTexture` de una textura STREAMING +
`SDL_RenderTexture` + `SDL_RenderPresent`.
El backend GPU vive en `source/core/rendering/sdl3gpu/` (SDL3 GPU API +
shaders SPIR-V compilados). En `configure()` (`screen.cpp:108`) se intenta el
path GPU primero; si falla, hay **fallback automático a `SDL_Renderer`**. El
escalado a la ventana usa modos *letterbox / integer / stretch / overscan*
(`Defaults::ScalingMode`).
**Post-procesado**: shaders en `data/shaders/` (`postfx.frag`, `postfx.vert`,
`crtpi_frag.glsl`). El efecto CRT y similares se aplican aquí, en GPU, sobre la
textura del canvas. El juego no sabe nada de esto: solo escribe píxeles en el
canvas. Los presets se gestionan desde `Options` y se ciclan con hotkeys (§5).
```mermaid
graph TD
LOG["Lógica del juego (game/)"] -->|Surface::render| CV["Canvas ARGB 320×240 (CPU)"]
HUD[HUD / Notifier / RenderInfo] --> CV
LIGHT[Lighting pass CPU] --> CV
MENU[ServiceMenu overlay] -->|hook en present| CV
CV -->|uploadPixels| GPU["ShaderBackend (SDL3 GPU)"]
GPU -->|shader postfx/crtpi| WIN[Ventana]
CV -.fallback.-> SDLR[SDL_Renderer] --> WIN
```
> **Resumen honesto**: la GPU en este proyecto **no dibuja geometría del
> juego**. Solo recibe un mapa de bits ya compuesto y lo escala + filtra. Todo
> el "render" del juego es manipulación de un array de `uint32_t` en RAM.
---
## 5. Entrada
Hay **dos vías de entrada totalmente distintas**, y entender la diferencia
evita mucha confusión:
### 5.1. Vía por eventos (`EventBuffer`)
Los `SDL_Event` se acumulan en `EventBuffer::events`
(`source/core/system/event_buffer.hpp`) durante `SDL_AppEvent`. Los consumen,
en orden, dentro de `Director::iterate()`:
- **`Mouse`** (`core/input/mouse.*`): solo reacciona a `MOUSE_MOTION` para
reaparecer el cursor (auto-ocultación).
- **Gamepad hot-plug y botón SERVICE** (`processGamepadEvents`,
`director.cpp:417`): ADDED/REMOVED van a `GamepadManager`; el botón SERVICE
(por defecto BACK, remapeable) abre/cierra el menú.
- **Menú de servicio** (`processMenuInput`, `director.cpp:446`): con el menú
abierto consume todas las teclas salvo F1–F12 y ESC.
- **`GlobalInputs`** (`core/input/global_inputs.cpp:190`): hotkeys de sistema
F1–F12 + ESC. Itera `EventBuffer` buscando `KEY_DOWN`. Las acciones se leen
de `KeyConfig` (`data/config/input.yaml`):
| Acción | Efecto |
|---|---|
| `dec_zoom` / `inc_zoom` | cambia zoom de ventana |
| `fullscreen` | alterna pantalla completa |
| `toggle_vsync` | alterna VSync |
| `screenshot` | guarda captura |
| `toggle_shader` / `next_shader` / `next_preset` | post-FX |
| `next_scaling_mode` | cicla letterbox/integer/… |
| `toggle_render_info` | cicla overlay de debug OFF→TOP→BOTTOM |
| `exit` (ESC) | **salida en dos pasos** (ver abajo) |
La salida con ESC es en dos pulsaciones: la primera muestra una notificación
de confirmación y arma un temporizador; la segunda, dentro de la ventana,
activa `wantsQuit()` (`global_inputs.cpp:176`, `global_inputs.hpp:1`).
### 5.2. Vía por *polling* (la del gameplay)
El **`Player` lee su input directamente cada frame**, no por eventos:
- Teclado: `SDL_GetKeyboardState(nullptr)` (`player.cpp:629`, `764`, `784`).
Cada `Player` tiene su propio `Controls` (struct de `SDL_Scancode`,
`player.hpp:23`). Por defecto P1 usa flechas + M/N (`Defaults::KeysGame`,
`defaults.hpp:75`) y P2 usa WASD (`Defaults::KeysGameP2`).
- Mando: `GamepadManager::get().getForPlayer(id)` +
`GamepadBindings::isPressed(pad, btn)` (`player.cpp:769`).
Esto encaja con que el movimiento se calcula en `Player::tick(dt)` durante el
bloque de mundo de `GameScene::update` (§7), donde importa el estado *actual*
de las teclas, no la secuencia de eventos.
### 5.3. Detección puntual de pulsaciones en escena
Para "¿se ha pulsado START este frame?" hay un helper compartido,
`Input::Detect` (`source/game/scenes/start_input.hpp`):
- `pressedStart(player_id)` — combina eventos de teclado y botón de mando.
Lo usan `TitleScene` (empezar partida) y `GameScene` (*join in progress*).
- `pressedActionButton(player_id)` — salto o ataque, usado para saltar el
*countdown* de CONTINUE?.
### 5.4. Mandos
`core/input/`:
- `gamepad_manager.*` — descubre, abre/cierra mandos y asigna mando↔jugador.
- `gamepad_bindings.*` — vinculaciones por dispositivo `(name, path)`,
persistidas en `gamepad_configs.yaml` (fichero aparte de `config.yaml`
porque es por hardware, no por perfil; `director.cpp:93`).
- `define_buttons.*`, `key_config.*` — catálogo de botones y mapeo de scancodes
de hotkeys.
---
## 6. Comunicación entre módulos
El proyecto combina tres mecanismos, cada uno para su propósito:
### 6.1. `EventBus` — pub/sub tipado dentro de un nivel
`source/game/entities/event_bus.hpp`. Hay **un `EventBus` por mundo**, propiedad
del `EntityManager` (`entity_manager.hpp:199`). Las entidades reciben un
puntero vía `Entity::setEventBus`.
- El *payload* es un **`std::variant`** tipado (`event_bus.hpp:98`), no
`void*` ni `std::any`: `int`, `std::string`, `Entity*`, `TilePos`,
`PaintedTile`, `WorldPos`, `WhipSpawnPayload`, `PlayerStatsPayload`.
- API: `subscribe(name, handler) → SubId`, `unsubscribe(id)`,
`publish(Event)` (+ sobrecargas ergonómicas por tipo de payload).
Eventos reales que circulan (vistos en el código): `sarcophagus_opened`,
`level_complete`, `player_hit`, `player_killed`, `player_disabled`,
`player_jumped`, `player_landed`, `on_tile_entered`, `tile_painted`,
`whip_spawn`, `player_stats_changed`.
Ejemplo de flujo desacoplado: el `Player` no conoce ni `WhipAttack` ni
`EntityManager`; al atacar publica `whip_spawn` con un `WhipSpawnPayload`, y
`GameScene` lo escucha y crea la entidad (`event_bus.hpp:66`).
Otro: cuando el `Player` muta vidas/score/tesoros, publica
`player_stats_changed` con un *snapshot completo*; el HUD lo consume como
estado absoluto (no delta). Así cualquier vía que cambie stats se refleja sin
que el caller recuerde notificar al HUD (`event_bus.hpp:28`).
### 6.2. Llamadas directas e inyección de dependencias
Donde la relación es estructural y estable, se usa llamada directa o inyección
por *setter*: p.ej. `Entity::setEntityManager` (una momia consulta el mundo
para no caer en un `Hole`, `entity.hpp:180`), o `Map::setEntityCollider` (el
`EntityManager` registra un predicado de colisión para muros dinámicos,
`map.hpp:171`).
### 6.3. *Hooks* / callbacks para invertir dependencias
`core/` no conoce `game/`, así que cuando `core/` necesita algo del juego se
inyecta un `std::function`:
- `Screen::setOverlayRenderer(callback)` — el `Director` registra una lambda
que pinta el `ServiceMenu` sobre el canvas en `present()`. Así `Screen` puede
poner el menú por encima de cualquier escena sin incluir `game/ui`
(`screen.hpp:81`, `director.cpp:291`).
### 6.4. Singletons
Varios subsistemas son **Meyers singletons** (`get()`): `Screen`,
`Resource::Cache`, `GamepadManager`, `GamepadBindings`, `Locale`, `UiConfig`,
`Lighting`, `LevelManager`, `ServiceMenu`, `Notifier`, `RenderInfo`. Ver §13
para el matiz de su ciclo de vida.
---
## 7. Lógica del juego
El grueso vive en `source/game/scenes/game_scene.cpp` (la clase más grande del
proyecto, ~1800 líneas). `GameScene` coordina mapa, entidades, jugadores, HUD,
y **dos máquinas de estado**.
### 7.1. FSM de sala — `RoomFsm`
`source/game/scenes/room_fsm.hpp`. Lógica **pura** (no toca SDL/Audio; solo
`Locale` para formatear texto), extraída para ser testeable.
```
ROOM_INTRO → PLAYING → ROOM_OUTRO → (siguiente sala) ROOM_INTRO
```
Cada estado tiene sub-fases con timings cargados de
`data/scenes/game/game.yaml` (`room_fsm.hpp:33`). Por ejemplo, `ROOM_INTRO`
tiene: `fade_in → text_delay → text_enter → text_hold → text_exit →
music_tail`. La fase `music_tail` es una "cola silenciosa" calculada para que
el *timeline* visual termine exactamente cuando drena el `.ogg` de música —
**no hay callback de fin de música**; la transición la dispara
`GameScene::updateRoomTransition` cuando la FSM consume la última fase
(`room_fsm.hpp:27`).
La función central es `RoomFsm::advance(current, transition, cfg, delta_ms)`
→ devuelve el estado resultante; si cambia, el caller resetea la transición.
### 7.2. FSM de game over — `GameOverFsm`
`source/game/scenes/game_over_fsm.*`. Sub-FSM independiente
(`CONTINUE? → countdown → GAME OVER → FADE_OUT → DONE`) que se superpone a la
`RoomFsm` y puede dispararse durante `PLAYING` cuando llegan a 0 los jugadores
en partida. Al terminar pide la transición a `LOGO`
(`game_scene.hpp:280`–`333`). El *countdown* puede saltarse pulsando acción
(`handleCountdownSkip`).
### 7.3. FSM del `Player`
`source/game/entities/player.hpp:66` define `enum class State`:
`GROUNDED, AIRBORNE, LANDING_STUN, HURT, ATTACKING, EXITED, DISABLED, DYING`.
Cada estado define qué input acepta y cómo se mueve/colisiona. Detalles
relevantes:
- El **salto** usa `jump_z_` (altura): saltar por encima de la `height_` de
una entidad permite esquivar momias/rocas (`entity.hpp:219`).
- `DISABLED` = slot inactivo (P2 en single-player, o todos durante
transiciones); `EXITED` = ha salido por la puerta; `DYING` = animación de
muerte + delay antes de respawn.
- El array `players_` **siempre tiene 2 entradas** (P1, P2); el slot inactivo
queda `DISABLED` y puede entrar a media partida ("join in progress") pulsando
START (`game_scene.hpp:87`, `handleJoinInput`).
### 7.4. El bloque de mundo: orden de update
Dentro de `GameScene::update` (`game_scene.cpp:1519`), el mundo solo avanza si
**no** está abierto el menú ni el editor y `delta_ms > 0`. El pipeline de
movimiento es de **tres pasadas** para intercalar la resolución jugador↔jugador
(`game_scene.cpp:1629`):
1. `player->tick(DT)` — cada jugador calcula su velocidad intencionada (teclado
+ mando + *facing*).
2. `resolvePlayerVsPlayer(...)` — anula velocidades en choque frontal o las
escala a `push_factor_` (empuje).
3. `player->applyMovement(DT)` — en orden "empujado primero".
Después: `entity_manager_.update(DT)` (tras los jugadores, para que un
`on_tile_entered` emitido este frame se procese ya) y
`checkPlayerCollisions(...)`.
### 7.5. Reglas del juego
- **Sarcófagos** (`concrete/sarcophagus.hpp`): el jugador debe "pintar"
(recorrer) todo el perímetro de tiles del sarcófago para abrirlo. FSM:
`CLOSED → OPENING → OPEN`. Al abrirse publica `sarcophagus_opened` y reparte
recompensa (`points`/`treasures`) al jugador atribuido.
- **Puerta** (`concrete/door.hpp`): FSM `CLOSED → OPENING → OPEN`. El
`EntityManager` la abre automáticamente cuando el nº de sarcófagos abiertos
iguala el total (`entity_manager.hpp:21`). Al abrirse libera el pasadizo
(`Map::door_open = true`). Cruzarla dispara `Player::exit()` →
`player_exited` → `level_complete` → OUTRO.
- **Pintado del suelo** (`game/map/paint_layer.*`): escucha `on_tile_entered`,
filtra pintabilidad (`Map::isPaintable`) y re-publica `tile_painted` para los
sarcófagos (`game_scene.hpp:219`).
- **Hazards**: momias, rocas rodantes (`rolling_rock`), trampas de suelo/pared
(`floor_trap`, `wall_trap`), agujeros (`hole`). Los letales publican
`player_killed`.
---
## 8. Entidades
### 8.1. `Entity`: composición, no herencia de sprite
`source/game/entities/entity.hpp`. Decisión de diseño explícita
(`entity.hpp:34`): **`Entity` NO hereda de `AnimatedSprite`**. La animación es
un *componente opcional* (`std::optional<AnimationComponent> anim_`), porque
no todas las entidades la necesitan (trampas invisibles, triggers) y para no
imponer semántica de movimiento continuo a actores estáticos.
`Entity` reúne lo común: posición en tiles y píxeles, caja de colisión
opcional (`CollisionBox`), flags de comportamiento (`collides_`,
`blocks_movement_`, `triggers_on_overlap_`, `is_floor_decal_`, `dead_`),
parámetros de luz, y un conjunto de **hooks polimórficos**:
| Hook | Para qué |
|---|---|
| `update(dt)` / `render()` | tick lógico / dibujo |
| `onPlayerContact(Player*)` | el jugador toca la entidad (daño, salida…) |
| `onWhipHit(WhipAttack*)` | recibe un latigazo (override en `Mummy`) |
| `notifyTilePainted(col,row,id)` | un tile se pintó (lo usa `Sarcophagus`) |
| `onJustSpawned()` | recién nacida (dispara animación EMERGING) |
| `onRemoved()` | antes de borrarse (mantener invariantes) |
| `shouldReceiveContact()` | filtro de si recibe contacto este frame |
| `getFeetY()` | Y de los pies, para el z-sort (§4.3) |
El polimorfismo evita "sacos" separados por tipo: el `EntityManager` itera
todas y cada entidad decide qué hace (`entity_manager.hpp:78`).
### 8.2. `EntityManager`: contenedor y coordinador por nivel
`source/game/entities/entity_manager.hpp`. **No es singleton**: una instancia
por nivel, vive dentro de `GameScene` (`game_scene.hpp:80`).
Responsabilidades: poseer las entidades (`std::vector<unique_ptr<Entity>>`),
propagar `update`, contar sarcófagos abiertos y abrir la puerta, hacer *fan-out*
de `notifyTilePainted`, comprobar colisiones jugador↔entidad, y cargar el nivel
desde YAML (`loadFromLevel`). También gestiona una **cola de actores pendientes**
(`pending_actors_`): los hazards dinámicos declarados en el YAML no se instancian
de inmediato, sino que `spawnPendingActors()` los materializa en una sub-fase
concreta del ROOM_INTRO con su cinemática de aparición (`entity_manager.hpp:130`).
### 8.3. `EntityFactory`: registro abierto a extensión
`source/game/entities/entity_factory.hpp`. Factory por nombre con
**auto-registro**: cada `concrete/*.cpp` declara su creador con la macro
`REGISTER_ENTITY(name, Class)`, que genera un objeto estático cuyo constructor
se ejecuta antes de `main` y se registra en la factory (`entity_factory.hpp:56`).
> **Añadir un tipo de entidad = nueva clase en `concrete/` + una línea
> `REGISTER_ENTITY`.** No se toca la factory ni el manager.
Aviso importante recogido en el código: para que el registrador se ejecute, el
`.cpp` debe enlazarse directamente al ejecutable (no oculto en una static lib
sin `--whole-archive`); todos los `concrete/*.cpp` entran en `APP_SOURCES`
(`entity_factory.hpp:53`).
Tipos concretos existentes (`source/game/entities/concrete/`): `door`, `dust`,
`floor_trap`, `hole`, `mummy`, `rolling_rock`, `sarcophagus`, `solid_props`,
`torch`, `wall_trap`, `whip_attack`.
### 8.4. Jerarquía intermedia
Entre `Entity` y las concretas hay clases intermedias que aportan semántica:
`decoration.hpp`, `solid_decoration.hpp`, `interactable.hpp`, `hazard.hpp`
(con `DynamicHazard` para actores con fase de aparición). `Sarcophagus` hereda
de `Interactable`, por ejemplo.
---
## 9. Mapa y niveles
### 9.1. `Map`: geometría y colisión (un namespace, no una clase)
`source/game/map/map.hpp`. **`Map` es un `namespace`**, no una clase, porque no
hay estado de instancia: la geometría de la sala activa vive como variables
`inline` del namespace (`map.hpp:32`). Se carga con `Map::loadFrom(yaml)` desde
el bloque `geometry:` del YAML de la pirámide.
Contiene la geometría de la sala (graella de sarcófagos, *pitch*, anchos de
muro, playfield, posición de la puerta `door_col`) y las funciones de colisión:
- `collides(box)` — ¿la caja choca con sarcófago, pasadizo de puerta cerrada o
entidad sólida? (`map.hpp:164`).
- `insidePlayfield(box)`, `isPaintable(col,row)`, `pixelToTile(px,py)`.
Para no acoplar a todos al namespace global, existe `Map::Info` — un *snapshot*
POD copiable de la geometría + punteros a funciones de colisión, que se inyecta
a entidades (hoy el `Player`, decoupling COUP-02; `map.hpp:201`).
### 9.2. `Tilemap`: prerenderizado una vez
`source/game/map/tilemap.hpp`. La cuadrícula de tiles 16×16 se **prerrenderiza
una sola vez a un buffer ARGB propio** en el constructor; cada frame es una
copia clipada al canvas (`tilemap.hpp:8`). Así no se recorre la cuadrícula cada
frame. En Debug guarda además `data_` (la rejilla de índices) para el editor.
### 9.3. `LevelManager`: progresión
`source/game/levels/level_manager.hpp`. Meyers singleton, **fuente de verdad de
la progresión**. Carga `data/maps/levels.yaml` (lista de salas con `pyramid`,
`room`, `file`). API: `currentPath()`, `advance()`, `restart()`, `setIndex()`,
`setStartingPoint(pyramid, room)`. No hace *loop* por sí mismo: la política la
decide `GameScene` (`level_manager.hpp:49`).
Los mapas reales viven en `data/maps/p1/`…`p5/` (5 pirámides × ~6 salas), cada
una con sus `rN.yaml` y un `pyramid.yaml` con la geometría.
---
## 10. Recursos
### 10.1. `Resource::Cache`
`source/core/resources/resource_cache.hpp`. Meyers singleton que cachea por
nombre: `Surface`, animaciones, paletas, música (`Ja::Music`) y sonidos
(`Ja::Sound`). API `loadX(name, path)` + `getX(name)`. La búsqueda es lineal
sobre vectores (suficiente para el tamaño actual).
Tiene un `clearAudio()` que **el `Director` llama explícitamente** antes de
destruir `Audio` y `SDL_Quit`, porque al ser Meyers el Cache se destruiría
tarde, con los streams SDL ya invalidados (`resource_cache.hpp:43`).
### 10.2. Pack vs disco
`source/core/resources/resource_pack.*` + `resource_helper.*`. Toda lectura de
fichero pasa por `Resource::Helper::loadFile(path)`. En **Release** los datos
salen de `resources.pack` (empaquetado por `tools/pack_resources/` en cada
build). En **Debug** se activa *fallback* a disco apuntando al árbol fuente, de
modo que editar un YAML o un GIF en `data/` se ve sin re-empaquetar
(`director.cpp:52`).
### 10.3. Formatos de arte
- **GIF** es el formato de sprites, tilesets y fuentes (`data/**/*.gif`),
decodificado por `core/rendering/gif.*`. Cada GIF lleva su paleta.
- **Fuentes**: pares `.fnt` + `.gif` (`data/fonts/`), consumidos por `Text`
(`core/rendering/text.*`).
- **Música**: `.ogg` decodificado con `stb_vorbis` (`source/external/`).
- **Paletas sueltas**: `.pal` JASC o `.gif` (`palette_loader.*`,
`data/palettes/`).
---
## 11. Configuración y constantes
### 11.1. La cadena YAML → `Options` → subsistema
El patrón es uniforme: un YAML en `data/config/` (o en la carpeta de sistema
del usuario) se carga, y si falta una clave cae al valor compilado en
`source/utils/defaults.hpp`. `defaults.hpp` es el **catálogo central de
constantes** organizado en sub-namespaces (`Window`, `Video`, `Audio`,
`KeysGame`, `KeysGameP2`, `I18n`, `Paths`, `UI`, …).
`source/game/options.*` + `options_apply.*` traducen esas opciones a structs
tipados que los subsistemas consumen sin conocer YAML (p.ej.
`Options::buildScreenConfig()` → `Screen::Config`). Esto mantiene `Screen`,
`Audio`, etc. desacoplados de la capa de configuración.
Ficheros de configuración por categoría:
- `data/config/`: `ui.yaml`, `input.yaml`, `lighting.yaml`, `sounds.yaml`,
`editor.yaml`.
- En la carpeta de sistema del usuario: `config.yaml`, `gameplay.yaml`,
`gamepad_configs.yaml`, `debug.yaml` (Debug), y `shaders/postfx.yaml`,
`shaders/crtpi.yaml`.
### 11.2. Carpeta de sistema por plataforma
`Director::createSystemFolder()` (`director.cpp:353`):
- Windows: `%APPDATA%\jailgames\aee_arcade`
- macOS: `~/Library/Application Support/jailgames/aee_arcade`
- Linux: `$XDG_CONFIG_HOME/jailgames/aee_arcade` (por defecto
`~/.config/jailgames/aee_arcade`)
Las capturas de pantalla y los presets de shader cuelgan de ahí.
### 11.3. Localización
`source/core/locale/locale.*` + `data/locale/{ca,en}.yaml`. Todo texto visible
pasa por `Locale::get().text(clave)`; si falta el fichero o la clave, devuelve
la propia clave como *fallback* (`director.cpp:124`). Idioma por defecto: `ca`
(`defaults.hpp:41`).
---
## 12. Modo demo / IA
**No existe un modo demo, *attract mode*, autoplay ni IA que controle a los
jugadores.** Lo he buscado explícitamente y no lo hay.
Aclaración para evitar malentendidos: la única aparición de la palabra "ai" en
el código es un **bloque YAML `ai:`** en las plantillas de `mummy` y
`rolling_rock`, que solo fija la **dirección inicial** del hazard:
```yaml
ai:
initial_direction: east # mummy.cpp:96, rolling_rock.cpp:41
```
No es un sistema de IA: es un parámetro de spawn. El movimiento de las momias
es lógica fija en `mummy.cpp` (patrullaje/persecución simple con consulta al
mundo para no caer en agujeros), no un módulo de IA separado ni dirigido por el
modo de juego.
Si en el futuro se quisiera un *attract mode*, el punto natural de enganche
sería el flujo de escenas (`scene_flow.hpp`) y/o sustituir el *polling* de
input del `Player` por una fuente sintética, pero **hoy nada de eso está
implementado**.
---
## 13. Convenciones y patrones recurrentes
Patrones que verás una y otra vez (reconocerlos acelera mucho la lectura):
- **Idioma de los comentarios**: valenciano/catalán. Los identificadores son en
inglés; las constantes locales suelen ir en `MAYÚSCULAS`.
- **Estilo C++ moderno**: `[[nodiscard]]` en casi todo getter, trailing return
types (`auto f() -> T`), `enum class` con tipo subyacente explícito,
`std::optional`/`std::variant` en vez de punteros nulos o `void*`. Hay
`.clang-format` y `.clang-tidy` en la raíz.
- **Meyers singleton con ciclo de vida manual**: los singletons que dependen de
SDL (`Screen`, `Audio`, `Cache` para audio) exponen `get()` pero su
`configure()`/`shutdown()` los llama explícitamente el `Director`, **no** el
destructor estático. El motivo (crashes en `vkDestroyInstance` al descargar
Vulkan/Wayland antes de tiempo) está documentado en
`screen.hpp:28` y `director.cpp:336`.
- **Inyección de dependencias por `std::function` / setters** para invertir la
dirección `core/` ↛ `game/` (ver §6.3).
- **Data-driven con fallback compilado**: nunca asumas que un valor es una
constante de código sin más; suele venir de un YAML con default en
`defaults.hpp`.
- **`std::any` solo en la frontera `Scene`↔`Director`** para no acoplar la base
de escenas a los tipos del juego (§3.1).
- **Composición sobre herencia para entidades** (§8.1).
- **Separación render-de-colisión**: el `Tilemap` que se ve y `Map::collides`
que bloquea son cosas distintas (un sarcófago se dibuja como actor pero el
`Map` lo trata como obstáculo).
- **Bloques `#ifdef _DEBUG`**: el editor de tilemap (`game/editor/`), los saltos
de sala (`[`/`]`), forzar apertura de puerta, y el arranque configurable por
`debug.yaml` solo existen en builds Debug.
- **Tests**: en `tests/` hay smoke tests y tests de FSM/animación
(`test_scene_flow`, `test_game_fsm`, `test_whip_attack`, etc.) con *stubs*
para aislar dependencias pesadas (audio, player).
- **Política de commits y branches**: ver `docs/convencions.md` (mensajes
cortos en valenciano, trabajo en ramas de feature, merge `--no-ff`).
---
## 14. Guía de navegación: "si quieres tocar X, mira Y"
| Quiero… | Empieza por… |
|---|---|
| Entender el arranque y el orden de init | `source/core/system/director.cpp` (ctor + `iterate()`) |
| Cambiar el orden update/render del frame | `Director::iterate()` (`director.cpp:509`) |
| Añadir una escena | `source/game/scenes/scene_flow.hpp` + `Director::buildScene` |
| Cómo se dibuja un sprite | `source/core/rendering/surface.cpp` (`renderWithColorReplace`) |
| El orden de pintado del juego | `GameScene::render()` (`game_scene.cpp:1682`) |
| El z-order / falsa perspectiva | `getFeetY()` + `stable_sort` en `game_scene.cpp:1755` |
| Iluminación / antorchas | `core/rendering/lighting.*` + `data/config/lighting.yaml` |
| Shaders / efecto CRT | `core/rendering/sdl3gpu/` + `data/shaders/` + `Options` |
| Escalado de ventana / fullscreen | `core/rendering/screen.cpp` (`adjustWindowSize`, `setScalingMode`) |
| Controles del jugador | `Player::Controls` (`player.hpp:23`) + `Player::tick` (`player.cpp`) |
| Hotkeys de sistema (F1–F12, ESC) | `core/input/global_inputs.cpp` + `data/config/input.yaml` |
| Menú de servicio (F9/SERVICE) | `source/game/ui/service_menu.*` |
| Mandos / vinculaciones | `core/input/gamepad_manager.*`, `gamepad_bindings.*` |
| Añadir un tipo de entidad | nueva clase en `game/entities/concrete/` + `REGISTER_ENTITY` |
| Eventos entre entidades | `game/entities/event_bus.hpp` |
| Lógica de una sala (intro/outro) | `RoomFsm` (`game/scenes/room_fsm.*`) + `data/scenes/game/game.yaml` |
| Game over / continue | `GameOverFsm` (`game/scenes/game_over_fsm.*`) |
| Sarcófagos / puerta / pintado | `concrete/sarcophagus.*`, `concrete/door.*`, `game/map/paint_layer.*` |
| Geometría/colisión de la sala | `source/game/map/map.hpp` |
| El tileset que se ve | `source/game/map/tilemap.*` + `data/tilesets/` |
| Progresión de salas | `game/levels/level_manager.*` + `data/maps/levels.yaml` |
| Editar mapas (Debug) | `source/game/editor/` |
| Cargar un recurso | `core/resources/resource_cache.*` + `resource_helper.*` |
| Empaquetar datos | `tools/pack_resources/` |
| Valores por defecto / constantes | `source/utils/defaults.hpp` |
| Textos / traducciones | `core/locale/locale.*` + `data/locale/*.yaml` |
| HUD (vidas/score/tesoros) | `source/game/ui/hud.*` (escucha `player_stats_changed`) |
| Audio (música/sfx) | `core/audio/` (`audio.*`, `jail_audio.*`) |
---
*Documento generado a partir de la lectura directa del código en el commit
actual de la rama `main`. Si algo aquí no cuadra con el código, el código
manda: actualiza este documento.*