42 KiB
Arquitectura de Orni Attack
Documento de orientación para alguien que llega nuevo al proyecto. Cada afirmación está anclada a código real (fichero/clase/función con su ruta). Cuando algo no se ha podido verificar o no existe, se indica explícitamente. El objetivo no es vender una arquitectura ideal, sino describir lo que este proyecto hace, incluso donde es poco convencional.
Índice
- Visión general
- Punto de entrada y el Director
- Bucle principal
- Sistema de escenas
- Renderizado: de la lógica al píxel
- Entrada
- Audio
- Recursos
- Comunicación entre módulos
- Lógica del juego
- IA del modo demo (attract)
- Efectos visuales
- Configuración, constantes y convenciones
- Guía de navegación
1. Visión general
Orni Attack es un arcade vectorial (estética CRT de líneas con bloom) construido
sobre SDL3, usando la GPU API de SDL3 (SDL_gpu) para el render — no
SDL_Renderer. El código está partido en dos grandes mundos:
source/core/— el "motor": ventana, GPU, audio, input, recursos, i18n, overlays de sistema. No conoce nada del juego concreto. Por ejemplo, audio.hpp recibe un struct de configuración y no lee YAML, e input.hpp no incluye nada degame/.source/game/— la lógica concreta de Orni Attack: escenas, entidades (naves, enemigos, balas), sistemas (colisiones, IA), stages/oleadas y efectos.
El punto de indirección entre ambos mundos para el render es
render_context.hpp: el juego habla con
un Rendering::Renderer* opaco que es un alias de GPU::GpuFrameRenderer. Esto
permite cambiar de backend sin tocar las firmas del juego.
graph TD
subgraph entry["Punto de entrada"]
MAIN["main.cpp<br/>SDL_MAIN_USE_CALLBACKS"]
end
MAIN -->|posee| DIR["Director<br/>(es el programa)"]
subgraph core["source/core (motor)"]
SDLM["SDLManager<br/>ventana + GPU"]
GE["GlobalEvents<br/>F1-F7/F12/ESC/hotplug"]
INPUT["Input (singleton)"]
AUDIO["Audio (singleton)"]
RES["Resource::Loader / Pack"]
LOC["Locale (i18n)"]
OVL["Notifier · ServiceMenu<br/>DebugOverlay · DefineInputs"]
end
subgraph game["source/game (juego)"]
SCN["Scenes<br/>Logo · Title · Game"]
ENT["Entities<br/>Ship · Enemy · Bullet"]
SYS["Systems<br/>Collision · EnemyAi · DemoPilot"]
STG["StageManager / WaveRunner"]
FX["Effects<br/>debris · firework · score · trail"]
end
DIR --> SDLM
DIR --> GE
DIR --> OVL
DIR --> SCN
SCN --> ENT
SCN --> SYS
SCN --> STG
SCN --> FX
GE --> INPUT
SCN -.usa.-> AUDIO
SCN -.usa.-> RES
OVL -.usa.-> LOC
Patrón dominante de comunicación: singletons globales (Input::get(),
Audio::get(), Locale::get(), Notifier, ServiceMenu) más paso por
referencia de un Rendering::Renderer* y un SceneContext&. No hay un bus de
eventos genérico ni un ECS — las entidades viven en std::array de tamaño fijo
dentro de GameScene y los sistemas operan sobre un struct Context de punteros
(ver §10).
Rasgo de diseño destacable: gran parte de la lógica es data-driven. Los
enemigos, balas y el jugador se describen en YAML declarativo
(data/entities/*/*.yaml: physics/ai/animation/events), los stages en
data/stages/stages.yaml (oleadas), y las figuras vectoriales en ficheros .shp.
2. Punto de entrada y el Director
El main real está en main.cpp y usa el modo de callbacks de
SDL3 (#define SDL_MAIN_USE_CALLBACKS 1). En lugar de un bucle while clásico,
SDL llama a cuatro funciones, y todas son pura fontanería que delega en un
Director:
// main.cpp
auto SDL_AppInit(void** appstate, int argc, char* argv[]) -> SDL_AppResult {
System::Relaunch::setArgv(argc, argv);
auto director = std::make_unique<Director>(argc, argv);
*appstate = director.release(); // SDL guarda el puntero
return SDL_APP_CONTINUE;
}
auto SDL_AppEvent(void* s, SDL_Event* e) { return ((Director*)s)->handleEvent(*e); }
auto SDL_AppIterate(void* s) { return ((Director*)s)->iterate(); }
void SDL_AppQuit(void* s, ...) { /* reabsorbe y destruye el Director */ }
La filosofía está escrita en el propio comentario de cabecera de director.hpp:
El Director és EL programa: posseeix la configuració, els subsistemes i l'estat.
Como con SDL_MAIN_USE_CALLBACKS no hay un scope que envuelva todo el bucle,
el estado que antes vivía en un run() ahora es miembro del Director:
sdl_ (SDLManager), context_ (SceneContext), debug_overlay_ y
current_scene_ (todos std::unique_ptr, ver
director.hpp:45-48).
Orden de arranque (constructor)
El constructor Director::Director ejecuta el bootstrap completo, en este orden:
ConfigYaml::init()— valores por defecto de configuración.- Parseo de argumentos (
--console,--reset-config) en checkProgramArguments. Utils::initializePathSystem()+ sistema de recursos (§8): en release elresources.packes obligatorio; en dev hay fallback adata/.- Crea la carpeta de sistema (
~/.config/jailgames/<NAME>en Linux) y carga/creaconfig.yaml(createSystemFolder). - Carga el
locale(§7 usa lo mismo: i18n). Input::init()con elgamecontrollerdb.txt(autoasigna mandos a P1/P2 la primera vez).- Crea
SDLManager(ventana + GPU), oculta el cursor, inicializaAudio. - Precarga bloqueante de todos los recursos (música, sonidos, shapes) para evitar tirones de I/O en las transiciones (director.cpp:187-195).
- Crea el
SceneContexty fija la escena inicial:TITLEen_DEBUG,LOGOen el resto (director.cpp:200-205). - Inicializa los overlays de sistema:
DebugOverlay,Notifier,ServiceMenu,DefineInputs.
El destructor Director::~Director guarda
la config y destruye los subsistemas en orden inverso a la construcción (el
Notifier referencia el renderer, así que debe morir antes que sdl_).
3. Bucle principal
Cada frame, SDL llama a SDL_AppIterate, que delega en
Director::iterate(). Su estructura es:
sequenceDiagram
participant SDL
participant Dir as Director::iterate()
participant Scene
participant SDLM as SDLManager
participant GPU as GpuFrameRenderer
SDL->>Dir: iterate()
Note over Dir: si wants_quit_ → SDL_APP_SUCCESS
Dir->>Dir: si !scene o scene.isFinished() → advanceScene()
Dir->>Dir: delta_time = (now - last) capeado a 50 ms
Dir->>Dir: Input::update()
Dir->>Scene: update(dt)
Dir->>Dir: overlays.update(dt) + Audio::update()
Dir->>SDLM: clear() (= GPU.beginFrame)
alt swapchain no disponible
SDLM-->>Dir: false → saltar draw+present
end
Dir->>SDLM: updateRenderingContext()
Dir->>Scene: draw()
Dir->>Dir: overlays.draw() (capas)
Dir->>SDLM: present() (= GPU.endFrame → bloom + postfx)
Puntos concretos a tener en cuenta:
- Pivot de escena: si no hay escena o la actual reporta
isFinished(), se llama a advanceScene(), que destruye la actual y construye la siguiente con buildScene() segúncontext_->nextScene(). - Delta time: se mide con
SDL_GetTicks()y se capea a 50 ms para evitar saltos grandes tras un stall (director.cpp:397-400). - Orden de update:
Input::update()→current_scene_->update(dt)→debug_overlay_→Notifier→ServiceMenu→DefineInputs→Audio::update(). - Render por capas (de abajo arriba, entre
clearypresent): escena →debug_overlay_→Notifier(toasts) →ServiceMenu→DefineInputs(modal de rebinding). Si el overlay de rebinding está activo, el menú de servicio no se pinta (director.cpp:432-439). - Salto de frame: si
sdl_->clear()devuelvefalse(swapchain no disponible, p. ej. ventana minimizada), se omitendrawypresentese frame.
El bucle de eventos vive aparte, en
Director::handleEvent(), que enruta cada
SDL_Event por la cadena: ventana → GlobalEvents → F11 (debug overlay) →
escena (ver §9).
4. Sistema de escenas
La interfaz base es scene.hpp. Como dice su cabecera, el frame loop vive en el Director, no en cada escena. Cada escena implementa cuatro métodos puros:
virtual void handleEvent(const SDL_Event&) = 0; // eventos no-globales
virtual void update(float delta_time) = 0; // lógica
virtual void draw() = 0; // pintado (entre clear y present)
virtual auto isFinished() const -> bool = 0; // ¿transición pendiente?
Una escena pide transición vía context_.setNextScene(...); en el siguiente frame
isFinished() devuelve true y el Director la destruye para construir la
siguiente.
SceneContext
scene_context.hpp es el "buzón" de transición que el Director posee y va pasando a cada escena por referencia. Tiene:
SceneType(enum):LOGO,TITLE,GAME,EXIT.Option(p. ej.JUMP_TO_TITLE_MAIN) consumible conconsumeOption().MatchConfig(jugadores activos, modo NORMAL/DEMO) para pasar aGAME.- El índice del escenario de demo (
demoScenarioIndex()/advanceDemoScenario()), que persiste entre escenas para que cada entrada al attract mode muestre el siguiente escenario curado (ver §11).
Existe además una variable global SceneManager::actual que el Director mantiene
sincronizada con la escena en curso (compatibilidad hacia atrás).
Las tres escenas (FSM jerárquica)
stateDiagram-v2
[*] --> LOGO
LOGO --> TITLE
TITLE --> GAME : START (1P/2P)
TITLE --> GAME : idle timeout (DEMO)
GAME --> TITLE : game over / fin demo (input)
GAME --> LOGO : fin demo (timeout/muerte)
TITLE --> [*] : EXIT
Cada escena tiene además su propia máquina de estados interna:
- LogoScene —
AnimationState:PRE_ANIMATION → ANIMATION → POST_ANIMATION → EXPLOSION → POST_EXPLOSION. Anima el logo JAILGAMES y lo hace explotar en fragmentos (debris). - TitleScene —
TitleState:STARFIELD_FADE_IN → STARFIELD → MAIN → PLAYER_JOIN_PHASE → BLACK_SCREEN → DEMO_DIVE → DEMO_CURTAIN. Naves 3D flotantes (vía ShipAnimator), selección 1P/2P, y unidle_timer_en el estadoMAINque dispara el attract mode por inactividad. - GameScene — es el núcleo del juego y se detalla en §10.
5. Renderizado: de la lógica al píxel
Este es el subsistema más denso. La idea central: toda la geometría son líneas
(la estética es vectorial). El juego acumula líneas en CPU durante draw(), y al
final del frame se envían a la GPU en un único batch, se rasterizan a una textura
offscreen, y un par de pases de post-procesado (bloom + flicker/fondo) componen
la imagen final sobre la swapchain.
5.1 Capas del subsistema
| Fichero | Rol |
|---|---|
| sdl_manager.hpp/.cpp | Crea la ventana SDL, posee el GpuFrameRenderer, gestiona zoom/fullscreen/letterbox. Expone clear() / present() / getRenderer(). |
| gpu/gpu_frame_renderer.hpp/.cpp | Orquestador del frame GPU: beginFrame → pushLine/pushRect → endFrame (flushBatch + bloomPass + compositePass). |
| gpu/gpu_device | Wrapper del SDL_GPUDevice (claim de ventana, formato de swapchain). |
| gpu/gpu_line_pipeline | Pipeline de líneas: dibuja cada línea como un quad (2 triángulos) con antialias geométrico. |
| gpu/gpu_bloom_pipeline | Blur gaussiano separable (pase H + pase V) sobre dos texturas ping-pong. |
| gpu/gpu_postfx_pipeline | Composite final: mezcla escena + bloom + flicker + fondo pulsante. |
| line_renderer.hpp/.cpp | API que usa el juego: Rendering::linea(...) y lineaGlow(...). |
| shape_renderer.hpp/.cpp | renderShape(...): dibuja una Shape aplicando transformación y, opcionalmente, glow multipase. |
5.2 Una Shape y cómo se carga
Una "shape" es una figura vectorial: un conjunto de polilíneas y líneas
(shape.hpp). Los ficheros viven en data/shapes/
con extensión .shp y un formato de texto tipo clave:valor. Ejemplo real
(data/shapes/ship/arrow.shp):
name: arrow
scale: 1.0
center: 0, 0
polyline: 0,-12 8.49,8.49 0,4 -8.49,8.49 0,-12
Nota: el formato real usa directivas
name:,scale:,center:,polyline:yline:(Y negativo = arriba). No es la sintaxisPOLYLINE: (x,y)que podría suponerse de otros motores.
La carga la centraliza shape_loader.hpp
(Graphics::ShapeLoader::load(filename)), con caché de std::shared_ptr<Shape>.
Todas las shapes se precargan en el boot del Director.
5.3 El flujo de un frame de render
graph TD
A["Scene::draw()<br/>(acumula en CPU)"] --> B["Rendering::linea / renderShape"]
B --> C["GpuFrameRenderer::pushLine()<br/>extruye quad → vertices_ / indices_"]
C -.repetido N veces.-> C
A --> D["SDLManager::present()<br/>= GpuFrameRenderer::endFrame()"]
D --> E["flushBatch()<br/>sube VBO/IBO, dibuja sobre OFFSCREEN"]
E --> F["bloomPass()<br/>H: high-pass+blur → bloom_a<br/>V: blur → bloom_b"]
F --> G["compositePass()<br/>offscreen + bloom_b + flicker + fondo<br/>→ swapchain (letterbox)"]
G --> H["SubmitGPUCommandBuffer + present"]
Paso a paso, con anclas reales:
- Emisión (juego). Durante
current_scene_->draw(), el juego llama a Rendering::linea() (yrenderShape,VectorText,Playfield, etc.). Las coordenadas son lógicas (1280×720). El color por defecto sialpha==0es el verde fósforo CRTDEFAULT_LINE_COLOR = {100,255,100,255}. - Acumulación (CPU).
linea()pre-multiplica el brillo y llama a GpuFrameRenderer::pushLine(), que extruye la línea en un quad (4 vértices, 6 índices) y lo acumula envertices_/indices_. Si el antialias está activo, añade ~0.5 px de padding y marcaedge_distpara el fade del fragment shader. - Flush (GPU). En
endFrame(),flushBatch()sube el batch a un VBO/IBO, abre un render pass sobre eloffscreen_texture_(R8G8B8A8, tamaño físico configurable, independiente del lógico) y dibuja con elline_pipeline_. El vertex shader transforma píxeles lógicos → NDC; el fragment shader aplicasmoothstepsobreedge_distpara el suavizado. - Bloom.
bloomPass()hace un blur separable: pase H (high-pass por luminancia + blur horizontal →bloom_texture_a_) y pase V (blur vertical →bloom_texture_b_). Parámetros enPostFxParams(gpu_frame_renderer.hpp:33-51). - Composite.
compositePass()dibuja un triángulo fullscreen sobre la swapchain, muestreando offscreen + bloom, aplicando flicker temporal y un fondo verde pulsante. Aquí se aplica el letterbox vía el viewport físico (setViewport).
El interruptor maestro de post-proceso es F6 (setPostFxEnabled): cuando está
OFF, la escena offscreen sale tal cual (passthrough), útil para A/B testing.
5.4 Texto, 3D y elementos de escena
- VectorText — renderiza texto donde
cada carácter es una
Shapeprecargada. - Camera3D + Wireframe3D — proyección perspectiva en CPU de mallas 3D (vértices + aristas) a líneas 2D. Lo usan el starfield 3D y las naves del título.
- Starfield (campo de estrellas 3D que vienen hacia la cámara) y StarfieldParallax (capas 2D de fondo con parallax).
- Playfield — rejilla de fondo con animación de construcción y ripples (ondas) que reaccionan a la nave y a las explosiones.
- Border — marco de 4 lados que se desplaza al recibir impactos.
- Curtain — cortinilla negra para transiciones; se pinta siempre la última.
5.5 Shaders: fuentes, compilación y selección
Las fuentes GLSL viven en shaders/: line.vert.glsl, line.frag.glsl,
postfx.vert.glsl, postfx.frag.glsl, bloom.frag.glsl. No se cargan de disco en
runtime: se embeben como arrays/strings en el binario.
Pipeline de compilación (SPIR-V, Linux/Windows). Lo orquesta CMakeLists.txt:139-187. La lógica clave:
- Para cada
.glslhay un header destino en gpu/spv/ (p. ej.line_vert_spv.h). - CMake busca
glslc(find_program(GLSLC_EXE ...)). Hay tres caminos:glslcpresente → unadd_custom_commandregenera los headers SPV cuando cambian los.glsl, vía el targetshadersdel que depende el ejecutable.glslcausente pero los headers ya están commiteados → se usan tal cual (los.spv.hestán versionados en el repo).glslcausente y faltan headers →FATAL_ERRORpidiendo instalarshaderc/vulkan-sdk.
- La conversión binario→header la hace el script
tools/shaders/compile_spirv.cmake: invoca
glslc -O -fshader-stage=<vert|frag>para producir el.spv, lee el binario como hex (file(READ ... HEX)) y escribe un header constatic const uint8_t LINE_VERT_SPV[] = { 0x.., ... };y su_SIZE. Es multiplataforma puro CMake (no necesitabashnixxd).
MSL (macOS). Los headers Metal en gpu/msl/
(line_vert.msl.h, etc.) están escritos a mano (no los genera CMake), como
strings literales C++.
Selección SPV vs MSL: es compile-time, no runtime. La hace
shader_factory.hpp con #ifdef __APPLE__:
en Apple expone createShaderMSL(...) (SDL_GPU_SHADERFORMAT_MSL), y en el resto
createShaderSPIRV(...) (SDL_GPU_SHADERFORMAT_SPIRV). Cada pipeline llama al helper
disponible con el header embebido correspondiente. (Es decir: no es GpuDevice quien
elige el backend de shader, sino el preprocesador al compilar.)
6. Entrada
El subsistema de input (core/input/) es un singleton
(Input::init() / Input::get() / Input::destroy()) que unifica teclado,
gamepads y ratón.
- Acciones: enum
InputAction(LEFT,RIGHT,THRUST,SHOOT,START,MENU, ...) en input_types.hpp. - Bindings por jugador: hay bindings separados de teclado y de gamepad para P1
y P2, que se cargan de la config con
applyPlayer1Bindings()/applyPlayer2Bindings()(llamados desde el constructor del Director). - Captura por frame:
Input::update()leeSDL_GetKeyboardState()y los ejes y botones del gamepad, y hace edge-detection para distinguirjust_presseddeis_held. La consulta escheckAction(...)/checkActionPlayer1/2(...). - Hotplug:
Input::handleEvent()procesaSDL_EVENT_GAMEPAD_ADDED/REMOVED(addGamepad/removeGamepad) y notifica con un toast víaNotifier. - Ratón: mouse.hpp auto-oculta el cursor.
- Rebinding en runtime: define_inputs.hpp es un modal singleton que captura una secuencia de acciones, persiste en config y reaplica bindings sin reiniciar.
El enrutado de input ocurre en dos sitios: los eventos globales pasan por
GlobalEvents::handle() (que primero deja a Input procesar el hotplug), y la
lógica de juego consulta directamente Input::get()->checkAction... durante
update() (p. ej. Ship::processInput).
7. Audio
core/audio/ es otro singleton (Audio::init/get/destroy)
con un motor de bajo nivel propio:
- Audio — capa lógica:
playMusic(),playSound(), volúmenes por grupo (GAME,INTERFACE),playSoundWithEcho/Reverb. - jail_audio.hpp (
Ja::Engine) — motor sobre SDL3 audio: streaming de OGG (víastb_vorbis) para música, WAV descomprimido para efectos, mezcla en N canales. - audio_adapter.hpp —
AudioResource::getMusic/getSound: caché lazy que carga bytes víaResource::Helpery los decodifica una sola vez. - audio_effects.hpp — DSP de echo y
reverb; presets en
data/config/sounds.yaml(sound_effects_config.hpp).
El Director precarga toda la música y todos los sonidos en el boot, y llama a
Audio::update() una vez por frame.
8. Recursos
core/resources/ abstrae de dónde salen los bytes:
- resource_pack (
Resource::Pack) — lee un fichero empaquetado con cabecera magic"ORNI"y entradas con CRC32 para validación de integridad. - resource_loader
(
Resource::Loader, singleton Meyers) —loadResource(),resourceExists(),listResources(prefix),validatePack(). - resource_helper — wrappers de
conveniencia (
initializeResourceSystem,listResources,loadFile).
Estrategia dual (decidida en el constructor del Director, director.cpp:64-93):
- Release (
RELEASE_BUILD):resources.packes obligatorio y se valida su integridad; si falla, el juego aborta. No hay fallback (ver memoria de proyecto "No fallback a SDL_Renderer" — aquí es la política equivalente para recursos). - Dev: intenta el pack; si no está, hace fallback al directorio
data/del filesystem, escaneándolo según prefijo (music/,sounds/,shapes/).
El formato de datos de juego:
- Entidades (
data/entities/<nombre>/<nombre>.yaml) — YAML declarativo conshape,physics,ai,animation,wounded,spawn,colors,score,events. Ejemplo: data/entities/square/square.yaml. - Stages (
data/stages/stages.yaml) — oleadas (waves) conspawn,spawn_interval,nexty multiplicadores de dificultad por stage. - Shapes (
data/shapes/**/*.shp) — figuras vectoriales (ver §5.2).
El parser YAML usado es fkyaml (cabecera única), envuelto por config_yaml.
9. Comunicación entre módulos
No hay un sistema de mensajería desacoplado. La comunicación es:
-
Eventos SDL → cadena del Director. Por cada
SDL_Event, Director::handleEvent intenta, en orden:SDLManager::handleWindowEvent→GlobalEvents::handle→ F11 (debug overlay) →current_scene_->handleEvent. -
GlobalEvents (global_events.cpp) es el orquestador de la entrada global. Su
handle()hace, en orden:Input::get()->handleEvent(hotplug) →consumeIfDefineActive(si el modal de rebinding está activo, engulle todo) →SDL_EVENT_QUIT→ ratón → botón MENU del mando → reenvío alServiceMenusi está abierto → teclas de función:Tecla Acción F1 / F2 reducir / aumentar tamaño de ventana F3 fullscreen F4 VSync F5 antialias geométrico F6 post-procesado (bloom/flicker/fondo) F7 idioma ca ↔ en (hot-swap de Locale)F11 debug overlay (gestionado en el Director, no en GlobalEvents) F12 menú de servicio ESC doble pulsación para salir (la 1ª muestra un toast de confirmación) -
Singletons compartidos.
Input,Audio,Locale,Notifier,ServiceMenu,DefineInputsse acceden globalmente vía::get(). Muchos compruebannullptrpara degradar con elegancia (p. ej. el hotplug notifica solo siNotifier::get() != nullptr). -
Paso por referencia. Las escenas reciben
SDLManager&ySceneContext&; el render se propaga comoRendering::Renderer*. Los sistemas de juego reciben un structContextcon punteros a los pools (ver §10).
Overlays de sistema (todos singletons, todos por encima de la escena):
- Notifier — toasts deslizantes centrados
(
notifyInfo/Warn/Exit), con máquina de animación HIDDEN/ENTERING/HOLDING/EXITING. - ServiceMenu — menú de configuración (F12) con pila de páginas (vídeo, audio, controles, sistema...).
- DebugOverlay — HUD de FPS/VSync (F11).
- Relaunch — reinicio en caliente vía
execv(lo solicita el ServiceMenu, lo ejecutaSDL_AppQuit).
Lo que NO existe (verificado): no hay event bus genérico, ni cola de mensajes desacoplada, ni un FSM genérico reutilizable fuera de las máquinas de estado concretas de cada escena/sistema, ni un ECS.
10. Lógica del juego
Toda la partida vive en GameScene. Es la clase más grande del juego y actúa como orquestador. Posee:
- El mundo físico Physics::PhysicsWorld (integración cinemática + colisiones físicas).
- Pools de tamaño fijo:
std::array<Ship, 2>,std::array<Enemy, MAX_ORNIS>(15),std::array<Bullet, MAX_BULLETS_TOTAL>(6: P1=[0,1,2], P2=[3,4,5]). - Estado de partida: vidas, score y death timers por jugador, máquina de
game over (
GameOverState:NONE/CONTINUE/GAME_OVER), continues usados. - El stage system, los efectos visuales, y los
DemoPilot(uno por nave).
10.1 Orquestación por frame
GameScene::update() es un orquestador delgado; cada paso es una función privada (descompuesto para reducir complejidad cognitiva):
void GameScene::update(float dt) {
if (ServiceMenu abierto) return; // pausa global (draw sí sigue)
stepPhysics(dt);
if (mode == DEMO) { if (stepDemo(dt)) return; }
else if (game_over_state_ == NONE) { stepShootingInput(); stepMidGameJoin(); }
if (stepContinueScreen(dt)) return;
if (stepGameOver(dt)) return;
stepDeathSequence(dt);
stepStageStateMachine(dt);
}
El corazón del gameplay es
stepStageStateMachine, que despacha según
el estado del stage; en PLAYING,
runStagePlaying ejecuta: WaveRunner
(spawns) → IA de cada enemigo → control de naves
(updateShipsControl, que en demo usa
applyMovement con el control del pilot y fuera de demo usa processInput) →
detección de colisiones (runCollisionDetections).
draw() despacha de forma análoga según GameOverState y el estado del stage, y
siempre pinta la cortinilla al final.
10.2 Entidades
Las tres heredan de Entities::Entity (entity.hpp):
- Ship — nave del jugador.
processInput()(humano) yapplyMovement()(usado por la IA demo). Estados: activa, invulnerable (parpadeo tras spawn), herida (hurt). Al morir genera debris con la inercia heredada. - Enemy — 5 tipos (
EnemyType:PENTAGON,SQUARE,PINWHEEL,STAR,ORB). Toda su config (físicas, IA, animación, eventos) viene del YAML vía EnemyRegistry. Tiene salud (la mayoría HP=1;ORBHP=10) y estado wounded (parpadeo). - Bullet — con
owner_id(0=P1, 1=P2, ≥16=enemigo) yprev_positionpara colisión swept (la bala que cruza un enemigo entre dos frames). Config en BulletRegistry.
10.3 IA de enemigos: declarativa
Los enemigos no tienen comportamiento hardcoded. El YAML describe:
- Una primitiva de movimiento (
MovementTypeen enemy_ai.hpp):ZIGZAG,TRACKING,RECTILINEAR_PROXIMITY,WANDER,CHASE,FLEE. - Acciones de tick periódicas (p. ej.
SHOOT). - Eventos (
on_hit,on_no_health,on_hurt_end,on_destroy) con acciones (APPLY_IMPULSE,DECREASE_HEALTH,CREATE_DEBRIS,ADD_SCORE,FLASH,FIRE_BULLET,DESTROY, ...).
Dos sistemas los ejecutan:
- EnemyAiSystem —
move()aplica la primitiva de movimiento;tick()añade las acciones periódicas. HelperfindNearestShipPosition()para las primitivas que buscan al jugador. - EnemyEventDispatcher — ejecuta las acciones declarativas cuando se dispara un evento.
10.4 Colisiones
CollisionSystem recibe un struct
Context (punteros a ships/enemies/bullets, managers de efectos, timers, scores,
vidas y un callback on_player_hit) que GameScene construye en
buildCollisionContext. Detecta:
bala↔enemigo, nave↔enemigo, bala↔jugador (fuego amigo / autodisparo), bala
enemiga↔nave, y balas fuera del área. Reglas observadas: el primer impacto deja al
enemigo wounded; el segundo lo destruye y suma score. La nave entra en hurt al
primer toque y muere al segundo durante ese estado.
10.5 Stages y oleadas
- StageManager — FSM del stage
(
EstatStage):INIT_HUD(anima el HUD, 3 s) →LEVEL_START("ENEMY INCOMING", 3 s, arrancagame.ogg) →PLAYING→LEVEL_COMPLETED("GOOD JOB COMMANDER!", 3 s) → siguiente stage.initDemo(stage_id)arranca directamente enPLAYINGpara el attract mode. - WaveRunner — emite los enemigos de
cada oleada según
spawn_intervaly avanza cuando se cumplenext(all_dead,timeout, o ambos). - StageConfig / StageLoader — modelo y carga del YAML de stages.
10.6 Dos capas de colisión: física vs gameplay
Conviene no confundirlas, porque conviven:
1. Física — PhysicsWorld /
physics_world.cpp. Es un mundo 2D
minimalista de arcade. Cada frame, update(dt) hace tres pasos:
- Integración semi-implícita de Euler con damping exponencial
(
v += (F·invMass)·dt; v *= exp(-damping·dt); x += v·dt) sobre cada RigidBody no estático. Un cuerpo conmass=0(inverse_mass=0) es estático (masa infinita). - Rebote contra los bordes del
PLAYAREA(resolveBoundsCollisions): reposiciona el cuerpo dentro del rect y refleja la componente normal de la velocidad por surestitution. Antes de reflejar, invoca unBoundsHitCallbackopcional con la velocidad de impacto entrante (lo usa GameScene para los efectos de borde). - Colisiones cuerpo-cuerpo (
resolveBodyCollisions): broadphase trivial O(n²) (suficiente para ~23 cuerpos), círculo-círculo, con corrección posicional de penetración + impulso elásticoj = -(1+e)(v_rel·n) / (1/mₐ + 1/m_b)(referencia Box2D / Chris Hecker, enresolveBodyPair). Los cuerpos conradius=0(las balas, cinemáticas puras) no participan aquí.
Los RigidBody los poseen las entidades; el mundo solo guarda punteros no-owning
(addBody/removeBody).
2. Gameplay — collision_system.cpp
(ver §10.4), que decide qué pasa (daño, score, muerte). Usa los
helpers de collision.hpp: checkCollision
(círculo-círculo discreto, distancia al cuadrado sin sqrt) y checkCollisionSwept
(segment-círculo, para que una bala rápida no atraviese un enemigo entre frames —
anti-tunneling). Estos checks usan el collision_radius de la entidad
(con amplificador opcional de hitbox), no el radius del body.
En resumen: la física mueve y rebota los cuerpos; el gameplay detecta los contactos relevantes para las reglas. Una bala no rebota físicamente (radius 0) pero sí provoca daño vía el check swept.
11. IA del modo demo (attract)
El attract mode es una partida que se juega sola para atraer al jugador. Se activa
desde TitleScene cuando el idle_timer_ en el
estado MAIN supera el umbral de inactividad, y desde
GameScene cuando match_config_.mode == DEMO.
La IA vive en DemoPilot / demo_pilot.cpp. Su diseño es explícito en la cabecera: busca parecer humano, no ser óptimo. Características clave:
- Solo lectura:
DemoPilot::compute(ship, enemies, bullets, play_area, dt)devuelve unControl{left,right,thrust,shoot}. No leeInputni muta entidades; GameScene aplica el resultado víaShip::applyMovement+fireBullet. - Escenarios curados: hay 4 (
SCENARIOSen demo_pilot.hpp:36-42): stages{5,8,6,10}con 1 o 2 naves IA. ElSceneContextrecuerda el índice y rota al siguiente en cada entrada al demo.
Lógica de decisión por prioridad (verificado en demo_pilot.cpp, con sus
constantes):
- Esquiva de bala — si una bala enemiga entrante está dentro de
DODGE_SCAN_RADIUS = 190 pxy viene hacia la nave (DODGE_HEADING_MIN = 0.25), maniobra perpendicular a la bala con sesgo al centro (WALL_BIAS = 0.6); no dispara mientras esquiva. - Sin enemigos — deriva tranquila (giro lento).
- Peligro cercano — si el objetivo está a menos de
DANGER_RADIUS = 95 px, se aleja con sesgo al centro. - Combate — apuntado con lead (
LEAD_TIME = 0.30 s) más un error humano (AIM_JITTER_MAX = 0.10 rad); dispara si el error es menor queFIRE_TOLERANCE = 0.18 rady el cooldown (FIRE_COOLDOWN = 0.32 s) lo permite; se acerca si está más lejos queAPPROACH_RADIUS = 250 px.
Temporización "humana": reevalúa el objetivo cada RETARGET_INTERVAL = 0.15 s y usa
una zona muerta de rotación (ROTATE_DEADZONE = 0.05 rad) para no oscilar. La demo
se rompe con cualquier input (vuelve a TITLE) o por timeout/muerte (vuelve a LOGO),
gestionado en stepDemo.
12. Efectos visuales
Viven en game/effects/ y son managers con pools:
- DebrisManager — rompe una shape en
fragmentos que vuelan radialmente, heredando inercia del cuerpo y, opcionalmente,
el impulso de la bala que causó la muerte. Notifica al
Border(bump) y alPlayfield(ripple). Lo usan muerte de nave/enemigo, balas fuera de área y las explosiones del logo. - FireworkManager — bursts de fuegos artificiales.
- FloatingScoreManager — números de puntuación flotantes ("+150").
- TrailManager — estela tras las naves.
13. Configuración, constantes y convenciones
Configuración:
- EngineConfig — struct POD con
ventana, rendering, audio, bindings de jugadores, locale, console. Es la config
persistente (
config.yaml), gestionada por config_yaml (ConfigYaml::engine_config,loadFromFile/saveToFile). - PostFxConfig — carga los
PostFxParams(bloom/flicker/fondo) desde YAML. - GameConfig::MatchConfig — config no persistente de la partida (jugadores activos, modo NORMAL/DEMO).
Constantes y tipos:
- core/types.hpp —
Vec2/Vec3(agregados con operadores y helpers comolength(),normalized(),dot(),cross()). - core/defaults/ — un fichero por dominio
(
window.hpp,rendering.hpp,audio.hpp,entities.hpp,notifier.hpp...) con todas las constantes por defecto.game/constants.hppreexporta varias como alias (MAX_ORNIS,MAX_BULLETS,PI) y añade helpers de área de juego.
Convenciones de código (de .clang-tidy, confirmadas en memoria de proyecto):
- Métodos en
camelBack, tipos enCamelCase, constantes enUPPER_CASE. - Comentarios mayormente en catalán (algunos en castellano); el código y los identificadores mezclan catalán/castellano/inglés.
- Patrón recurrente: singletons con
init/get/destroyy comprobación denullptrpara degradación elegante. - Patrón recurrente: descomposición de funciones grandes (
update/draw) en sub-pasos privados (stepX/runX/drawXState) para mantener baja la complejidad cognitiva. - Análisis estático (cppcheck/clang-tidy) corre vía git hooks (.githooks/); la política es arreglar la causa, no suprimir el diagnóstico.
14. Guía de navegación
| Si quieres tocar… | Mira… |
|---|---|
| El arranque, orden de init, o el bucle de frame | director.cpp (Director::iterate / handleEvent) |
| Las callbacks de SDL | main.cpp |
| Añadir/cambiar una escena o una transición | scene.hpp, scene_context.hpp, Director::buildScene |
| Cómo se dibuja una línea / el frame de render | line_renderer.cpp → gpu_frame_renderer.cpp |
| Bloom / flicker / fondo (post-proceso) | gpu_postfx_pipeline, gpu_bloom_pipeline, shaders en shaders/ |
| Crear/editar una figura vectorial | data/shapes/**/*.shp + shape_loader.hpp |
| El texto en pantalla | vector_text.hpp |
| Eventos globales (teclas F, ESC, hotplug) | global_events.cpp |
| Controles, bindings, rebinding | input.cpp, define_inputs.cpp |
| Reproducir música/efectos | audio.hpp, audio_adapter.hpp |
| Cómo se cargan los recursos / el pack | resource_loader.cpp, resource_pack.cpp |
| Reglas de la partida, vidas, game over | game_scene.cpp |
| Comportamiento de un enemigo | su YAML en data/entities/<tipo>/ + enemy_ai_system.cpp |
| Definir oleadas / dificultad de un nivel | data/stages/stages.yaml + stage_manager.cpp |
| Colisiones | collision_system.cpp |
| La IA del modo demo | demo_pilot.cpp |
| Explosiones / partículas | debris_manager.cpp |
| El menú de servicio (F12) | service_menu.cpp |
| Textos traducibles | data/locale/*.yaml + locale.cpp |
| Constantes por defecto | core/defaults/ |
Notas de honestidad sobre la cobertura
- Todas las secciones se verificaron leyendo directamente los ficheros y firmas
citados, incluyendo el pipeline de compilación de shaders
(§5.5:
CMakeLists.txt+tools/shaders/compile_spirv.cmake+shader_factory.hpp) y el interior de la física (§10.6:physics_world.cpp+collision.hpp+rigid_body.hpp). - Lo que no se ha trazado a fondo y queda como lectura directa del código si hace
falta: los detalles finos de animación de cada overlay (curvas de easing del
Notifier/ServiceMenu) y la coreografía interna completa deLogoSceneyTitleScene(más allá de sus estados). Son descriptivos, no estructurales.