Files
coffee-crisis/docs/ARQUITECTURA.md
T

24 KiB
Raw Blame History

Arquitectura de Coffee Crisis

Guía de orientación para un desarrollador nuevo en el proyecto.

Cada afirmación 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, o donde el código contradice a la documentación previa, lo digo explícitamente en lugar de inventarlo.

Coffee Crisis es un arcade en C++20 + SDL3: el jugador defiende la UPV de globos de café rebotantes a lo largo de 10 fases. Soporta 12 jugadores, teclado y mando, y varios idiomas. Es el predecesor de Coffee Crisis Arcade Edition; al final del documento (§15) hay un resumen de las diferencias entre ambos. Los comentarios del código están en español/valenciano; este documento está en castellano.


Índice

  1. Visión general
  2. Punto de entrada y bucle principal
  3. Secciones y flujo de la aplicación
  4. Renderizado: de la lógica al píxel
  5. Entrada
  6. Lógica del juego: la clase Game
  7. Entidades
  8. Modo demo y attract mode
  9. Recursos
  10. Audio
  11. Configuración y constantes
  12. Localización
  13. Convenciones y patrones recurrentes
  14. Guía de navegación: "si quieres tocar X, mira Y"
  15. Diferencias frente a la Arcade Edition

1. Visión general

El árbol source/ separa motor y juego:

  • source/core/ — motor genérico: system (director, delta_time, demo), rendering (+ sdl3gpu, sprites), input, resources, audio, locale.
  • source/game/ — el juego concreto: game.* (el hub de gameplay), entities/ (player, balloon, bullet, item), scenes/ (logo, intro, title, instructions), ui/ (menu), options.* y defaults.hpp.
  • source/utils/utils.* (helpers, struct Section, Color, dificultad…) y defines.hpp (macros de build).
  • source/external/ — vendorizado: stb_image, stb_vorbis (y headers YAML/JSON).

~51 ficheros C++ y ~16.000 líneas. Nota sobre cabeceras: los módulos antiguos usan extensión .h (p.ej. director.h, game.h, screen.h); los módulos nuevos usan .hpp (p.ej. demo.hpp, options.hpp, delta_time.hpp). Es un proyecto en migración, y eso se nota en varias capas.

Ideas-fuerza que conviene interiorizar:

  1. El flujo se controla con un struct Section { name, subsection } que el Director lee cada frame (§3).
  2. El render dibuja con texturas GPU (SDL_Renderer) sobre un canvas virtual de 256×192, con post-procesado opcional vía un backend SDL3 GPU (§4).
  3. El gameplay es monolítico: casi todo vive en la clase Game, con vectores de punteros crudos y new/delete manual (§6, §7).
  4. Sí hay modo demo (attract mode): reproducción de input grabado, no IA, orquestada desde la pantalla de título (§8).
  5. El proyecto está migrando de frame-based a time-based y de config.txt a YAML; conviven ambos mundos (§2, §11).
graph TD
    SDL[SDL3 callbacks · main.cpp] --> DIR[Director]
    DIR -->|struct Section| ST{handleSectionTransition}
    ST --> SEC["Logo / Intro / Title / Game"]
    SEC --> TITLE[Title] -.attract.-> NESTED["Game anidado en demo + Instructions"]
    SEC --> GAME["Game (monolítico)"]
    GAME --> ENT["Player* / Balloon* / Bullet* / Item* (punteros crudos)"]
    GAME --> DEMOSYS["Demo (playback grabado)"] -.-> ENT
    GAME -->|SDL_RenderTexture| CANVAS["game_canvas_ 256×192"]
    CANVAS --> SCREEN[Screen] --> SB["ShaderBackend PostFX/CrtPi"] --> WIN[Ventana]
    RES["Asset / Resource"] -.-> GAME & SEC

2. Punto de entrada y bucle principal

2.1. SDL conduce el bucle (callbacks)

source/main.cpp define SDL_MAIN_USE_CALLBACKS: no hay while propio. SDL llama a cuatro funciones, todas delegando en el Director:

SDL_AppInit    new Director(argc, argv);
SDL_AppIterate Director::iterate();         // un frame
SDL_AppEvent   Director::handleEvent(event);
SDL_AppQuit    delete Director;

