Files
orni-attack/DOCS/ARQUITECTURA.md
T

42 KiB
Raw Blame History

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

  1. Visión general
  2. Punto de entrada y el Director
  3. Bucle principal
  4. Sistema de escenas
  5. Renderizado: de la lógica al píxel
  6. Entrada
  7. Audio
  8. Recursos
  9. Comunicación entre módulos
  10. Lógica del juego
  11. IA del modo demo (attract)
  12. Efectos visuales
  13. Configuración, constantes y convenciones
  14. 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 de game/.
  • 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:

  1. ConfigYaml::init() — valores por defecto de configuración.
  2. Parseo de argumentos (--console, --reset-config) en checkProgramArguments.
  3. Utils::initializePathSystem() + sistema de recursos (§8): en release el resources.pack es obligatorio; en dev hay fallback a data/.
  4. Crea la carpeta de sistema (~/.config/jailgames/<NAME> en Linux) y carga/crea config.yaml (createSystemFolder).
  5. Carga el locale (§7 usa lo mismo: i18n).
  6. Input::init() con el gamecontrollerdb.txt (autoasigna mandos a P1/P2 la primera vez).
  7. Crea SDLManager (ventana + GPU), oculta el cursor, inicializa Audio.
  8. Precarga bloqueante de todos los recursos (música, sonidos, shapes) para evitar tirones de I/O en las transiciones (director.cpp:187-195).
  9. Crea el SceneContext y fija la escena inicial: TITLE en _DEBUG, LOGO en el resto (director.cpp:200-205).
  10. 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ún context_->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_NotifierServiceMenuDefineInputsAudio::update().
  • Render por capas (de abajo arriba, entre clear y present): escena → debug_overlay_Notifier (toasts) → ServiceMenuDefineInputs (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() devuelve false (swapchain no disponible, p. ej. ventana minimizada), se omiten draw y present ese 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 con consumeOption().
  • MatchConfig (jugadores activos, modo NORMAL/DEMO) para pasar a GAME.
  • 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:

  • LogoSceneAnimationState: PRE_ANIMATION → ANIMATION → POST_ANIMATION → EXPLOSION → POST_EXPLOSION. Anima el logo JAILGAMES y lo hace explotar en fragmentos (debris).
  • TitleSceneTitleState: 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 un idle_timer_ en el estado MAIN que 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: beginFramepushLine/pushRectendFrame (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: y line: (Y negativo = arriba). No es la sintaxis POLYLINE: (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:

  1. Emisión (juego). Durante current_scene_->draw(), el juego llama a Rendering::linea() (y renderShape, VectorText, Playfield, etc.). Las coordenadas son lógicas (1280×720). El color por defecto si alpha==0 es el verde fósforo CRT DEFAULT_LINE_COLOR = {100,255,100,255}.
  2. 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 en vertices_ / indices_. Si el antialias está activo, añade ~0.5 px de padding y marca edge_dist para el fade del fragment shader.
  3. Flush (GPU). En endFrame(), flushBatch() sube el batch a un VBO/IBO, abre un render pass sobre el offscreen_texture_ (R8G8B8A8, tamaño físico configurable, independiente del lógico) y dibuja con el line_pipeline_. El vertex shader transforma píxeles lógicos → NDC; el fragment shader aplica smoothstep sobre edge_dist para el suavizado.
  4. 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 en PostFxParams (gpu_frame_renderer.hpp:33-51).
  5. 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 Shape precargada.
  • 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 .glsl hay un header destino en gpu/spv/ (p. ej. line_vert_spv.h).
  • CMake busca glslc (find_program(GLSLC_EXE ...)). Hay tres caminos:
    1. glslc presente → un add_custom_command regenera los headers SPV cuando cambian los .glsl, vía el target shaders del que depende el ejecutable.
    2. glslc ausente pero los headers ya están commiteados → se usan tal cual (los .spv.h están versionados en el repo).
    3. glslc ausente y faltan headers → FATAL_ERROR pidiendo instalar shaderc/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 con static const uint8_t LINE_VERT_SPV[] = { 0x.., ... }; y su _SIZE. Es multiplataforma puro CMake (no necesita bash ni xxd).

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() lee SDL_GetKeyboardState() y los ejes y botones del gamepad, y hace edge-detection para distinguir just_pressed de is_held. La consulta es checkAction(...) / checkActionPlayer1/2(...).
  • Hotplug: Input::handleEvent() procesa SDL_EVENT_GAMEPAD_ADDED/REMOVED (addGamepad / removeGamepad) y notifica con un toast vía Notifier.
  • 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ía stb_vorbis) para música, WAV descomprimido para efectos, mezcla en N canales.
  • audio_adapter.hppAudioResource::getMusic/getSound: caché lazy que carga bytes vía Resource::Helper y 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.pack es 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 con shape, physics, ai, animation, wounded, spawn, colors, score, events. Ejemplo: data/entities/square/square.yaml.
  • Stages (data/stages/stages.yaml) — oleadas (waves) con spawn, spawn_interval, next y 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:

  1. Eventos SDL → cadena del Director. Por cada SDL_Event, Director::handleEvent intenta, en orden: SDLManager::handleWindowEventGlobalEvents::handle → F11 (debug overlay) → current_scene_->handleEvent.

  2. 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 al ServiceMenu si 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)
  3. Singletons compartidos. Input, Audio, Locale, Notifier, ServiceMenu, DefineInputs se acceden globalmente vía ::get(). Muchos comprueban nullptr para degradar con elegancia (p. ej. el hotplug notifica solo si Notifier::get() != nullptr).

  4. Paso por referencia. Las escenas reciben SDLManager& y SceneContext&; el render se propaga como Rendering::Renderer*. Los sistemas de juego reciben un struct Context con 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 ejecuta SDL_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) y applyMovement() (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; ORB HP=10) y estado wounded (parpadeo).
  • Bullet — con owner_id (0=P1, 1=P2, ≥16=enemigo) y prev_position para 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 (MovementType en 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:

  • EnemyAiSystemmove() aplica la primitiva de movimiento; tick() añade las acciones periódicas. Helper findNearestShipPosition() 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, arranca game.ogg) → PLAYINGLEVEL_COMPLETED ("GOOD JOB COMMANDER!", 3 s) → siguiente stage. initDemo(stage_id) arranca directamente en PLAYING para el attract mode.
  • WaveRunner — emite los enemigos de cada oleada según spawn_interval y avanza cuando se cumple next (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ísicaPhysicsWorld / physics_world.cpp. Es un mundo 2D minimalista de arcade. Cada frame, update(dt) hace tres pasos:

  1. 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 con mass=0 (inverse_mass=0) es estático (masa infinita).
  2. Rebote contra los bordes del PLAYAREA (resolveBoundsCollisions): reposiciona el cuerpo dentro del rect y refleja la componente normal de la velocidad por su restitution. Antes de reflejar, invoca un BoundsHitCallback opcional con la velocidad de impacto entrante (lo usa GameScene para los efectos de borde).
  3. 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ástico j = -(1+e)(v_rel·n) / (1/mₐ + 1/m_b) (referencia Box2D / Chris Hecker, en resolveBodyPair). Los cuerpos con radius=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. Gameplaycollision_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 un Control{left,right,thrust,shoot}. No lee Input ni muta entidades; GameScene aplica el resultado vía Ship::applyMovement + fireBullet.
  • Escenarios curados: hay 4 (SCENARIOS en demo_pilot.hpp:36-42): stages {5,8,6,10} con 1 o 2 naves IA. El SceneContext recuerda 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):

  1. Esquiva de bala — si una bala enemiga entrante está dentro de DODGE_SCAN_RADIUS = 190 px y 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.
  2. Sin enemigos — deriva tranquila (giro lento).
  3. Peligro cercano — si el objetivo está a menos de DANGER_RADIUS = 95 px, se aleja con sesgo al centro.
  4. 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 que FIRE_TOLERANCE = 0.18 rad y el cooldown (FIRE_COOLDOWN = 0.32 s) lo permite; se acerca si está más lejos que APPROACH_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 al Playfield (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.hppVec2 / Vec3 (agregados con operadores y helpers como length(), 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.hpp reexporta 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 en CamelCase, constantes en UPPER_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/destroy y comprobación de nullptr para 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.cppgpu_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 de LogoScene y TitleScene (más allá de sus estados). Son descriptivos, no estructurales.