24 KiB
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 1–2 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
- Visión general
- Punto de entrada y bucle principal
- Secciones y flujo de la aplicación
- Renderizado: de la lógica al píxel
- Entrada
- Lógica del juego: la clase
Game - Entidades
- Modo demo y attract mode
- Recursos
- Audio
- Configuración y constantes
- Localización
- Convenciones y patrones recurrentes
- Guía de navegación: "si quieres tocar X, mira Y"
- 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.*ydefaults.hpp.source/utils/—utils.*(helpers,struct Section,Color, dificultad…) ydefines.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:
- El flujo se controla con un
struct Section { name, subsection }que elDirectorlee cada frame (§3). - 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). - El gameplay es monolítico: casi todo vive en la clase
Game, con vectores de punteros crudos ynew/deletemanual (§6, §7). - Sí hay modo demo (attract mode): reproducción de input grabado, no IA, orquestada desde la pantalla de título (§8).
- El proyecto está migrando de frame-based a time-based y de
config.txta 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_->namea unenum class ActiveSection(director.h:24). - Si coincide con la activa, no hace nada.
- Si cambió: libera las cuatro secciones (
reset()) y construye la nueva. ParaGAMEdecide el nº de jugadores segúnsection_->subsection(SUBSECTION_GAME_PLAY_1P→ 1, si no → 2) y creaGame(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:
Instructionsy la demo no son secciones delDirector. Viven dentro deTitle, que ejecuta un attract loop (ver §8). ElDirectorsolo 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.h—Textureenvuelve unSDL_Texture*cargado de PNG; métodorender(...)con clip/zoom/flip.core/rendering/sprite.hy 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_conSDL_RenderReadPixelsa unpixel_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_RenderTexturedelgame_canvas_a la ventana ySDL_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 enHelper(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 conKind(UP/LEFT/RIGHT) y estado de power-up.Item(item.h) — power-ups y objetos de puntos que caen, conIdpor 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_DEMOpropia del Director. Aquí elDirectorni se entera: todo el attract está encapsulado enTitle.
9. Recursos
Asset(core/resources/asset.h) — índice de ficheros de recurso (add/getpor 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 desderesources.pack, con fallback al filesystem en desarrollo. - Formatos: PNG (spritesheets) + ficheros
.ani(definición de animaciones); OGG (audio, víastb_vorbis); fuentes bitmap endata/font/. Los shaders GLSL dedata/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 namespaceOptions::(window,videocongpu/shader,audio,loading,settings,gameplay,inputs), más presetsPostFXPresetyCrtPiPreset.⚠️ El
CLAUDE.mdestá desactualizado en este punto: dice que la config vive enconfig.txtcon "migración a YAML pendiente". El código real (options.hpp:16) ya persiste enconfig.yamlvía fkyaml, con presets de shaders enpostfx.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 constantesSECTION_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:.hen lo antiguo,.hppen lo nuevo — pista fiable de qué módulos se han reescrito. - Punteros crudos +
new/deleteen 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 esDeltaTime::tick(). - Flujo por
struct Section+ constantesSECTION_PROG_*(no enums tipados ni objetos de transición). - Sub-estados dentro de la sección (pausa/game-over como
subsectiondeGame), no como secciones delDirector. - Attract mode encapsulado en
Title(demo + instrucciones). Gamemonolítico: la lógica no está repartida en managers; todo cuelga de la claseGamey de structs internos (Stage,EnemyFormation, …).- Comentarios en español/valenciano; muchos
#includecon comentario "// for X" (estilo IWYU). - El
CLAUDE.mdpuede 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.