2.2. El Director

source/core/system/director.h / .cpp. Inicializa SDL, ventana y renderer, crea la carpeta de sistema, monta input/audio/recursos, y mantiene un unique_ptr por sección (logo_, intro_, title_, game_) de los que solo uno está vivo (director.h:55). Guarda un puntero a la struct Section* section_ que comparte con la sección activa.

Director::iterate() cada frame: comprueba salida (doble ESC vía GlobalInputs::wantsQuit()), actualiza la visibilidad del cursor, llama a handleSectionTransition() y despacha iterate() a la sección activa (director.cpp, switch (active_section_)).

2.3. Gestión del tiempo (en migración)

El reloj central es source/core/system/delta_time.*: DeltaTime::tick() devuelve el delta en segundos consumido al inicio de cada frame de la sección (game.cpp, Game::iterate). El proyecto está migrando de frame-based a time-based: en game.h se ven contadores duplicados, el viejo frame-based (Uint16 death_counter_) y el nuevo time-based (float death_counter_s_), documentados como tales (game.h:347). El playback de la demo también es time-based: index = elapsed_s * 60 (demo.hpp:11).


3. Secciones y flujo de la aplicación

3.1. struct Section

source/utils/utils.h:58 define un POD minimalista:

struct Section {
    Uint8 name;        // SECTION_PROG_* (LOGO/INTRO/TITLE/GAME/QUIT)
    Uint8 subsection;  // SUBSECTION_* (p.ej. GAME_PLAY_1P, GAME_PAUSE, TITLE_INSTRUCTIONS…)
};

Los valores son constantes constexpr int en source/game/defaults.hpp (SECTION_PROG_LOGO = 0, …, SECTION_PROG_QUIT = 4; SUBSECTION_GAME_PLAY_1P, SUBSECTION_GAME_PAUSE, SUBSECTION_GAME_GAMEOVER, SUBSECTION_TITLE_INSTRUCTIONS, etc.; defaults.hpp:90). Cualquier parte del código cambia el flujo asignando section_->name = ... / section_->subsection = ....

3.2. Transición de secciones

Director::handleSectionTransition() (director.cpp):

  • Traduce section_->name a un enum class ActiveSection (director.h:24).
  • Si coincide con la activa, no hace nada.
  • Si cambió: libera las cuatro secciones (reset()) y construye la nueva. Para GAME decide el nº de jugadores según section_->subsection (SUBSECTION_GAME_PLAY_1P → 1, si no → 2) y crea Game(NUM_PLAYERS, 0, renderer_, /*demo=*/false, section_).

Cada sección recibe el renderer_ y el Section*, y expone iterate() (y, en Game, handleEvent()).

graph LR
    LOGO --> INTRO --> TITLE
    TITLE -->|jugar| GAME --> TITLE
    TITLE -->|attract / manual| INSTR["Instructions (dentro de Title)"]
    TITLE -->|attract| DEMOG["Game anidado en demo (dentro de Title)"]
    TITLE --> QUIT

Matiz importante: Instructions y la demo no son secciones del Director. Viven dentro de Title, que ejecuta un attract loop (ver §8). El Director solo conoce Logo/Intro/Title/Game/Quit.


4. Renderizado: de la lógica al píxel

Los sprites son texturas GPU dibujadas por SDL_Renderer sobre un canvas virtual; el post-procesado va por un backend SDL3 GPU.

4.1. El canvas virtual 256×192

Screen (core/rendering/screen.h) crea game_canvas_, una SDL_Texture de 256×192 (GAMECANVAS_WIDTH/HEIGHT en defaults.hpp:64) con SDL_TEXTUREACCESS_TARGET (screen.cpp:106). Toda la geometría del juego se deriva de esa resolución y de un BLOCK base (áreas de juego en defaults.hpp).

Screen::start() (screen.cpp:166) fija el render-target a game_canvas_; a partir de ahí, la sección activa dibuja sus sprites sobre él.

