Files
aee-arcade/docs/ARQUITECTURA.md
T

41 KiB
Raw Blame History

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
  2. Punto de entrada y bucle principal
  3. Gestión de escenas
  4. Renderizado: de la lógica al píxel
  5. Entrada
  6. Comunicación entre módulos
  7. Lógica del juego
  8. Entidades
  9. Mapa y niveles
  10. Recursos
  11. Configuración y constantes
  12. Modo demo / IA
  13. Convenciones y patrones recurrentes
  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, vendorizado en source/external/fkyaml_node.hpp.

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.

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):

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:

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.

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).

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:

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.