41 KiB
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
- Visión general
- Punto de entrada y bucle principal
- Gestión de escenas
- Renderizado: de la lógica al píxel
- Entrada
- Comunicación entre módulos
- Lógica del juego
- Entidades
- Mapa y niveles
- Recursos
- Configuración y constantes
- Modo demo / IA
- Convenciones y patrones recurrentes
- 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:
- 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.
- 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:
SDL_Init(0)(sin subsistemas todavía).- Sistema de recursos: localiza
resources.packjunto al binario; en_DEBUGactiva 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). createSystemFolder()— carpeta de configuración por plataforma (ver §11).GamepadManager::get().init()antes de cargar opciones (las opciones re-vinculan mandos a jugadores;director.cpp:86).- Carga de
config.yaml,gamepad_configs.yaml,gameplay.yaml, presets de shaders (postfx.yaml,crtpi.yaml). Screen::get().configure(...)— crea ventana + backend GPU.Locale,KeyConfig,UiConfig,Lighting,EditorConfig(Debug).- Pre-carga de recursos de UI: paleta, fuentes, sombra compartida, pistas de música, sonidos de menú.
Audio::init(...).- Construcción de los overlays compartidos (
Notifier,RenderInfo,ServiceMenu) y de lasSurface/Textcompartidas entre escenas. - Registro del overlay hook del menú en
Screen(director.cpp:291). - 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 unstd::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
buildScenese ve que algunas escenas todavía reciben recursos directamente delDirectorpor el constructor en vez de pasar por el canalExitRequest. Es un estado intermedio, comentado endirector.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.yamlde la carpeta de sistema; si no existe, lo crea constart_scene: GAMEpara no tragarse los placeholders en el día a día (director.cpp:301).debug.yamltambién puede fijarstarting_playersystarting_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). Screenmantienestd::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 porRendering::activeCanvas()(source/core/rendering/canvas.hpp:38), para no acoplarse al ciclo de vida deScreen. 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
Surfaceguarda 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 conGIF::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 asetTransparentColor().
El núcleo del dibujo es renderWithColorReplace() (surface.cpp:90). Para
cada píxel del rectángulo fuente:
- Comprueba el clip y descarta fuera de él.
- Lee el índice fuente; si es el transparente, salta (no escribe).
- Aplica
fliphorizontal/vertical reindexando la coordenada fuente. - Opcionalmente reemplaza un índice por otro (
source_color → target_color), usado porTextpara recolorear glifos (la "tinta" se guarda con un único índice). - 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íastd::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).AnimatedSpritereproduce tiras de fotogramas; su parser está enanimated_sprite_parser.cpp. ElPlayerhereda deAnimatedSprite(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:
- Fondo:
Screen::fillRectcon el color de fondo de la paleta UI. - Tilemap:
tilemap_.render()— copia de la textura prerenderizada (ver §9.2). Las celdas de sarcófago quedan transparentes. - (Editor Debug): si está activo, pinta entidades congeladas y vuelve.
- Capa de pintado (
PaintLayer): el "rastro" del jugador por el suelo. - Sombras:
p->renderShadow()de cada jugador en el aire — por debajo de todos los actores. - Z-sort de actores: entidades + jugadores se fusionan en un vector y se
ordenan con
std::ranges::stable_sortporgetFeetY()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. - Pasada de iluminación:
applyLightingPass()(ver §4.4). - Fade de sala (
renderRoomFade): negro entre actores y HUD. - HUD:
hud_->render(). - Texto de transición de sala y rótulo de game over.
- Fade final de game over.
Notifier::render()yRenderInfo::render()(overlays de debug).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):
- Llama al overlay hook (menú de servicio) para que pinte sobre el
canvas antes de subirlo (
screen.cpp:310). - Actualiza el contador de FPS.
- Si hay backend GPU (
shader_backend_):uploadPixels(canvas)+render(). Este es el camino por defecto. - Si no (fallback):
SDL_UpdateTexturede 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_ten 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 aMOUSE_MOTIONpara reaparecer el cursor (auto-ocultación). -
Gamepad hot-plug y botón SERVICE (
processGamepadEvents,director.cpp:417): ADDED/REMOVED van aGamepadManager; 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. IteraEventBufferbuscandoKEY_DOWN. Las acciones se leen deKeyConfig(data/config/input.yaml):Acción Efecto dec_zoom/inc_zoomcambia zoom de ventana fullscreenalterna pantalla completa toggle_vsyncalterna VSync screenshotguarda captura toggle_shader/next_shader/next_presetpost-FX next_scaling_modecicla letterbox/integer/… toggle_render_infocicla 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). CadaPlayertiene su propioControls(struct deSDL_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 usanTitleScene(empezar partida) yGameScene(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 engamepad_configs.yaml(fichero aparte deconfig.yamlporque 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::varianttipado (event_bus.hpp:98), novoid*nistd::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)— elDirectorregistra una lambda que pinta elServiceMenusobre el canvas enpresent(). AsíScreenpuede poner el menú por encima de cualquier escena sin incluirgame/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 laheight_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 quedaDISABLEDy 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):
player->tick(DT)— cada jugador calcula su velocidad intencionada (teclado- mando + facing).
resolvePlayerVsPlayer(...)— anula velocidades en choque frontal o las escala apush_factor_(empuje).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 publicasarcophagus_openedy reparte recompensa (points/treasures) al jugador atribuido. - Puerta (
concrete/door.hpp): FSMCLOSED → OPENING → OPEN. ElEntityManagerla 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 disparaPlayer::exit()→player_exited→level_complete→ OUTRO. - Pintado del suelo (
game/map/paint_layer.*): escuchaon_tile_entered, filtra pintabilidad (Map::isPaintable) y re-publicatile_paintedpara 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 publicanplayer_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íneaREGISTER_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 porcore/rendering/gif.*. Cada GIF lleva su paleta. - Fuentes: pares
.fnt+.gif(data/fonts/), consumidos porText(core/rendering/text.*). - Música:
.oggdecodificado constb_vorbis(source/external/). - Paletas sueltas:
.palJASC 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), yshaders/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 classcon tipo subyacente explícito,std::optional/std::varianten vez de punteros nulos ovoid*. Hay.clang-formaty.clang-tidyen la raíz. - Meyers singleton con ciclo de vida manual: los singletons que dependen de
SDL (
Screen,Audio,Cachepara audio) exponenget()pero suconfigure()/shutdown()los llama explícitamente elDirector, no el destructor estático. El motivo (crashes envkDestroyInstanceal descargar Vulkan/Wayland antes de tiempo) está documentado enscreen.hpp:28ydirector.cpp:336. - Inyección de dependencias por
std::function/ setters para invertir la direccióncore/↛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::anysolo en la fronteraScene↔Directorpara 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
Tilemapque se ve yMap::collidesque bloquea son cosas distintas (un sarcófago se dibuja como actor pero elMaplo 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 pordebug.yamlsolo 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.