4.2. Texturas y jerarquía de sprites

  • core/rendering/texture.hTexture envuelve un SDL_Texture* cargado de PNG; método render(...) con clip/zoom/flip.
  • core/rendering/sprite.h y derivados:
    • Sprite — dibuja desde un spritesheet.
    • AnimatedSprite — animación por fotogramas, definida en ficheros .ani.
    • MovingSprite — añade posición/velocidad (p.ej. las nubes del fondo).
    • SmartSprite — sprite autónomo (popups de puntuación, el café que salta al recibir un golpe).
  • Texto: core/rendering/text.h + writer.h (fuentes bitmap).
  • Transiciones: core/rendering/fade.h. Notificaciones: core/rendering/notifications.*.

4.3. Post-procesado y presentación

El path de presentación (screen.cpp:185) decide cómo llega el canvas a la ventana:

  • Con backend GPU acelerado: lee los píxeles de game_canvas_ con SDL_RenderReadPixels a un pixel_buffer_ (ARGB8888; screen.h:162), los sube al backend (shader_backend_->uploadPixels(...)) y este renderiza con el shader activo a la ventana.
  • Sin backend / desactivado (fallback): SDL_RenderTexture del game_canvas_ a la ventana y SDL_RenderPresent (screen.cpp:233).

El backend vive en core/rendering/sdl3gpu/ (interfaz abstracta en shader_backend.hpp). Dos shaders: PostFX (viñeta, scanlines, chroma, gamma, máscara, curvatura, bleeding, flicker) y CrtPi (scanlines continuas con bloom). Los GLSL de data/shaders/ se compilan a SPIR-V (spv/*_spv.h) vía glslc; en macOS se usan shaders Metal (MSL) inline (sdl3gpu/msl/). El build NO_SHADERS (Emscripten) fuerza la ruta clásica.

graph TD
    OBJ["fondo, globos, jugador, balas, items…"] -->|SDL_RenderTexture| CANVAS["game_canvas_ 256×192 (render target)"]
    CANVAS -->|RenderReadPixels → uploadPixels| SHADER["ShaderBackend (PostFX / CrtPi)"]
    SHADER --> WIN[Ventana]
    CANVAS -.fallback sin GPU.-> WIN

4.4. Modos de escalado y efectos

La presentación a la ventana respeta Options::video.presentation_mode (INTEGER_SCALE, LETTERBOX, STRETCHED, OVERSCAN; options.hpp:24). El Game añade efectos como flash, shake y un death shake intenso (game.h:100, DeathShake).


5. Entrada

5.1. Input

source/core/input/input.h — abstracción de teclado y mando bajo un enum de acciones (Input::Action). El jugador se mueve con flechas y dispara izquierda/centro/derecha; el Input::Device selecciona teclado o mando por jugador (Game::player_one_control_, game.h:379).

5.2. Hotkeys globales y salida

source/core/input/global_inputs.* gestiona las teclas de sistema (ventana, vídeo, post-FX, idioma, FPS overlay…) y la salida en dos pasos: la primera pulsación de ESC arma una confirmación y la segunda activa wantsQuit(), que el Director traduce a SECTION_PROG_QUIT (director.cpp). El cursor del ratón se autooculta (core/input/mouse.*).

Las hotkeys de shaders documentadas: F4 activa/desactiva post-procesado, F5 alterna PostFX↔CrtPi, F6 siguiente preset (ver CLAUDE.md).

5.3. Cómo llega la entrada al jugador

Dentro de Game, checkGameInput()processLiveInput()processPlayerLiveInput(player, i) consulta Input y llama a player->setInput(Input::Action) y a createBullet(...) al disparar (game.h:228). En modo demo, esa misma vía la alimenta processDemoInput() con datos grabados (§8).


6. Lógica del juego: la clase Game

source/game/game.{h,cpp} es el hub de gameplay y, con diferencia, la clase más grande del proyecto: ~400 líneas solo de declaración. A diferencia de la Arcade Edition, no delega en managers: las formaciones, fases, globos, balas e ítems se gestionan directamente aquí.

6.1. El frame y sus sub-bucles

Game::iterate() (game.cpp) consume el delta con DeltaTime::tick() y despacha según section_->subsection:

switch (section_->subsection) {
    case SUBSECTION_GAME_PAUSE:    iteratePaused(dt);   break;
    case SUBSECTION_GAME_GAMEOVER: iterateGameOver(dt); break;
    case SUBSECTION_GAME_PLAY_1P:
    case SUBSECTION_GAME_PLAY_2P:  iteratePlaying(dt);  break;
}

Es decir, pausa y game-over son sub-estados del propio Game (no secciones del Director), cada uno con su update/render. En modo demo, entrar en pausa o game-over rebota directamente a Title (game.cpp, Game::iterate).

6.2. Lo que gestiona Game

Todo dentro de la misma clase (game.h):

  • Fases (Stage stage_[10]): cada fase tiene un pool de formaciones enemigas (EnemyPool/EnemyFormation/EnemyInit, structs internos), poder para completarla y umbrales de amenaza. initEnemyFormations* precalcula las formaciones (lineales, simétricas, hexágonos…).
  • Nivel de amenaza (menace_current_/menace_threshold_): si la amenaza cae bajo el umbral, se despliega otra formación (updateMenace, evaluateAndSetMenace).
  • Ítems y power-ups: disco/gaviota/pacmar (puntos), café (toque extra), máquina de café (power-up), reloj (detener el tiempo, enableTimeStopItem), power ball. Probabilidades en Helper (game.h:128).
  • Colisiones: jugador↔globo, jugador↔ítem, bala↔globo (checkPlayer…, checkBulletBalloonCollision).
  • Muerte del jugador: secuencia con death shake y fases (DeathSequence/DeathPhase, game.h:113).
  • Marcador, hi-score, fades, fondo (nubes con parallax via MovingSprite), menús de pausa y game-over (Menu), audio (Ja::Sound*/Ja::Music*).

