24 KiB
Arquitectura de JailDoctor's Dilemma
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 el código contradice a la documentación previa (
CLAUDE.md), lo señalo: manda el código.JailDoctor's Dilemma es un puzzle-platformer 2D retro en C++20 + SDL3: 60+ habitaciones interconectadas, ítems coleccionables, enemigos y logros. Resolución de juego 256×192. 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
- Escenas y flujo de la aplicación
- Renderizado: de la lógica al píxel
- Entrada
- Lógica del juego: la escena
Game - Habitaciones y colisión
- Entidades
- Logros, estadísticas y marcador
- Editor de mapas (Debug)
- Consola y notificaciones
- Modo demo
- Recursos
- Audio, localización y configuración
- Convenciones y patrones recurrentes
- Guía de navegación: "si quieres tocar X, mira Y"
1. Visión general
El árbol source/ separa motor y juego:
source/core/— motor genérico:system(director,debug,global_events),rendering(+sprite,sdl3gpu),input,resources,audio,locale.source/game/— el juego concreto:scenes/,gameplay/,entities/,editor/,ui/,options.*,scene_manager.hpp,defaults.hpp.source/utils/—delta_timer,easing_functions,utils,defines.source/external/— vendorizado:fkyaml,stb_image,stb_vorbis.
Es el proyecto más grande de su familia: 138 ficheros C++, ~54.000 líneas.
Ideas-fuerza que conviene interiorizar:
- Render paletizado por CPU:
Surfacede 8 bits indexados + paleta, igual filosofía que un motor retro clásico; la GPU solo escala y aplica post-FX (§4). - Flujo por
SceneManager::current(variable global) + un únicoactive_scene_que elDirectorconmuta (§3). - El mundo son habitaciones de 256×128 px en tiles de 8 px, con colisión por superficies (suelos, paredes, rampas, cintas) y transición entre salas contiguas (§7).
- Trae editor de mapas y consola de comandos integrados (solo Debug) (§10, §11), un sistema de logros persistente (§9), y un modo demo que es un tour de habitaciones (§12).
graph TD
SDL[SDL3 callbacks · main.cpp] --> DIR[Director]
DIR -->|SceneManager::current| SW{switchToActiveScene}
SW --> SCN["BootLoader / Logo / Title / Game / Demo / Ending…"]
SCN --> GAME["Game (Mode GAME/DEMO)"]
GAME --> ROOM[Room + colisión] & RL[room_loader]
GAME --> PLAYER[Player] & EM[enemy_manager] & IM[item_manager]
GAME --> CHV[Cheevos] & STT[Stats] & SCB[Scoreboard]
GAME -->|blit paletizado| SURF["Surface (8-bit indexed)"]
SURF -->|toARGBBuffer / copyToTexture| SCREEN[Screen]
SCREEN --> GPU["ShaderBackend PostFX/CrtPi"] --> WIN[Ventana]
SCREEN -.fallback.-> WIN
RES["Resource::Cache / List"] -.-> GAME & SCN
EDIT["MapEditor (Debug)"] -.-> GAME
CON[Console] -.-> GAME
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_AppInit → new Director();
SDL_AppIterate→ Director::iterate(); // un frame
SDL_AppEvent → Director::handleEvent(event);
SDL_AppQuit → delete Director;
⚠️ Discrepancia con el
CLAUDE.md: este describe unDirector::run()con un buclewhile (SceneManager::current != QUIT). El código actual no es así: usa la API de callbacks de SDL3. ElDirectorreal (core/system/director.hpp) exponeiterate()yhandleEvent(), norun().
2.2. El Director
source/core/system/director.{hpp,cpp}. Mantiene un solo
std::unique_ptr<Scene> active_scene_ y un enum current_scene_. No guarda un
puntero por escena (a diferencia de los proyectos hermanos): construye la escena
bajo demanda en switchToActiveScene() (director.cpp).
El constructor inicializa los subsistemas en orden: Resource::List (registro
de assets desde config/assets.yaml), Options, Audio, Screen, Input,
Resource::Cache (con beginLoad()), y arranca en la escena BOOT_LOADER.
2.3. Arranque NO bloqueante
Resource::Cache no se carga de golpe. El constructor deja la escena en
BOOT_LOADER (una barra de progreso) y cada frame Director::iterate() llama a
Resource::Cache::get()->loadStep(50 /*ms*/) (director.cpp): carga assets
hasta agotar un presupuesto de 50 ms por frame, manteniendo ventana y eventos
vivos. Cuando termina, finishBoot() inicializa lo que depende de los recursos
(Notifier, RenderInfo, Console, Cheevos, Locale, en Debug Debug y
MapEditor) y fija la escena destino (LOGO en release; en Debug, la que diga
debug.yaml vía Debug::getInitialScene()).
2.4. Gestión del tiempo
Time-based: la escena Game posee un DeltaTimer delta_timer_
(utils/delta_timer.hpp) y toda la física/animación consume delta_time en
segundos. Las constantes de tiempo se documentan como "N frames a 66.67 fps →
segundos" (p.ej. BLACK_SCREEN_DURATION = 0.30F, game.hpp:46).
3. Escenas y flujo de la aplicación
3.1. La base Scene y el SceneManager
source/game/scenes/scene.hpp es minimalista:
class Scene {
public:
virtual void iterate() = 0; // un frame (update + render)
virtual void handleEvent(const SDL_Event&) = 0; // un evento
};
source/game/scene_manager.hpp define el flujo con variables globales
inline en el namespace SceneManager:
enum class Scene { BOOT_LOADER, LOGO, LOADING_SCREEN, TITLE, CREDITS,
GAME, DEMO, GAME_OVER, ENDING, ENDING2, RESTART_CURRENT, QUIT };
inline Scene current = Scene::BOOT_LOADER;
inline Options options = Options::LOGO_TO_LOADING_SCREEN;
inline Scene scene_before_restart = Scene::LOGO;
Cualquier escena solicita una transición asignando SceneManager::current.
3.2. La conmutación
Director::switchToActiveScene() (director.cpp):
RESTART_CURRENTes especial: restaurascene_before_restart(relanza la escena que estaba activa).active_scene_.reset()destruye la anterior (su destructor puede parar la música, etc.).- Un
switchconstruye la concreta:BootLoader,Logo,LoadingScreen,Title,Credits,Game(Mode::DEMO),Game(Mode::GAME),GameOver,Ending,Ending2.
Nótese que DEMO y GAME son la misma clase Game, parametrizada por
Game::Mode (§12).
graph LR
BOOT[BOOT_LOADER] --> LOGO --> LOADING[LOADING_SCREEN] --> TITLE
TITLE -->|jugar| GAME --> ENDING --> ENDING2 --> CREDITS
TITLE -->|attract| DEMO --> TITLE
GAME --> GAME_OVER --> TITLE
TITLE --> QUIT
4. Renderizado: de la lógica al píxel
El render es paletizado por CPU: se dibuja sobre superficies de 8 bits indexados y solo al final se sube a la GPU.
4.1. Surface: 8 bits indexados + paleta
source/core/rendering/surface.hpp. Una Surface guarda los píxeles como
índices Uint8 (SurfaceData) más una Palette de 256 colores ARGB y
una SubPalette (remapeo de índices, identidad por defecto vía std::iota).
Operaciones clave:
render(...)/renderWithColorReplace(src, dst)— blit con color transparente y reemplazo de índice (para recolorear sprites/glifos).renderWithVerticalFade(...)— disolución por hash 2D (cantos).fadePalette()/fadeSubPalette()— fundidos manipulando la paleta.copyToTexture(...)ytoARGBBuffer(buffer)— vuelcan la surface a unaSDL_Textureo a un buffer ARGB externo.
Sobre Surface se construyen los sprites (core/rendering/sprite/):
Sprite → AnimatedSprite (frames .yaml) → MovingSprite (posición/velocidad)
y DissolveSprite (transición). Texto: text.*. Efectos: pixel_reveal.*.
Paletas: palette_manager.* (el juego permite cambiar de paleta en caliente;
ver §5).
4.2. Screen y la composición
source/core/rendering/screen.{hpp,cpp}. Hay dos superficies/texturas:
game_surface_/game_texture_— el canvas de juego 256×192 (SDL_TEXTUREACCESS_STREAMING, ARGB8888;screen.cpp:125).border_surface_/border_texture_— el borde/overscan alrededor del canvas.
El path de presentación (Screen::render, screen.cpp:197):
- Con backend GPU acelerado: vuelca las superficies a buffers ARGB
(
toARGBBuffer) y los sube alshader_backend_(uploadPixels), que renderiza con el shader activo. - Sin backend (fallback):
copyToTexture+SDL_RenderTexturedegame_texture_yborder_texture_a la ventana.
El backend vive en core/rendering/sdl3gpu/ (interfaz shader_backend.hpp). Dos
shaders: PostFX y CrtPi (scanlines, curvatura, máscara, etc.), GLSL en
data/shaders/ compilados a SPIR-V (spv/*_spv.h), o Metal (MSL) en macOS
(sdl3gpu/msl/). En Emscripten (NO_SHADERS) se fuerza la ruta clásica.
graph TD
OBJ["room, player, enemies, items, HUD…"] -->|blit índices| GS["game_surface_ (256×192, 8-bit)"]
BORDER["borde / overscan"] --> BS[border_surface_]
GS -->|toARGBBuffer / copyToTexture| SCREEN[Screen]
BS --> SCREEN
SCREEN -->|uploadPixels| SHADER["ShaderBackend (PostFX / CrtPi)"]
SHADER --> WIN[Ventana]
SCREEN -.fallback SDL_Renderer.-> WIN
5. Entrada
5.1. Input
source/core/input/input.{hpp,cpp} + input_types.* — abstracción de teclado y
mando bajo un enum InputAction. Las vinculaciones se aplican desde Options
(Input::applyKeyboardBindingsFromOptions() /
applyGamepadBindingsFromOptions(), director.cpp). mouse.* gestiona el ratón
(usado sobre todo por el editor).
5.2. Hotkeys globales
source/core/input/global_inputs.{hpp,cpp} traduce eventos a acciones de sistema
(global_inputs.cpp):
- Ventana/vídeo: fullscreen, zoom ±, integer scale, vsync, info.
- Shaders: toggle; con Ctrl → siguiente shader, con Shift → siguiente preset.
- Paletas: siguiente / anterior (
NEXT_PALETTE/PREVIOUS_PALETTE), y orden de paleta — una seña de identidad de este juego (paleta intercambiable). - Borde (overscan) toggle, consola toggle, EXIT.
- En
GAME, EXIT vuelve aTITLE; elQUITglobal sale del programa.
Cuando la consola está activa, EXIT/ACCEPT se redirigen a ella en vez de
a la escena (global_inputs.cpp:231).
6. Lógica del juego: la escena Game
source/game/scenes/game.{hpp,cpp} es la escena de gameplay. Hereda de Scene y
coordina habitación, jugador, enemigos, ítems, marcador, estadísticas y logros.
6.1. FSM de la escena
Game::State (game.hpp:28): PLAYING → BLACK_SCREEN → GAME_OVER → FADE_TO_ENDING → POST_FADE_ENDING. Cada estado tiene su updateX/renderX;
transitionToState() cambia de estado y resetea los timers. El modo
(Game::Mode::GAME o DEMO) condiciona el comportamiento.
6.2. El frame
Game::iterate() calcula el delta con el DeltaTimer, llama a update()
(input + lógica + colisiones + cambio de sala) y a render(). El render del
estado PLAYING va directo a las superficies; los fades de fin de juego usan un
game_backbuffer_surface_.
6.3. Qué gestiona
- Habitación activa (
std::shared_ptr<Room> room_) y cambio de sala al tocar un borde (changeRoom,checkPlayerIsOnBorder; §7). - Jugador (
Player), con muerte (killPlayer,BLACK_SCREEN), y la "Jail" que restaura vidas con el tiempo (checkRestoringJail,JAIL_RESTORE_INTERVAL). - Colisiones: jugador↔enemigos (
checkPlayerAndEnemies) y jugador↔ítems (checkPlayerAndItems). - Progresión:
RoomTracker(salas visitadas),Stats,Scoreboard, fin de juego (checkEndGame) y secuencias de ending (fades aENDING/ENDING2). - Logros:
checkSomeCheevos,checkEndGameCheevos(§9).
7. Habitaciones y colisión
source/game/gameplay/room.{hpp,cpp} modela cada sala. Geometría: tiles de
8 px, mapa de 32×16 tiles (256×128 px). Los tipos de tile
(Room::Tile) son EMPTY, WALL, PASSABLE, SLOPE_L, SLOPE_R, KILL, ANIMATED
(room.hpp:32).
7.1. Datos de sala
Room::Data (room.hpp:42) se carga de YAML (vía RoomLoader,
Room::loadYAML). Contiene número/nombre, colores (fondo, borde, ítems),
salas contiguas (upper_room, lower_room, left_room, right_room →
navegación tipo metroidvania), tileset, el tile_map embebido, y las listas
de enemigos e ítems.
7.2. Colisión por superficies
La colisión no es AABB simple contra tiles, sino consultas de superficies:
checkRightSurfaces, checkLeftSurfaces, checkTopSurfaces,
checkBottomSurfaces, checkAutoSurfaces (cintas), más rampas
(checkLeftSlopes/checkRightSlopes, getSlopeHeight, getSlopeAtPoint) y
cintas transportadoras (checkConveyorBelts, conveyor_belt_direction_). El
jugador aporta puntos de colisión finos (8 collider_points_ + under_left_foot_
/ under_right_foot_; player.hpp:147). Los tiles KILL matan al jugador.
Subobjetos de Room: CollisionMap (datos de colisión), TilemapRenderer
(dibujo del tilemap), EnemyManager e ItemManager (ciclo de vida de enemigos
e ítems de la sala). RoomTracker (gameplay/room_tracker.*) registra las salas
visitadas.
8. Entidades
source/game/entities/:
Player(player.hpp) — física time-based conJUMP_VELOCITY = -80,GRAVITY_FORCE = 155.6px/s² (player.hpp:42). FSM de estados (IDLE/WALKING/ JUMPING/…), colisión por 8 puntos + "pies", controladores de sonido de salto y caída (JumpSoundController/FallSoundController), y unSpawnDatapara reaparecer (también usado al cambiar de sala conservando velocidad).Enemy(enemy.hpp) — enemigos con datos (Enemy::Data), colisión AABB, gestionados porEnemyManager(gameplay/enemy_manager.*).Item(item.hpp) — coleccionables (Item::Data), gestionados porItemManager(gameplay/item_manager.*) y rastreados porItemTracker.
No hay una clase base de entidad común con polimorfismo profundo: cada tipo tiene
su update/render/colisión y su manager dedicado dentro de la Room.
9. Logros, estadísticas y marcador
Cheevos(source/game/gameplay/cheevos.{hpp,cpp}) — singleton del sistema de logros.unlock(id),setUnobtainable(id),getTotalUnlockedAchievements(); estado persistido encheevos.bin(loadFromFile/saveToFile). La escenaGamellama acheckSomeCheevos/checkEndGameCheevos, y elNotifiermuestra el logro en pantalla (§11).Stats(gameplay/stats.*) — diccionario de estadísticas de partida (initStats).Scoreboard(gameplay/scoreboard.*) — datos y dibujo del marcador (Scoreboard::Datase comparte porshared_ptrcon la sala y el editor).ItemTracker/RoomTracker— progreso de ítems recogidos y salas visitadas.
10. Editor de mapas (Debug)
source/game/editor/ — solo se compila en _DEBUG (todo el header de
MapEditor está bajo #ifdef _DEBUG, map_editor.hpp:3). Es un editor de
habitaciones in-game completo, integrado con la escena Game y con la consola.
10.1. MapEditor (singleton)
map_editor.hpp. Se entra con enter(room, player, room_path, scoreboard_data)
sobre la sala viva. Funcionalidades:
- Pintado de tiles con brush (
brush_tile_,ERASER_BRUSH,painting_), preview bajo el cursor y rejilla opcional (renderGrid,settings_.grid). - Drag & drop de jugador, enemigos (posición inicial y bounds de patrulla)
e ítems (
DragTarget,DragState,handleMouseDown/Up,updateDrag), con snap a rejilla. - Edición de propiedades de enemigos, ítems y de la sala
(
setEnemyProperty,setItemProperty,setRoomProperty, colores, color de fondo…), invocables tanto por teclas como por comandos de consola. - Gestión de salas: crear (
createNewRoom(direction)), borrar (deleteRoom), con conexión a las salas contiguas. - Persistencia:
autosave()+room_saver.*escribe el YAML de la sala;revert()restaura desde el backup del nodo YAML (yaml_backup_).
10.2. Subcomponentes del editor
TilePicker(tile_picker.*) — selector visual de tiles del tileset (openTilePicker).MiniMap(mini_map.*) — minimapa de salas con conexiones, colores configurables (setMiniMapBg/setMiniMapConn).EditorStatusBar(editor_statusbar.*) — barra de estado con info de edición (updateStatusBarInfo).RoomSaver(room_saver.*) — serialización de la sala a YAML preservando campos no editados.
El editor guarda/restaura estado del juego al entrar/salir (invencibilidad, overlay de info) para no contaminar la partida.
11. Consola y notificaciones
11.1. Console
source/game/ui/console.{hpp,cpp} — consola de comandos in-game (singleton),
con estética de terminal verde sobre Surface propia. Características
(console.hpp):
- Panel animado (
StatusHIDDEN/RISING/ACTIVE/VANISHING), efecto typewriter, cursor parpadeante. - Historial navegable (flechas), autocompletado por TAB (
tab_matches_), word-wrap por ancho en píxeles. CommandRegistry(console_commands.{hpp,cpp}): metadatos (desde YAML) + handlers C++. Los comandos cubren depuración del juego y pilotan el editor de mapas (setEnemyProperty,addItem,setRoomProperty, etc.).- Scopes (
setScope/getScope): filtran qué comandos y autocompletados están disponibles según el contexto (p.ej. dentro del editor). on_togglenotifica a la escena cuando se abre/cierra (para pausar input de juego).
11.2. Notifier
source/game/ui/notifier.{hpp,cpp} — cola de notificaciones en pantalla (logros
desbloqueados, cambios de opción…). Se inicializa en finishBoot() y el Screen
las pinta como overlay (renderNotifications). El overlay de FPS/driver es
core/rendering/render_info.* (toggle por hotkey).
12. Modo demo
El modo demo de este juego NO es reproducción de input grabado. Es un tour automático de habitaciones (escaparate de niveles).
La escena Game construida con Game::Mode::DEMO recorre una lista curada de
salas y va cambiando cada DEMO_ROOM_DURATION = 6.0F segundos
(game.hpp:48):
// game.cpp — demoInit()
demo_ = DemoData(0.0F, 0, {"04.yaml","54.yaml","20.yaml","09.yaml",
"05.yaml","11.yaml","31.yaml","44.yaml"});
// demoCheckRoomChange(): acumula delta_time y, al llegar a 6s,
// avanza demo_.room_index y changeRoom(...). Al agotar la lista, vuelve.
No hay ficheros .bin ni DemoKeys: la demo simplemente pasea por las
habitaciones para la pantalla de atracción. La salida de la demo devuelve a la
escena de título.
13. Recursos
Resource::List(core/resources/resource_list.*) — registro de rutas de asset cargado deconfig/assets.yaml, con consultaget(filename)O(1).Resource::Cache(core/resources/resource_cache.*) — caché de surfaces, música, sonidos y datos de animación (getSurface,getMusic,getAnimationData). Carga incremental víabeginLoad()+loadStep(ms)(§2.3), con una FSM interna de etapas (LoadStage).- Pack y fallback:
resource_pack.*+resource_loader.*+resource_helper.*sirven desderesources.pack(release) o el filesystem (desarrollo). - Formatos: GIF (gráficos + paletas,
core/rendering/gif.*);.yamlpara animaciones y para las salas (data/room/);.palpara paletas (data/palette/); OGG/WAV para audio; GLSL para shaders.
14. Audio, localización y configuración
- Audio:
core/audio/audio.*(singleton, música y SFX) +audio_adapter.*sobrejail_audio(jail_audio.hpp), wrapper SDL3 first-party constb_vorbispara OGG. - Localización:
core/locale/locale.*carga las cadenas dedata/locale/. En release el locale vive dentro del pack (Locale::initFromContent);Options::languageselecciona el idioma. - Configuración:
source/game/options.{hpp,cpp}mantiene las opciones (ventana, vídeo+shaders, audio, idioma, controles, presets PostFX/CrtPi) y las persiste;source/game/defaults.hppreúne las constantes de gameplay y layout (canvas 256×192, tamaños de tile, colores de paleta). En Debug,debug.yaml(core/system/debug.*) fija la escena inicial. - Builds condicionales:
_DEBUG(editor, consola, overlay),RELEASE_BUILD,__EMSCRIPTEN__(locale/paths especiales,NO_SHADERS), y la selección de shaders por plataforma (SPIR-V vs Metal).
15. Convenciones y patrones recurrentes
- Singletons con
init()/destroy()/get():Screen,Input,Audio,Resource::Cache,Resource::List,Cheevos,Console,Notifier,RenderInfo,MapEditor,Debug. Se crean/destruyen en orden explícito desde elDirector(no por destructores estáticos). - Render paletizado por CPU (
Surfacede 8 bits +Palette/SubPalette), con recoloreado por reemplazo de índice y paletas intercambiables en caliente. - Flujo por variable global (
SceneManager::current) + un únicoactive_scene_. - Time-based: todo consume
delta_time(DeltaTimer); las constantes citan su equivalencia en frames a 66.67 fps. #ifdef _DEBUGenvuelve editor, consola de propiedades, overlays y atajos de depuración — ausentes en release.- Comentarios en español/valenciano; muchos
#includecon comentario "// Para X" (estilo IWYU). - El
CLAUDE.mdpuede ir por detrás del código (casoDirector::run()vs callbacks). Ante duda, manda el código.
16. Guía de navegación: "si quieres tocar X, mira Y"
| Quiero… | Empieza por… |
|---|---|
| Entender el arranque | core/system/director.cpp (ctor, iterate, finishBoot) |
| Cambiar el flujo de pantallas | game/scene_manager.hpp + Director::switchToActiveScene |
| Añadir/editar una pantalla | game/scenes/ (hereda de Scene) + un case en switchToActiveScene |
| La barra de carga / arranque | Resource::Cache::beginLoad/loadStep + scenes/boot_loader.* |
| Cómo se dibuja todo | core/rendering/surface.* + Screen::render (screen.cpp) |
| Sprites / animaciones | core/rendering/sprite/ + data/**/*.yaml (animaciones) |
| Paletas / recoloreado | core/rendering/palette_manager.* + Surface |
| Shaders / CRT | core/rendering/sdl3gpu/ + data/shaders/ |
| Controles / hotkeys | core/input/input.* + global_inputs.cpp |
| Lógica de partida | game/scenes/game.cpp (updatePlaying, FSM State) |
| Habitaciones / colisión | game/gameplay/room.* (check*Surfaces, slopes, cintas) |
| Cargar una sala | game/gameplay/room_loader.* + data/room/*.yaml |
| El jugador (física) | game/entities/player.* (JUMP_VELOCITY, GRAVITY_FORCE, colisión por puntos) |
| Enemigos / ítems | game/entities/{enemy,item}.* + gameplay/{enemy,item}_manager.* |
| Logros | game/gameplay/cheevos.* (+ cheevos.bin) |
| Marcador / estadísticas | game/gameplay/{scoreboard,stats,item_tracker,room_tracker}.* |
| Editor de mapas | game/editor/map_editor.* (+ tile_picker, mini_map, room_saver) |
| Consola / comandos | game/ui/console.* + console_commands.* |
| Notificaciones / FPS | game/ui/notifier.* + core/rendering/render_info.* |
| Modo demo (tour) | Game::demoInit/demoCheckRoomChange (game.cpp) |
| Cargar un recurso | core/resources/resource_cache.* + resource_list.* + config/assets.yaml |
| Audio | core/audio/audio.* + jail_audio.hpp |
| Idiomas | core/locale/locale.* + data/locale/ |
| Opciones / constantes | game/options.*, game/defaults.hpp |
| Escena inicial en Debug | core/system/debug.* + debug.yaml |
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.