Compare commits
20 Commits
v0.8.1
..
6f29731679
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f29731679 | |||
| d7a9bd4ab2 | |||
| ab5489a080 | |||
| f4567a2e82 | |||
| 4b298ffc1c | |||
| 0f986cbf80 | |||
| 582bd0ee30 | |||
| 2e4030c2f2 | |||
| a9b662840b | |||
| 30bbb37bff | |||
| 2f6d6c405f | |||
| 068f42782b | |||
| 472c543c7b | |||
| 4e67a67ace | |||
| 1e63d3ae9d | |||
| b363efd1f0 | |||
| 0abbaa09f8 | |||
| 455b7a6893 | |||
| 92f76d091d | |||
| c1956e0028 |
@@ -0,0 +1,838 @@
|
|||||||
|
# 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](#1-visión-general)
|
||||||
|
2. [Punto de entrada y el Director](#2-punto-de-entrada-y-el-director)
|
||||||
|
3. [Bucle principal](#3-bucle-principal)
|
||||||
|
4. [Sistema de escenas](#4-sistema-de-escenas)
|
||||||
|
5. [Renderizado: de la lógica al píxel](#5-renderizado-de-la-lógica-al-píxel)
|
||||||
|
6. [Entrada](#6-entrada)
|
||||||
|
7. [Audio](#7-audio)
|
||||||
|
8. [Recursos](#8-recursos)
|
||||||
|
9. [Comunicación entre módulos](#9-comunicación-entre-módulos)
|
||||||
|
10. [Lógica del juego](#10-lógica-del-juego)
|
||||||
|
11. [IA del modo demo (attract)](#11-ia-del-modo-demo-attract)
|
||||||
|
12. [Efectos visuales](#12-efectos-visuales)
|
||||||
|
13. [Configuración, constantes y convenciones](#13-configuración-constantes-y-convenciones)
|
||||||
|
14. [Guía de navegación](#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](source/core/audio/audio.hpp) recibe un struct de configuración y no
|
||||||
|
lee YAML, e [input.hpp](source/core/input/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](source/core/rendering/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.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
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](#10-lógica-del-juego)).
|
||||||
|
|
||||||
|
**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](source/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`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// 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](source/core/system/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](source/core/system/director.hpp#L45-L48)).
|
||||||
|
|
||||||
|
### Orden de arranque (constructor)
|
||||||
|
|
||||||
|
El constructor [Director::Director](source/core/system/director.cpp#L46) 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](source/core/system/director.cpp#L241).
|
||||||
|
3. `Utils::initializePathSystem()` + sistema de recursos
|
||||||
|
([§8](#8-recursos)): 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](source/core/system/director.cpp#L260)).
|
||||||
|
5. Carga el `locale` ([§7](#7-audio) 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](source/core/system/director.cpp#L187-L195)).
|
||||||
|
9. Crea el `SceneContext` y fija la escena inicial: `TITLE` en `_DEBUG`, `LOGO`
|
||||||
|
en el resto ([director.cpp:200-205](source/core/system/director.cpp#L200-L205)).
|
||||||
|
10. Inicializa los overlays de sistema: `DebugOverlay`, `Notifier`, `ServiceMenu`,
|
||||||
|
`DefineInputs`.
|
||||||
|
|
||||||
|
El destructor [Director::~Director](source/core/system/director.cpp#L218) 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()](source/core/system/director.cpp#L383). Su estructura es:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
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()](source/core/system/director.cpp#L338), que destruye la
|
||||||
|
actual y construye la siguiente con
|
||||||
|
[buildScene()](source/core/system/director.cpp#L323) 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](source/core/system/director.cpp#L397-L400)).
|
||||||
|
- **Orden de update**: `Input::update()` → `current_scene_->update(dt)` →
|
||||||
|
`debug_overlay_` → `Notifier` → `ServiceMenu` → `DefineInputs` → `Audio::update()`.
|
||||||
|
- **Render por capas** (de abajo arriba, entre `clear` y `present`):
|
||||||
|
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](source/core/system/director.cpp#L432-L439)).
|
||||||
|
- **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()](source/core/system/director.cpp#L354), que enruta cada
|
||||||
|
`SDL_Event` por la cadena: **ventana → GlobalEvents → F11 (debug overlay) →
|
||||||
|
escena** (ver [§9](#9-comunicación-entre-módulos)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Sistema de escenas
|
||||||
|
|
||||||
|
La interfaz base es [scene.hpp](source/core/system/scene.hpp). Como dice su
|
||||||
|
cabecera, *el frame loop vive en el Director, no en cada escena*. Cada escena
|
||||||
|
implementa cuatro métodos puros:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
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](source/core/system/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](#11-ia-del-modo-demo-attract)).
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
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](source/game/scenes/logo_scene.hpp)** — `AnimationState`:
|
||||||
|
`PRE_ANIMATION → ANIMATION → POST_ANIMATION → EXPLOSION → POST_EXPLOSION`. Anima
|
||||||
|
el logo JAILGAMES y lo hace explotar en fragmentos (debris).
|
||||||
|
- **[TitleScene](source/game/scenes/title_scene.hpp)** — `TitleState`:
|
||||||
|
`STARFIELD_FADE_IN → STARFIELD → MAIN → PLAYER_JOIN_PHASE → BLACK_SCREEN →
|
||||||
|
DEMO_DIVE → DEMO_CURTAIN`. Naves 3D flotantes (vía
|
||||||
|
[ShipAnimator](source/game/title/ship_animator.hpp)), selección 1P/2P, y un
|
||||||
|
`idle_timer_` en el estado `MAIN` que dispara el attract mode por inactividad.
|
||||||
|
- **[GameScene](source/game/scenes/game_scene.hpp)** — es el núcleo del juego y se
|
||||||
|
detalla en [§10](#10-lógica-del-juego).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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](source/core/rendering/sdl_manager.hpp) | Crea la ventana SDL, posee el `GpuFrameRenderer`, gestiona zoom/fullscreen/letterbox. Expone `clear()` / `present()` / `getRenderer()`. |
|
||||||
|
| [gpu/gpu_frame_renderer.hpp/.cpp](source/core/rendering/gpu/gpu_frame_renderer.hpp) | Orquestador del frame GPU: `beginFrame` → `pushLine`/`pushRect` → `endFrame` (`flushBatch` + `bloomPass` + `compositePass`). |
|
||||||
|
| [gpu/gpu_device](source/core/rendering/gpu/gpu_device.hpp) | Wrapper del `SDL_GPUDevice` (claim de ventana, formato de swapchain). |
|
||||||
|
| [gpu/gpu_line_pipeline](source/core/rendering/gpu/gpu_line_pipeline.hpp) | Pipeline de líneas: dibuja cada línea como un quad (2 triángulos) con antialias geométrico. |
|
||||||
|
| [gpu/gpu_bloom_pipeline](source/core/rendering/gpu/gpu_bloom_pipeline.hpp) | Blur gaussiano separable (pase H + pase V) sobre dos texturas ping-pong. |
|
||||||
|
| [gpu/gpu_postfx_pipeline](source/core/rendering/gpu/gpu_postfx_pipeline.hpp) | Composite final: mezcla escena + bloom + flicker + fondo pulsante. |
|
||||||
|
| [line_renderer.hpp/.cpp](source/core/rendering/line_renderer.hpp) | API que usa el juego: `Rendering::linea(...)` y `lineaGlow(...)`. |
|
||||||
|
| [shape_renderer.hpp/.cpp](source/core/rendering/shape_renderer.hpp) | `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](source/core/graphics/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](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](source/core/graphics/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
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
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()](source/core/rendering/line_renderer.hpp#L33) (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()](source/core/rendering/gpu/gpu_frame_renderer.hpp#L88),
|
||||||
|
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](source/core/rendering/gpu/gpu_frame_renderer.hpp#L33-L51)).
|
||||||
|
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](source/core/graphics/vector_text.hpp)** — renderiza texto donde
|
||||||
|
cada carácter es una `Shape` precargada.
|
||||||
|
- **[Camera3D](source/core/graphics/camera3d.hpp)** + **[Wireframe3D](source/core/graphics/wireframe3d.hpp)**
|
||||||
|
— 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](source/core/graphics/starfield.hpp)** (campo de estrellas 3D que
|
||||||
|
vienen hacia la cámara) y **[StarfieldParallax](source/core/graphics/starfield_parallax.hpp)**
|
||||||
|
(capas 2D de fondo con parallax).
|
||||||
|
- **[Playfield](source/core/graphics/playfield.hpp)** — rejilla de fondo con
|
||||||
|
animación de construcción y *ripples* (ondas) que reaccionan a la nave y a las
|
||||||
|
explosiones.
|
||||||
|
- **[Border](source/core/graphics/border.hpp)** — marco de 4 lados que se desplaza
|
||||||
|
al recibir impactos.
|
||||||
|
- **[Curtain](source/core/graphics/curtain.hpp)** — cortinilla negra para
|
||||||
|
transiciones; se pinta siempre la última.
|
||||||
|
|
||||||
|
### 5.5 Shaders: fuentes, compilación y selección
|
||||||
|
|
||||||
|
Las fuentes GLSL viven en [shaders/](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](CMakeLists.txt#L139). La lógica clave:
|
||||||
|
|
||||||
|
- Para cada `.glsl` hay un header destino en
|
||||||
|
[gpu/spv/](source/core/rendering/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](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/](source/core/rendering/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](source/core/rendering/gpu/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/](source/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](source/core/input/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](source/core/input/mouse.hpp) auto-oculta el cursor.
|
||||||
|
- **Rebinding en runtime**: [define_inputs.hpp](source/core/input/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](source/game/entities/ship.hpp)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Audio
|
||||||
|
|
||||||
|
[core/audio/](source/core/audio/) es otro singleton (`Audio::init/get/destroy`)
|
||||||
|
con un motor de bajo nivel propio:
|
||||||
|
|
||||||
|
- **[Audio](source/core/audio/audio.hpp)** — capa lógica: `playMusic()`,
|
||||||
|
`playSound()`, volúmenes por grupo (`GAME`, `INTERFACE`), `playSoundWithEcho/Reverb`.
|
||||||
|
- **[jail_audio.hpp](source/core/audio/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.hpp](source/core/audio/audio_adapter.hpp)** —
|
||||||
|
`AudioResource::getMusic/getSound`: caché *lazy* que carga bytes vía
|
||||||
|
`Resource::Helper` y los decodifica una sola vez.
|
||||||
|
- **[audio_effects.hpp](source/core/audio/audio_effects.hpp)** — DSP de echo y
|
||||||
|
reverb; presets en `data/config/sounds.yaml`
|
||||||
|
([sound_effects_config.hpp](source/core/audio/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/](source/core/resources/) abstrae de dónde salen los bytes:
|
||||||
|
|
||||||
|
- **[resource_pack](source/core/resources/resource_pack.hpp)** (`Resource::Pack`)
|
||||||
|
— lee un fichero empaquetado con cabecera *magic* `"ORNI"` y entradas con CRC32
|
||||||
|
para validación de integridad.
|
||||||
|
- **[resource_loader](source/core/resources/resource_loader.hpp)**
|
||||||
|
(`Resource::Loader`, singleton Meyers) — `loadResource()`, `resourceExists()`,
|
||||||
|
`listResources(prefix)`, `validatePack()`.
|
||||||
|
- **[resource_helper](source/core/resources/resource_helper.hpp)** — wrappers de
|
||||||
|
conveniencia (`initializeResourceSystem`, `listResources`, `loadFile`).
|
||||||
|
|
||||||
|
**Estrategia dual** (decidida en el constructor del Director,
|
||||||
|
[director.cpp:64-93](source/core/system/director.cpp#L64-L93)):
|
||||||
|
|
||||||
|
- **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](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](#52-una-shape-y-cómo-se-carga)).
|
||||||
|
|
||||||
|
El parser YAML usado es [fkyaml](source/external/fkyaml_node.hpp) (cabecera única),
|
||||||
|
envuelto por [config_yaml](source/game/config_yaml.hpp).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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](source/core/system/director.cpp#L354) intenta, en orden:
|
||||||
|
`SDLManager::handleWindowEvent` → `GlobalEvents::handle` → F11 (debug overlay) →
|
||||||
|
`current_scene_->handleEvent`.
|
||||||
|
|
||||||
|
2. **GlobalEvents** ([global_events.cpp](source/core/system/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](#10-lógica-del-juego)).
|
||||||
|
|
||||||
|
**Overlays de sistema** (todos singletons, todos por encima de la escena):
|
||||||
|
|
||||||
|
- **[Notifier](source/core/system/notifier.hpp)** — toasts deslizantes centrados
|
||||||
|
(`notifyInfo/Warn/Exit`), con máquina de animación HIDDEN/ENTERING/HOLDING/EXITING.
|
||||||
|
- **[ServiceMenu](source/core/system/service_menu.hpp)** — menú de configuración
|
||||||
|
(F12) con pila de páginas (vídeo, audio, controles, sistema...).
|
||||||
|
- **[DebugOverlay](source/core/system/debug_overlay.hpp)** — HUD de FPS/VSync (F11).
|
||||||
|
- **[Relaunch](source/core/system/relaunch.hpp)** — 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](source/game/scenes/game_scene.hpp). Es la clase
|
||||||
|
más grande del juego y actúa como orquestador. Posee:
|
||||||
|
|
||||||
|
- El mundo físico [Physics::PhysicsWorld](source/core/physics/physics_world.hpp)
|
||||||
|
(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()](source/game/scenes/game_scene.cpp) es un orquestador delgado;
|
||||||
|
cada paso es una función privada (descompuesto para reducir complejidad cognitiva):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
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](source/game/scenes/game_scene.hpp#L166), que despacha según
|
||||||
|
el estado del stage; en `PLAYING`,
|
||||||
|
[runStagePlaying](source/game/scenes/game_scene.hpp#L169) ejecuta: WaveRunner
|
||||||
|
(spawns) → IA de cada enemigo → control de naves
|
||||||
|
([updateShipsControl](source/game/scenes/game_scene.cpp), que en demo usa
|
||||||
|
`applyMovement` con el control del pilot y fuera de demo usa `processInput`) →
|
||||||
|
detección de colisiones ([runCollisionDetections](source/game/scenes/game_scene.hpp#L176)).
|
||||||
|
|
||||||
|
`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](source/core/entities/entity.hpp)):
|
||||||
|
|
||||||
|
- **[Ship](source/game/entities/ship.hpp)** — 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](source/game/entities/enemy.hpp)** — 5 tipos (`EnemyType`: `PENTAGON`,
|
||||||
|
`SQUARE`, `PINWHEEL`, `STAR`, `ORB`). Toda su config (físicas, IA, animación,
|
||||||
|
eventos) viene del **YAML** vía [EnemyRegistry](source/game/entities/enemy_registry.hpp).
|
||||||
|
Tiene salud (la mayoría HP=1; `ORB` HP=10) y estado *wounded* (parpadeo).
|
||||||
|
- **[Bullet](source/game/entities/bullet.hpp)** — 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](source/game/entities/bullet_registry.hpp).
|
||||||
|
|
||||||
|
### 10.3 IA de enemigos: declarativa
|
||||||
|
|
||||||
|
Los enemigos **no** tienen comportamiento hardcoded. El YAML describe:
|
||||||
|
|
||||||
|
- Una **primitiva de movimiento** (`MovementType` en
|
||||||
|
[enemy_ai.hpp](source/game/entities/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](source/game/systems/enemy_ai_system.hpp)** — `move()` aplica la
|
||||||
|
primitiva de movimiento; `tick()` añade las acciones periódicas. Helper
|
||||||
|
`findNearestShipPosition()` para las primitivas que buscan al jugador.
|
||||||
|
- **[EnemyEventDispatcher](source/game/systems/enemy_event_dispatcher.hpp)** —
|
||||||
|
ejecuta las acciones declarativas cuando se dispara un evento.
|
||||||
|
|
||||||
|
### 10.4 Colisiones
|
||||||
|
|
||||||
|
[CollisionSystem](source/game/systems/collision_system.hpp) 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](source/game/scenes/game_scene.hpp#L174). 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](source/game/stage_system/stage_manager.hpp)** — FSM del stage
|
||||||
|
(`EstatStage`): `INIT_HUD` (anima el HUD, 3 s) → `LEVEL_START` ("ENEMY INCOMING",
|
||||||
|
3 s, arranca `game.ogg`) → `PLAYING` → `LEVEL_COMPLETED` ("GOOD JOB COMMANDER!",
|
||||||
|
3 s) → siguiente stage. `initDemo(stage_id)` arranca directamente en `PLAYING`
|
||||||
|
para el attract mode.
|
||||||
|
- **[WaveRunner](source/game/stage_system/wave_runner.hpp)** — emite los enemigos de
|
||||||
|
cada oleada según `spawn_interval` y avanza cuando se cumple `next` (`all_dead`,
|
||||||
|
`timeout`, o ambos).
|
||||||
|
- **[StageConfig](source/game/stage_system/stage_config.hpp)** /
|
||||||
|
[StageLoader](source/game/stage_system/stage_loader.hpp) — 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](source/core/physics/physics_world.hpp) /
|
||||||
|
[physics_world.cpp](source/core/physics/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](source/core/physics/rigid_body.hpp) 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. Gameplay** — [collision_system.cpp](source/game/systems/collision_system.cpp)
|
||||||
|
(ver [§10.4](#104-colisiones)), que decide *qué pasa* (daño, score, muerte). Usa los
|
||||||
|
helpers de [collision.hpp](source/core/physics/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](source/game/scenes/title_scene.hpp) cuando el `idle_timer_` en el
|
||||||
|
estado `MAIN` supera el umbral de inactividad, y desde
|
||||||
|
[GameScene](source/game/scenes/game_scene.hpp) cuando `match_config_.mode == DEMO`.
|
||||||
|
|
||||||
|
La IA vive en [DemoPilot](source/game/systems/demo_pilot.hpp) /
|
||||||
|
[demo_pilot.cpp](source/game/systems/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](source/game/systems/demo_pilot.hpp#L36-L42)): 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](source/game/scenes/game_scene.hpp#L157).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Efectos visuales
|
||||||
|
|
||||||
|
Viven en [game/effects/](source/game/effects/) y son managers con pools:
|
||||||
|
|
||||||
|
- **[DebrisManager](source/game/effects/debris_manager.hpp)** — 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](source/game/effects/firework_manager.hpp)** — bursts de fuegos
|
||||||
|
artificiales.
|
||||||
|
- **[FloatingScoreManager](source/game/effects/floating_score_manager.hpp)** —
|
||||||
|
números de puntuación flotantes ("+150").
|
||||||
|
- **[TrailManager](source/game/effects/trail_manager.hpp)** — estela tras las naves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Configuración, constantes y convenciones
|
||||||
|
|
||||||
|
**Configuración:**
|
||||||
|
|
||||||
|
- **[EngineConfig](source/core/config/engine_config.hpp)** — struct POD con
|
||||||
|
ventana, rendering, audio, bindings de jugadores, locale, console. Es la config
|
||||||
|
persistente (`config.yaml`), gestionada por
|
||||||
|
[config_yaml](source/game/config_yaml.hpp) (`ConfigYaml::engine_config`,
|
||||||
|
`loadFromFile`/`saveToFile`).
|
||||||
|
- **[PostFxConfig](source/core/config/postfx_config.hpp)** — carga los `PostFxParams`
|
||||||
|
(bloom/flicker/fondo) desde YAML.
|
||||||
|
- **[GameConfig::MatchConfig](source/core/system/game_config.hpp)** — config no
|
||||||
|
persistente de la partida (jugadores activos, modo NORMAL/DEMO).
|
||||||
|
|
||||||
|
**Constantes y tipos:**
|
||||||
|
|
||||||
|
- **[core/types.hpp](source/core/types.hpp)** — `Vec2` / `Vec3` (agregados con
|
||||||
|
operadores y helpers como `length()`, `normalized()`, `dot()`, `cross()`).
|
||||||
|
- **[core/defaults/](source/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/](.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](source/core/system/director.cpp) (`Director::iterate` / `handleEvent`) |
|
||||||
|
| Las callbacks de SDL | [main.cpp](source/main.cpp) |
|
||||||
|
| Añadir/cambiar una escena o una transición | [scene.hpp](source/core/system/scene.hpp), [scene_context.hpp](source/core/system/scene_context.hpp), `Director::buildScene` |
|
||||||
|
| Cómo se dibuja una línea / el frame de render | [line_renderer.cpp](source/core/rendering/line_renderer.cpp) → [gpu_frame_renderer.cpp](source/core/rendering/gpu/gpu_frame_renderer.cpp) |
|
||||||
|
| Bloom / flicker / fondo (post-proceso) | [gpu_postfx_pipeline](source/core/rendering/gpu/gpu_postfx_pipeline.hpp), [gpu_bloom_pipeline](source/core/rendering/gpu/gpu_bloom_pipeline.hpp), shaders en [shaders/](shaders/) |
|
||||||
|
| Crear/editar una figura vectorial | `data/shapes/**/*.shp` + [shape_loader.hpp](source/core/graphics/shape_loader.hpp) |
|
||||||
|
| El texto en pantalla | [vector_text.hpp](source/core/graphics/vector_text.hpp) |
|
||||||
|
| Eventos globales (teclas F, ESC, hotplug) | [global_events.cpp](source/core/system/global_events.cpp) |
|
||||||
|
| Controles, bindings, rebinding | [input.cpp](source/core/input/input.cpp), [define_inputs.cpp](source/core/input/define_inputs.cpp) |
|
||||||
|
| Reproducir música/efectos | [audio.hpp](source/core/audio/audio.hpp), [audio_adapter.hpp](source/core/audio/audio_adapter.hpp) |
|
||||||
|
| Cómo se cargan los recursos / el pack | [resource_loader.cpp](source/core/resources/resource_loader.cpp), [resource_pack.cpp](source/core/resources/resource_pack.cpp) |
|
||||||
|
| Reglas de la partida, vidas, game over | [game_scene.cpp](source/game/scenes/game_scene.cpp) |
|
||||||
|
| Comportamiento de un enemigo | su YAML en `data/entities/<tipo>/` + [enemy_ai_system.cpp](source/game/systems/enemy_ai_system.cpp) |
|
||||||
|
| Definir oleadas / dificultad de un nivel | [data/stages/stages.yaml](data/stages/stages.yaml) + [stage_manager.cpp](source/game/stage_system/stage_manager.cpp) |
|
||||||
|
| Colisiones | [collision_system.cpp](source/game/systems/collision_system.cpp) |
|
||||||
|
| La IA del modo demo | [demo_pilot.cpp](source/game/systems/demo_pilot.cpp) |
|
||||||
|
| Explosiones / partículas | [debris_manager.cpp](source/game/effects/debris_manager.cpp) |
|
||||||
|
| El menú de servicio (F12) | [service_menu.cpp](source/core/system/service_menu.cpp) |
|
||||||
|
| Textos traducibles | `data/locale/*.yaml` + [locale.cpp](source/core/locale/locale.cpp) |
|
||||||
|
| Constantes por defecto | [core/defaults/](source/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](#55-shaders-fuentes-compilación-y-selección): `CMakeLists.txt` +
|
||||||
|
`tools/shaders/compile_spirv.cmake` + `shader_factory.hpp`) y el interior de la
|
||||||
|
**física** ([§10.6](#106-dos-capas-de-colisión-física-vs-gameplay):
|
||||||
|
`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.
|
||||||
|
</content>
|
||||||
|
</invoke>
|
||||||
@@ -43,7 +43,7 @@ visual_thrust:
|
|||||||
|
|
||||||
colors:
|
colors:
|
||||||
normal: [255, 255, 255] # blanc neutre
|
normal: [255, 255, 255] # blanc neutre
|
||||||
hurt: [255, 220, 60] # daurat (estat ferit)
|
hurt: [255, 0, 0] # roig pur (estat ferit)
|
||||||
|
|
||||||
weapon:
|
weapon:
|
||||||
bullet_speed: 700.0 # velocitat escalar de la bullet (px/s)
|
bullet_speed: 700.0 # velocitat escalar de la bullet (px/s)
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ hud:
|
|||||||
title:
|
title:
|
||||||
press_start: "PREMEU START PER JUGAR"
|
press_start: "PREMEU START PER JUGAR"
|
||||||
|
|
||||||
|
demo:
|
||||||
|
banner: "MODE DEMO - PREMEU START"
|
||||||
|
|
||||||
game_screen:
|
game_screen:
|
||||||
game_over: "FI DEL JOC"
|
game_over: "FI DEL JOC"
|
||||||
continue: "CONTINUAR"
|
continue: "CONTINUAR"
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ hud:
|
|||||||
title:
|
title:
|
||||||
press_start: "PRESS START TO PLAY"
|
press_start: "PRESS START TO PLAY"
|
||||||
|
|
||||||
|
demo:
|
||||||
|
banner: "DEMO MODE - PRESS START"
|
||||||
|
|
||||||
game_screen:
|
game_screen:
|
||||||
game_over: "GAME OVER"
|
game_over: "GAME OVER"
|
||||||
continue: "CONTINUE"
|
continue: "CONTINUE"
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ namespace Defaults::Game {
|
|||||||
constexpr float LEVEL_COMPLETED_DURATION = 3.0F; // Duración total
|
constexpr float LEVEL_COMPLETED_DURATION = 3.0F; // Duración total
|
||||||
constexpr float LEVEL_COMPLETED_TYPING_RATIO = 0.05F; // ~150ms de typewriter (escan ràpid però visible)
|
constexpr float LEVEL_COMPLETED_TYPING_RATIO = 0.05F; // ~150ms de typewriter (escan ràpid però visible)
|
||||||
|
|
||||||
|
// Attract mode: transició TÍTOL → DEMO. Primer un "dive" de càmera cap al
|
||||||
|
// punt de fuga (deixa enrere títol/naus/logo) i després una cortinilla negra
|
||||||
|
// que cau per tapar; a la demo, la cortinilla segueix caient i destapa.
|
||||||
|
namespace Dive {
|
||||||
|
constexpr float DURATION = 0.55F; // Durada del dive (s), amb acceleració (ease-in)
|
||||||
|
constexpr float CAMERA_DISTANCE = 450.0F; // Avanç de la càmera en +Z (passa les naus, a Z≈323)
|
||||||
|
constexpr float ZOOM_MAX = 7.0F; // Zoom final dels elements 2D (logo + peu) en travessar-los
|
||||||
|
} // namespace Dive
|
||||||
|
namespace Curtain {
|
||||||
|
constexpr float COVER_DURATION = 0.35F; // TÍTOL: la tela cau i tapa
|
||||||
|
constexpr float REVEAL_DURATION = 0.45F; // DEMO: la tela segueix caient i destapa
|
||||||
|
} // namespace Curtain
|
||||||
|
|
||||||
// Transición INIT_HUD (animación inicial del HUD)
|
// Transición INIT_HUD (animación inicial del HUD)
|
||||||
constexpr float INIT_HUD_DURATION = 3.0F; // Duración total del estado
|
constexpr float INIT_HUD_DURATION = 3.0F; // Duración total del estado
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// curtain.cpp - Implementació de la cortinilla negra
|
||||||
|
// © 2026 JailDesigner
|
||||||
|
|
||||||
|
#include "core/graphics/curtain.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "core/defaults/game.hpp"
|
||||||
|
#include "core/math/easing.hpp"
|
||||||
|
|
||||||
|
namespace Graphics {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr float SCREEN_H = static_cast<float>(Defaults::Game::HEIGHT);
|
||||||
|
constexpr float SCREEN_W = static_cast<float>(Defaults::Game::WIDTH);
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Curtain::Curtain(Rendering::Renderer* renderer)
|
||||||
|
: renderer_(renderer) {}
|
||||||
|
|
||||||
|
void Curtain::cover(float duration) {
|
||||||
|
// Caire superior de -H (fora, a dalt) fins a 0 (tela tapant tota la pantalla).
|
||||||
|
from_ = -SCREEN_H;
|
||||||
|
to_ = 0.0F;
|
||||||
|
duration_ = duration;
|
||||||
|
elapsed_ = 0.0F;
|
||||||
|
active_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Curtain::reveal(float duration) {
|
||||||
|
// Caire superior de 0 (tapant) fins a +H (tela fora per baix).
|
||||||
|
from_ = 0.0F;
|
||||||
|
to_ = SCREEN_H;
|
||||||
|
duration_ = duration;
|
||||||
|
elapsed_ = 0.0F;
|
||||||
|
active_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Curtain::update(float delta_time) {
|
||||||
|
if (!active_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elapsed_ += delta_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto Curtain::topY() const -> float {
|
||||||
|
if (duration_ <= 0.0F) {
|
||||||
|
return to_;
|
||||||
|
}
|
||||||
|
const float T = std::clamp(elapsed_ / duration_, 0.0F, 1.0F);
|
||||||
|
// Ease-in: la tela "cau" accelerant, com per gravetat.
|
||||||
|
return Easing::lerp(from_, to_, Easing::easeInQuad(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto Curtain::isDone() const -> bool {
|
||||||
|
return !active_ || elapsed_ >= duration_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Curtain::draw() const {
|
||||||
|
if (!active_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const float TOP = topY();
|
||||||
|
// Si la tela ja ha sortit completament per baix, no hi ha res a pintar.
|
||||||
|
if (TOP >= SCREEN_H) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderer_->pushRect(0.0F, TOP, SCREEN_W, SCREEN_H, 0.0F, 0.0F, 0.0F, 1.0F);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Graphics
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// curtain.hpp - Cortinilla negra per a transicions d'escena
|
||||||
|
// © 2026 JailDesigner
|
||||||
|
//
|
||||||
|
// Tela negra a pantalla completa que es mou SEMPRE cap avall:
|
||||||
|
// - cover(): cau des de dalt fins a tapar-ho tot (queda negre).
|
||||||
|
// - reveal(): segueix caient i surt per baix, deixant veure l'escena.
|
||||||
|
// Una escena la posseeix, l'actualitza cada frame i la dibuixa l'ÚLTIM (per
|
||||||
|
// damunt de tot). En repòs (no arrencada o reveal acabada) el draw() és no-op.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "core/rendering/render_context.hpp"
|
||||||
|
|
||||||
|
namespace Graphics {
|
||||||
|
|
||||||
|
class Curtain {
|
||||||
|
public:
|
||||||
|
explicit Curtain(Rendering::Renderer* renderer);
|
||||||
|
|
||||||
|
// Tela que cau des de dalt fins a tapar tota la pantalla en 'duration' s.
|
||||||
|
void cover(float duration);
|
||||||
|
|
||||||
|
// Tela que segueix caient i surt per baix (destapa) en 'duration' s.
|
||||||
|
void reveal(float duration);
|
||||||
|
|
||||||
|
// Avança el temporitzador intern.
|
||||||
|
void update(float delta_time);
|
||||||
|
|
||||||
|
// Dibuixa la tela negra a la seva posició vertical actual. No-op si no
|
||||||
|
// queda res visible.
|
||||||
|
void draw() const;
|
||||||
|
|
||||||
|
// Cert quan el moviment actual ha acabat (o no s'ha arrencat mai).
|
||||||
|
[[nodiscard]] auto isDone() const -> bool;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Posició actual del caire superior de la tela (píxels lògics).
|
||||||
|
[[nodiscard]] auto topY() const -> float;
|
||||||
|
|
||||||
|
Rendering::Renderer* renderer_;
|
||||||
|
float from_{0.0F};
|
||||||
|
float to_{0.0F};
|
||||||
|
float duration_{0.0F};
|
||||||
|
float elapsed_{0.0F};
|
||||||
|
bool active_{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Graphics
|
||||||
@@ -62,6 +62,12 @@ namespace Graphics {
|
|||||||
buildLines();
|
buildLines();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Playfield::completeBuild() {
|
||||||
|
// Avançar el rellotge intern més enllà de tota la finestra d'spawn + el
|
||||||
|
// creixement de l'última línia: computeLineProgress() retorna 1.0 per a totes.
|
||||||
|
elapsed_s_ = Defaults::Playfield::TOTAL_ANIMATION_DURATION_S;
|
||||||
|
}
|
||||||
|
|
||||||
void Playfield::update(float delta_time) {
|
void Playfield::update(float delta_time) {
|
||||||
elapsed_s_ += delta_time;
|
elapsed_s_ += delta_time;
|
||||||
for (auto& ripple : ripples_) {
|
for (auto& ripple : ripples_) {
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ namespace Graphics {
|
|||||||
// Avança timers interns (creació + ripples).
|
// Avança timers interns (creació + ripples).
|
||||||
void update(float delta_time);
|
void update(float delta_time);
|
||||||
|
|
||||||
|
// Completa instantàniament l'animació de creació de la graella (totes les
|
||||||
|
// línies al 100%). Útil per a la demo (attract), que arrenca amb la
|
||||||
|
// partida "ja començada" i no ha de mostrar el muntatge del fons.
|
||||||
|
void completeBuild();
|
||||||
|
|
||||||
// Pinta la graella. La porció dibuixada de cada línia depèn del timer intern,
|
// Pinta la graella. La porció dibuixada de cada línia depèn del timer intern,
|
||||||
// i s'aplica deformació radial per cada ripple activa que afecti la línia.
|
// i s'aplica deformació radial per cada ripple activa que afecti la línia.
|
||||||
void draw() const;
|
void draw() const;
|
||||||
|
|||||||
@@ -70,10 +70,24 @@ namespace SceneManager {
|
|||||||
return match_config_;
|
return match_config_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nombre d'escenaris demo curats (cicle del attract mode).
|
||||||
|
static constexpr std::uint8_t DEMO_SCENARIO_COUNT = 4;
|
||||||
|
|
||||||
|
// Índex de l'escenari demo actual. Persisteix entre transicions (el
|
||||||
|
// SceneContext el posseeix el Director), així cada entrada al mode demo
|
||||||
|
// mostra el següent escenari de la llista curada.
|
||||||
|
[[nodiscard]] auto demoScenarioIndex() const -> std::uint8_t {
|
||||||
|
return demo_scenario_index_;
|
||||||
|
}
|
||||||
|
void advanceDemoScenario() {
|
||||||
|
demo_scenario_index_ = (demo_scenario_index_ + 1) % DEMO_SCENARIO_COUNT;
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
SceneType next_scene_{SceneType::LOGO}; // SceneType a la qual transicionar
|
SceneType next_scene_{SceneType::LOGO}; // SceneType a la qual transicionar
|
||||||
Option option_{Option::NONE}; // Opción específica per l'escena
|
Option option_{Option::NONE}; // Opción específica per l'escena
|
||||||
GameConfig::MatchConfig match_config_; // Configuración de match (jugadors active, mode)
|
GameConfig::MatchConfig match_config_; // Configuración de match (jugadors active, mode)
|
||||||
|
std::uint8_t demo_scenario_index_{0}; // Índex de l'escenari demo (attract mode)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Variable global inline per gestionar l'escena actual (backward compatibility)
|
// Variable global inline per gestionar l'escena actual (backward compatibility)
|
||||||
|
|||||||
@@ -64,8 +64,11 @@ namespace Effects {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reproducir sonido de explosión
|
// Reproducir sonido de explosión. Cadena buida = explosió silenciosa
|
||||||
Audio::get()->playSound(sound, Audio::Group::GAME);
|
// (p. ex. el logo dins el cicle d'atracció).
|
||||||
|
if (!sound.empty()) {
|
||||||
|
Audio::get()->playSound(sound, Audio::Group::GAME);
|
||||||
|
}
|
||||||
|
|
||||||
// Notificar als subscriptors (border, playfield, etc.).
|
// Notificar als subscriptors (border, playfield, etc.).
|
||||||
if (explosion_callback_) {
|
if (explosion_callback_) {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ namespace Effects {
|
|||||||
const Vec2& velocitat_objecte = {.x = 0.0F, .y = 0.0F},
|
const Vec2& velocitat_objecte = {.x = 0.0F, .y = 0.0F},
|
||||||
float velocitat_angular = 0.0F,
|
float velocitat_angular = 0.0F,
|
||||||
float factor_herencia_visual = 0.0F,
|
float factor_herencia_visual = 0.0F,
|
||||||
|
// sound: nom del so d'explosió. Cadena buida ("") = explosió silenciosa.
|
||||||
const std::string& sound = Defaults::Sound::ENEMY_EXPLOSION,
|
const std::string& sound = Defaults::Sound::ENEMY_EXPLOSION,
|
||||||
SDL_Color color = {0, 0, 0, 0}, // alpha==0 → fragmentos usan oscilador global
|
SDL_Color color = {0, 0, 0, 0}, // alpha==0 → fragmentos usan oscilador global
|
||||||
float lifetime = Defaults::Physics::Debris::TEMPS_VIDA,
|
float lifetime = Defaults::Physics::Debris::TEMPS_VIDA,
|
||||||
|
|||||||
@@ -85,16 +85,24 @@ void Ship::processInput(float delta_time, uint8_t player_id) {
|
|||||||
? input->checkActionPlayer1(InputAction::THRUST, Input::ALLOW_REPEAT)
|
? input->checkActionPlayer1(InputAction::THRUST, Input::ALLOW_REPEAT)
|
||||||
: input->checkActionPlayer2(InputAction::THRUST, Input::ALLOW_REPEAT);
|
: input->checkActionPlayer2(InputAction::THRUST, Input::ALLOW_REPEAT);
|
||||||
|
|
||||||
if (ROTATE_RIGHT) {
|
applyMovement(ROTATE_LEFT, ROTATE_RIGHT, THRUST, delta_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Ship::applyMovement(bool rotate_left, bool rotate_right, bool thrust, float delta_time) {
|
||||||
|
if (is_hit_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rotate_right) {
|
||||||
body_.angle += config_.physics.rotation_speed * delta_time;
|
body_.angle += config_.physics.rotation_speed * delta_time;
|
||||||
}
|
}
|
||||||
if (ROTATE_LEFT) {
|
if (rotate_left) {
|
||||||
body_.angle -= config_.physics.rotation_speed * delta_time;
|
body_.angle -= config_.physics.rotation_speed * delta_time;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thrust: fuerza vectorial en la dirección de la nariz.
|
// Thrust: fuerza vectorial en la dirección de la nariz.
|
||||||
// angle - PI/2 porque angle=0 apunta hacia arriba (eje Y negativo SDL).
|
// angle - PI/2 porque angle=0 apunta hacia arriba (eje Y negativo SDL).
|
||||||
if (THRUST) {
|
if (thrust) {
|
||||||
const float DIR_X = std::cos(body_.angle - (Constants::PI / 2.0F));
|
const float DIR_X = std::cos(body_.angle - (Constants::PI / 2.0F));
|
||||||
const float DIR_Y = std::sin(body_.angle - (Constants::PI / 2.0F));
|
const float DIR_Y = std::sin(body_.angle - (Constants::PI / 2.0F));
|
||||||
const float MAGNITUDE = body_.mass * config_.physics.acceleration;
|
const float MAGNITUDE = body_.mass * config_.physics.acceleration;
|
||||||
@@ -154,7 +162,7 @@ void Ship::draw() const {
|
|||||||
const float THRUST_MODULATOR = 1.0F + (VISUAL_PUSH / config_.visual_thrust.scale_divisor);
|
const float THRUST_MODULATOR = 1.0F + (VISUAL_PUSH / config_.visual_thrust.scale_divisor);
|
||||||
const float SCALE = config_.shape.scale * THRUST_MODULATOR;
|
const float SCALE = config_.shape.scale * THRUST_MODULATOR;
|
||||||
|
|
||||||
// Parpelleig daurat mentre està ferida: alterna color normal ↔ color hurt.
|
// Parpelleig mentre està ferida: alterna color normal ↔ color hurt.
|
||||||
SDL_Color color = config_.colors.normal;
|
SDL_Color color = config_.colors.normal;
|
||||||
if (hurt_timer_ > 0.0F) {
|
if (hurt_timer_ > 0.0F) {
|
||||||
const float CYCLE = 1.0F / config_.hurt.blink_hz;
|
const float CYCLE = 1.0F / config_.hurt.blink_hz;
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ class Ship : public Entities::Entity {
|
|||||||
void init() override { init(nullptr, false); }
|
void init() override { init(nullptr, false); }
|
||||||
void init(const Vec2* spawn_point, bool activar_invulnerabilitat = false);
|
void init(const Vec2* spawn_point, bool activar_invulnerabilitat = false);
|
||||||
void processInput(float delta_time, uint8_t player_id);
|
void processInput(float delta_time, uint8_t player_id);
|
||||||
|
// Aplica rotació/empenta des de booleans de control (mateixa física que
|
||||||
|
// processInput, però sense llegir Input). Usat pel pilot IA del mode demo.
|
||||||
|
void applyMovement(bool rotate_left, bool rotate_right, bool thrust, float delta_time);
|
||||||
void update(float delta_time) override;
|
void update(float delta_time) override;
|
||||||
void postUpdate(float delta_time) override;
|
void postUpdate(float delta_time) override;
|
||||||
void draw() const override;
|
void draw() const override;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "game_scene.hpp"
|
#include "game_scene.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
#include "core/audio/audio.hpp"
|
#include "core/audio/audio.hpp"
|
||||||
#include "core/entities/entity_loader.hpp"
|
#include "core/entities/entity_loader.hpp"
|
||||||
#include "core/input/input.hpp"
|
#include "core/input/input.hpp"
|
||||||
|
#include "core/input/input_types.hpp"
|
||||||
#include "core/locale/locale.hpp"
|
#include "core/locale/locale.hpp"
|
||||||
#include "core/system/scene_context.hpp"
|
#include "core/system/scene_context.hpp"
|
||||||
#include "core/system/service_menu.hpp"
|
#include "core/system/service_menu.hpp"
|
||||||
@@ -29,6 +31,20 @@ using SceneManager::SceneContext;
|
|||||||
using SceneType = SceneContext::SceneType;
|
using SceneType = SceneContext::SceneType;
|
||||||
using Option = SceneContext::Option;
|
using Option = SceneContext::Option;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Attract mode: durada fixa de la demo. Amb vides infinites, sempre dura
|
||||||
|
// això (les morts respawnegen); només input o aquest timeout la tallen.
|
||||||
|
constexpr float DEMO_DURATION = 35.0F;
|
||||||
|
|
||||||
|
// Qualsevol d'aquestes accions trenca la demo i torna al títol.
|
||||||
|
constexpr std::array<InputAction, 5> DEMO_EXIT_ACTIONS = {
|
||||||
|
InputAction::LEFT,
|
||||||
|
InputAction::RIGHT,
|
||||||
|
InputAction::THRUST,
|
||||||
|
InputAction::SHOOT,
|
||||||
|
InputAction::START};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
GameScene::GameScene(SDLManager& sdl, SceneContext& context)
|
GameScene::GameScene(SDLManager& sdl, SceneContext& context)
|
||||||
: sdl_(sdl),
|
: sdl_(sdl),
|
||||||
context_(context),
|
context_(context),
|
||||||
@@ -39,7 +55,8 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
|
|||||||
text_(sdl.getRenderer()),
|
text_(sdl.getRenderer()),
|
||||||
starfield_parallax_(sdl.getRenderer()),
|
starfield_parallax_(sdl.getRenderer()),
|
||||||
playfield_(sdl.getRenderer()),
|
playfield_(sdl.getRenderer()),
|
||||||
border_(sdl.getRenderer()) {
|
border_(sdl.getRenderer()),
|
||||||
|
curtain_(sdl.getRenderer()) {
|
||||||
// Recuperar configuración de match des del context
|
// Recuperar configuración de match des del context
|
||||||
match_config_ = context_.getMatchConfig();
|
match_config_ = context_.getMatchConfig();
|
||||||
|
|
||||||
@@ -139,7 +156,27 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
|
|||||||
|
|
||||||
// Initialize stage manager
|
// Initialize stage manager
|
||||||
stage_manager_ = std::make_unique<StageSystem::StageManager>(stage_config_.get());
|
stage_manager_ = std::make_unique<StageSystem::StageManager>(stage_config_.get());
|
||||||
stage_manager_->init();
|
if (match_config_.mode == GameConfig::Mode::DEMO) {
|
||||||
|
// Attract mode: arrencar directament en PLAYING a l'escenari curat
|
||||||
|
// actual (partida "ja començada") i avançar l'índex perquè la pròxima
|
||||||
|
// demo mostri un escenari diferent. El nombre de jugadors ja l'ha fixat
|
||||||
|
// TitleScene al match_config llegint el mateix escenari.
|
||||||
|
const Systems::Demo::Scenario SC = Systems::Demo::scenario(context_.demoScenarioIndex());
|
||||||
|
context_.advanceDemoScenario();
|
||||||
|
stage_manager_->initDemo(SC.stage);
|
||||||
|
demo_timer_ = DEMO_DURATION;
|
||||||
|
// Silenciar els SFX durant la demo (la música segueix). Guardem l'estat
|
||||||
|
// previ per restaurar-lo al destructor sense xafar la preferència de l'usuari.
|
||||||
|
sound_was_enabled_ = Audio::get()->isSoundEnabled();
|
||||||
|
Audio::get()->enableSound(false);
|
||||||
|
// El fons (graella) ha d'aparèixer ja muntat: la demo és una partida en marxa.
|
||||||
|
playfield_.completeBuild();
|
||||||
|
// La cortinilla arrenca tapant i cau per destapar la demo (continua el
|
||||||
|
// moviment iniciat al títol, que va acabar amb la pantalla negra).
|
||||||
|
curtain_.reveal(Defaults::Game::Curtain::REVEAL_DURATION);
|
||||||
|
} else {
|
||||||
|
stage_manager_->init();
|
||||||
|
}
|
||||||
|
|
||||||
// Set ship position reference for safe spawn (P1 for now, TODO: dual tracking)
|
// Set ship position reference for safe spawn (P1 for now, TODO: dual tracking)
|
||||||
stage_manager_->getWaveRunner().setShipPosition(&ships_[0].getCenter());
|
stage_manager_->getWaveRunner().setShipPosition(&ships_[0].getCenter());
|
||||||
@@ -204,6 +241,13 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
|
|||||||
init_hud_rect_sound_played_ = false;
|
init_hud_rect_sound_played_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GameScene::~GameScene() {
|
||||||
|
// Si la demo havia silenciat els SFX, restaurar l'estat previ en sortir.
|
||||||
|
if (match_config_.mode == GameConfig::Mode::DEMO) {
|
||||||
|
Audio::get()->enableSound(sound_was_enabled_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
auto GameScene::isFinished() const -> bool {
|
auto GameScene::isFinished() const -> bool {
|
||||||
return context_.nextScene() != SceneType::GAME;
|
return context_.nextScene() != SceneType::GAME;
|
||||||
}
|
}
|
||||||
@@ -226,7 +270,12 @@ void GameScene::update(float delta_time) {
|
|||||||
// mantener update() legible y reducir complejidad cognitiva.
|
// mantener update() legible y reducir complejidad cognitiva.
|
||||||
stepPhysics(delta_time);
|
stepPhysics(delta_time);
|
||||||
|
|
||||||
if (game_over_state_ == GameOverState::NONE) {
|
if (match_config_.mode == GameConfig::Mode::DEMO) {
|
||||||
|
// Mode demo (attract): salida por input/timeout/muerte + control del pilot.
|
||||||
|
if (stepDemo(delta_time)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (game_over_state_ == GameOverState::NONE) {
|
||||||
stepShootingInput();
|
stepShootingInput();
|
||||||
stepMidGameJoin();
|
stepMidGameJoin();
|
||||||
}
|
}
|
||||||
@@ -299,6 +348,66 @@ void GameScene::stepShootingInput() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GameScene::updateShipsControl(float delta_time) {
|
||||||
|
const bool DEMO = (match_config_.mode == GameConfig::Mode::DEMO);
|
||||||
|
for (uint8_t i = 0; i < 2; i++) {
|
||||||
|
const bool ACTIU = (i == 0) ? match_config_.player1_active : match_config_.player2_active;
|
||||||
|
if (!ACTIU || hit_timer_per_player_[i] != 0.0F) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// En demo, cada nau activa es mou amb el seu pilot IA (control calculat
|
||||||
|
// a stepDemo); la resta de casos llegeixen Input com sempre.
|
||||||
|
if (DEMO) {
|
||||||
|
ships_[i].applyMovement(demo_ctrls_[i].left, demo_ctrls_[i].right, demo_ctrls_[i].thrust, delta_time);
|
||||||
|
} else {
|
||||||
|
ships_[i].processInput(delta_time, i);
|
||||||
|
}
|
||||||
|
ships_[i].update(delta_time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto GameScene::stepDemo(float delta_time) -> bool {
|
||||||
|
curtain_.update(delta_time); // cortinilla que destapa la demo
|
||||||
|
|
||||||
|
// Qualsevol input trenca la demo i torna al títol (música intacta).
|
||||||
|
if (Input::get()->checkAnyPlayerAction(DEMO_EXIT_ACTIONS)) {
|
||||||
|
context_.setNextScene(SceneType::TITLE, Option::JUMP_TO_TITLE_MAIN);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vides infinites: la demo dura sempre el temps fix; en morir, stepDeathSequence
|
||||||
|
// respawneja (no acaba ni passa per CONTINUE/GAME_OVER).
|
||||||
|
demo_timer_ -= delta_time;
|
||||||
|
if (demo_timer_ <= 0.0F) {
|
||||||
|
endDemo();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control de cada nau activa per al frame; el disparo el dispara GameScene.
|
||||||
|
for (uint8_t i = 0; i < 2; i++) {
|
||||||
|
const bool ACTIU = (i == 0) ? match_config_.player1_active : match_config_.player2_active;
|
||||||
|
if (!ACTIU || hit_timer_per_player_[i] != 0.0F) {
|
||||||
|
demo_ctrls_[i] = {}; // nau inactiva/morta: sense control
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
demo_ctrls_[i] = demo_pilots_[i].compute(
|
||||||
|
ships_[i],
|
||||||
|
enemies_,
|
||||||
|
bullets_,
|
||||||
|
Defaults::Zones::PLAYAREA,
|
||||||
|
delta_time);
|
||||||
|
if (demo_ctrls_[i].shoot) {
|
||||||
|
fireBullet(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameScene::endDemo() {
|
||||||
|
// No parem la música: title.ogg segueix sonant durant el cicle atrae.
|
||||||
|
context_.setNextScene(SceneType::LOGO);
|
||||||
|
}
|
||||||
|
|
||||||
void GameScene::stepMidGameJoin() {
|
void GameScene::stepMidGameJoin() {
|
||||||
// Permitir join solo durante PLAYING.
|
// Permitir join solo durante PLAYING.
|
||||||
if (stage_manager_->getState() != StageSystem::EstatStage::PLAYING) {
|
if (stage_manager_->getState() != StageSystem::EstatStage::PLAYING) {
|
||||||
@@ -375,7 +484,7 @@ auto GameScene::stepGameOver(float delta_time) -> bool {
|
|||||||
game_over_timer_ -= delta_time;
|
game_over_timer_ -= delta_time;
|
||||||
if (game_over_timer_ <= 0.0F) {
|
if (game_over_timer_ <= 0.0F) {
|
||||||
Audio::get()->stopMusic();
|
Audio::get()->stopMusic();
|
||||||
context_.setNextScene(SceneType::TITLE);
|
context_.setNextScene(SceneType::LOGO);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,6 +517,15 @@ void GameScene::stepDeathSequence(float delta_time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// *** PHASE 3: RESPAWN OR GAME OVER ***
|
// *** PHASE 3: RESPAWN OR GAME OVER ***
|
||||||
|
// Mode demo: vides infinites. Respawn sempre, sense decrementar vides ni
|
||||||
|
// passar mai per CONTINUE/GAME_OVER — la demo dura el seu temps fix.
|
||||||
|
if (match_config_.mode == GameConfig::Mode::DEMO) {
|
||||||
|
Vec2 spawn_pos = getSpawnPoint(i);
|
||||||
|
ships_[i].init(&spawn_pos, /*activar_invulnerabilitat=*/true);
|
||||||
|
hit_timer_per_player_[i] = 0.0F;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
lives_per_player_[i]--;
|
lives_per_player_[i]--;
|
||||||
if (lives_per_player_[i] > 0) {
|
if (lives_per_player_[i] > 0) {
|
||||||
Vec2 spawn_pos = getSpawnPoint(i);
|
Vec2 spawn_pos = getSpawnPoint(i);
|
||||||
@@ -496,13 +614,7 @@ void GameScene::runStageLevelStart(float delta_time) {
|
|||||||
stage_manager_->update(delta_time);
|
stage_manager_->update(delta_time);
|
||||||
|
|
||||||
// Ambas naves pueden moverse y disparar durante el intro.
|
// Ambas naves pueden moverse y disparar durante el intro.
|
||||||
for (uint8_t i = 0; i < 2; i++) {
|
updateShipsControl(delta_time);
|
||||||
const bool ACTIU = (i == 0) ? match_config_.player1_active : match_config_.player2_active;
|
|
||||||
if (ACTIU && hit_timer_per_player_[i] == 0.0F) {
|
|
||||||
ships_[i].processInput(delta_time, i);
|
|
||||||
ships_[i].update(delta_time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (auto& bullet : bullets_) {
|
for (auto& bullet : bullets_) {
|
||||||
bullet.update(delta_time);
|
bullet.update(delta_time);
|
||||||
}
|
}
|
||||||
@@ -524,13 +636,7 @@ void GameScene::runStagePlaying(float delta_time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gameplay normal: ships activos + entidades + colisiones + efectos.
|
// Gameplay normal: ships activos + entidades + colisiones + efectos.
|
||||||
for (uint8_t i = 0; i < 2; i++) {
|
updateShipsControl(delta_time);
|
||||||
const bool ACTIU = (i == 0) ? match_config_.player1_active : match_config_.player2_active;
|
|
||||||
if (ACTIU && hit_timer_per_player_[i] == 0.0F) {
|
|
||||||
ships_[i].processInput(delta_time, i);
|
|
||||||
ships_[i].update(delta_time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auto ai_ctx = buildCollisionContext();
|
auto ai_ctx = buildCollisionContext();
|
||||||
for (std::size_t i = 0; i < enemies_.size(); ++i) {
|
for (std::size_t i = 0; i < enemies_.size(); ++i) {
|
||||||
Systems::EnemyAi::tick(ai_ctx, enemies_[i], i, delta_time);
|
Systems::EnemyAi::tick(ai_ctx, enemies_[i], i, delta_time);
|
||||||
@@ -552,13 +658,7 @@ void GameScene::runStagePlaying(float delta_time) {
|
|||||||
|
|
||||||
void GameScene::runStageLevelCompleted(float delta_time) {
|
void GameScene::runStageLevelCompleted(float delta_time) {
|
||||||
stage_manager_->update(delta_time);
|
stage_manager_->update(delta_time);
|
||||||
for (uint8_t i = 0; i < 2; i++) {
|
updateShipsControl(delta_time);
|
||||||
const bool ACTIU = (i == 0) ? match_config_.player1_active : match_config_.player2_active;
|
|
||||||
if (ACTIU && hit_timer_per_player_[i] == 0.0F) {
|
|
||||||
ships_[i].processInput(delta_time, i);
|
|
||||||
ships_[i].update(delta_time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (auto& bullet : bullets_) {
|
for (auto& bullet : bullets_) {
|
||||||
bullet.update(delta_time);
|
bullet.update(delta_time);
|
||||||
}
|
}
|
||||||
@@ -614,6 +714,10 @@ void GameScene::draw() {
|
|||||||
drawLevelCompletedState();
|
drawLevelCompletedState();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cortinilla d'entrada de la demo: per damunt de tot. No-op fora del mode
|
||||||
|
// DEMO (curtain_ mai s'arrenca) i quan ja ha sortit per baix.
|
||||||
|
curtain_.draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameScene::drawEnemies() const {
|
void GameScene::drawEnemies() const {
|
||||||
@@ -823,6 +927,12 @@ void GameScene::drawScoreboard() {
|
|||||||
.x = scoreboard_zone.w / 2.0F,
|
.x = scoreboard_zone.w / 2.0F,
|
||||||
.y = scoreboard_zone.y + (scoreboard_zone.h / 2.0F),
|
.y = scoreboard_zone.y + (scoreboard_zone.h / 2.0F),
|
||||||
};
|
};
|
||||||
|
// En mode demo (attract) el marcador no té sentit: substituïm puntuacions i
|
||||||
|
// vides per un rètol que indica que és una demo i convida a jugar.
|
||||||
|
if (match_config_.mode == GameConfig::Mode::DEMO) {
|
||||||
|
text_.renderCentered(Locale::get().text("demo.banner"), CENTER, SCALE, SPACING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Systems::InitHud::drawScoreboardSegmentsAt(text_, buildScoreboardSegments(), CENTER, SCALE, SPACING);
|
Systems::InitHud::drawScoreboardSegmentsAt(text_, buildScoreboardSegments(), CENTER, SCALE, SPACING);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "core/graphics/border.hpp"
|
#include "core/graphics/border.hpp"
|
||||||
|
#include "core/graphics/curtain.hpp"
|
||||||
#include "core/graphics/playfield.hpp"
|
#include "core/graphics/playfield.hpp"
|
||||||
#include "core/graphics/starfield_parallax.hpp"
|
#include "core/graphics/starfield_parallax.hpp"
|
||||||
#include "core/graphics/vector_text.hpp"
|
#include "core/graphics/vector_text.hpp"
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
#include "game/stage_system/stage_config.hpp"
|
#include "game/stage_system/stage_config.hpp"
|
||||||
#include "game/stage_system/stage_manager.hpp"
|
#include "game/stage_system/stage_manager.hpp"
|
||||||
#include "game/systems/collision_system.hpp"
|
#include "game/systems/collision_system.hpp"
|
||||||
|
#include "game/systems/demo_pilot.hpp"
|
||||||
#include "game/systems/init_hud_animator.hpp"
|
#include "game/systems/init_hud_animator.hpp"
|
||||||
|
|
||||||
// Game over state machine
|
// Game over state machine
|
||||||
@@ -42,7 +44,7 @@ enum class GameOverState : uint8_t {
|
|||||||
class GameScene final : public Scene {
|
class GameScene final : public Scene {
|
||||||
public:
|
public:
|
||||||
explicit GameScene(SDLManager& sdl, SceneManager::SceneContext& context);
|
explicit GameScene(SDLManager& sdl, SceneManager::SceneContext& context);
|
||||||
~GameScene() override = default;
|
~GameScene() override; // Restaura l'estat dels SFX si la demo els ha silenciat
|
||||||
|
|
||||||
// Scene interface
|
// Scene interface
|
||||||
void handleEvent(const SDL_Event& event) override;
|
void handleEvent(const SDL_Event& event) override;
|
||||||
@@ -94,6 +96,9 @@ class GameScene final : public Scene {
|
|||||||
// Border del playfield (4 línies amb desplaçaments i flash per impactes)
|
// Border del playfield (4 línies amb desplaçaments i flash per impactes)
|
||||||
Graphics::Border border_;
|
Graphics::Border border_;
|
||||||
|
|
||||||
|
// Cortinilla que destapa en entrar a la demo (només mode DEMO; inactiva en partida normal).
|
||||||
|
Graphics::Curtain curtain_;
|
||||||
|
|
||||||
// [NEW] Stage system
|
// [NEW] Stage system
|
||||||
std::unique_ptr<StageSystem::StageSystemConfig> stage_config_;
|
std::unique_ptr<StageSystem::StageSystemConfig> stage_config_;
|
||||||
std::unique_ptr<StageSystem::StageManager> stage_manager_;
|
std::unique_ptr<StageSystem::StageManager> stage_manager_;
|
||||||
@@ -101,6 +106,12 @@ class GameScene final : public Scene {
|
|||||||
// Control de sons de animación INIT_HUD
|
// Control de sons de animación INIT_HUD
|
||||||
bool init_hud_rect_sound_played_{false}; // Flag para evitar repetir sonido del rectángulo
|
bool init_hud_rect_sound_played_{false}; // Flag para evitar repetir sonido del rectángulo
|
||||||
|
|
||||||
|
// Attract mode (mode DEMO): un pilot IA per nau activa (1 o 2 jugadors).
|
||||||
|
std::array<Systems::Demo::DemoPilot, 2> demo_pilots_;
|
||||||
|
std::array<Systems::Demo::Control, 2> demo_ctrls_{}; // Control per nau al frame actual
|
||||||
|
float demo_timer_{0.0F}; // Temps restant de la demo (→ LOGO)
|
||||||
|
bool sound_was_enabled_{true}; // Estat dels SFX abans de la demo (per restaurar-lo)
|
||||||
|
|
||||||
// Funciones privades
|
// Funciones privades
|
||||||
// bullet_velocity: velocitat de la bala que ha causat la mort (Vec2{} si no
|
// bullet_velocity: velocitat de la bala que ha causat la mort (Vec2{} si no
|
||||||
// ve d'una bala). Es passa al debris perquè els fragments volin en direcció
|
// ve d'una bala). Es passa al debris perquè els fragments volin en direcció
|
||||||
@@ -138,6 +149,13 @@ class GameScene final : public Scene {
|
|||||||
void stepPhysics(float delta_time);
|
void stepPhysics(float delta_time);
|
||||||
void stepShootingInput();
|
void stepShootingInput();
|
||||||
void stepMidGameJoin();
|
void stepMidGameJoin();
|
||||||
|
// Mueve las naves activas: en mode DEMO cada nau usa su pilot IA (demo_ctrls_),
|
||||||
|
// el resto usa processInput (Input). Compartido por los 3 estados jugables.
|
||||||
|
void updateShipsControl(float delta_time);
|
||||||
|
// Mode DEMO: gestiona salida (input→título, timeout/muerte→logo) y calcula
|
||||||
|
// el control + disparo del pilot. Devuelve true si la escena transiciona.
|
||||||
|
[[nodiscard]] auto stepDemo(float delta_time) -> bool;
|
||||||
|
void endDemo();
|
||||||
// Devuelven true si el frame debe salir tras esta sección.
|
// Devuelven true si el frame debe salir tras esta sección.
|
||||||
[[nodiscard]] auto stepContinueScreen(float delta_time) -> bool;
|
[[nodiscard]] auto stepContinueScreen(float delta_time) -> bool;
|
||||||
[[nodiscard]] auto stepGameOver(float delta_time) -> bool;
|
[[nodiscard]] auto stepGameOver(float delta_time) -> bool;
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ LogoScene::LogoScene(SDLManager& sdl, SceneContext& context)
|
|||||||
(void)option; // Suprimir warning
|
(void)option; // Suprimir warning
|
||||||
|
|
||||||
sound_played_.fill(false); // Inicialitzar seguiment de sons
|
sound_played_.fill(false); // Inicialitzar seguiment de sons
|
||||||
|
|
||||||
|
// Si ja sona música (venim de la demo del cicle d'atracció), operar en
|
||||||
|
// silenci: ni sons de lletres ni reinici de title.ogg.
|
||||||
|
attract_silent_ = (Audio::getMusicState() == Audio::MusicState::PLAYING);
|
||||||
|
|
||||||
initLetters();
|
initLetters();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +176,10 @@ void LogoScene::changeState(AnimationState nou_estat) {
|
|||||||
std::mt19937 g(rd());
|
std::mt19937 g(rd());
|
||||||
std::shuffle(explosion_order_.begin(), explosion_order_.end(), g);
|
std::shuffle(explosion_order_.begin(), explosion_order_.end(), g);
|
||||||
} else if (nou_estat == AnimationState::POST_EXPLOSION) {
|
} else if (nou_estat == AnimationState::POST_EXPLOSION) {
|
||||||
Audio::get()->playMusic("title.ogg");
|
// En el cicle d'atracció la música ja sona; no la reiniciem.
|
||||||
|
if (!attract_silent_) {
|
||||||
|
Audio::get()->playMusic("title.ogg");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::cout << "[LogoScene] Canvi a state: " << static_cast<int>(nou_estat)
|
std::cout << "[LogoScene] Canvi a state: " << static_cast<int>(nou_estat)
|
||||||
@@ -194,14 +202,18 @@ void LogoScene::updateExplosions(float delta_time) {
|
|||||||
const auto& letter = letters_[index_actual];
|
const auto& letter = letters_[index_actual];
|
||||||
|
|
||||||
debris_manager_->explode(
|
debris_manager_->explode(
|
||||||
letter.shape, // Forma a explode
|
letter.shape, // Forma a explode
|
||||||
letter.position, // Posición
|
letter.position, // Posición
|
||||||
0.0F, // Angle (sin rotación)
|
0.0F, // Angle (sin rotación)
|
||||||
FINAL_SCALE, // Escala (lletres a scale final)
|
FINAL_SCALE, // Escala (lletres a scale final)
|
||||||
SPEED_EXPLOSIO, // Velocidad base
|
SPEED_EXPLOSIO, // Velocidad base
|
||||||
1.0F, // Brightness màxim (per defecte)
|
1.0F, // Brightness màxim (per defecte)
|
||||||
{.x = 0.0F, .y = 0.0F} // Sin velocity (per defecte)
|
{.x = 0.0F, .y = 0.0F}, // Sin velocity (per defecte)
|
||||||
);
|
0.0F, // Velocitat angular (per defecte)
|
||||||
|
0.0F, // Factor herència visual (per defecte)
|
||||||
|
// Dins el cicle d'atracció les explosions són mudes; fora, so
|
||||||
|
// d'explosió per defecte.
|
||||||
|
attract_silent_ ? "" : Defaults::Sound::ENEMY_EXPLOSION);
|
||||||
|
|
||||||
std::cout << "[LogoScene] Explota letter " << letter_explosion_index_ << "\n";
|
std::cout << "[LogoScene] Explota letter " << letter_explosion_index_ << "\n";
|
||||||
|
|
||||||
@@ -237,9 +249,13 @@ void LogoScene::update(float delta_time) {
|
|||||||
global_progress,
|
global_progress,
|
||||||
LETTER_THRESHOLD);
|
LETTER_THRESHOLD);
|
||||||
|
|
||||||
// Reproduir so cuando la letter comença a aparèixer (progress > 0)
|
// Reproduir so cuando la letter comença a aparèixer (progress > 0).
|
||||||
|
// En mode silenciós (cicle d'atracció) saltem el so però igualment
|
||||||
|
// marquem la letter per no acumular pendents.
|
||||||
if (letter_progress > 0.0F) {
|
if (letter_progress > 0.0F) {
|
||||||
Audio::get()->playSound(Defaults::Sound::LOGO, Audio::Group::GAME);
|
if (!attract_silent_) {
|
||||||
|
Audio::get()->playSound(Defaults::Sound::LOGO, Audio::Group::GAME);
|
||||||
|
}
|
||||||
sound_played_[i] = true;
|
sound_played_[i] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ class LogoScene final : public Scene {
|
|||||||
// Seguiment de sons de lletres (evitar reproduccions repetides)
|
// Seguiment de sons de lletres (evitar reproduccions repetides)
|
||||||
std::array<bool, 9> sound_played_; // Track si cada letter ya ha reproduit el so
|
std::array<bool, 9> sound_played_; // Track si cada letter ya ha reproduit el so
|
||||||
|
|
||||||
|
// Cicle d'atracció: si en entrar al logo ja hi ha música sonant (venim de la
|
||||||
|
// demo amb title.ogg en marxa), el logo no ha d'emetre sons ni reiniciar la
|
||||||
|
// música — només repintar-se en silenci.
|
||||||
|
bool attract_silent_{false};
|
||||||
|
|
||||||
// Constants de animación
|
// Constants de animación
|
||||||
static constexpr float DURATION_PRE = 1.5F; // Duració PRE_ANIMATION (pantalla negra)
|
static constexpr float DURATION_PRE = 1.5F; // Duració PRE_ANIMATION (pantalla negra)
|
||||||
static constexpr float DURATION_ZOOM = 4.0F; // Duració del zoom (segons)
|
static constexpr float DURATION_ZOOM = 4.0F; // Duració del zoom (segons)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
#include "core/rendering/shape_renderer.hpp"
|
#include "core/rendering/shape_renderer.hpp"
|
||||||
#include "core/system/scene_context.hpp"
|
#include "core/system/scene_context.hpp"
|
||||||
#include "core/system/service_menu.hpp"
|
#include "core/system/service_menu.hpp"
|
||||||
|
#include "game/systems/demo_pilot.hpp"
|
||||||
#include "project.h"
|
#include "project.h"
|
||||||
|
|
||||||
using SceneManager::SceneContext;
|
using SceneManager::SceneContext;
|
||||||
@@ -36,7 +37,8 @@ namespace {
|
|||||||
TitleScene::TitleScene(SDLManager& sdl, SceneContext& context)
|
TitleScene::TitleScene(SDLManager& sdl, SceneContext& context)
|
||||||
: sdl_(sdl),
|
: sdl_(sdl),
|
||||||
context_(context),
|
context_(context),
|
||||||
text_(sdl.getRenderer()) {
|
text_(sdl.getRenderer()),
|
||||||
|
curtain_(sdl.getRenderer()) {
|
||||||
std::cout << "SceneType Titol: Inicialitzant...\n";
|
std::cout << "SceneType Titol: Inicialitzant...\n";
|
||||||
|
|
||||||
match_config_.player1_active = false;
|
match_config_.player1_active = false;
|
||||||
@@ -94,7 +96,15 @@ TitleScene::TitleScene(SDLManager& sdl, SceneContext& context)
|
|||||||
}
|
}
|
||||||
|
|
||||||
TitleScene::~TitleScene() {
|
TitleScene::~TitleScene() {
|
||||||
Audio::get()->stopMusic();
|
// Attract mode: si saltem a la demo, NO parem la música — ha de seguir
|
||||||
|
// sonant durant tot el cicle TÍTOL→DEMO→LOGO→TÍTOL. La resta de sortides
|
||||||
|
// (partida normal, EXIT) sí paren.
|
||||||
|
const bool ENTERING_DEMO =
|
||||||
|
context_.nextScene() == SceneType::GAME &&
|
||||||
|
context_.getMatchConfig().mode == GameConfig::Mode::DEMO;
|
||||||
|
if (!ENTERING_DEMO) {
|
||||||
|
Audio::get()->stopMusic();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void TitleScene::initTitle() {
|
void TitleScene::initTitle() {
|
||||||
@@ -267,8 +277,9 @@ void TitleScene::dibuixarPeuTitol(float spacing) const {
|
|||||||
// a 1.0 (a la mida i posició finals, "lluny" al VP).
|
// a 1.0 (a la mida i posició finals, "lluny" al VP).
|
||||||
const float SCREEN_CENTRE_X = Defaults::Game::WIDTH / 2.0F;
|
const float SCREEN_CENTRE_X = Defaults::Game::WIDTH / 2.0F;
|
||||||
const float SCREEN_CENTRE_Y = Defaults::Game::HEIGHT / 2.0F;
|
const float SCREEN_CENTRE_Y = Defaults::Game::HEIGHT / 2.0F;
|
||||||
const float JAILGAMES_S = std::lerp(S::FOOTER_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_jailgames_progress_));
|
// dive_zoom_ (attract) afegeix el zoom del dive per travessar el peu.
|
||||||
const float COPYRIGHT_S = std::lerp(S::FOOTER_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_copyright_progress_));
|
const float JAILGAMES_S = dive_zoom_ * std::lerp(S::FOOTER_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_jailgames_progress_));
|
||||||
|
const float COPYRIGHT_S = dive_zoom_ * std::lerp(S::FOOTER_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_copyright_progress_));
|
||||||
|
|
||||||
const float JAILGAMES_RENDER_SCALE = Defaults::Title::Layout::JAILGAMES_SCALE * JAILGAMES_S;
|
const float JAILGAMES_RENDER_SCALE = Defaults::Title::Layout::JAILGAMES_SCALE * JAILGAMES_S;
|
||||||
for (const auto& letter : letters_jailgames_) {
|
for (const auto& letter : letters_jailgames_) {
|
||||||
@@ -324,6 +335,12 @@ void TitleScene::update(float delta_time) {
|
|||||||
case TitleState::BLACK_SCREEN:
|
case TitleState::BLACK_SCREEN:
|
||||||
updateBlackScreenState(delta_time);
|
updateBlackScreenState(delta_time);
|
||||||
break;
|
break;
|
||||||
|
case TitleState::DEMO_DIVE:
|
||||||
|
updateDemoDiveState(delta_time);
|
||||||
|
break;
|
||||||
|
case TitleState::DEMO_CURTAIN:
|
||||||
|
updateDemoCurtainState(delta_time);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Les animacions segueixen pero els inputs es bloquegen mentre el menu
|
// Les animacions segueixen pero els inputs es bloquegen mentre el menu
|
||||||
@@ -340,6 +357,36 @@ void TitleScene::update(float delta_time) {
|
|||||||
handleSkipInput();
|
handleSkipInput();
|
||||||
handleStartInput();
|
handleStartInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attract mode: al state MAIN, acumular inactivitat; qualsevol botó
|
||||||
|
// arcade la reseteja. En esgotar el timeout, saltar a la demo (mode DEMO,
|
||||||
|
// P1 actiu) sense fer fadeOut de la música (a diferència del START real).
|
||||||
|
if (current_state_ == TitleState::MAIN && !INPUT_BLOCKED) {
|
||||||
|
if (Input::get()->checkAnyPlayerAction(ARCADE_BUTTONS, Input::ALLOW_REPEAT)) {
|
||||||
|
idle_timer_ = 0.0F;
|
||||||
|
} else {
|
||||||
|
idle_timer_ += delta_time;
|
||||||
|
}
|
||||||
|
if (idle_timer_ >= TITLE_DEMO_TIMEOUT) {
|
||||||
|
// L'escenari curat (mateix índex que llegirà GameScene) decideix
|
||||||
|
// quants jugadors IA hi ha. No avancem l'índex ací: ho fa GameScene.
|
||||||
|
const Systems::Demo::Scenario SC = Systems::Demo::scenario(context_.demoScenarioIndex());
|
||||||
|
GameConfig::MatchConfig demo_cfg;
|
||||||
|
demo_cfg.player1_active = true;
|
||||||
|
demo_cfg.player2_active = (SC.players >= 2);
|
||||||
|
demo_cfg.mode = GameConfig::Mode::DEMO;
|
||||||
|
context_.setMatchConfig(demo_cfg);
|
||||||
|
// No saltem en sec: primer un "dive" de càmera cap al punt de fuga
|
||||||
|
// (deixa enrere títol/naus/logo) i després la cortinilla. L'estat
|
||||||
|
// deixa de ser MAIN, així que ni es re-dispara la demo ni s'accepta
|
||||||
|
// START durant la transició. Amaguem ja el "PREMEU START".
|
||||||
|
press_start_visible_ = false;
|
||||||
|
current_state_ = TitleState::DEMO_DIVE;
|
||||||
|
dive_time_ = 0.0F;
|
||||||
|
dive_zoom_ = 1.0F;
|
||||||
|
temps_acumulat_ = 0.0F;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void TitleScene::updateStarfieldFadeInState(float delta_time) {
|
void TitleScene::updateStarfieldFadeInState(float delta_time) {
|
||||||
@@ -442,6 +489,50 @@ void TitleScene::updateBlackScreenState(float delta_time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TitleScene::advanceDive(float delta_time) {
|
||||||
|
namespace D = Defaults::Game::Dive;
|
||||||
|
dive_time_ += delta_time;
|
||||||
|
// T SENSE topall: easeInQuad (=T²) fa que la posició creixi quadràticament,
|
||||||
|
// és a dir, la velocitat creix linealment → acceleració constant que no
|
||||||
|
// s'atura. CAMERA_DISTANCE i ZOOM_MAX deixen de ser límits: són el valor
|
||||||
|
// assolit en l'instant de referència (T=1, quan cau la cortinilla) i a
|
||||||
|
// partir d'ací el moviment continua accelerant (std::lerp extrapola).
|
||||||
|
const float T = dive_time_ / D::DURATION;
|
||||||
|
const float EASED = Easing::easeInQuad(T);
|
||||||
|
|
||||||
|
// Càmera 3D real cap a +Z: starfield i naus es projecten amb la càmera, així
|
||||||
|
// que les estrelles es rasguen i les naus creixen i s'escapen pels costats.
|
||||||
|
// IMPORTANT: cal moure posició I target alhora; si només es mou la posició,
|
||||||
|
// forward = (target - position) s'inverteix en passar el target i la càmera
|
||||||
|
// gira cap enrere (tot apareix invertit i mirant a l'espectador).
|
||||||
|
if (camera_ != nullptr) {
|
||||||
|
const float Z = EASED * D::CAMERA_DISTANCE;
|
||||||
|
camera_->setPosition(Vec3{.x = 0.0F, .y = 0.0F, .z = Z});
|
||||||
|
camera_->setTarget(Vec3{.x = 0.0F, .y = 0.0F, .z = Z + 1.0F});
|
||||||
|
}
|
||||||
|
// Logo i peu són 2D: els fakegem el dive amb un zoom des del centre.
|
||||||
|
dive_zoom_ = std::lerp(1.0F, D::ZOOM_MAX, EASED);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TitleScene::updateDemoDiveState(float delta_time) {
|
||||||
|
advanceDive(delta_time);
|
||||||
|
|
||||||
|
// En l'instant de referència (dive_time_ == DURATION) cau la cortinilla,
|
||||||
|
// però la càmera NO s'atura: segueix accelerant a DEMO_CURTAIN.
|
||||||
|
if (dive_time_ >= Defaults::Game::Dive::DURATION) {
|
||||||
|
current_state_ = TitleState::DEMO_CURTAIN;
|
||||||
|
curtain_.cover(Defaults::Game::Curtain::COVER_DURATION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TitleScene::updateDemoCurtainState(float delta_time) {
|
||||||
|
advanceDive(delta_time); // la càmera continua accelerant sota la tela
|
||||||
|
curtain_.update(delta_time);
|
||||||
|
if (curtain_.isDone()) {
|
||||||
|
context_.setNextScene(SceneType::GAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void TitleScene::handleSkipInput() {
|
void TitleScene::handleSkipInput() {
|
||||||
if (current_state_ != TitleState::STARFIELD_FADE_IN && current_state_ != TitleState::STARFIELD) {
|
if (current_state_ != TitleState::STARFIELD_FADE_IN && current_state_ != TitleState::STARFIELD) {
|
||||||
return;
|
return;
|
||||||
@@ -546,7 +637,9 @@ void TitleScene::draw() {
|
|||||||
(current_state_ == TitleState::STARFIELD_FADE_IN ||
|
(current_state_ == TitleState::STARFIELD_FADE_IN ||
|
||||||
current_state_ == TitleState::STARFIELD ||
|
current_state_ == TitleState::STARFIELD ||
|
||||||
current_state_ == TitleState::MAIN ||
|
current_state_ == TitleState::MAIN ||
|
||||||
current_state_ == TitleState::PLAYER_JOIN_PHASE)) {
|
current_state_ == TitleState::PLAYER_JOIN_PHASE ||
|
||||||
|
current_state_ == TitleState::DEMO_DIVE ||
|
||||||
|
current_state_ == TitleState::DEMO_CURTAIN)) {
|
||||||
ship_animator_->draw();
|
ship_animator_->draw();
|
||||||
}
|
}
|
||||||
drawFlashes();
|
drawFlashes();
|
||||||
@@ -555,14 +648,21 @@ void TitleScene::draw() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current_state_ != TitleState::MAIN && current_state_ != TitleState::PLAYER_JOIN_PHASE) {
|
// DEMO_DIVE/DEMO_CURTAIN es pinten com MAIN (logo + peu) perquè el títol
|
||||||
|
// segueixi visible sota el dive i la cortinilla.
|
||||||
|
if (current_state_ != TitleState::MAIN &&
|
||||||
|
current_state_ != TitleState::PLAYER_JOIN_PHASE &&
|
||||||
|
current_state_ != TitleState::DEMO_DIVE &&
|
||||||
|
current_state_ != TitleState::DEMO_CURTAIN) {
|
||||||
|
curtain_.draw(); // BLACK_SCREEN i altres: només la cortinilla (si activa)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Factor d'escala+posició per simular un moviment 3D des de l'usuari (prop,
|
// Factor d'escala+posició per simular un moviment 3D des de l'usuari (prop,
|
||||||
// sprite gran i posició projectada extrema) cap al VP (lluny, sprite a la
|
// sprite gran i posició projectada extrema) cap al VP (lluny, sprite a la
|
||||||
// seva mida i posició finals). Pivot: centre de pantalla (= projecció VP).
|
// seva mida i posició finals). Pivot: centre de pantalla (= projecció VP).
|
||||||
const float LOGO_S = std::lerp(Defaults::Title::Sequence::LOGO_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_logo_progress_));
|
// El dive (attract) hi afegeix un zoom extra (dive_zoom_) per travessar-los.
|
||||||
|
const float LOGO_S = dive_zoom_ * std::lerp(Defaults::Title::Sequence::LOGO_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_logo_progress_));
|
||||||
const float SCREEN_CENTRE_X = Defaults::Game::WIDTH / 2.0F;
|
const float SCREEN_CENTRE_X = Defaults::Game::WIDTH / 2.0F;
|
||||||
const float SCREEN_CENTRE_Y = Defaults::Game::HEIGHT / 2.0F;
|
const float SCREEN_CENTRE_Y = Defaults::Game::HEIGHT / 2.0F;
|
||||||
const float LOGO_RENDER_SCALE = Defaults::Title::Layout::LOGO_SCALE * LOGO_S;
|
const float LOGO_RENDER_SCALE = Defaults::Title::Layout::LOGO_SCALE * LOGO_S;
|
||||||
@@ -626,6 +726,9 @@ void TitleScene::draw() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dibuixarPeuTitol(SPACING);
|
dibuixarPeuTitol(SPACING);
|
||||||
|
|
||||||
|
// Cortinilla negra (attract): per damunt de tot. No-op si no està activa.
|
||||||
|
curtain_.draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
auto TitleScene::checkSkipButtonPressed() -> bool {
|
auto TitleScene::checkSkipButtonPressed() -> bool {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/graphics/camera3d.hpp"
|
#include "core/graphics/camera3d.hpp"
|
||||||
|
#include "core/graphics/curtain.hpp"
|
||||||
#include "core/graphics/shape.hpp"
|
#include "core/graphics/shape.hpp"
|
||||||
#include "core/graphics/starfield.hpp"
|
#include "core/graphics/starfield.hpp"
|
||||||
#include "core/graphics/vector_text.hpp"
|
#include "core/graphics/vector_text.hpp"
|
||||||
@@ -46,6 +47,8 @@ class TitleScene final : public Scene {
|
|||||||
MAIN,
|
MAIN,
|
||||||
PLAYER_JOIN_PHASE,
|
PLAYER_JOIN_PHASE,
|
||||||
BLACK_SCREEN,
|
BLACK_SCREEN,
|
||||||
|
DEMO_DIVE, // Attract: dive de càmera cap al punt de fuga
|
||||||
|
DEMO_CURTAIN, // Attract: cortinilla negra que cau i tapa abans del salt
|
||||||
};
|
};
|
||||||
|
|
||||||
struct LogoLetter {
|
struct LogoLetter {
|
||||||
@@ -60,6 +63,7 @@ class TitleScene final : public Scene {
|
|||||||
SceneManager::SceneContext& context_;
|
SceneManager::SceneContext& context_;
|
||||||
GameConfig::MatchConfig match_config_;
|
GameConfig::MatchConfig match_config_;
|
||||||
Graphics::VectorText text_;
|
Graphics::VectorText text_;
|
||||||
|
Graphics::Curtain curtain_; // Cortinilla negra en saltar a la demo (attract)
|
||||||
std::unique_ptr<Graphics::Camera3D> camera_;
|
std::unique_ptr<Graphics::Camera3D> camera_;
|
||||||
std::unique_ptr<Graphics::Starfield> starfield_;
|
std::unique_ptr<Graphics::Starfield> starfield_;
|
||||||
std::unique_ptr<Title::ShipAnimator> ship_animator_;
|
std::unique_ptr<Title::ShipAnimator> ship_animator_;
|
||||||
@@ -79,6 +83,12 @@ class TitleScene final : public Scene {
|
|||||||
void drawFlashes();
|
void drawFlashes();
|
||||||
TitleState current_state_{TitleState::STARFIELD_FADE_IN};
|
TitleState current_state_{TitleState::STARFIELD_FADE_IN};
|
||||||
float temps_acumulat_{0.0F};
|
float temps_acumulat_{0.0F};
|
||||||
|
float idle_timer_{0.0F}; // Attract mode: inactivitat acumulada al state MAIN
|
||||||
|
|
||||||
|
// Dive (attract): temps transcorregut i zoom aplicat als elements 2D
|
||||||
|
// (logo + peu) mentre la càmera avança cap al punt de fuga.
|
||||||
|
float dive_time_{0.0F};
|
||||||
|
float dive_zoom_{1.0F};
|
||||||
|
|
||||||
std::vector<LogoLetter> letters_orni_;
|
std::vector<LogoLetter> letters_orni_;
|
||||||
std::vector<LogoLetter> letters_attack_;
|
std::vector<LogoLetter> letters_attack_;
|
||||||
@@ -110,6 +120,10 @@ class TitleScene final : public Scene {
|
|||||||
static constexpr float DURATION_BLACK_SCREEN = 2.0F;
|
static constexpr float DURATION_BLACK_SCREEN = 2.0F;
|
||||||
static constexpr int MUSIC_FADE = 1500;
|
static constexpr int MUSIC_FADE = 1500;
|
||||||
|
|
||||||
|
// Attract mode: temps d'inactivitat al títol (state MAIN) abans de saltar
|
||||||
|
// a la demo. Qualsevol input el reseteja.
|
||||||
|
static constexpr float TITLE_DEMO_TIMEOUT = 20.0F;
|
||||||
|
|
||||||
static constexpr float ORBIT_AMPLITUDE_X = 4.0F;
|
static constexpr float ORBIT_AMPLITUDE_X = 4.0F;
|
||||||
static constexpr float ORBIT_AMPLITUDE_Y = 3.0F;
|
static constexpr float ORBIT_AMPLITUDE_Y = 3.0F;
|
||||||
static constexpr float ORBIT_FREQUENCY_X = 0.8F;
|
static constexpr float ORBIT_FREQUENCY_X = 0.8F;
|
||||||
@@ -138,6 +152,13 @@ class TitleScene final : public Scene {
|
|||||||
void updateMainState(float delta_time);
|
void updateMainState(float delta_time);
|
||||||
void updatePlayerJoinPhaseState(float delta_time);
|
void updatePlayerJoinPhaseState(float delta_time);
|
||||||
void updateBlackScreenState(float delta_time);
|
void updateBlackScreenState(float delta_time);
|
||||||
|
void updateDemoDiveState(float delta_time);
|
||||||
|
void updateDemoCurtainState(float delta_time);
|
||||||
|
// Integra el dive (attract): avança la càmera cap a +Z i puja el zoom dels
|
||||||
|
// elements 2D amb acceleració (easeInQuad), sense topall. Es crida tant a
|
||||||
|
// DEMO_DIVE com a DEMO_CURTAIN perquè la càmera no pare mai un cop comença:
|
||||||
|
// segueix accelerant fins i tot sota la cortinilla, fins al salt a la demo.
|
||||||
|
void advanceDive(float delta_time);
|
||||||
void handleSkipInput();
|
void handleSkipInput();
|
||||||
void handleStartInput();
|
void handleStartInput();
|
||||||
void triggerExitForJoinedPlayers(bool p1_was_active, bool p2_was_active, const char* log_prefix);
|
void triggerExitForJoinedPlayers(bool p1_was_active, bool p2_was_active, const char* log_prefix);
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ namespace StageSystem {
|
|||||||
<< '\n';
|
<< '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void StageManager::initDemo(uint8_t stage_id) {
|
||||||
|
if (config_ == nullptr) {
|
||||||
|
std::cerr << "[StageManager] Error: config es null, no es pot iniciar la demo\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clamp al rang vàlid: si l'escenari demana un stage inexistent, cau a 1.
|
||||||
|
if (config_->findStage(stage_id) == nullptr) {
|
||||||
|
stage_id = 1;
|
||||||
|
}
|
||||||
|
stage_actual_ = stage_id;
|
||||||
|
loadStage(stage_actual_);
|
||||||
|
changeState(EstatStage::PLAYING);
|
||||||
|
|
||||||
|
std::cout << "[StageManager] Demo inicialitzada a stage "
|
||||||
|
<< static_cast<int>(stage_actual_) << '\n';
|
||||||
|
}
|
||||||
|
|
||||||
void StageManager::update(float delta_time, bool pause_spawn) {
|
void StageManager::update(float delta_time, bool pause_spawn) {
|
||||||
switch (estat_) {
|
switch (estat_) {
|
||||||
case EstatStage::INIT_HUD:
|
case EstatStage::INIT_HUD:
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ namespace StageSystem {
|
|||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
void init(); // Reset to stage 1
|
void init(); // Reset to stage 1
|
||||||
|
// Arranque para el mode demo (attract): carga stage_id y entra directamente
|
||||||
|
// en PLAYING, saltando INIT_HUD y "ENEMY INCOMING" para mostrar una partida
|
||||||
|
// ya en marcha. No reproduce game.ogg (changeState solo lo hace en LEVEL_START),
|
||||||
|
// así la música del título sigue sonando durante el ciclo atrae.
|
||||||
|
void initDemo(uint8_t stage_id);
|
||||||
void update(float delta_time, bool pause_spawn = false);
|
void update(float delta_time, bool pause_spawn = false);
|
||||||
|
|
||||||
// Stage progression
|
// Stage progression
|
||||||
|
|||||||
@@ -145,6 +145,12 @@ namespace Systems::Collision {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const uint8_t BULLET_OWNER = bullet.getOwnerId();
|
const uint8_t BULLET_OWNER = bullet.getOwnerId();
|
||||||
|
// Les bales d'enemic NO són friendly fire: les gestiona
|
||||||
|
// detectEnemyBulletShip. Sense aquest filtre, owner >= ENEMY_OWNER_BASE
|
||||||
|
// entraria ací i faria un out-of-bounds a lives_per_player[owner].
|
||||||
|
if (BULLET_OWNER >= Defaults::Entities::ENEMY_OWNER_BASE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (uint8_t player_id = 0; player_id < 2; player_id++) {
|
for (uint8_t player_id = 0; player_id < 2; player_id++) {
|
||||||
// Una bala mai no impacta al seu propi shooter: les bales d'aquest joc no
|
// Una bala mai no impacta al seu propi shooter: les bales d'aquest joc no
|
||||||
@@ -208,22 +214,11 @@ namespace Systems::Collision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// *** BALA D'ENEMIC → SHIP ***
|
// *** BALA D'ENEMIC → SHIP ***
|
||||||
// Regla "cos XOR trossos": l'impuls de la bala s'aplica al cos
|
// Mata d'un sol toc (decisió de disseny). El bullet va als trossos
|
||||||
// només si el ship sobreviu (fereix). Si el ship mor, el bullet
|
// via tocado; el cos no rep impuls (seria double-count amb la
|
||||||
// va directament als trossos (via tocado) i el cos no rep impuls
|
// velocitat que ja hereten els trossos).
|
||||||
// — els trossos ja porten la força de la bala, qualsevol impuls
|
|
||||||
// afegit al cos seria double-count.
|
|
||||||
const Vec2 BULLET_VEL = bullet.getBody().velocity;
|
const Vec2 BULLET_VEL = bullet.getBody().velocity;
|
||||||
if (ctx.ships[player_id].isHurt()) {
|
ctx.on_player_hit(player_id, BULLET_VEL);
|
||||||
// Segon impacte durant HURT → mort.
|
|
||||||
ctx.on_player_hit(player_id, BULLET_VEL);
|
|
||||||
} else {
|
|
||||||
// Fereix: el cos sobreviu, rep l'impuls. No hi ha debris encara.
|
|
||||||
const Vec2 IMPULSE = BULLET_VEL *
|
|
||||||
(bullet.getBody().mass * bullet.getConfig().physics.impact_momentum_factor);
|
|
||||||
ctx.ships[player_id].getBody().applyImpulse(IMPULSE);
|
|
||||||
ctx.ships[player_id].hurt();
|
|
||||||
}
|
|
||||||
breakBullet(ctx.debris_manager, bullet);
|
breakBullet(ctx.debris_manager, bullet);
|
||||||
break; // una bala impacta una vegada per frame
|
break; // una bala impacta una vegada per frame
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// demo_pilot.cpp - Implementación de la IA del attract mode
|
||||||
|
// © 2026 JailDesigner
|
||||||
|
|
||||||
|
#include "game/systems/demo_pilot.hpp"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
#include "core/types.hpp"
|
||||||
|
|
||||||
|
namespace Systems::Demo {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr float PI = Constants::PI;
|
||||||
|
constexpr float TWO_PI = 2.0F * PI;
|
||||||
|
|
||||||
|
// Cadencia y reacción (segundos).
|
||||||
|
constexpr float RETARGET_INTERVAL = 0.15F; // re-evaluar objetivo (reacción humana)
|
||||||
|
constexpr float FIRE_COOLDOWN = 0.32F; // entre disparos
|
||||||
|
|
||||||
|
// Apuntado.
|
||||||
|
constexpr float ROTATE_DEADZONE = 0.05F; // rad: zona muerta para no oscilar
|
||||||
|
constexpr float FIRE_TOLERANCE = 0.18F; // rad: error máximo para disparar
|
||||||
|
constexpr float AIM_JITTER_MAX = 0.10F; // rad: error de apuntado humano
|
||||||
|
constexpr float LEAD_TIME = 0.30F; // s: anticipación sobre el enemigo
|
||||||
|
|
||||||
|
// Distancias (px).
|
||||||
|
constexpr float DANGER_RADIUS = 95.0F; // por debajo: maniobra de esquiva
|
||||||
|
constexpr float APPROACH_RADIUS = 250.0F; // por encima: acercarse al objetivo
|
||||||
|
constexpr float WALL_MARGIN = 60.0F; // px desde el borde: sesgo al centro
|
||||||
|
|
||||||
|
constexpr float WALL_BIAS = 0.6F; // peso del empuje hacia el centro al esquivar
|
||||||
|
|
||||||
|
// Esquiva de balas enemigas.
|
||||||
|
constexpr float DODGE_SCAN_RADIUS = 190.0F; // px: distancia a la que reacciona
|
||||||
|
constexpr float DODGE_HEADING_MIN = 0.25F; // dot mínimo: la bala viene hacia la nave
|
||||||
|
|
||||||
|
// [-1, 1] aleatorio (estética: jitter de apuntado; no afecta a la simulación).
|
||||||
|
auto randSigned() -> float {
|
||||||
|
return (static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX) * 2.0F) - 1.0F;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envuelve a [-PI, PI].
|
||||||
|
auto wrapPi(float angle) -> float {
|
||||||
|
return std::remainder(angle, TWO_PI);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Nearest {
|
||||||
|
int index{-1};
|
||||||
|
float distance{0.0F};
|
||||||
|
Vec2 center{};
|
||||||
|
Vec2 velocity{};
|
||||||
|
};
|
||||||
|
|
||||||
|
auto findNearest(const Vec2& from,
|
||||||
|
const std::array<Enemy, Constants::MAX_ORNIS>& enemies) -> Nearest {
|
||||||
|
Nearest best;
|
||||||
|
float best_d2 = 0.0F;
|
||||||
|
for (std::size_t i = 0; i < enemies.size(); ++i) {
|
||||||
|
if (!enemies[i].isActive()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const Vec2 C = enemies[i].getCenter();
|
||||||
|
const float D2 = (C - from).lengthSquared();
|
||||||
|
if (best.index == -1 || D2 < best_d2) {
|
||||||
|
best.index = static_cast<int>(i);
|
||||||
|
best_d2 = D2;
|
||||||
|
best.center = C;
|
||||||
|
best.velocity = enemies[i].getVelocityVector();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best.index != -1) {
|
||||||
|
best.distance = std::sqrt(best_d2);
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BulletThreat {
|
||||||
|
bool found{false};
|
||||||
|
Vec2 position{};
|
||||||
|
Vec2 velocity{};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bala enemiga más cercana que viene hacia la nave (dentro del radio de
|
||||||
|
// reacción). Solo balas de enemic (owner_id >= ENEMY_OWNER_BASE).
|
||||||
|
auto findBulletThreat(const Vec2& ship_pos,
|
||||||
|
const std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS_TOTAL)>& bullets)
|
||||||
|
-> BulletThreat {
|
||||||
|
BulletThreat threat;
|
||||||
|
float best_d2 = 0.0F;
|
||||||
|
for (const auto& bullet : bullets) {
|
||||||
|
if (!bullet.isActive() ||
|
||||||
|
bullet.getOwnerId() < Defaults::Entities::ENEMY_OWNER_BASE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const Vec2 BPOS = bullet.getCenter();
|
||||||
|
const Vec2 TO_SHIP = ship_pos - BPOS;
|
||||||
|
const float D2 = TO_SHIP.lengthSquared();
|
||||||
|
if (D2 > DODGE_SCAN_RADIUS * DODGE_SCAN_RADIUS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const Vec2 BVEL = bullet.getBody().velocity;
|
||||||
|
if (BVEL.lengthSquared() < 1.0F) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ¿La bala se dirige hacia la nave?
|
||||||
|
if (BVEL.normalized().dot(TO_SHIP.normalized()) < DODGE_HEADING_MIN) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!threat.found || D2 < best_d2) {
|
||||||
|
threat.found = true;
|
||||||
|
best_d2 = D2;
|
||||||
|
threat.position = BPOS;
|
||||||
|
threat.velocity = BVEL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return threat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error angular (rad, [-PI,PI]) entre la nariz de la nave y desired_dir,
|
||||||
|
// con el jitter de apuntado aplicado. La nariz apunta hacia (angle - PI/2).
|
||||||
|
auto steerError(const Ship& ship, const Vec2& desired_dir, float jitter) -> float {
|
||||||
|
const float DESIRED = std::atan2(desired_dir.y, desired_dir.x) + jitter;
|
||||||
|
const float NOSE = ship.getAngle() - (PI / 2.0F);
|
||||||
|
return wrapPi(DESIRED - NOSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setea rotación según el error (RIGHT incrementa ship.angle; LEFT lo
|
||||||
|
// decrementa), con zona muerta para no oscilar.
|
||||||
|
void applyRotation(Control& ctrl, float error) {
|
||||||
|
if (error > ROTATE_DEADZONE) {
|
||||||
|
ctrl.right = true;
|
||||||
|
} else if (error < -ROTATE_DEADZONE) {
|
||||||
|
ctrl.left = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
auto DemoPilot::compute(const Ship& ship,
|
||||||
|
const std::array<Enemy, Constants::MAX_ORNIS>& enemies,
|
||||||
|
const std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS_TOTAL)>& bullets,
|
||||||
|
const SDL_FRect& play_area,
|
||||||
|
float delta_time) -> Control {
|
||||||
|
Control ctrl;
|
||||||
|
|
||||||
|
if (fire_cooldown_ > 0.0F) {
|
||||||
|
fire_cooldown_ -= delta_time;
|
||||||
|
}
|
||||||
|
retarget_timer_ -= delta_time;
|
||||||
|
if (retarget_timer_ <= 0.0F) {
|
||||||
|
retarget_timer_ = RETARGET_INTERVAL;
|
||||||
|
aim_jitter_ = randSigned() * AIM_JITTER_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ship.isActive()) {
|
||||||
|
return ctrl; // nave muerta: sin control
|
||||||
|
}
|
||||||
|
|
||||||
|
const Vec2 SHIP_POS = ship.getCenter();
|
||||||
|
const Vec2 PLAY_CENTRE{
|
||||||
|
.x = play_area.x + (play_area.w / 2.0F),
|
||||||
|
.y = play_area.y + (play_area.h / 2.0F)};
|
||||||
|
const Vec2 TO_CENTRE = (PLAY_CENTRE - SHIP_POS).normalized();
|
||||||
|
|
||||||
|
// Prioridad 1: esquivar una bala enemiga entrante. Se mueve perpendicular
|
||||||
|
// a la trayectoria de la bala (con sesgo al centro) y no dispara.
|
||||||
|
const BulletThreat THREAT = findBulletThreat(SHIP_POS, bullets);
|
||||||
|
if (THREAT.found) {
|
||||||
|
const Vec2 BV = THREAT.velocity.normalized();
|
||||||
|
Vec2 perp{.x = -BV.y, .y = BV.x};
|
||||||
|
if ((SHIP_POS - THREAT.position).dot(perp) < 0.0F) {
|
||||||
|
perp = -perp; // hacia el lado en que ya está la nave
|
||||||
|
}
|
||||||
|
const Vec2 ESCAPE = (perp + (TO_CENTRE * WALL_BIAS)).normalized();
|
||||||
|
applyRotation(ctrl, steerError(ship, ESCAPE, aim_jitter_));
|
||||||
|
ctrl.thrust = true;
|
||||||
|
return ctrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Nearest TARGET = findNearest(SHIP_POS, enemies);
|
||||||
|
target_idx_ = TARGET.index;
|
||||||
|
|
||||||
|
// Sin enemigos: deriva tranquila (gira despacio, sin empuje ni disparo).
|
||||||
|
if (TARGET.index == -1) {
|
||||||
|
ctrl.right = true;
|
||||||
|
return ctrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool NEAR_WALL =
|
||||||
|
SHIP_POS.x < play_area.x + WALL_MARGIN ||
|
||||||
|
SHIP_POS.x > play_area.x + play_area.w - WALL_MARGIN ||
|
||||||
|
SHIP_POS.y < play_area.y + WALL_MARGIN ||
|
||||||
|
SHIP_POS.y > play_area.y + play_area.h - WALL_MARGIN;
|
||||||
|
|
||||||
|
Vec2 desired_dir;
|
||||||
|
const bool DANGER = TARGET.distance < DANGER_RADIUS;
|
||||||
|
if (DANGER) {
|
||||||
|
// Prioridad 2: alejarse del enemigo pegado, con sesgo al centro.
|
||||||
|
const Vec2 AWAY = (SHIP_POS - TARGET.center).normalized();
|
||||||
|
desired_dir = (AWAY + (TO_CENTRE * WALL_BIAS)).normalized();
|
||||||
|
ctrl.thrust = true;
|
||||||
|
} else {
|
||||||
|
// Combate: apuntar al enemigo con lead + jitter.
|
||||||
|
const Vec2 PREDICTED = TARGET.center + (TARGET.velocity * LEAD_TIME);
|
||||||
|
desired_dir = (PREDICTED - SHIP_POS).normalized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const float ERROR = steerError(ship, desired_dir, aim_jitter_);
|
||||||
|
applyRotation(ctrl, ERROR);
|
||||||
|
|
||||||
|
if (!DANGER) {
|
||||||
|
// Acercarse si el objetivo está lejos (mantiene la nave cazando),
|
||||||
|
// pero no empujar de cara a una pared.
|
||||||
|
const bool FACING_WALL = NEAR_WALL && TO_CENTRE.dot(desired_dir) < 0.0F;
|
||||||
|
if (TARGET.distance > APPROACH_RADIUS && !FACING_WALL &&
|
||||||
|
std::fabs(ERROR) < FIRE_TOLERANCE) {
|
||||||
|
ctrl.thrust = true;
|
||||||
|
}
|
||||||
|
// Disparar cuando está bien encarada y el cooldown lo permite.
|
||||||
|
if (std::fabs(ERROR) < FIRE_TOLERANCE && fire_cooldown_ <= 0.0F) {
|
||||||
|
ctrl.shoot = true;
|
||||||
|
fire_cooldown_ = FIRE_COOLDOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Systems::Demo
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// demo_pilot.hpp - IA de piloto para el attract mode (demo jugándose sola)
|
||||||
|
// © 2026 JailDesigner
|
||||||
|
//
|
||||||
|
// Calcula, cada frame, los controles de la nave (rotar/empujar/disparar) leyendo
|
||||||
|
// el estado de juego en solo-lectura. No lee Input ni muta entidades: GameScene
|
||||||
|
// aplica el Control resultante via Ship::applyMovement + fireBullet.
|
||||||
|
//
|
||||||
|
// El comportamiento busca parecer "humano", no óptimo: apuntado con lead y un
|
||||||
|
// pequeño error, cadencia de disparo con cooldown, esquiva por radio de peligro
|
||||||
|
// y sesgo hacia el centro para no pegarse a las paredes.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "core/defaults/entities.hpp"
|
||||||
|
#include "core/system/scene_context.hpp"
|
||||||
|
#include "game/constants.hpp"
|
||||||
|
#include "game/entities/bullet.hpp"
|
||||||
|
#include "game/entities/enemy.hpp"
|
||||||
|
#include "game/entities/ship.hpp"
|
||||||
|
|
||||||
|
namespace Systems::Demo {
|
||||||
|
|
||||||
|
// Escenari curat del attract mode: stage de arranque + nombre de naus IA.
|
||||||
|
// Tots inclouen estrelles (que disparen) per donar feina al pilot.
|
||||||
|
struct Scenario {
|
||||||
|
std::uint8_t stage; // stage_id de stages.yaml on arrenca la demo
|
||||||
|
std::uint8_t players; // 1 o 2 naus controlades per la IA
|
||||||
|
};
|
||||||
|
|
||||||
|
inline constexpr std::array<Scenario, SceneManager::SceneContext::DEMO_SCENARIO_COUNT>
|
||||||
|
SCENARIOS = {{
|
||||||
|
{.stage = 5, .players = 1}, // estrelles + mix, 1 jugador
|
||||||
|
{.stage = 8, .players = 1}, // pressió alta, 1 jugador
|
||||||
|
{.stage = 6, .players = 2}, // densitat alta, 2 jugadors (foc amic ON)
|
||||||
|
{.stage = 10, .players = 2}, // repte final, 2 jugadors (foc amic ON)
|
||||||
|
}};
|
||||||
|
|
||||||
|
// Escenari per a l'índex donat (cíclic).
|
||||||
|
[[nodiscard]] inline auto scenario(std::uint8_t index) -> Scenario {
|
||||||
|
return SCENARIOS.at(index % SCENARIOS.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control de la nave para un frame.
|
||||||
|
struct Control {
|
||||||
|
bool left{false};
|
||||||
|
bool right{false};
|
||||||
|
bool thrust{false};
|
||||||
|
bool shoot{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
class DemoPilot {
|
||||||
|
public:
|
||||||
|
[[nodiscard]] auto compute(const Ship& ship,
|
||||||
|
const std::array<Enemy, Constants::MAX_ORNIS>& enemies,
|
||||||
|
const std::array<Bullet, static_cast<std::size_t>(Defaults::Entities::MAX_BULLETS_TOTAL)>& bullets,
|
||||||
|
const SDL_FRect& play_area,
|
||||||
|
float delta_time) -> Control;
|
||||||
|
|
||||||
|
private:
|
||||||
|
float retarget_timer_{0.0F}; // re-evalúa objetivo cada RETARGET_INTERVAL
|
||||||
|
float fire_cooldown_{0.0F}; // tiempo restante hasta poder disparar
|
||||||
|
float aim_jitter_{0.0F}; // error de apuntado, refrescado al retargetar
|
||||||
|
int target_idx_{-1}; // índice del enemigo objetivo (o -1)
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Systems::Demo
|
||||||
Reference in New Issue
Block a user