6.3. Gestión de memoria

Las entidades viven como vectores de punteros crudos (std::vector<Player*>, <Balloon*>, <Bullet*>, <Item*>, <SmartSprite*>; game.h:264), creados con new y liberados con métodos freeBalloons(), freeBullets(), freeItems(), deleteAllVectorObjects(). Es un estilo más antiguo que el de la Arcade Edition (smart pointers).


7. Entidades

source/game/entities/:

  • Player (player.h) — movimiento, disparo (tres direcciones), animaciones, power-up, invulnerabilidad, vidas/score. Puede usar teclado o mando.
  • Balloon (balloon.h) — enemigo básico que rebota; al explotar puede generar globos hijos. Tiene varios contadores de estado.
  • Bullet (bullet.h) — proyectil con Kind (UP/LEFT/RIGHT) y estado de power-up.
  • Item (item.h) — power-ups y objetos de puntos que caen, con Id por tipo.

No hay clase base de entidad común ni managers: el ciclo de vida lo lleva Game directamente sobre los vectores (§6.3). Los efectos visuales tipo "popup de puntuación" o "café arrojado" se modelan como SmartSprite (core/rendering/smartsprite.h).


8. Modo demo y attract mode

El modo demo SÍ existe, y NO es IA: es reproducción de input pregrabado, igual concepto que en la Arcade Edition.

8.1. Formato

source/core/system/demo.hpp: cada fotograma es un DemoKeys con seis banderas (left, right, no_input, fire, fire_left, fire_right). Una demo es un vector<DemoKeys> de TOTAL_DEMO_DATA = 2000 fotogramas "a 60 Hz de referencia" (demo.hpp:9). Hay tres ficheros: data/demo/demo{1,2,3}.bin. El playback es time-based: index = elapsed_s * 60.

8.2. Reproducción

Cuando un Game corre en modo demo, processDemoInput() (game.cpp) lee el fotograma actual del set seleccionado y lo inyecta por la misma vía que un humano sobre players_[0]:

const DemoKeys &keys = dd.at(demo_.index % dd.size());
if (keys.left  == 1) players_[0]->setInput(Input::Action::LEFT);
if (keys.fire  == 1 && players_[0]->canFire()) {
    players_[0]->setInput(Input::Action::FIRE_CENTER);
    createBullet(...); players_[0]->setFireCooldown(10);
}
// … (right, no_input, fire_left, fire_right)

No hay toma de decisiones: repite las pulsaciones grabadas. Al agotar el playback (index >= TOTAL_DEMO_DATA) vuelve a Title.

8.3. Attract mode (dentro de Title)

El bucle de atracción vive en source/game/scenes/title.cpp: el Title arma un timeout (demo_remaining_s_) y, al agotarse, lanza un Game anidado en modo demo (runDemoGame(), demo_game_, demo_game_active_; title.cpp:323). Title tiquea ese demo_game_->iterate() directamente y, al terminar la demo, encadena las instrucciones (demo_then_instructions_runInstructions(Instructions::Mode::AUTO), title.cpp:334) antes de volver al título. Así el Title alterna atracción → demo → instrucciones de forma autónoma.

Es una diferencia notable con la Arcade Edition, donde la demo es una sección GAME_DEMO propia del Director. Aquí el Director ni se entera: todo el attract está encapsulado en Title.


9. Recursos

  • Asset (core/resources/asset.h) — índice de ficheros de recurso (add/get por nombre).
  • Resource (core/resources/resource.h) — carga y caché de los recursos (texturas, sonidos, música, fuentes, animaciones).
  • Pack: resource_pack.* + resource_loader.* + resource_helper.* sirven desde resources.pack, con fallback al filesystem en desarrollo.
  • Formatos: PNG (spritesheets) + ficheros .ani (definición de animaciones); OGG (audio, vía stb_vorbis); fuentes bitmap en data/font/. Los shaders GLSL de data/shaders/ no van al pack (se embeben en el binario como cabeceras SPIR-V).

10. Audio

source/core/audio/Audio (audio.hpp) + audio_adapter sobre jail_audio (jail_audio.hpp), wrapper de audio SDL3 first-party (no librería externa) que usa stb_vorbis para OGG y mezcla por canales (API JA_*). Game mantiene punteros Ja::Sound* para cada efecto (explosión, disparo, colisión, reloj, etc.) y un Ja::Music* game_music_.


11. Configuración y constantes

  • Options (source/game/options.hpp) — opciones persistentes en el namespace Options:: (window, video con gpu/shader, audio, loading, settings, gameplay, inputs), más presets PostFXPreset y CrtPiPreset.

    ⚠️ El CLAUDE.md está desactualizado en este punto: dice que la config vive en config.txt con "migración a YAML pendiente". El código real (options.hpp:16) ya persiste en config.yaml vía fkyaml, con presets de shaders en postfx.yaml/crtpi.yaml. El código manda.

  • defaults.hpp (source/game/) — constantes de gameplay y layout: tamaño de canvas (256×192), BLOCK, áreas de juego, colores, y las constantes SECTION_PROG_* / SUBSECTION_* del flujo (§3).

  • utils/defines.hpp — macros de build.

Builds condicionales

Aparecen sobre todo en Director/Screen: __EMSCRIPTEN__ (web: no se puede salir, reinicia al logo; NO_SHADERS forzado), DEBUG, y la selección de plataforma para shaders (SPIR-V vs Metal). make release empaqueta .tar.gz / .dmg / .zip según el SO.


12. Localización

source/core/locale/lang.*Lang carga las cadenas desde data/lang/ (es_ES, ba_BA/euskera, en_UK). El idioma se elige en Options::settings.language.


13. Convenciones y patrones recurrentes

  • Cabeceras mixtas .h / .hpp: .h en lo antiguo, .hpp en lo nuevo — pista fiable de qué módulos se han reescrito.
  • Punteros crudos + new/delete en el gameplay (Game), frente a smart pointers en el resto (secciones, Screen). Migración a medias.
  • Migración frame-based → time-based: contadores duplicados (x_counter_ + x_counter_s_) conviviendo; el reloj es DeltaTime::tick().
  • Flujo por struct Section + constantes SECTION_PROG_* (no enums tipados ni objetos de transición).
  • Sub-estados dentro de la sección (pausa/game-over como subsection de Game), no como secciones del Director.
  • Attract mode encapsulado en Title (demo + instrucciones).
  • Game monolítico: la lógica no está repartida en managers; todo cuelga de la clase Game y de structs internos (Stage, EnemyFormation, …).
  • Comentarios en español/valenciano; muchos #include con comentario "// for X" (estilo IWYU).
  • El CLAUDE.md puede ir por detrás del código (caso config.txt→YAML): ante duda, manda el código.

14. Guía de navegación: "si quieres tocar X, mira Y"

Quiero… Empieza por…
Entender el arranque source/core/system/director.cpp
Cambiar el flujo de pantallas struct Section (utils/utils.h) + constantes en game/defaults.hpp + handleSectionTransition
Añadir/editar una pantalla source/game/scenes/ (Logo/Intro/Title/Instructions)
Gestión del tiempo source/core/system/delta_time.*
Cómo se dibuja todo Screen::start/render (core/rendering/screen.cpp)
Canvas / resolución / áreas source/game/defaults.hpp (256×192, BLOCK)
Sprites / animaciones .ani core/rendering/sprite.h + animatedsprite.h + texture.h
Shaders / CRT / post-FX core/rendering/sdl3gpu/ + data/shaders/ + Options
Modos de escalado / efectos Screen + Options::video.presentation_mode
Controles / mandos core/input/input.h
Hotkeys / salida en dos pasos core/input/global_inputs.cpp
Toda la lógica de partida source/game/game.cpp (iteratePlaying/Paused/GameOver)
Fases / formaciones / amenaza Game::initEnemyFormations*, Stage stage_[10], updateMenace
Globos / balas / ítems game/entities/{balloon,bullet,item}.* (gestionados en Game)
El jugador game/entities/player.*
Ítems y power-ups Game::dropItem/createItem, Helper (game.h)
Modo demo / attract core/system/demo.*, Game::processDemoInput, scenes/title.cpp (runDemoGame)
Cargar un recurso core/resources/asset.h + resource.h
Audio core/audio/audio.hpp + jail_audio.hpp
Opciones del usuario game/options.hpp (+ config.yaml)
Valores por defecto / constantes game/defaults.hpp, utils/defines.hpp
Idiomas core/locale/lang.* + data/lang/
Empaquetar datos tools/ + make pack

15. Diferencias frente a la Arcade Edition

coffee_crisis es el predecesor de coffee_crisis_arcade_edition. Ambos comparten ADN (SDL3, jail_audio, demo por input grabado, backend SDL3 GPU con PostFX/CrtPi, capas core/game), pero el código diverge de forma sistemática. Resumen de lo observado leyendo ambos repos:

Dimensión Coffee Crisis (este) Arcade Edition
Tamaño ~51 ficheros, ~16k LOC ~150 ficheros, ~32k LOC
Cabeceras mixto .h (antiguo) / .hpp todo .hpp
Flujo struct Section{name,subsection} + enum ActiveSection; 4 secciones (Logo/Intro/Title/Game) variable global Section::name con muchas más (Preload, HiScore, Credits, GameDemo, Instructions…)
Arranque directo no bloqueante con sección PRELOAD + Resource::loadStep(50ms)
Gameplay Game monolítico; formaciones/fases como structs internos managers (BalloonManager, BulletManager, StageManager con IStageInfo)
Memoria de entidades punteros crudos + new/delete shared_ptr/unique_ptr + listas
Pausa / game-over sub-estados dentro de Game (subsection) FSM de estados de Game + managers dedicados
Demo / attract encapsulado en Title (Game anidado en demo + instrucciones) sección GAME_DEMO propia del Director + attract Title↔Logo/Demo
Canvas 256×192 fijo (defaults.hpp) parametrizable (param_320x*.txt)
Render un único game_canvas_ → readback → shader / fallback dos render-targets (canvas_ zona de juego → game_canvas_) → shader / fallback
Sprites Sprite/AnimatedSprite/MovingSprite/SmartSprite añade PathSprite, CardSprite
Reinicio en caliente (no observado a nivel de relaunch()) Director::relaunch() vía execv
Plataformas Linux/macOS/Windows/Emscripten + Raspberry Pi, Anbernic
Estado del código en migración: frame→time-based, config.txt→YAML más consolidado (YAML, time-based)

En una frase: la Arcade Edition es esta misma idea refactorizada y ampliada — se troceó el Game monolítico en managers, se pasó a smart pointers, se añadieron secciones y plataformas, y se consolidó la migración a time-based y YAML que aquí todavía está a medias.


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.