Compare commits

..

82 Commits

Author SHA1 Message Date
JailDesigner 817c8fc8a0 merge fix/neteja-warnings: neteja de warnings 2026-05-31 00:20:35 +02:00
JailDesigner 3fe8fa9b32 fix: silencia -Wtautological-compare de stb_vorbis al cmake 2026-05-31 00:20:35 +02:00
JailDesigner 65f710bf7a chore(release): actualitza icones noves de l'aplicació 2026-05-30 23:20:01 +02:00
JailDesigner 72302554ae fix(release): apuja el target de macOS a 13.3 per std::format 2026-05-30 17:15:03 +02:00
JailDesigner 03530d0439 chore(release): actualitza icones de l'aplicació 2026-05-30 15:27:29 +02:00
JailDesigner 705d32e919 Merge branch 'feat/captures-pantalla': captura de pantalla amb F9 (PNG amb shaders) i fix de text 2026-05-30 11:19:58 +02:00
JailDesigner e420db2896 fix(text): fallback de minúscules a majúscules i glif de barra baixa (_) 2026-05-30 11:19:41 +02:00
JailDesigner 785700f819 feat(captures): captura de pantalla amb F9 (PNG amb shaders, a mida de finestra) 2026-05-30 11:06:38 +02:00
JailDesigner 07863577bc Merge branch 'tweak/colors-titol-ambre': PULSA START en ambre i ombra del títol més brillant 2026-05-30 10:32:33 +02:00
JailDesigner 8a341be027 tweak(títol): PULSA START en ambre i ombra del títol una mica més brillant 2026-05-30 10:32:23 +02:00
JailDesigner 93fb914e54 Merge branch 'feat/demo-no-friendly-fire': el pilot IA de la demo no dispara si té el company en la línia de tir 2026-05-30 10:15:50 +02:00
JailDesigner 8d659c44e5 feat(demo): el pilot IA retén el tret si té el company en la línia de tir (evita foc amic) 2026-05-30 10:06:30 +02:00
JailDesigner 5407f66c9e Merge branch 'fix/servicemenu': el menu de servei flota sense pausar la demo ni congelar el contador del títol 2026-05-30 09:50:16 +02:00
JailDesigner dd91b07a14 fix(servicemenu): el menu flota i no pausa la demo ni congela el contador del títol 2026-05-30 09:44:28 +02:00
JailDesigner fc8233ef57 Merge branch 'fix/demo-silencia-sfx-i-fuga-veu': la demo calla només els SFX de joc i ja no es cola la veu de fase al títol 2026-05-30 09:14:26 +02:00
JailDesigner ef2c13b011 fix(demo): silencia només els SFX de joc i evita que la veu de fase es cole al títol 2026-05-30 09:13:41 +02:00
JailDesigner 69e337393a Merge branch 'fix/color-frases-fase': frases de fase en ambre (desacoblades del títol) 2026-05-30 08:41:46 +02:00
JailDesigner 56c3f978d3 tweak(joc): les frases de fase tornen a l'ambre, desacoblades del PULSA START blanc del títol 2026-05-30 08:41:23 +02:00
JailDesigner cb958f33ba Merge branch 'tweak/colors-titol': colors del títol (ORNI ATTACK i JAILGAMES en cian, naus blanques, PRESS START blanc i intermitent) 2026-05-29 22:26:35 +02:00
JailDesigner e3d12e6e27 feat(titol): PRESS START intermitent (lent en aparèixer, ràpid en prémer START) sincronitzat 2026-05-29 22:26:05 +02:00
JailDesigner 47e9d85708 tweak(titol): ORNI ATTACK en cian (ombra color estrelles), naus blanques, JAILGAMES menys brillant i PULSA START una mica més amunt 2026-05-29 22:17:30 +02:00
JailDesigner 82027e4975 docs(titol): corregeix comentari obsolet (cian, no verd) de COPYRIGHT_BRIGHTNESS 2026-05-29 22:00:23 +02:00
JailDesigner ab06cb32c9 tweak(titol): JAILGAMES i copyright en cian pur en lloc de verd 2026-05-29 22:00:07 +02:00
JailDesigner 9e7061d8b7 tweak(titol): PREMEU START en blanc, JAILGAMES en verd estàndard i copyright el mateix verd amb menys brillo 2026-05-29 21:56:33 +02:00
JailDesigner b4b95c883f Merge branch 'feature/marcador': redisseny del marcador (color per jugador, ceros atenuats, vides com a slots/dígits commutables i layout centrat) 2026-05-29 21:42:21 +02:00
JailDesigner a46b93c917 tweak(hud): el mode numèric de vides mostra repuestos (vides-1), coherent amb els slots 2026-05-29 21:39:52 +02:00
JailDesigner 8d18c50aaa tweak(hud): mode de vides commutable a Defaults (slots o dígits); per defecte dígits per veure'l 2026-05-29 21:36:51 +02:00
JailDesigner b412435862 tweak(hud): NIVELL encès i el número amb els zeros de farciment atenuats com els punts 2026-05-29 21:27:11 +02:00
JailDesigner 5b90a9a767 tweak(hud): jugador inactiu = marcador apagat (tot atenuat, no en blanc) 2026-05-29 21:11:53 +02:00
JailDesigner 5ba562178b tweak(hud): el bloc d'un jugador inactiu es deixa apagat (sense dibuixar, reservant l'ample) 2026-05-29 21:06:29 +02:00
JailDesigner 55b37ba594 tweak(hud): alinea verticalment els slots de vides amb la línia del marcador (centre del bbox, no el declarat) 2026-05-29 21:00:21 +02:00
JailDesigner 20825c8138 tweak(hud): puja una mica l'alçada dels slots de vides (factor d'ajust sobre el glif) 2026-05-29 20:56:37 +02:00
JailDesigner 9235e684e8 tweak(hud): redueix els slots de vides a l'alçada real del glif i els pinta sense glow 2026-05-29 20:52:17 +02:00
JailDesigner 0350063fb7 tweak(hud): torna el tracking de les xifres a l'original (spacing 0.0) 2026-05-29 20:52:17 +02:00
JailDesigner 56065995fd tweak(hud): fila del marcador centrada amb posicions fixes (sense justificar a les vores) 2026-05-29 20:44:37 +02:00
JailDesigner 17e9206d26 tweak(hud): vides com a slots fixos (NUM_SLOTS = MAX_VIDES-1) que s'encenen/atenuen 2026-05-29 20:43:26 +02:00
JailDesigner 462e91d967 tweak(hud): restaura el tracking de les xifres del marcador (spacing 2.0) 2026-05-29 20:41:32 +02:00
JailDesigner 3bc87ad652 tweak(hud): l'últim dígit de la puntuació sempre encès (puntuació 0 no apaga el marcador) 2026-05-29 20:41:05 +02:00
JailDesigner a7233e13df tweak(hud): MAX_VIDES com a font única de vides i recalibra el groc atenuat de P2 2026-05-29 20:40:40 +02:00
JailDesigner 0abd661905 tweak(hud): vides com a icones de la nau en miniatura en lloc d'un número 2026-05-29 20:14:32 +02:00
JailDesigner a808226481 tweak(hud): zeros de farciment de la puntuació atenuats i etiqueta NIVELL en verd atenuat 2026-05-29 20:11:37 +02:00
JailDesigner 317e2a3fd9 tweak(hud): marcador en tres blocs ancorats (P1 esquerra, P2 dreta, nivell centrat) amb color per jugador 2026-05-29 20:09:28 +02:00
JailDesigner e4f8f586d6 tweak(hud): constants de l'esquema de color per jugador i de les icones de vides 2026-05-29 20:06:01 +02:00
JailDesigner 6f29731679 Merge branch 'tweaks/varis': nau ferida en roig i retorn al logo en acabar la partida 2026-05-29 19:55:11 +02:00
JailDesigner d7a9bd4ab2 tweak(game over): en acabar la partida es torna al logo en lloc del títol 2026-05-29 19:13:07 +02:00
JailDesigner ab5489a080 tweak(nau): la nau ferida parpelleja en roig pur en lloc de daurat 2026-05-29 19:12:53 +02:00
JailDesigner f4567a2e82 Merge branch 'fix/attract-dive-i-debris': logo silenciós en explotar i dive de càmera continu sota la cortinilla 2026-05-29 19:07:37 +02:00
JailDesigner 4b298ffc1c fix(attract): el logo no sona en explotar i la càmera del dive no frena amb la cortinilla 2026-05-29 19:06:40 +02:00
JailDesigner 0f986cbf80 Merge branch 'docs/arquitectura': document d'arquitectura per a nous companys 2026-05-29 11:56:23 +02:00
JailDesigner 582bd0ee30 docs: detalla el pipeline de shaders i la física al document d'arquitectura 2026-05-29 11:56:14 +02:00
JailDesigner 2e4030c2f2 docs: document d'arquitectura del projecte per a nous companys 2026-05-29 11:54:39 +02:00
JailDesigner a9b662840b Merge branch 'feature/attract-polish': polish de l'attract mode (logo silenciós, demo sense SFX, fons ja muntat, rètol de demo i transició dive+cortinilla títol→demo) 2026-05-29 10:15:04 +02:00
JailDesigner 30bbb37bff fix(demo): el dive movia només la posició de la càmera i s'invertia el forward; ara mou posició i target alhora 2026-05-29 10:13:30 +02:00
JailDesigner 2f6d6c405f feat(demo): transició títol→demo amb dive de càmera + cortinilla negra (substitueix el fundido) 2026-05-29 10:03:17 +02:00
JailDesigner 068f42782b feat(demo): transició per fosa a/desde negre en el salt títol→demo 2026-05-29 09:21:02 +02:00
JailDesigner 472c543c7b feat(demo): el marcador mostra el rètol de demo en lloc de puntuacions 2026-05-29 09:16:55 +02:00
JailDesigner 4e67a67ace feat(demo): la graella del fons apareix ja muntada en entrar a la demo 2026-05-29 09:12:30 +02:00
JailDesigner 1e63d3ae9d feat(demo): silenciar els efectes de so durant la demo (música intacta) 2026-05-29 09:09:37 +02:00
JailDesigner b363efd1f0 feat(demo): logo silenciós dins el cicle d'atracció (no sons ni reinici de música si ja sona) 2026-05-29 08:54:56 +02:00
JailDesigner 0abbaa09f8 Merge branch 'feature/attract-mode': attract mode (demo jugant-se sola) amb IA, 1P/2P, vides infinites i música contínua 2026-05-28 13:25:40 +02:00
JailDesigner 455b7a6893 feat(demo): demos a 1 i 2 jugadors, esquiva de bales enemigues i vides infinites 2026-05-28 13:14:19 +02:00
JailDesigner 92f76d091d fix(col·lisions): les bales d'enemic deixen de comptar com a foc amic (out-of-bounds a lives_per_player) i maten d'un toc 2026-05-28 13:13:36 +02:00
JailDesigner c1956e0028 feat(demo): attract mode amb pilot IA, escenaris curats i música contínua del títol 2026-05-28 12:01:12 +02:00
JailDesigner 491992a4d7 bump version a 0.8.1 2026-05-26 19:40:08 +02:00
JailDesigner e5b727216c Merge branch 'refactor/move-gamecontrollerdb-to-root': gamecontrollerdb fora de data/ (al costat del binari) + logs uniformes 2026-05-26 19:39:11 +02:00
JailDesigner f03e337b9a refactor(input): gamecontrollerdb.txt a l'arrel + target controllerdb + logs estil [Input] 2026-05-26 19:38:31 +02:00
JailDesigner 99e99e7e08 Merge branch 'refactor/remove-dead-oscillator-code': neteja del ColorOscillator (ara via shader) 2026-05-26 19:23:45 +02:00
JailDesigner b93761eb1e refactor(render): eliminar restes del ColorOscillator (setLineColor/getLineColor/global mutable) i deixar DEFAULT_LINE_COLOR constexpr 2026-05-26 19:23:29 +02:00
JailDesigner 4f5421191d Merge branch 'feat/hud-palette': HUD amb colors per funció + diferenciació P1/P2 2026-05-26 19:18:07 +02:00
JailDesigner 71ed9dc24f feat(hud): paleta per segments (P1 blanc, vides ambre, nivell verd, P2 rosa) 2026-05-26 19:17:22 +02:00
JailDesigner 1a0cc504c4 Merge branch 'refactor/rename-explosion-sounds': sons d'explosió i bullet_zap amb noms descriptius + enemy_hit per a debris_partial 2026-05-26 19:06:00 +02:00
JailDesigner 86775d4642 refactor(audio): renombrar hit.wav a bullet_zap.wav (desintegració de bala, no HURT d'enemic) 2026-05-26 19:05:43 +02:00
JailDesigner b936f410ce feat(audio): so enemy_hit per a debris_partial (impacte parcial a enemic amb HP>1) 2026-05-26 19:03:19 +02:00
JailDesigner ddcd2076a1 refactor(audio): renombrar explosion/explosion2 a enemy_explosion/player_explosion 2026-05-26 18:57:26 +02:00
JailDesigner 9345facaed Merge branch 'feat/orb-counterattack': orb taronja rosat dispara bullet_double cap al jugador en cada hit 2026-05-26 18:54:27 +02:00
JailDesigner 885caa6bc3 feat(orb): contra-atac amb bullet_double dirigida al jugador en rebre impacte 2026-05-26 18:53:34 +02:00
JailDesigner a77bbe4420 Merge branch 'feat/reorganize-shapes': renombre big_pentagon→orb i reorganització de data/shapes per categoria 2026-05-26 18:27:11 +02:00
JailDesigner 61a4886e62 refactor(shapes): reorganitzar data/shapes en subcarpetes per categoria (enemy/bullet/ship/effect) 2026-05-26 18:25:15 +02:00
JailDesigner 164f58c883 refactor(enemies): renombrar big_pentagon a orb i enemy_big_orb a enemy_orb 2026-05-26 18:09:29 +02:00
JailDesigner fbfacb825b Merge branch 'refactor/revert-stl-loops': bucles for explícits en lloc de std::ranges::* on aplica 2026-05-26 13:50:46 +02:00
JailDesigner 5e4d2cf993 refactor(physics): tornar std::ranges::find a bucle for explícit 2026-05-26 13:49:16 +02:00
JailDesigner 97d3749269 refactor: tornar std::ranges::{any,all,find}_of a bucles for explícits 2026-05-26 13:45:54 +02:00
102 changed files with 4910 additions and 493 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.16)
project(orni VERSION 0.8.0 LANGUAGES CXX)
project(orni VERSION 0.8.1 LANGUAGES CXX)
# Info del projecte (font de veritat per a project.h)
set(PROJECT_LONG_NAME "Orni Attack")
@@ -69,7 +69,7 @@ target_link_libraries(${PROJECT_NAME} PRIVATE SDL3::SDL3)
if(EXTERNAL_SOURCES)
set_source_files_properties(
${EXTERNAL_SOURCES}
PROPERTIES COMPILE_OPTIONS "-Wno-missing-field-initializers;-Wno-deprecated-declarations"
PROPERTIES COMPILE_OPTIONS "-Wno-missing-field-initializers;-Wno-deprecated-declarations;-Wno-tautological-compare"
)
endif()
+838
View File
@@ -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>
+29 -2
View File
@@ -84,9 +84,18 @@ else
endif
.PHONY: all debug release _windows-release _macos-release _linux-release \
run run-debug clean rebuild show-version pack \
run run-debug clean rebuild show-version pack controllerdb \
format format-check tidy tidy-fix cppcheck hooks-install help
# Còpia del gamecontrollerdb.txt (si existeix) al directori de build, perquè
# director.cpp el resolgui via resource_base = directori de l'executable.
# Silenciós si el fitxer no existeix (l'usuari encara no ha fet `make controllerdb`).
ifeq ($(OS),Windows_NT)
CP_CONTROLLERDB = @powershell -Command "if (Test-Path 'gamecontrollerdb.txt') { Copy-Item 'gamecontrollerdb.txt' -Destination '$(BUILDDIR)' -Force }"
else
CP_CONTROLLERDB = @if [ -f gamecontrollerdb.txt ]; then cp gamecontrollerdb.txt $(BUILDDIR)/; fi
endif
# ==============================================================================
# COMPILACIÓ
# ==============================================================================
@@ -98,10 +107,12 @@ endif
all:
@cmake -S . -B $(BUILDDIR) $(CMAKE_GEN) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) -j$(JOBS)
$(CP_CONTROLLERDB)
debug:
@cmake -S . -B $(BUILDDIR) $(CMAKE_GEN) -DCMAKE_BUILD_TYPE=Debug $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) -j$(JOBS)
$(CP_CONTROLLERDB)
run: all
@./$(BUILDDIR)/$(PROJECT)
@@ -138,6 +149,7 @@ _linux-release:
# Còpia de fitxers
cp $(BUILDDIR)/resources.pack "$(RELEASE_FOLDER)"
cp gamecontrollerdb.txt "$(RELEASE_FOLDER)"
cp README.md "$(RELEASE_FOLDER)"
@[ -f LICENSE ] && cp LICENSE "$(RELEASE_FOLDER)" || true
cp "$(TARGET_FILE)" "$(RELEASE_FILE)"
@@ -166,6 +178,7 @@ _windows-release:
@powershell -Command "if (-not (Test-Path '$(RELEASE_FOLDER)')) {New-Item '$(RELEASE_FOLDER)' -ItemType Directory}"
@powershell -Command "Copy-Item -Path '$(BUILDDIR)/resources.pack' -Destination '$(RELEASE_FOLDER)'"
@powershell -Command "Copy-Item 'gamecontrollerdb.txt' -Destination '$(RELEASE_FOLDER)'"
@powershell -Command "if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' -Destination '$(RELEASE_FOLDER)' }"
@powershell -Command "Copy-Item 'README.md' -Destination '$(RELEASE_FOLDER)'"
@powershell -Command "if (Test-Path 'release\windows\dll') { Copy-Item 'release\windows\dll\*.dll' -Destination '$(RELEASE_FOLDER)' }"
@@ -189,7 +202,7 @@ _macos-release:
# Compila la versió Apple Silicon
@cmake -S . -B $(BUILDDIR)/arm $(CMAKE_GEN) -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
-DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=13.3 \
-DMACOS_BUNDLE=ON $(CMAKE_DEFS)
@cmake --build $(BUILDDIR)/arm -j$(JOBS)
@@ -206,6 +219,7 @@ _macos-release:
# Còpia de recursos i metadades del bundle
cp $(BUILDDIR)/arm/resources.pack "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources"
cp gamecontrollerdb.txt "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources"
cp -R release/macos/frameworks/SDL3.xcframework/macos-arm64_x86_64/SDL3.framework "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Frameworks"
cp release/icons/icon.icns "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents/Resources"
cp release/macos/Info.plist "$(RELEASE_FOLDER)/$(APP_NAME).app/Contents"
@@ -274,6 +288,19 @@ pack:
@cmake --build $(BUILDDIR) --target pack_resources
@./$(BUILDDIR)/pack_resources data $(BUILDDIR)/resources.pack
# ==============================================================================
# DESCÀRREGA DE GAMECONTROLLERDB
# ==============================================================================
# Descarrega l'última versió de gamecontrollerdb.txt (mappings de gamepads
# mantinguts per la comunitat) a l'arrel del projecte. SDL el carrega via
# filesystem real (no dins resources.pack) i s'ha de copiar al costat del binari
# en cada build (gestionat per CP_CONTROLLERDB a `all`/`debug` i pels release targets).
controllerdb:
@echo "Descargando gamecontrollerdb.txt..."
curl -fsSL https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/master/gamecontrollerdb.txt \
-o gamecontrollerdb.txt
@echo "gamecontrollerdb.txt actualizado"
# ==============================================================================
# CODE QUALITY (delegats a cmake)
# ==============================================================================
+1 -1
View File
@@ -3,7 +3,7 @@ name: bullet
# Shape de la bala. El bounding_radius del .shp dóna el hitbox base (~3 px);
# scale el modula visualment i pel hitbox.
shape:
path: bullet.shp
path: bullet/basic.shp
scale: 1.0
collision_factor: 1.0
@@ -0,0 +1,21 @@
name: bullet_double
# Variant de bala "anular" (dos cercles concèntrics, aspecte d'aura de plasma).
# Pensada per a contra-atacs d'enemic (ex: orb dispara una bullet_double al
# jugador quan rep un impacte). Mateixa física que la bala bàsica del player;
# canvien la forma (cercle doble) i el color per llegir-se com a tret enemic
# distintiu (groc verdós vs. el verd laser del player o el roig de bullet_long).
shape:
path: bullet/double.shp
scale: 1.5
collision_factor: 1.0
physics:
mass: 0.5
restitution: 0.0
linear_damping: 0.0
angular_damping: 0.0
impact_momentum_factor: 4.0
colors:
normal: [200, 255, 80] # groc verdós (chartreuse) — contra-atac de l'orb
+1 -1
View File
@@ -4,7 +4,7 @@ name: bullet_long
# jugador i amb prou marge per reaccionar. La velocitat NO viu aquí: es passa
# a Bullet::fire() i la decideix qui dispara (l'AiTickAction).
shape:
path: bullet_long.shp
path: bullet/long.shp
scale: 1.0
collision_factor: 0.5
@@ -1,12 +1,12 @@
name: big_pentagon
ai_type: big_pentagon # Validat contra el directori; mapeja a EnemyType::BIG_PENTAGON.
name: orb
ai_type: orb # Validat contra el directori; mapeja a EnemyType::ORB.
# Shape circular pròpia (anell exterior + anell interior + 6 radis + nucli),
# pensada per llegir-se com a "reactor / orb" amb més detall que els enemics
# petits.
shape:
path: enemy_big_orb.shp
scale: 1.5
path: enemy/orb.shp
scale: 1.0
collision_factor: 1.0
physics:
@@ -55,7 +55,7 @@ spawn:
safety_distance: 54.0 # 1.5× del normal (alineat amb scale 1.5).
colors:
normal: [66, 195, 208] # #42C3D0 — turquesa-cyan distintiu per al boss.
normal: [255, 140, 110] # taronja rosat (coral) — distintiu del boss orb.
wounded: [255, 220, 60]
score: 500 # 5x un enemic normal: aguanta 10x més.
@@ -69,10 +69,14 @@ health: 10
events:
on_hit:
- action: fire_bullet # contra-atac: dispara bullet_double dirigida al jugador
bullet: bullet_double
bullet_speed: 200.0
aim_mode: aimed
- action: decrease_health # primer: si arriba a 0 dispara on_no_health
#- action: flash # feedback visual de damage parcial
- action: create_debris_partial # xip a 0.3x mida (sense ser letal)
- action: create_fireworks_small # espurna a cada hit (12 punts, lent)
#- action: create_fireworks_small # espurna a cada hit (12 punts, lent)
- action: apply_impulse # empenta el cos (skip si will_die)
on_no_health:
- action: destroy # mort directa, sense wounded
+1 -1
View File
@@ -2,7 +2,7 @@ name: pentagon
ai_type: pentagon # Validat contra el directori; mapeja a EnemyType::PENTAGON.
shape:
path: enemy_pentagon.shp
path: enemy/pentagon.shp
scale: 1.0 # multiplicador visual + hitbox sobre la mida nativa del .shp
collision_factor: 1.0 # ajust opcional del hitbox (default 1.0)
+1 -1
View File
@@ -2,7 +2,7 @@ name: pinwheel
ai_type: pinwheel # Validat contra el directori; mapeja a EnemyType::PINWHEEL.
shape:
path: enemy_pinwheel.shp
path: enemy/pinwheel.shp
scale: 1.0 # multiplicador visual + hitbox sobre la mida nativa del .shp
collision_factor: 1.0 # ajust opcional del hitbox (default 1.0)
+3 -3
View File
@@ -1,7 +1,7 @@
name: player_ship
# Shape de la nau. Resolt per ShapeLoader (busca a "shapes/<path>").
# Nota: el segon jugador rep un override del shape ("ship2.shp") al ctor.
# Nota: el segon jugador rep un override del shape ("ship/wedge.shp") al ctor.
# Quan s'introdueixin variants reals de nau, es crearà un YAML separat
# per cada model.
#
@@ -10,7 +10,7 @@ name: player_ship
# automàtic de la shape; tocar només si el feel del hitbox
# no quadra amb la silueta visual (default 1.0).
shape:
path: ship.shp
path: ship/arrow.shp
scale: 1.0
collision_factor: 1.0
@@ -43,7 +43,7 @@ visual_thrust:
colors:
normal: [255, 255, 255] # blanc neutre
hurt: [255, 220, 60] # daurat (estat ferit)
hurt: [255, 0, 0] # roig pur (estat ferit)
weapon:
bullet_speed: 700.0 # velocitat escalar de la bullet (px/s)
+1 -1
View File
@@ -2,7 +2,7 @@ name: square
ai_type: square # Validat contra el directori; mapeja a EnemyType::SQUARE.
shape:
path: enemy_square.shp
path: enemy/square.shp
scale: 1.0 # multiplicador visual + hitbox sobre la mida nativa del .shp
collision_factor: 1.0 # ajust opcional del hitbox (default 1.0)
+1 -1
View File
@@ -2,7 +2,7 @@ name: star
ai_type: star # Validat contra el directori; mapeja a EnemyType::STAR.
shape:
path: star_5.shp
path: enemy/star.shp
scale: 0.7 # Lleugerament més petit que els altres enemics per diferenciar visualment.
collision_factor: 1.0
+4
View File
@@ -13,6 +13,7 @@ notification:
antialias_off: "AA INACTIU"
postfx_on: "POSTPROCESSAT ACTIU"
postfx_off: "POSTPROCESSAT INACTIU"
screenshot: "IMATGE {file} GUARDADA A {folder}"
locale_switched: "IDIOMA: {lang}"
gamepad_connected: "{name} CONNECTAT"
gamepad_disconnected: "{name} DESCONNECTAT"
@@ -27,6 +28,9 @@ hud:
title:
press_start: "PREMEU START PER JUGAR"
demo:
banner: "MODE DEMO - PREMEU START"
game_screen:
game_over: "FI DEL JOC"
continue: "CONTINUAR"
+4
View File
@@ -12,6 +12,7 @@ notification:
antialias_off: "AA OFF"
postfx_on: "POSTPROCESS ON"
postfx_off: "POSTPROCESS OFF"
screenshot: "IMAGE {file} SAVED AT {folder}"
locale_switched: "LANGUAGE: {lang}"
gamepad_connected: "{name} CONNECTED"
gamepad_disconnected: "{name} DISCONNECTED"
@@ -26,6 +27,9 @@ hud:
title:
press_start: "PRESS START TO PLAY"
demo:
banner: "DEMO MODE - PRESS START"
game_screen:
game_over: "GAME OVER"
continue: "CONTINUE"
@@ -1,6 +1,6 @@
# bullet.shp - Projectil (octàgon, radi=3)
# bullet/basic.shp - Projectil (octàgon, radi=3)
name: bullet
name: basic
scale: 1.0
center: 0, 0
@@ -1,4 +1,4 @@
# bullet_double.shp - Bala anular (dos cercles concèntrics)
# bullet/double.shp - Bala anular (dos cercles concèntrics)
# © 2026 JailDesigner
#
# Dos octàgons concèntrics al centre (0,0):
@@ -6,7 +6,7 @@
# - Interior: radi 2 (lleugerament més petit que la bala estàndard)
# Aspecte d'anell / aura de plasma. Bounding radius natiu = 4.
name: bullet_double
name: double
scale: 1.0
center: 0, 0
@@ -1,4 +1,4 @@
# bullet_long.shp - Bala allargada vertical (dos mig-octàgons + dos costats)
# bullet/long.shp - Bala allargada vertical (dos mig-octàgons + dos costats)
# © 2026 JailDesigner
#
# Càpsula orientada al llarg de l'eix Y: la bala viatja segons el seu angle
@@ -15,7 +15,7 @@
# Bounding radius natiu = 6 (extrem vertical a y=±6).
# collision_factor al YAML compensa el bounding doble (0.5 → hitbox ≈ 3).
name: bullet_long
name: long
scale: 1.0
center: 0, 0
@@ -1,7 +1,7 @@
# star.shp - Estrella per a starfield
# effect/starfield.shp - Estrella per a starfield
# © 2026 JailDesigner
name: star
name: starfield
scale: 1.0
center: 0, 0
@@ -1,4 +1,4 @@
# title_flash.shp - Sparkle 4-puntes amb costats còncaus (Atari-style)
# effect/title_flash.shp - Sparkle 4-puntes amb costats còncaus (Atari-style)
# 4 puntes als cardinals (radi 30) i valls còncaus als 45° (corba Bezier
# quadràtica amb control point ±8). 5 punts per arc subdividint la corba.
@@ -1,4 +1,4 @@
# enemy_big_orb.shp - ORNI enemic gegant (orb circular, doble anell amb radis)
# enemy/orb.shp - ORNI enemic gegant (orb circular, doble anell amb radis)
# © 2026 JailDesigner
#
# Forma "reactor / boss circular" — més detall que els enemics petits perquè
@@ -9,7 +9,7 @@
# - Petit "+" central com a nucli.
# Bounding radius natiu = 20 (alineat amb la resta d'enemics).
name: enemy_big_orb
name: orb
scale: 1.0
center: 0, 0
@@ -1,6 +1,6 @@
# enemy_pentagon.shp - ORNI enemic (pentàgon doble concentric, radi exterior=20)
# enemy/pentagon.shp - ORNI enemic (pentàgon doble concentric, radi exterior=20)
name: enemy_pentagon
name: pentagon
scale: 1.0
center: 0, 0
@@ -1,7 +1,7 @@
# enemy_pinwheel.shp - ORNI enemic (molinillo de 4 triangles)
# enemy/pinwheel.shp - ORNI enemic (molinillo de 4 triangles)
# © 2026 JailDesigner
name: enemy_pinwheel
name: pinwheel
scale: 1.0
center: 0, 0
@@ -1,6 +1,6 @@
# enemy_square.shp - ORNI enemic (rombe, radi=20) + ull amb pupil·la al centre
# enemy/square.shp - ORNI enemic (rombe, radi=20) + ull amb pupil·la al centre
name: enemy_square
name: square
scale: 1.0
center: 0, 0
@@ -1,4 +1,4 @@
# star_5.shp - ORNI enemic (estrella de 5 puntes, només perímetre)
# enemy/star.shp - ORNI enemic (estrella de 5 puntes, només perímetre)
# © 2026 JailDesigner
#
# Pentagrama clàssic: 5 vèrtexs exteriors (radi 20) alternant amb 5 vèrtexs
@@ -8,7 +8,7 @@
# Sense línies interiors: una única polyline que recorre el perímetre.
# Bounding radius natiu ≈ 20 (alineat amb pentagon/square/pinwheel).
name: star_5
name: star
scale: 1.0
center: 0, 0
+9
View File
@@ -0,0 +1,9 @@
# char_underscore.shp - Símbolo _ (barra baja)
# Dimensiones: 20×40 (blocky display)
name: char_underscore
scale: 1.0
center: 10, 20
# Línea horizontal abajo (bajo la baseline de las letras)
line: 3,33 17,33
-8
View File
@@ -1,8 +0,0 @@
# ship.shp - Nau del jugador 1
# Triangle amb base còncava (punta de fletxa)
name: ship
scale: 1.0
center: 0, 0
polyline: 0,-12 8.49,8.49 0,4 -8.49,8.49 0,-12
+7
View File
@@ -0,0 +1,7 @@
# ship/arrow.shp - Nau del jugador 1 (triangle amb base còncava, punta de fletxa)
name: arrow
scale: 1.0
center: 0, 0
polyline: 0,-12 8.49,8.49 0,4 -8.49,8.49 0,-12
@@ -1,7 +1,7 @@
# ship2.shp - Nau del jugador 2 (interceptor amb ales)
# ship/interceptor.shp - Interceptor amb ales laterals pronunciades
# © 2026 JailDesigner
name: ship2
name: interceptor
scale: 1.0
center: 0, 0
@@ -1,7 +1,6 @@
# ship2.shp - Nau del jugador 2
# Triangle amb cercle central (distintiu visual)
# ship/wedge.shp - Nau del jugador 2 (triangle amb cercle central)
name: ship2
name: wedge
scale: 1.0
center: 0, 0
Binary file not shown.
+14 -13
View File
@@ -9,7 +9,7 @@
# - { timeout: T } → quan han passat T segons des de l'inici de la wave.
# - { all_dead: true, timeout: T } → el que arribe abans (amuntegament si vas lent).
#
# Tipus d'enemic: pentagon, square (alias: cuadrado), pinwheel (alias: molinillo), star, big_pentagon.
# Tipus d'enemic: pentagon, square (alias: cuadrado), pinwheel (alias: molinillo), star, orb.
metadata:
version: "2.0"
@@ -18,10 +18,11 @@ metadata:
stages:
# STAGE 1 — Tutorial: contacte amb pentagons i un cuadrado.
# (Test: també hi ha un orb a la primera onada per provar el contra-atac.)
- stage_id: 1
multipliers: { velocity: 0.85, rotation: 0.9, tracking: 0.3 }
waves:
- spawn: [pentagon, pentagon]
- spawn: [pentagon, pentagon, orb]
spawn_interval: 0.6
next: all_dead
- spawn: [pentagon, pentagon, square]
@@ -47,14 +48,14 @@ stages:
spawn_interval: 0.5
next: end
# STAGE 3 — Primer big_pentagon (HP=10).
# STAGE 3 — Primer orb (HP=10).
- stage_id: 3
multipliers: { velocity: 1.0, rotation: 1.0, tracking: 0.5 }
waves:
- spawn: [pentagon, pentagon, square]
spawn_interval: 0.4
next: all_dead
- spawn: [big_pentagon]
- spawn: [orb]
next: { all_dead: true, timeout: 12.0 }
- spawn: [pinwheel, pinwheel]
spawn_interval: 0.5
@@ -76,7 +77,7 @@ stages:
- spawn: [pinwheel, pinwheel, pinwheel]
spawn_interval: 0.4
next: all_dead
- spawn: [big_pentagon, pentagon, pentagon]
- spawn: [orb, pentagon, pentagon]
spawn_interval: 0.5
next: end
@@ -93,7 +94,7 @@ stages:
- spawn: [pinwheel, pinwheel, star, star]
spawn_interval: 0.4
next: all_dead
- spawn: [big_pentagon, square, square]
- spawn: [orb, square, square]
spawn_interval: 0.5
next: end
@@ -110,7 +111,7 @@ stages:
- spawn: [pinwheel, pinwheel, pinwheel]
spawn_interval: 0.3
next: all_dead
- spawn: [big_pentagon, pinwheel, pinwheel]
- spawn: [orb, pinwheel, pinwheel]
spawn_interval: 0.4
next: end
@@ -127,7 +128,7 @@ stages:
- spawn: [star, star, star]
spawn_interval: 0.4
next: all_dead
- spawn: [big_pentagon, pinwheel, pinwheel, square]
- spawn: [orb, pinwheel, pinwheel, square]
spawn_interval: 0.5
next: end
@@ -141,7 +142,7 @@ stages:
- spawn: [square, square, star, star]
spawn_interval: 0.3
next: { all_dead: true, timeout: 5.0 }
- spawn: [big_pentagon]
- spawn: [orb]
next: { all_dead: true, timeout: 8.0 }
- spawn: [pinwheel, pinwheel, square, star, pentagon]
spawn_interval: 0.3
@@ -154,13 +155,13 @@ stages:
- spawn: [pinwheel, pinwheel, star, star]
spawn_interval: 0.3
next: { all_dead: true, timeout: 4.0 }
- spawn: [big_pentagon, square, square]
- spawn: [orb, square, square]
spawn_interval: 0.4
next: { all_dead: true, timeout: 8.0 }
- spawn: [pinwheel, pinwheel, pinwheel, pinwheel]
spawn_interval: 0.3
next: { all_dead: true, timeout: 5.0 }
- spawn: [big_pentagon, pinwheel, pinwheel, square, star]
- spawn: [orb, pinwheel, pinwheel, square, star]
spawn_interval: 0.4
next: end
@@ -171,12 +172,12 @@ stages:
- spawn: [pinwheel, pinwheel, pinwheel, pinwheel]
spawn_interval: 0.25
next: { all_dead: true, timeout: 4.0 }
- spawn: [big_pentagon, square, star]
- spawn: [orb, square, star]
spawn_interval: 0.4
next: { all_dead: true, timeout: 6.0 }
- spawn: [pinwheel, pinwheel, star, star, square]
spawn_interval: 0.3
next: { all_dead: true, timeout: 5.0 }
- spawn: [big_pentagon, big_pentagon, pinwheel, pinwheel, star]
- spawn: [orb, orb, pinwheel, pinwheel, star]
spawn_interval: 0.4
next: end
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 537 KiB

+1 -1
View File
@@ -29,7 +29,7 @@
<key>CSResourcesFileMapped</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
<string>13.3</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
+11
View File
@@ -281,6 +281,17 @@ void Audio::enableMusic(bool value) {
setMusicVolume(config_.music_volume);
}
// Silencia o restaura un grup de sons concret sense alterar config_ (el volum
// que l'usuari va triar) ni els altres grups. Silenciar posa la ganancia del
// grup a 0; restaurar-la torna al volum efectiu normal (que ja aplica els gates
// master/sound i el volum de l'usuari). A diferència de setSoundVolume, no
// xafa config_.sound_volume, así que el menu de servei segueix mostrant i
// operant el volum real durant la demo.
void Audio::silenceGroup(Group group, bool silenced) {
const float VOL = silenced ? 0.0F : effectiveVolume(config_.sound_volume, sound_enabled_);
engine_->setSoundVolume(VOL, static_cast<int>(group));
}
// Inicialitza SDL Audio y el motor Ja::Engine owned.
void Audio::initSDLAudio() {
if (!SDL_Init(SDL_INIT_AUDIO)) {
+6
View File
@@ -128,6 +128,12 @@ class Audio {
// --- Configuración de sons ---
void enableSound(bool value); // Estableix l'estat dels sons (reaplica volum)
void toggleSound() { enableSound(!sound_enabled_); } // Alterna l'estat dels sons (reaplica volum)
// Silencia (o restaura) un únic grup de sons sense tocar el volum cachejat
// de l'usuari ni la resta de grups. Pensat per a l'attract/demo: vol callar
// els SFX de joc (Group::GAME) pero mantenir els del menu de servei
// (Group::INTERFACE) i la música. En restaurar, reaplica el volum efectiu
// normal del canal (que ja respecta els gates master/sound).
void silenceGroup(Group group, bool silenced);
// --- Configuración de música ---
void enableMusic(bool value); // Estableix l'estat de la música (reaplica volum)
+4 -3
View File
@@ -35,10 +35,11 @@ namespace Defaults::Music {
namespace Defaults::Sound {
constexpr const char* CONTINUE = "effects/continue.wav"; // Cuenta atras
constexpr const char* EXPLOSION = "effects/explosion.wav"; // Explosión
constexpr const char* EXPLOSION2 = "effects/explosion2.wav"; // Explosión alternativa
constexpr const char* ENEMY_EXPLOSION = "effects/enemy_explosion.wav"; // Explosió d'enemic (debris default)
constexpr const char* ENEMY_HIT = "effects/enemy_hit.wav"; // Impacte parcial a enemic (debris_partial — HP > 1)
constexpr const char* PLAYER_EXPLOSION = "effects/player_explosion.wav"; // Explosió de la nau del jugador
constexpr const char* FRIENDLY_FIRE_HIT = "effects/friendly_fire.wav"; // Friendly fire hit
constexpr const char* HIT = "effects/hit.wav"; // Enemic ferit (primer impacte → HURT)
constexpr const char* BULLET_ZAP = "effects/bullet_zap.wav"; // Bala desintegrant-se (qualsevol impacte o eixida de playarea)
constexpr const char* HURT = "effects/hurt.wav"; // Nau pròpia entra a HURT
constexpr const char* INIT_HUD = "effects/init_hud.wav"; // Para la animación del HUD
constexpr const char* LASER = "effects/laser_shoot.wav"; // Disparo
+15 -1
View File
@@ -10,7 +10,8 @@ namespace Defaults::Game {
constexpr int HEIGHT = 720;
// Regles de partida
constexpr int STARTING_LIVES = 3; // Initial lives
constexpr int MAX_VIDES = 3; // Vides màximes per jugador (font única; el HUD en deriva els slots)
constexpr int STARTING_LIVES = MAX_VIDES; // S'arrenca amb les vides al màxim
constexpr float DEATH_DURATION = 3.0F; // Seconds of death animation
constexpr float GAME_OVER_DURATION = 5.0F; // Seconds to display game over
@@ -39,6 +40,19 @@ namespace Defaults::Game {
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)
// 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)
constexpr float INIT_HUD_DURATION = 3.0F; // Duración total del estado
+38
View File
@@ -5,6 +5,8 @@
#include <SDL3/SDL.h>
#include <cstdint>
namespace Defaults::Hud {
// Marcador (scoreboard inferior). Usado por GameScene::drawScoreboard()
@@ -12,6 +14,42 @@ namespace Defaults::Hud {
constexpr float SCOREBOARD_TEXT_SCALE = 0.85F;
constexpr float SCOREBOARD_TEXT_SPACING = 0.0F;
// Mode de presentació de les vides al marcador (no es canvia en calent;
// es defineix ací mentre no estiga decidit si el nombre de vides serà fix).
// SLOTS → naus en miniatura en posicions fixes (s'encenen/atenuen).
// DIGITS → número de 2 dígits (mateixa regla que el nivell: zeros a
// l'esquerra atenuats, dígit significatiu en endavant encès).
enum class LivesDisplay : std::uint8_t { SLOTS,
DIGITS };
constexpr LivesDisplay LIVES_DISPLAY = LivesDisplay::DIGITS;
// Ajust fi de l'alçada dels slots de vides respecte a l'alçada del glif del
// dígit: la silueta de la nau ompli menys que un dígit, així que un xicotet
// factor >1 la fa casar visualment amb les xifres (calibrat a ull).
constexpr float LIFE_SLOT_HEIGHT_FACTOR = 1.2F;
// Esquema de color del marcador: "per jugador + sistema". Cada jugador usa
// el SEU color (parella brillant/atenuat) en tot el seu bloc (punts + vides);
// el nivell central va sempre en verd de sistema. Colors plans i purs: el
// glow/bloom el posa el shader de postpro, NO s'horneja al color. Amb
// alpha=255 el line_renderer usa el color directament sense caure al fallback
// verd (Rendering::DEFAULT_LINE_COLOR).
namespace Colors {
// Jugador 1 → cian.
constexpr SDL_Color P1_BRIGHT = {.r = 41, .g = 231, .b = 255, .a = 255}; // #29E7FF
constexpr SDL_Color P1_DIM = {.r = 12, .g = 90, .b = 102, .a = 255}; // #0C5A66
// Jugador 2 → groc.
constexpr SDL_Color P2_BRIGHT = {.r = 255, .g = 226, .b = 58, .a = 255}; // #FFE23A
constexpr SDL_Color P2_DIM = {.r = 90, .g = 82, .b = 16, .a = 255}; // #5A5210
// Nivell / sistema → verd.
constexpr SDL_Color LEVEL_BRIGHT = {.r = 77, .g = 255, .b = 102, .a = 255}; // #4DFF66
constexpr SDL_Color LEVEL_DIM = {.r = 29, .g = 107, .b = 44, .a = 255}; // #1D6B2C
} // namespace Colors
// Les vides es dibuixen com a slots fixos de naus en miniatura (NUM_SLOTS =
// MAX_VIDES 1). Mida i pas dels slots es deriven de la mètrica del glif del
// dígit a init_hud_animator, no de constants soltes.
// Animación de entrada del HUD (init_hud_animator).
namespace InitAnim {
// Spawn vertical de la nave: 50 px bajo la PLAYAREA (sale desde fuera).
+2 -2
View File
@@ -6,8 +6,8 @@
#include <SDL3/SDL.h>
// Paleta semántica por tipo de entidad. Si una entity declara color, lo
// pasa al pipeline con alpha=255 (sentinela "color válido"); si no, se
// usa el color global del oscilador (g_current_line_color).
// pasa al pipeline con alpha=255 (sentinela "color válido"); si no,
// line_renderer::linea() cau a DEFAULT_LINE_COLOR (verd fòsfor fallback).
namespace Defaults::Palette {
// Paleta neon: pujada lleugera dels canals secundaris per millorar la
+6 -3
View File
@@ -3,7 +3,6 @@
#pragma once
#include <algorithm>
#include <array>
namespace Defaults::Rendering {
@@ -35,8 +34,12 @@ namespace Defaults::Rendering {
constexpr int RENDER_HEIGHT_DEFAULT = 720;
constexpr auto isValidRenderResolution(int w, int h) -> bool {
return std::ranges::any_of(RESOLUTION_PRESETS,
[w, h](const ResolutionPreset& preset) { return preset.w == w && preset.h == h; });
for (const auto& preset : RESOLUTION_PRESETS) {
if (preset.w == w && preset.h == h) {
return true;
}
}
return false;
}
} // namespace Defaults::Rendering
+19 -8
View File
@@ -105,7 +105,7 @@ namespace Defaults::Title {
namespace Layout {
// Posicions verticals (anclatges des del TOP de pantalla lógica, 0.0-1.0)
constexpr float LOGO_POS = 0.20F; // Logo "ORNI"
constexpr float PRESS_START_POS = 0.75F; // "PRESS START TO PLAY"
constexpr float PRESS_START_POS = 0.72F; // "PRESS START TO PLAY" (una mica més amunt)
constexpr float COPYRIGHT1_POS = 0.90F; // Primera línia copyright
// Separacions relatives (proporció respecte Game::HEIGHT = 480px)
@@ -116,6 +116,13 @@ namespace Defaults::Title {
constexpr float LOGO_SCALE = 0.6F; // Escala "ORNI ATTACK!"
constexpr float PRESS_START_SCALE = 1.0F; // Escala "PRESS START TO PLAY"
constexpr float COPYRIGHT_SCALE = 0.5F; // Escala copyright
constexpr float JAILGAMES_BRIGHTNESS = 0.8F; // Logo JAILGAMES una mica menys brillant
constexpr float COPYRIGHT_BRIGHTNESS = 0.55F; // Mateix cian que JAILGAMES, però menys brillant
// Parpelleig del "PRESS START" (blinks per segon). Ritme pausat quan el
// text apareix (MAIN) i més ràpid quan ja s'ha premut START (join phase).
constexpr float PRESS_START_BLINK_HZ_SLOW = 1.0F;
constexpr float PRESS_START_BLINK_HZ_FAST = 3.0F;
constexpr float JAILGAMES_SCALE = 0.25F; // Escala del logo JAILGAMES pequeño sobre el copyright
// Separación entre el logo JAILGAMES y la línea de copyright (proporción de Game::HEIGHT).
@@ -157,14 +164,18 @@ namespace Defaults::Title {
// alpha = 255 (sentinela "color vàlid") fa que el pipeline ignori
// el color global de l'oscil·lador per a aquesta crida.
namespace Colors {
constexpr SDL_Color LOGO_MAIN = {.r = 80, .g = 240, .b = 255, .a = 255}; // Cian elèctric
constexpr SDL_Color LOGO_SHADOW = {.r = 255, .g = 60, .b = 180, .a = 255}; // Magenta neon (offset)
constexpr SDL_Color SHIP_P1 = {.r = 255, .g = 100, .b = 200, .a = 255}; // Rosa hot
constexpr SDL_Color SHIP_P2 = {.r = 160, .g = 120, .b = 255, .a = 255}; // Violeta elèctric
// Ambre neon: el mateix to dels missatges d'inici/fi de fase
// (STAGE_MESSAGE_COLOR a game_scene.cpp) per unificar el feel.
constexpr SDL_Color AMBER = {.r = 255, .g = 200, .b = 70, .a = 255};
constexpr SDL_Color STARFIELD = {.r = 200, .g = 220, .b = 255, .a = 255}; // Blanc-blau gel
constexpr SDL_Color PRESS_START = {.r = 255, .g = 200, .b = 70, .a = 255}; // Ambre neon
constexpr SDL_Color JAILGAMES_LOGO = {.r = 120, .g = 220, .b = 200, .a = 255}; // Teal suau
constexpr SDL_Color COPYRIGHT = {.r = 140, .g = 180, .b = 200, .a = 255}; // Gris-cian apagat
constexpr SDL_Color LOGO_MAIN = {.r = 0, .g = 255, .b = 255, .a = 255}; // Cian pur
constexpr SDL_Color LOGO_SHADOW = STARFIELD; // Color de l'starfield (offset)
constexpr SDL_Color SHIP_P1 = {.r = 255, .g = 255, .b = 255, .a = 255}; // Blanc
constexpr SDL_Color SHIP_P2 = {.r = 255, .g = 255, .b = 255, .a = 255}; // Blanc
constexpr SDL_Color PRESS_START = AMBER; // Ambre (com les frases de fase)
constexpr SDL_Color JAILGAMES_LOGO = {.r = 0, .g = 255, .b = 255, .a = 255}; // Cian pur
constexpr SDL_Color COPYRIGHT = {.r = 0, .g = 255, .b = 255, .a = 255}; // Mateix cian (el brillo es baixa al render: COPYRIGHT_BRIGHTNESS)
} // namespace Colors
} // namespace Defaults::Title
+4 -4
View File
@@ -51,12 +51,12 @@ namespace Graphics {
namespace {
// Lerp de l'oscil·lador (color base actual) cap a un color "flash" en
// funció de f ∈ [0, 1]. Retorna sempre amb alpha>0 perquè el line_renderer
// l'use directament (sense barrejar amb el global).
// Lerp del color base verd fòsfor cap a un color "flash" en funció de
// f ∈ [0, 1]. Retorna sempre amb alpha>0 perquè el line_renderer l'usi
// directament (sense caure al fallback DEFAULT_LINE_COLOR).
auto lerpColor(SDL_Color flash, float f) -> SDL_Color {
const float CLAMPED = std::clamp(f, 0.0F, 1.0F);
const SDL_Color BASE = Rendering::getLineColor();
constexpr SDL_Color BASE = Rendering::DEFAULT_LINE_COLOR;
const auto LERP_U8 = [&](unsigned char a, unsigned char b) {
const float OUT = (static_cast<float>(a) * (1.0F - CLAMPED)) + (static_cast<float>(b) * CLAMPED);
return static_cast<unsigned char>(OUT);
+71
View File
@@ -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
+48
View File
@@ -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
+6
View File
@@ -62,6 +62,12 @@ namespace Graphics {
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) {
elapsed_s_ += delta_time;
for (auto& ripple : ripples_) {
+5
View File
@@ -30,6 +30,11 @@ namespace Graphics {
// Avança timers interns (creació + ripples).
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,
// i s'aplica deformació radial per cada ripple activa que afecti la línia.
void draw() const;
+1 -1
View File
@@ -20,7 +20,7 @@ namespace Graphics {
return it->second; // Cache hit
}
// Normalize path: "ship.shp" → "shapes/ship.shp"
// Normalize path: "ship/arrow.shp" → "shapes/ship/arrow.shp"
// "logo/letra_j.shp" → "shapes/logo/letra_j.shp"
std::string normalized = filename;
if (!normalized.starts_with("shapes/")) {
+1 -1
View File
@@ -19,7 +19,7 @@ namespace Graphics {
// Carregar shape desde file (con caché)
// Retorna punter compartit (nullptr si error)
// Exemple: load("ship.shp") → busca a "data/shapes/ship.shp"
// Exemple: load("ship/arrow.shp") → busca a "data/shapes/ship/arrow.shp"
static auto load(const std::string& filename) -> std::shared_ptr<Shape>;
// Netejar caché (útil per debug/recàrrega)
+22 -3
View File
@@ -11,8 +11,9 @@
namespace Graphics {
// Constants para mides base dels caràcters
constexpr float BASE_CHAR_WIDTH = 20.0F; // Amplada base del caràcter
constexpr float BASE_CHAR_HEIGHT = 40.0F; // Altura base del caràcter
constexpr float BASE_CHAR_WIDTH = 20.0F; // Amplada base del caràcter (cel·la)
constexpr float BASE_CHAR_HEIGHT = 40.0F; // Altura base del caràcter (cel·la, amb marge)
constexpr float BASE_GLYPH_HEIGHT = 20.0F; // Altura real del glif (la majúscula/dígit ocupa 20 dels 40)
VectorText::VectorText(Rendering::Renderer* renderer)
: renderer_(renderer) {
@@ -47,7 +48,7 @@ namespace Graphics {
}
// Cargar símbolos
const std::string SYMBOLS[] = {".", ",", "-", ":", "!", "?", "/", "(", ")"};
const std::string SYMBOLS[] = {".", ",", "-", ":", "!", "?", "/", "(", ")", "_"};
for (const auto& sym : SYMBOLS) {
char c = sym[0];
std::string filename = getShapeFilename(c);
@@ -170,6 +171,8 @@ namespace Graphics {
return "font/char_lparen.shp";
case ')':
return "font/char_rparen.shp";
case '_':
return "font/char_underscore.shp";
case ' ':
return ""; // Espai es maneja sin load shape
@@ -182,6 +185,10 @@ namespace Graphics {
}
auto VectorText::isSupported(char c) const -> bool {
// Mateix fallback que render(): a-z es resol al glif A-Z.
if (c >= 'a' && c <= 'z') {
c -= 32;
}
return chars_.contains(c);
}
@@ -220,6 +227,14 @@ namespace Graphics {
continue;
}
// Fallback de la font (només tenim glifs en majúscula): tractem
// les minúscules a-z com les seves majúscules A-Z. Mentre no hi
// haja glifs de minúscula, això evita que el text en minúscules
// (p. ex. rutes de fitxer) desaparega.
if (c >= 'a' && c <= 'z') {
c -= 32;
}
// Verificar si el carácter está soportado
auto it = chars_.find(c);
if (it != chars_.end()) {
@@ -287,4 +302,8 @@ namespace Graphics {
return BASE_CHAR_HEIGHT * scale;
}
auto VectorText::getGlyphHeight(float scale) -> float {
return BASE_GLYPH_HEIGHT * scale;
}
} // namespace Graphics
+6 -2
View File
@@ -26,7 +26,7 @@ namespace Graphics {
// - scale: factor de scale (1.0 = 20×40 px por carácter)
// - spacing: espacio entre caracteres en píxeles (a scale 1.0)
// - brightness: factor de brightness (0.0-1.0, default 1.0 = màxima brightness)
// - color: color RGBA explícit; si alpha==0 (default) s'usa l'oscil·lador global
// - color: color RGBA explícit; si alpha==0 (default) es fa fallback a Rendering::DEFAULT_LINE_COLOR
void render(const std::string& text, const Vec2& position, float scale = 1.0F, float spacing = 2.0F, float brightness = 1.0F, SDL_Color color = {0, 0, 0, 0}) const;
// Renderizar string centrado en un punto
@@ -35,7 +35,7 @@ namespace Graphics {
// - scale: factor de scale (1.0 = 20×40 px por carácter)
// - spacing: espacio entre caracteres en píxeles (a scale 1.0)
// - brightness: factor de brightness (0.0-1.0, default 1.0 = màxima brightness)
// - color: color RGBA explícit; si alpha==0 (default) s'usa l'oscil·lador global
// - color: color RGBA explícit; si alpha==0 (default) es fa fallback a Rendering::DEFAULT_LINE_COLOR
void renderCentered(const std::string& text, const Vec2& centre_point, float scale = 1.0F, float spacing = 2.0F, float brightness = 1.0F, SDL_Color color = {0, 0, 0, 0}) const;
// Calcular ancho total de un string (útil para centrado).
@@ -46,6 +46,10 @@ namespace Graphics {
// Calcular altura del texto (útil para centrado vertical).
[[nodiscard]] static auto getTextHeight(float scale = 1.0F) -> float;
// Altura real del glif (la majúscula/dígit, sense el marge vertical de la
// cel·la). Útil per dimensionar icones que han de casar amb el text.
[[nodiscard]] static auto getGlyphHeight(float scale = 1.0F) -> float;
// Verificar si un carácter está soportado
[[nodiscard]] auto isSupported(char c) const -> bool;
+1 -1
View File
@@ -4,7 +4,7 @@
// Mesh3D = llista de vèrtexs Vec3 + llista d'arestes (parells d'índexs).
// drawWireframe() aplica una Transform3D al mesh, projecta amb Camera3D i
// emet cada aresta com una línia 2D pel pipeline `Rendering::linea` (mateix
// pipeline que la resta del joc: glow verd via ColorOscillator si color.a==0).
// pipeline que la resta del joc: verd fòsfor via Rendering::DEFAULT_LINE_COLOR si color.a==0).
//
// Sense depth buffer: el caller és responsable d'ordenar els meshos per
// profunditat decreixent si vol oclusió coherent (la pipeline és LINE_LIST
+6 -4
View File
@@ -3,7 +3,6 @@
#include "core/input/define_inputs.hpp"
#include <algorithm>
#include <format>
#include <string>
#include <vector>
@@ -154,9 +153,12 @@ namespace System {
}
auto DefineInputs::isInUse(int code) const -> bool {
return std::ranges::any_of(sequence_, [code](const Step& s) {
return s.captured == code;
});
for (const auto& s : sequence_) {
if (s.captured == code) {
return true;
}
}
return false;
}
void DefineInputs::captureAndAdvance(int code) {
+19 -11
View File
@@ -2,7 +2,6 @@
#include <SDL3/SDL.h> // Para SDL_GetGamepadAxis, SDL_GamepadAxis, SDL_GamepadButton, SDL_GetError, SDL_JoystickID, SDL_AddGamepadMappingsFromFile, SDL_Event, SDL_EventType, SDL_GetGamepadButton, SDL_GetKeyboardState, SDL_INIT_GAMEPAD, SDL_InitSubSystem, SDL_LogError, SDL_OpenGamepad, SDL_PollEvent, SDL_WasInit, Sint16, SDL_Gamepad, SDL_LogCategory, SDL_Scancode
#include <algorithm> // Para std::ranges::any_of
#include <iostream> // Para basic_ostream, operator<<, cout, cerr
#include <memory> // Para shared_ptr, __shared_ptr_access, allocator, operator==, make_shared
#include <unordered_map> // Para unordered_map, _Node_iterator, operator==, _Node_iterator_base, _Node_const_iterator
@@ -166,9 +165,12 @@ auto Input::checkAnyButton(bool repeat) -> bool {
// Comprueba si algún player (P1 o P2) presionó alguna acción de una lista
auto Input::checkAnyPlayerAction(const std::span<const InputAction>& actions, bool repeat) -> bool {
return std::ranges::any_of(actions, [this, repeat](const InputAction& action) {
return checkActionPlayer1(action, repeat) || checkActionPlayer2(action, repeat);
});
for (const auto& action : actions) {
if (checkActionPlayer1(action, repeat) || checkActionPlayer2(action, repeat)) {
return true;
}
}
return false;
}
// Comprueba si hay algun mando conectado
@@ -307,8 +309,11 @@ auto Input::checkTriggerInput(Action action, const std::shared_ptr<Gamepad>& gam
}
void Input::addGamepadMappingsFromFile() {
if (SDL_AddGamepadMappingsFromFile(gamepad_mappings_file_.c_str()) < 0) {
std::cout << "Error, could not load " << gamepad_mappings_file_.c_str() << " file: " << SDL_GetError() << '\n';
const int COUNT = SDL_AddGamepadMappingsFromFile(gamepad_mappings_file_.c_str());
if (COUNT < 0) {
std::cerr << "[Input] Error carregant " << gamepad_mappings_file_ << ": " << SDL_GetError() << '\n';
} else {
std::cout << "[Input] " << gamepad_mappings_file_ << " carregat (" << COUNT << " mappings)\n";
}
}
@@ -326,8 +331,7 @@ void Input::initSDLGamePad() {
} else {
addGamepadMappingsFromFile();
discoverGamepads();
std::cout << "\n** INPUT SYSTEM **\n";
std::cout << "Input System initialized successfully\n";
std::cout << "[Input] inicialitzat\n";
}
}
}
@@ -441,9 +445,13 @@ auto Input::addGamepad(int device_index) -> std::string {
}
auto Input::removeGamepad(SDL_JoystickID id) -> std::string {
auto it = std::ranges::find_if(gamepads_, [id](const std::shared_ptr<Gamepad>& gamepad) {
return gamepad->instance_id == id;
});
auto it = gamepads_.end();
for (auto i = gamepads_.begin(); i != gamepads_.end(); ++i) {
if ((*i)->instance_id == id) {
it = i;
break;
}
}
if (it != gamepads_.end()) {
std::string name = (*it)->name;
+5 -3
View File
@@ -3,7 +3,6 @@
#include "core/physics/physics_world.hpp"
#include <algorithm>
#include <cmath>
#include "core/physics/rigid_body.hpp"
@@ -14,10 +13,13 @@ namespace Physics {
if (body == nullptr) {
return;
}
if (std::ranges::find(bodies_, body) == bodies_.end()) {
bodies_.push_back(body);
for (const auto* b : bodies_) {
if (b == body) {
return;
}
}
bodies_.push_back(body);
}
void PhysicsWorld::removeBody(RigidBody* body) {
std::erase(bodies_, body);
@@ -7,6 +7,7 @@
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <iostream>
@@ -132,6 +133,7 @@ namespace Rendering::GPU {
}
void GpuFrameRenderer::destroy() {
destroyCaptureResources();
destroyOffscreen();
postfx_pipeline_.destroy();
bloom_pipeline_.destroy();
@@ -172,7 +174,7 @@ namespace Rendering::GPU {
return false;
}
if (!SDL_WaitAndAcquireGPUSwapchainTexture(cmd_buffer_, device_.window(), &swapchain_texture_, nullptr, nullptr)) {
if (!SDL_WaitAndAcquireGPUSwapchainTexture(cmd_buffer_, device_.window(), &swapchain_texture_, &swapchain_w_, &swapchain_h_)) {
std::cerr << "[GpuFrameRenderer] WaitAndAcquire: " << SDL_GetError() << '\n';
SDL_SubmitGPUCommandBuffer(cmd_buffer_);
cmd_buffer_ = nullptr;
@@ -540,11 +542,20 @@ namespace Rendering::GPU {
SDL_EndGPURenderPass(render_pass_);
render_pass_ = nullptr;
}
compositeTo(swapchain_texture_);
}
// Pase final: render pass sobre SWAPCHAIN con clear a negro (cubre el
void GpuFrameRenderer::compositeTo(SDL_GPUTexture* target_tex) {
// Cos comú del pase de postpro: sample offscreen + bloom → target_tex
// (swapchain en el camí normal, o la textura de captura per al
// screenshot). El caller s'encarrega de tancar qualsevol render pass
// previ. Com que la textura de captura té la mateixa mida que la
// swapchain, el mateix viewport/letterbox produeix píxels idèntics.
// Pase final: render pass sobre el target con clear a negro (cubre el
// letterbox del viewport físico).
SDL_GPUColorTargetInfo target{};
target.texture = swapchain_texture_;
target.texture = target_tex;
target.clear_color = SDL_FColor{.r = 0.0F, .g = 0.0F, .b = 0.0F, .a = 1.0F};
target.load_op = SDL_GPU_LOADOP_CLEAR;
target.store_op = SDL_GPU_STOREOP_STORE;
@@ -664,14 +675,150 @@ namespace Rendering::GPU {
}
flushBatch();
bloomPass();
compositePass();
compositePass(); // → swapchain (camí de presentació normal)
if (render_pass_ != nullptr) {
SDL_EndGPURenderPass(render_pass_);
render_pass_ = nullptr;
}
// Captura (screenshot): segon composite sobre la textura pròpia +
// readback a CPU. Només quan hi ha petició pendent i els recursos es
// poden crear; si no, submit normal i prou. La petició es consumeix
// sempre (s'haja pogut capturar o no) per no quedar enganxada.
if (capture_requested_ && ensureCaptureResources()) {
compositeTo(capture_texture_);
if (render_pass_ != nullptr) {
SDL_EndGPURenderPass(render_pass_);
render_pass_ = nullptr;
}
downloadCapture(); // fa submit + fence + map; deixa cmd_buffer_ a nullptr
} else {
SDL_SubmitGPUCommandBuffer(cmd_buffer_);
cmd_buffer_ = nullptr;
}
capture_requested_ = false;
swapchain_texture_ = nullptr;
}
auto GpuFrameRenderer::ensureCaptureResources() -> bool {
SDL_GPUDevice* dev = device_.get();
if (dev == nullptr || swapchain_w_ == 0 || swapchain_h_ == 0) {
return false;
}
const int W = static_cast<int>(swapchain_w_);
const int H = static_cast<int>(swapchain_h_);
if (capture_texture_ != nullptr && capture_tex_w_ == W && capture_tex_h_ == H) {
return true; // ja són de la mida correcta
}
destroyCaptureResources();
// Mateix format que la swapchain: és el target del postfx_pipeline_.
SDL_GPUTextureCreateInfo tex_info{};
tex_info.type = SDL_GPU_TEXTURETYPE_2D;
tex_info.format = device_.swapchainFormat();
tex_info.usage = SDL_GPU_TEXTUREUSAGE_COLOR_TARGET;
tex_info.width = swapchain_w_;
tex_info.height = swapchain_h_;
tex_info.layer_count_or_depth = 1;
tex_info.num_levels = 1;
tex_info.sample_count = SDL_GPU_SAMPLECOUNT_1;
capture_texture_ = SDL_CreateGPUTexture(dev, &tex_info);
if (capture_texture_ == nullptr) {
std::cerr << "[GpuFrameRenderer] CreateGPUTexture (captura): " << SDL_GetError() << '\n';
return false;
}
SDL_GPUTransferBufferCreateInfo tbo_info{};
tbo_info.usage = SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD;
tbo_info.size = static_cast<uint32_t>(W * H * 4);
capture_transfer_ = SDL_CreateGPUTransferBuffer(dev, &tbo_info);
if (capture_transfer_ == nullptr) {
std::cerr << "[GpuFrameRenderer] CreateGPUTransferBuffer (captura): " << SDL_GetError() << '\n';
SDL_ReleaseGPUTexture(dev, capture_texture_);
capture_texture_ = nullptr;
return false;
}
capture_tex_w_ = W;
capture_tex_h_ = H;
return true;
}
void GpuFrameRenderer::downloadCapture() {
SDL_GPUDevice* dev = device_.get();
// Copy pass: descarrega la textura de captura al transfer buffer.
SDL_GPUCopyPass* copy_pass = SDL_BeginGPUCopyPass(cmd_buffer_);
SDL_GPUTextureRegion src{};
src.texture = capture_texture_;
src.w = swapchain_w_;
src.h = swapchain_h_;
src.d = 1;
SDL_GPUTextureTransferInfo dst{};
dst.transfer_buffer = capture_transfer_;
dst.offset = 0;
dst.pixels_per_row = swapchain_w_;
dst.rows_per_layer = swapchain_h_;
SDL_DownloadFromGPUTexture(copy_pass, &src, &dst);
SDL_EndGPUCopyPass(copy_pass);
// Submit amb fence i esperar: el readback ha d'estar complet abans de mapar.
SDL_GPUFence* fence = SDL_SubmitGPUCommandBufferAndAcquireFence(cmd_buffer_);
cmd_buffer_ = nullptr;
if (fence == nullptr) {
std::cerr << "[GpuFrameRenderer] SubmitAndAcquireFence (captura): " << SDL_GetError() << '\n';
return;
}
SDL_WaitForGPUFences(dev, true, &fence, 1);
SDL_ReleaseGPUFence(dev, fence);
const auto* mapped = static_cast<const uint8_t*>(SDL_MapGPUTransferBuffer(dev, capture_transfer_, false));
if (mapped == nullptr) {
std::cerr << "[GpuFrameRenderer] MapGPUTransferBuffer (captura): " << SDL_GetError() << '\n';
return;
}
// Conversió a ARGB8888 (0xAARRGGBB), que és el que espera Screenshot::save.
// El swapchain a Linux/Vulkan sol ser B8G8R8A8_UNORM (bytes B,G,R,A);
// altrament tractem com R8G8B8A8_UNORM (bytes R,G,B,A). Alpha forçat a 255
// perquè el composite ja escriu opac.
const int W = static_cast<int>(swapchain_w_);
const int H = static_cast<int>(swapchain_h_);
const auto COUNT = static_cast<std::size_t>(W) * static_cast<std::size_t>(H);
capture_pixels_.resize(COUNT);
const bool BGRA = device_.swapchainFormat() == SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM;
for (std::size_t i = 0; i < COUNT; ++i) {
const uint8_t* px = mapped + (i * 4);
const std::uint32_t R = BGRA ? px[2] : px[0];
const std::uint32_t G = px[1];
const std::uint32_t B = BGRA ? px[0] : px[2];
capture_pixels_[i] = (0xFFU << 24) | (R << 16) | (G << 8) | B;
}
SDL_UnmapGPUTransferBuffer(dev, capture_transfer_);
capture_w_ = W;
capture_h_ = H;
capture_ready_ = true;
}
void GpuFrameRenderer::destroyCaptureResources() {
SDL_GPUDevice* dev = device_.get();
if (dev == nullptr) {
capture_texture_ = nullptr;
capture_transfer_ = nullptr;
capture_tex_w_ = 0;
capture_tex_h_ = 0;
return;
}
if (capture_texture_ != nullptr) {
SDL_ReleaseGPUTexture(dev, capture_texture_);
capture_texture_ = nullptr;
}
if (capture_transfer_ != nullptr) {
SDL_ReleaseGPUTransferBuffer(dev, capture_transfer_);
capture_transfer_ = nullptr;
}
capture_tex_w_ = 0;
capture_tex_h_ = 0;
}
} // namespace Rendering::GPU
@@ -133,6 +133,19 @@ namespace Rendering::GPU {
void setPostFxEnabled(bool enabled) { postfx_enabled_ = enabled; }
[[nodiscard]] auto isPostFxEnabled() const -> bool { return postfx_enabled_; }
// === Captura de pantalla (screenshot) ===
// Demana una captura del frame actual: el pròxim endFrame fa un segon
// composite sobre una textura pròpia (mida swapchain) i el descarrega a
// CPU en ARGB8888. Cost zero quan no hi ha cap petició pendent. Després
// del present, el caller comprova hasCapture(), llig captureData() i
// crida clearCapture().
void requestCapture() { capture_requested_ = true; }
[[nodiscard]] auto hasCapture() const -> bool { return capture_ready_; }
[[nodiscard]] auto captureData() const -> const std::uint32_t* { return capture_pixels_.data(); }
[[nodiscard]] auto captureWidth() const -> int { return capture_w_; }
[[nodiscard]] auto captureHeight() const -> int { return capture_h_; }
void clearCapture() { capture_ready_ = false; }
// Acceso a internals.
[[nodiscard]] auto device() -> GpuDevice& { return device_; }
[[nodiscard]] auto isInsideFrame() const -> bool { return cmd_buffer_ != nullptr; }
@@ -173,6 +186,26 @@ namespace Rendering::GPU {
SDL_GPUTexture* bloom_texture_a_{nullptr};
SDL_GPUTexture* bloom_texture_b_{nullptr};
// === Captura de pantalla ===
// Dimensions reals de la swapchain (capturades a beginFrame). El target
// de captura es crea a aquesta mida perquè el PNG surti exactament com
// es veu en pantalla (amb letterbox), no a la resolució del offscreen.
uint32_t swapchain_w_{0};
uint32_t swapchain_h_{0};
// Textura on es fa el segon composite (mateix format que la swapchain) i
// transfer buffer per a descarregar-la a CPU. Es recreen si canvia la
// mida de la finestra (capture_tex_w_/h_ = mida amb què es van crear).
SDL_GPUTexture* capture_texture_{nullptr};
SDL_GPUTransferBuffer* capture_transfer_{nullptr};
int capture_tex_w_{0};
int capture_tex_h_{0};
// Píxels descarregats (ARGB8888, 0xAARRGGBB) i estat de la petició.
std::vector<std::uint32_t> capture_pixels_;
int capture_w_{0};
int capture_h_{0};
bool capture_requested_{false};
bool capture_ready_{false};
// Batch del frame en curso.
std::vector<LineVertex> vertices_;
std::vector<uint16_t> indices_;
@@ -202,8 +235,14 @@ namespace Rendering::GPU {
void flushBatch();
void bloomPass(); // pre-composite: H + V passes sobre les bloom textures
void compositePass();
void compositeTo(SDL_GPUTexture* target_tex); // cos comú del pase de postpro
void applyFinalViewport();
void applyCurrentScissor(); // re-aplica el top de clip_stack_ al render_pass_
// Captura: (re)crea recursos a mida swapchain, descarrega a CPU i allibera.
[[nodiscard]] auto ensureCaptureResources() -> bool;
void downloadCapture(); // copy pass + fence + map → capture_pixels_
void destroyCaptureResources();
};
} // namespace Rendering::GPU
+2 -11
View File
@@ -8,11 +8,6 @@
namespace Rendering {
// Color global compartido para líneas sin paleta propia (HUD, debug, texto
// genérico). Equivale al "color máximo" de la antigua oscilación CPU: verde
// fósforo CRT. El pulso de brillo lo aplica ahora el shader de postpro.
SDL_Color g_current_line_color = {100, 255, 100, 255};
// Grosor global por defecto. Configurable via setLineThickness.
float g_current_line_thickness = Defaults::Rendering::LINE_THICKNESS_DEFAULT;
@@ -36,8 +31,8 @@ namespace Rendering {
const auto FX2 = static_cast<float>(x2);
const auto FY2 = static_cast<float>(y2);
// color.alpha==0 → usar color global (verde fósforo). alpha>0 → color directo.
const SDL_Color SOURCE = (color.a > 0) ? color : g_current_line_color;
// color.alpha==0 → fallback a DEFAULT_LINE_COLOR (verd fòsfor). alpha>0 → color directo.
const SDL_Color SOURCE = (color.a > 0) ? color : DEFAULT_LINE_COLOR;
const float R = (static_cast<float>(SOURCE.r) * brightness) / 255.0F;
const float G = (static_cast<float>(SOURCE.g) * brightness) / 255.0F;
const float B = (static_cast<float>(SOURCE.b) * brightness) / 255.0F;
@@ -68,10 +63,6 @@ namespace Rendering {
}
}
void setLineColor(SDL_Color color) { g_current_line_color = color; }
auto getLineColor() -> SDL_Color { return g_current_line_color; }
void setLineThickness(float thickness) {
if (thickness > 0.0F) {
g_current_line_thickness = thickness;
+11 -9
View File
@@ -3,9 +3,10 @@
//
// El dibujo de líneas pasa por el pipeline GPU. Las coordenadas (x1,y1,x2,y2)
// son lógicas (1280×720); el shader las mapea a NDC y el viewport del SDLManager
// hace el letterbox a píxeles físicos. El brillo modula el color global de
// línea (lo gestiona ColorOscillator). El grosor es configurable por línea
// (parámetro thickness>0) o global (g_current_line_thickness vía setLineThickness).
// hace el letterbox a píxeles físicos. El pulse de brillo lo aplica el shader
// de postpro (ya no hi ha un ColorOscillator a CPU). El grosor es configurable
// per línia (parámetro thickness>0) o global (g_current_line_thickness vía
// setLineThickness).
#pragma once
@@ -15,12 +16,17 @@
namespace Rendering {
// Color verd fòsfor CRT per defecte: s'usa quan el caller passa color amb
// alpha==0 (sentinella "sense color propi"). Constant immutable: la
// semàntica de "color global" ja no existeix (era de l'antic ColorOscillator).
constexpr SDL_Color DEFAULT_LINE_COLOR = {.r = 100, .g = 255, .b = 100, .a = 255};
// Dibuja una línea entre dos puntos en coordenadas lógicas (1280×720).
// brightness: factor de brillo (0.0..1.0, default 1.0 = brillo máximo).
// Pre-multiplica el RGB del color (color dim sobre fons negre).
// thickness: grosor en píxeles lógicos. Si <= 0 usa g_current_line_thickness.
// color: si alpha==0, se usa el color global del oscilador; si alpha>0 se
// usa este color directo (paleta semántica por entidad).
// color: si alpha==0, se usa DEFAULT_LINE_COLOR (verd fòsfor fallback);
// si alpha>0 se usa este color directo (paleta semántica por entidad).
// alpha: alpha que arriba al GPU (default 1.0 = opac, behavior original).
// Valors <1.0 fan que la línia es barregi de veritat sobre el dest
// en comptes de sobrepintar-lo (útil per halos translúcids).
@@ -49,10 +55,6 @@ namespace Rendering {
SDL_Color color = {0, 0, 0, 0},
SDL_Color glow_color = {0, 0, 0, 0});
// Color global de las líneas (lo actualiza ColorOscillator vía SDLManager).
void setLineColor(SDL_Color color);
[[nodiscard]] auto getLineColor() -> SDL_Color;
// Grosor global por defecto (en píxeles lógicos). Default: 1.5.
void setLineThickness(float thickness);
[[nodiscard]] auto getLineThickness() -> float;
+72
View File
@@ -0,0 +1,72 @@
#include "core/rendering/screenshot.hpp"
#include <chrono>
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <string>
#include <utility>
#include <vector>
#include "external/stb_image_write.h"
namespace Screenshot {
namespace {
// Estat de mòdul: ruta base on s'escriu la subcarpeta
// `screenshots/`. Buida per defecte ⇒ relativa al CWD (fallback
// si el Director no ha pogut establir la ruta per plataforma).
std::string g_base_dir; // NOLINT(*-avoid-non-const-global-variables) -- estat de mòdul (singleton-like): s'estableix una vegada al startup via setBaseDir() i es llegeix N vegades; encapsulat dins del namespace anònim, no és accessible des de fora.
// Construeix la ruta del directori on van les captures:
// `<base>/screenshots/`. Si la base és buida, retorna
// "screenshots/" relativa al CWD.
auto screenshotsDir() -> std::string {
if (g_base_dir.empty()) { return "screenshots/"; }
const bool ENDS_WITH_SEP = (g_base_dir.back() == '/' || g_base_dir.back() == '\\');
return g_base_dir + (ENDS_WITH_SEP ? "" : "/") + "screenshots/";
}
// Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write
// espera RGBA en ordre byte (little-endian: R al byte baix, A al byte alt).
auto writePng(std::uint32_t* buffer, int width, int height, const std::string& filepath) -> bool {
const int TOTAL = width * height;
for (int i = 0; i < TOTAL; ++i) {
const std::uint32_t P = buffer[i];
const std::uint32_t A = (P >> 24) & 0xFF;
const std::uint32_t R = (P >> 16) & 0xFF;
const std::uint32_t G = (P >> 8) & 0xFF;
const std::uint32_t B = P & 0xFF;
buffer[i] = R | (G << 8) | (B << 16) | (A << 24);
}
return stbi_write_png(filepath.c_str(), width, height, 4, buffer, width * 4) != 0;
}
} // namespace
void setBaseDir(std::string dir) {
g_base_dir = std::move(dir);
}
auto save(const std::uint32_t* buffer, int width, int height) -> Result {
const std::string FOLDER = screenshotsDir();
std::error_code ec;
std::filesystem::create_directories(FOLDER, ec);
const auto NOW = std::chrono::system_clock::now();
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW);
const std::tm* tm = std::localtime(&TIME);
char timestamp[20];
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm);
const std::string FILENAME = std::string("scr_") + timestamp + ".png";
const std::string FILEPATH = FOLDER + FILENAME;
// Còpia local per no tocar el canvas original durant la conversió.
std::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height));
if (!writePng(copy.data(), width, height, FILEPATH)) { return {}; }
return {.filename = FILENAME, .folder = FOLDER};
}
} // namespace Screenshot
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <cstdint>
#include <string>
// Volca un buffer ARGB8888 a un fitxer PNG dins de la subcarpeta
// `screenshots/` del directori configurat amb `setBaseDir`. El nom es
// genera amb un timestamp i la funció retorna el basename i la ruta
// del directori on s'ha escrit (o un Result amb `filename` buit si
// l'escriptura ha fallat).
namespace Screenshot {
// Configura el directori base on s'escriuran les captures (s'hi
// crea una subcarpeta `screenshots/`). El Director el crida una
// vegada al boot amb `system_folder_` perquè les captures vagin
// al mateix lloc per plataforma que la resta de fitxers generats
// (config.yaml, gamepad_configs.yaml, shaders/, etc.). Si no es
// crida, el fallback és el CWD — comportament antic.
void setBaseDir(std::string dir);
// Resultat d'una captura: el `filename` és el basename del PNG
// (per a notificacions/títol) i el `folder` és la ruta absoluta
// on s'ha guardat (per a poder dir "guardada a {folder}"). Si
// l'escriptura falla, ambdós camps tornen buits.
struct Result {
std::string filename;
std::string folder;
};
auto save(const std::uint32_t* buffer, int width, int height) -> Result;
} // namespace Screenshot
+29
View File
@@ -16,6 +16,7 @@
#include "core/input/mouse.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/coordinate_transform.hpp"
#include "core/rendering/screenshot.hpp"
#include "core/system/notifier.hpp"
#include "project.h"
@@ -360,8 +361,36 @@ auto SDLManager::clear(uint8_t r, uint8_t g, uint8_t b) -> bool {
return gpu_renderer_.beginFrame(0.0F, 0.0F, 0.0F);
}
void SDLManager::requestScreenshot() {
// La captura es fa dins del pròxim endFrame (segon composite + readback);
// el resultat es recull ací mateix, a present(), un cop el frame ja s'ha
// compost. Així el PNG mostra exactament el que es veu en pantalla.
gpu_renderer_.requestCapture();
}
void SDLManager::present() {
gpu_renderer_.endFrame();
// Si el frame que s'acaba de presentar portava una captura demanada,
// l'escrivim a PNG i notifiquem la ruta. La notificació arriba DESPRÉS de
// la captura, així que el toast "guardada" no apareix dins de la imatge.
if (gpu_renderer_.hasCapture()) {
const auto RESULT = Screenshot::save(
gpu_renderer_.captureData(),
gpu_renderer_.captureWidth(),
gpu_renderer_.captureHeight());
gpu_renderer_.clearCapture();
if (!RESULT.filename.empty()) {
if (auto* notifier = System::Notifier::get(); notifier != nullptr) {
std::string msg = localeSubstitute(
Locale::get().text("notification.screenshot"),
"{file}",
RESULT.filename);
msg = localeSubstitute(msg, "{folder}", RESULT.folder);
notifier->notifyInfo(msg);
}
}
}
}
void SDLManager::toggleVSync() {
+1
View File
@@ -36,6 +36,7 @@ class SDLManager {
void toggleVSync(); // F4
void toggleAntialias(); // F5
void togglePostFx(); // F6
void requestScreenshot(); // F9: demana una captura del pròxim frame
// Canvia la resolució del render target offscreen (recrea la textura).
// Cal cridar-lo fora d'un frame (event phase, no draw phase). Si el
// valor no es un preset valid o ja es l'actual, es no-op.
+10 -2
View File
@@ -17,6 +17,7 @@
#include "core/input/input.hpp"
#include "core/input/mouse.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/screenshot.hpp"
#include "core/rendering/sdl_manager.hpp"
#include "core/resources/resource_helper.hpp"
#include "core/resources/resource_loader.hpp"
@@ -99,6 +100,10 @@ Director::Director(int argc, char* argv[])
// Establir ruta del file de configuración
ConfigYaml::setConfigFile(system_folder_ + "/config.yaml");
// Les captures de pantalla van sota la mateixa carpeta per plataforma
// (subcarpeta screenshots/). Sense açò, Screenshot::save cauria al CWD.
Screenshot::setBaseDir(system_folder_);
// Carregar o crear configuración
ConfigYaml::loadFromFile();
@@ -106,8 +111,11 @@ Director::Director(int argc, char* argv[])
// falla, Locale::text() retorna la clau crua i el joc segueix funcionant.
Locale::get().load(std::string("locale/") + cfg_->locale + ".yaml");
// Inicialitzar sistema de input
Input::init("data/gamecontrollerdb.txt");
// Inicialitzar sistema de input. El gamecontrollerdb.txt viu al costat del
// binari (no dins de resources.pack, perquè SDL_AddGamepadMappingsFromFile
// necessita una ruta real de filesystem). resource_base ja apunta al directori
// de l'executable (o a Contents/Resources en bundles de macOS).
Input::init(resource_base + "/gamecontrollerdb.txt");
// Autoassignacio de primer arranque: si cap dels dos jugadors te mando
// assignat al config, repartim els que hi haja detectats (P1 = pad 0,
+5
View File
@@ -160,6 +160,11 @@ namespace GlobalEvents {
sdl.togglePostFx();
return true;
case SDL_SCANCODE_F9:
// Captura de pantalla (PNG) amb shaders, a mida de finestra.
sdl.requestScreenshot();
return true;
case SDL_SCANCODE_F7: {
// Toggle d'idioma en runtime entre català i anglès. Els
// strings ja capturats (toast actiu, banner stage start)
+14
View File
@@ -70,10 +70,24 @@ namespace SceneManager {
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:
SceneType next_scene_{SceneType::LOGO}; // SceneType a la qual transicionar
Option option_{Option::NONE}; // Opción específica per l'escena
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)
+1724
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
// Unitat de compilació aïllada per a la implementació de stb_image_write.
// Viu dins de source/external/ perquè el `.clang-tidy` d'aquesta carpeta
// desactiva tots els checks (com fa per stb_vorbis.c) i el pre-commit hook
// ja filtra aquesta ruta de clang-format / clang-tidy. Així els fals
// positius de clang-analyzer-* dins de codi de tercers no afecten el
// nostre codi, que continua tenint tots els checks actius.
//
// La resta del codi inclou només el header (sense la macro d'implementació),
// que queda només amb declaracions — clang-analyzer no pot trobar cap bug
// dins d'una declaració, així que l'inclusió és innòcua.
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "external/stb_image_write.h"
+4 -1
View File
@@ -64,8 +64,11 @@ namespace Effects {
return;
}
// Reproducir sonido de explosión
// Reproducir sonido de explosión. Cadena buida = explosió silenciosa
// (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.).
if (explosion_callback_) {
+2 -1
View File
@@ -64,7 +64,8 @@ namespace Effects {
const Vec2& velocitat_objecte = {.x = 0.0F, .y = 0.0F},
float velocitat_angular = 0.0F,
float factor_herencia_visual = 0.0F,
const std::string& sound = Defaults::Sound::EXPLOSION,
// sound: nom del so d'explosió. Cadena buida ("") = explosió silenciosa.
const std::string& sound = Defaults::Sound::ENEMY_EXPLOSION,
SDL_Color color = {0, 0, 0, 0}, // alpha==0 → fragmentos usan oscilador global
float lifetime = Defaults::Physics::Debris::TEMPS_VIDA,
float friction = Defaults::Physics::Debris::ACCELERACIO,
+2 -2
View File
@@ -33,9 +33,9 @@ namespace Effects {
TrailManager::TrailManager(Rendering::Renderer* renderer)
: renderer_(renderer),
star_shape_(Graphics::ShapeLoader::load("star.shp")) {
star_shape_(Graphics::ShapeLoader::load("effect/starfield.shp")) {
if (!star_shape_ || !star_shape_->isValid()) {
std::cerr << "[TrailManager] Warning: no s'ha pogut load star.shp\n";
std::cerr << "[TrailManager] Warning: no s'ha pogut load effect/starfield.shp\n";
}
for (auto& particle : pool_) {
particle.active = false;
+1 -1
View File
@@ -114,6 +114,6 @@ void Bullet::desactivar() {
void Bullet::draw() const {
if (is_active_ && shape_) {
Rendering::renderShape(renderer_, shape_, center_, angle_, 1.0F, 1.0F, brightness_, config_->colors.normal);
Rendering::renderShape(renderer_, shape_, center_, angle_, config_->shape.scale, 1.0F, brightness_, config_->colors.normal);
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ enum class EnemyType : uint8_t {
SQUARE = 1, // Square perseguidor (tracks ship)
PINWHEEL = 2, // Molinillo agresivo (rápido, girando)
STAR = 3, // Estrella de 5 puntes (clone visual de Pentagon, comportament zigzag)
BIG_PENTAGON = 4, // Pentàgon gegant tough (HP=10, chase lent — primer enemic HP>1)
ORB = 4, // Orb gegant tough (HP=10, chase lent — primer enemic HP>1)
};
// Forward declaration — EnemyConfig viu a enemy_config.hpp i s'inclou només a enemy.cpp.
+46 -9
View File
@@ -30,7 +30,7 @@ namespace {
if (s == "square") { return EnemyType::SQUARE; }
if (s == "pinwheel") { return EnemyType::PINWHEEL; }
if (s == "star") { return EnemyType::STAR; }
if (s == "big_pentagon") { return EnemyType::BIG_PENTAGON; }
if (s == "orb") { return EnemyType::ORB; }
return std::nullopt;
}
@@ -186,6 +186,11 @@ namespace {
}
}
// Forward-decl: aimModeFromString viu més avall (junt amb la resta de
// helpers d'AI) però parseActionList el necessita per al payload de
// FIRE_BULLET. Evita reordenar tot el bloc.
auto aimModeFromString(const std::string& s) -> std::optional<AimMode>;
auto actionTypeFromString(const std::string& s) -> std::optional<EnemyActionType> {
if (s == "set_hurt") { return EnemyActionType::SET_HURT; }
if (s == "destroy") { return EnemyActionType::DESTROY; }
@@ -197,6 +202,7 @@ namespace {
if (s == "apply_impulse") { return EnemyActionType::APPLY_IMPULSE; }
if (s == "decrease_health") { return EnemyActionType::DECREASE_HEALTH; }
if (s == "flash") { return EnemyActionType::FLASH; }
if (s == "fire_bullet") { return EnemyActionType::FIRE_BULLET; }
return std::nullopt;
}
@@ -219,19 +225,50 @@ namespace {
<< event_name << " (" << enemy_name << ")\n";
return false;
}
out.push_back({*PARSED});
EnemyAction action;
action.type = *PARSED;
// Payload de FIRE_BULLET. Camps opcionals; els defaults són els del struct.
if (action.type == EnemyActionType::FIRE_BULLET) {
if (item.contains("bullet")) {
action.bullet_config_name = item["bullet"].get_value<std::string>();
}
if (item.contains("bullet_speed")) {
action.bullet_speed = item["bullet_speed"].get_value<float>();
}
if (item.contains("aim_mode")) {
const auto AIM_STR = item["aim_mode"].get_value<std::string>();
const auto AIM = aimModeFromString(AIM_STR);
if (!AIM) {
std::cerr << "[EnemyConfig] Error: aim_mode desconegut '" << AIM_STR
<< "' a " << event_name << " (" << enemy_name << ")\n";
return false;
}
action.aim_mode = *AIM;
}
if (item.contains("jitter_rad")) {
action.jitter_rad = item["jitter_rad"].get_value<float>();
}
}
out.push_back(action);
}
return true;
}
// Defaults: replica el flux hardcoded actual (set_hurt → destroy → score+debris+fireworks).
// Construïm via mutació per esquivar warnings de designated-init parcial sobre
// l'EnemyAction (que té payload de FIRE_BULLET no rellevant ací).
void fillLegacyDefaults(EnemyEventConfig& events) {
events.on_hit = {{EnemyActionType::SET_HURT}};
events.on_hurt_end = {{EnemyActionType::DESTROY}};
const auto MAKE = [](EnemyActionType type) {
EnemyAction a;
a.type = type;
return a;
};
events.on_hit = {MAKE(EnemyActionType::SET_HURT)};
events.on_hurt_end = {MAKE(EnemyActionType::DESTROY)};
events.on_destroy = {
{EnemyActionType::ADD_SCORE},
{EnemyActionType::CREATE_DEBRIS},
{EnemyActionType::CREATE_FIREWORKS},
MAKE(EnemyActionType::ADD_SCORE),
MAKE(EnemyActionType::CREATE_DEBRIS),
MAKE(EnemyActionType::CREATE_FIREWORKS),
};
}
@@ -353,8 +390,8 @@ namespace {
out.movement.rotation_proximity_multiplier = legacy.rotation_proximity_multiplier;
out.movement.proximity_distance = legacy.proximity_distance;
break;
case EnemyType::BIG_PENTAGON:
// Sense legacy fallback: el YAML del big_pentagon ha de definir
case EnemyType::ORB:
// Sense legacy fallback: el YAML de l'orb ha de definir
// ai.movement explícitament. Default chase lent perquè el switch
// siga exhaustiu i no falli si algú omet el bloc ai.
out.movement.type = MovementType::CHASE;
+11
View File
@@ -8,8 +8,11 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "game/entities/enemy_ai.hpp" // AimMode
enum class EnemyEventType : uint8_t {
ON_HIT, // Impactat per una bala
ON_NO_HEALTH, // health ha arribat a 0 o menys aquest frame (via DECREASE_HEALTH)
@@ -28,10 +31,18 @@ enum class EnemyActionType : uint8_t {
APPLY_IMPULSE, // Aplica l'impuls de la bala impactant
DECREASE_HEALTH, // Decrementa health_; si <=0, dispatcha ON_NO_HEALTH
FLASH, // Flash visual breu (feedback per impacte parcial)
FIRE_BULLET, // Dispara una bala (config per nom) dirigida o aleatòria
};
struct EnemyAction {
EnemyActionType type;
// Payload de FIRE_BULLET (ignorat per altres tipus). Paral·lel a AiTickAction
// perquè un futur refactor pugui compartir doShoot/doFireBullet si val la pena.
std::string bullet_config_name;
float bullet_speed{200.0F};
AimMode aim_mode{AimMode::RANDOM};
float jitter_rad{0.0F};
};
struct EnemyEventConfig {
+4 -4
View File
@@ -13,7 +13,7 @@ EnemyConfig EnemyRegistry::pentagon_config;
EnemyConfig EnemyRegistry::square_config;
EnemyConfig EnemyRegistry::pinwheel_config;
EnemyConfig EnemyRegistry::star_config;
EnemyConfig EnemyRegistry::big_pentagon_config;
EnemyConfig EnemyRegistry::orb_config;
bool EnemyRegistry::loaded = false;
namespace {
@@ -40,7 +40,7 @@ auto EnemyRegistry::loadAll() -> bool {
loadOne("square", EnemyType::SQUARE, square_config) &&
loadOne("pinwheel", EnemyType::PINWHEEL, pinwheel_config) &&
loadOne("star", EnemyType::STAR, star_config) &&
loadOne("big_pentagon", EnemyType::BIG_PENTAGON, big_pentagon_config);
loadOne("orb", EnemyType::ORB, orb_config);
loaded = OK;
if (OK) {
std::cout << "[EnemyRegistry] 5 configuracions d'enemic carregades.\n";
@@ -62,8 +62,8 @@ auto EnemyRegistry::get(EnemyType type) -> const EnemyConfig& {
return pinwheel_config;
case EnemyType::STAR:
return star_config;
case EnemyType::BIG_PENTAGON:
return big_pentagon_config;
case EnemyType::ORB:
return orb_config;
}
std::cerr << "[EnemyRegistry] FATAL: tipus desconegut\n";
std::exit(EXIT_FAILURE);
+1 -1
View File
@@ -27,6 +27,6 @@ class EnemyRegistry {
static EnemyConfig square_config;
static EnemyConfig pinwheel_config;
static EnemyConfig star_config;
static EnemyConfig big_pentagon_config;
static EnemyConfig orb_config;
static bool loaded;
};
+13 -5
View File
@@ -26,7 +26,7 @@ Ship::Ship(Rendering::Renderer* renderer, PlayerConfig config, const char* shape
config_(std::move(config)) {
brightness_ = Defaults::Brightness::NAU;
// El shape pot venir del YAML o ser overridden (ex: P2 amb "ship2.shp").
// El shape pot venir del YAML o ser overridden (ex: P2 amb "ship/wedge.shp").
const std::string SHAPE_PATH = (shape_override != nullptr) ? shape_override : config_.shape.path;
shape_ = Graphics::ShapeLoader::load(SHAPE_PATH);
if (!shape_ || !shape_->isValid()) {
@@ -85,16 +85,24 @@ void Ship::processInput(float delta_time, uint8_t player_id) {
? input->checkActionPlayer1(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;
}
if (ROTATE_LEFT) {
if (rotate_left) {
body_.angle -= config_.physics.rotation_speed * delta_time;
}
// Thrust: fuerza vectorial en la dirección de la nariz.
// 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_Y = std::sin(body_.angle - (Constants::PI / 2.0F));
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 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;
if (hurt_timer_ > 0.0F) {
const float CYCLE = 1.0F / config_.hurt.blink_hz;
+3
View File
@@ -22,6 +22,9 @@ class Ship : public Entities::Entity {
void init() override { init(nullptr, false); }
void init(const Vec2* spawn_point, bool activar_invulnerabilitat = false);
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 postUpdate(float delta_time) override;
void draw() const override;
+197 -77
View File
@@ -4,6 +4,7 @@
#include "game_scene.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdlib>
#include <ctime>
@@ -12,6 +13,7 @@
#include "core/audio/audio.hpp"
#include "core/entities/entity_loader.hpp"
#include "core/input/input.hpp"
#include "core/input/input_types.hpp"
#include "core/locale/locale.hpp"
#include "core/system/scene_context.hpp"
#include "core/system/service_menu.hpp"
@@ -29,6 +31,25 @@ using SceneManager::SceneContext;
using SceneType = SceneContext::SceneType;
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};
// Color de les frases d'inici/fi de fase (àmbar neon). És propi del joc i
// independent del "PULSA START" del títol (ara blanc): abans compartien la
// mateixa constant i en posar el títol en blanc aquestes frases també ho feien.
constexpr SDL_Color STAGE_MESSAGE_COLOR = {.r = 255, .g = 200, .b = 70, .a = 255};
} // namespace
GameScene::GameScene(SDLManager& sdl, SceneContext& context)
: sdl_(sdl),
context_(context),
@@ -39,7 +60,8 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
text_(sdl.getRenderer()),
starfield_parallax_(sdl.getRenderer()),
playfield_(sdl.getRenderer()),
border_(sdl.getRenderer()) {
border_(sdl.getRenderer()),
curtain_(sdl.getRenderer()) {
// Recuperar configuración de match des del context
match_config_ = context_.getMatchConfig();
@@ -83,7 +105,7 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
// Inicialitzar naves: P1 amb el shape del YAML, P2 amb override visual.
ships_[0] = Ship(sdl.getRenderer(), *player_config); // Jugador 1: nau estàndard
ships_[1] = Ship(sdl.getRenderer(), *player_config, "ship2.shp"); // Jugador 2: interceptor amb ales
ships_[1] = Ship(sdl.getRenderer(), *player_config, "ship/wedge.shp"); // Jugador 2: triangle amb cercle central
// Inicialitzar balas con renderer
std::ranges::fill(bullets_, Bullet(sdl.getRenderer()));
@@ -139,7 +161,27 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
// Initialize stage manager
stage_manager_ = std::make_unique<StageSystem::StageManager>(stage_config_.get());
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 només els SFX de joc (Group::GAME) durant la demo: la música
// i els sons del menu de servei (Group::INTERFACE) segueixen sonant. No
// toquem el volum global ni la preferència de l'usuari.
Audio::get()->silenceGroup(Audio::Group::GAME, true);
// 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)
stage_manager_->getWaveRunner().setShipPosition(&ships_[0].getCenter());
@@ -204,6 +246,18 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
init_hud_rect_sound_played_ = false;
}
GameScene::~GameScene() {
// En sortir de la demo, primer parem qualsevol SFX encara sonant (p. ex. la
// veu de "fase completa" que la demo va llançar muteada): si no, en restaurar
// el volum del grup el motor reaplicaria la ganancia al canal viu i el so es
// colaria a la pantalla de títol. Després restaurem el grup GAME per al
// pròxim joc real.
if (match_config_.mode == GameConfig::Mode::DEMO) {
Audio::get()->stopAllSounds();
Audio::get()->silenceGroup(Audio::Group::GAME, false);
}
}
auto GameScene::isFinished() const -> bool {
return context_.nextScene() != SceneType::GAME;
}
@@ -215,10 +269,16 @@ void GameScene::handleEvent(const SDL_Event& event) {
}
void GameScene::update(float delta_time) {
// Pausa global: mentre el menu de servei esta obert, congelem la lògica
// de joc. El draw() segueix executant-se per a mantenir l'escena visible
// sota el menu.
if (const auto* menu = System::ServiceMenu::get(); menu != nullptr && menu->isOpen()) {
// El menu de servei "flota" sobre l'escena: l'input se'n va cap a ell, mai
// "a baix". En partida normal pausem la lògica de joc mentre està obert (el
// draw() segueix executant-se per a mantindre l'escena visible sota el menu).
// En DEMO (attract) NO pausem: la demo ha de seguir viva sota el menu; només
// evitem que l'input la trenque (flag MENU_OPEN cap a stepDemo). Amb l'overlay
// de redefinir controls actiu el menu segueix "open", així que aquest únic
// check ja cobreix tots dos casos.
const auto* menu = System::ServiceMenu::get();
const bool MENU_OPEN = (menu != nullptr) && menu->isOpen();
if (MENU_OPEN && match_config_.mode != GameConfig::Mode::DEMO) {
return;
}
@@ -226,7 +286,12 @@ void GameScene::update(float delta_time) {
// mantener update() legible y reducir complejidad cognitiva.
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, MENU_OPEN)) {
return;
}
} else if (game_over_state_ == GameOverState::NONE) {
stepShootingInput();
stepMidGameJoin();
}
@@ -299,6 +364,75 @@ 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 input_blocked) -> bool {
curtain_.update(delta_time); // cortinilla que destapa la demo
// Qualsevol input trenca la demo i torna al títol (música intacta), pero NO
// mentre el menu de servei estiga obert: en eixe cas l'input va al menu i no
// arriba "a baix", així que la demo ha de seguir corrent sota seu.
if (!input_blocked && 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;
}
// Company per a evitar foc amic en demo de 2 naus: només es passa si l'altre
// jugador està actiu al match (un slot no usat tindria isActive()==true).
const uint8_t OTHER = 1U - i;
const bool OTHER_ACTIVE = (OTHER == 0) ? match_config_.player1_active
: match_config_.player2_active;
const Ship* teammate = OTHER_ACTIVE ? &ships_[OTHER] : nullptr;
demo_ctrls_[i] = demo_pilots_[i].compute(
ships_[i],
teammate,
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() {
// Permitir join solo durante PLAYING.
if (stage_manager_->getState() != StageSystem::EstatStage::PLAYING) {
@@ -375,7 +509,7 @@ auto GameScene::stepGameOver(float delta_time) -> bool {
game_over_timer_ -= delta_time;
if (game_over_timer_ <= 0.0F) {
Audio::get()->stopMusic();
context_.setNextScene(SceneType::TITLE);
context_.setNextScene(SceneType::LOGO);
return true;
}
@@ -408,6 +542,15 @@ void GameScene::stepDeathSequence(float delta_time) {
}
// *** 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]--;
if (lives_per_player_[i] > 0) {
Vec2 spawn_pos = getSpawnPoint(i);
@@ -496,13 +639,7 @@ void GameScene::runStageLevelStart(float delta_time) {
stage_manager_->update(delta_time);
// Ambas naves pueden moverse y disparar durante el intro.
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) {
ships_[i].processInput(delta_time, i);
ships_[i].update(delta_time);
}
}
updateShipsControl(delta_time);
for (auto& bullet : bullets_) {
bullet.update(delta_time);
}
@@ -524,13 +661,7 @@ void GameScene::runStagePlaying(float delta_time) {
}
// Gameplay normal: ships activos + entidades + colisiones + efectos.
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) {
ships_[i].processInput(delta_time, i);
ships_[i].update(delta_time);
}
}
updateShipsControl(delta_time);
auto ai_ctx = buildCollisionContext();
for (std::size_t i = 0; i < enemies_.size(); ++i) {
Systems::EnemyAi::tick(ai_ctx, enemies_[i], i, delta_time);
@@ -552,13 +683,7 @@ void GameScene::runStagePlaying(float delta_time) {
void GameScene::runStageLevelCompleted(float delta_time) {
stage_manager_->update(delta_time);
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) {
ships_[i].processInput(delta_time, i);
ships_[i].update(delta_time);
}
}
updateShipsControl(delta_time);
for (auto& bullet : bullets_) {
bullet.update(delta_time);
}
@@ -614,6 +739,10 @@ void GameScene::draw() {
drawLevelCompletedState();
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 {
@@ -710,7 +839,7 @@ void GameScene::drawInitHudState() {
}
if (score_progress > 0.0F) {
Systems::InitHud::drawScoreboardAnimated(text_, buildScoreboard(), score_progress);
Systems::InitHud::drawScoreboardAnimated(sdl_.getRenderer(), text_, buildScoreboardData(), score_progress);
}
if (ship1_progress > 0.0F && match_config_.player1_active && ships_[0].isActive()) {
@@ -801,7 +930,7 @@ void GameScene::tocado(uint8_t player_id, const Vec2& bullet_velocity) {
INHERITED_VEL,
0.0F, // sense herència angular
0.0F, // sin herencia visual
Defaults::Sound::EXPLOSION2,
Defaults::Sound::PLAYER_EXPLOSION,
ships_[player_id].getConfig().colors.normal,
Defaults::Physics::Debris::ENEMY_LIFETIME,
Defaults::Physics::Debris::ENEMY_FRICTION,
@@ -816,59 +945,50 @@ void GameScene::tocado(uint8_t player_id, const Vec2& bullet_velocity) {
}
void GameScene::drawScoreboard() {
// Construir text del marcador
std::string text = buildScoreboard();
// Parámetros de renderització
const float SCALE = Defaults::Hud::SCOREBOARD_TEXT_SCALE;
const float SPACING = Defaults::Hud::SCOREBOARD_TEXT_SPACING;
// Calcular centro de la zone del marcador
const SDL_FRect& scoreboard_zone = Defaults::Zones::SCOREBOARD;
float center_x = scoreboard_zone.w / 2.0F;
float center_y = scoreboard_zone.y + (scoreboard_zone.h / 2.0F);
// Renderizar centrat
text_.renderCentered(text, {.x = center_x, .y = center_y}, SCALE, SPACING);
const Vec2 CENTER = {
.x = scoreboard_zone.w / 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::drawScoreboardAt(sdl_.getRenderer(), text_, buildScoreboardData(), CENTER.y, SCALE, SPACING);
}
auto GameScene::buildScoreboard() const -> std::string {
// Puntuación P1 (6 dígits) - mostrar zeros si inactiu
std::string score_p1;
std::string vides_p1;
if (match_config_.player1_active) {
score_p1 = std::to_string(score_per_player_[0]);
score_p1 = std::string(6 - std::min(6, static_cast<int>(score_p1.length())), '0') + score_p1;
vides_p1 = (lives_per_player_[0] < 10)
? "0" + std::to_string(lives_per_player_[0])
: std::to_string(lives_per_player_[0]);
} else {
score_p1 = "000000";
vides_p1 = "00";
}
auto GameScene::buildScoreboardData() const -> Systems::InitHud::ScoreboardData {
Systems::InitHud::ScoreboardData out;
// Nivel (2 dígits)
uint8_t stage_num = stage_manager_->getCurrentStage();
std::string stage_str = (stage_num < 10) ? "0" + std::to_string(stage_num)
: std::to_string(stage_num);
// Puntuació a 6 dígits amb zeros a l'esquerra (inactiu → tot zeros, 0 vides).
const auto FORMAT_SCORE = [](int score) {
const std::string S = std::to_string(score);
return std::string(6 - std::min(6, static_cast<int>(S.length())), '0') + S;
};
// Puntuación P2 (6 dígits) - mostrar zeros si inactiu
std::string score_p2;
std::string vides_p2;
if (match_config_.player2_active) {
score_p2 = std::to_string(score_per_player_[1]);
score_p2 = std::string(6 - std::min(6, static_cast<int>(score_p2.length())), '0') + score_p2;
vides_p2 = (lives_per_player_[1] < 10)
? "0" + std::to_string(lives_per_player_[1])
: std::to_string(lives_per_player_[1]);
} else {
score_p2 = "000000";
vides_p2 = "00";
}
out.p1_active = match_config_.player1_active;
out.p2_active = match_config_.player2_active;
out.score_p1 = match_config_.player1_active ? FORMAT_SCORE(score_per_player_[0]) : "000000";
out.lives_p1 = match_config_.player1_active ? lives_per_player_[0] : 0;
out.score_p2 = match_config_.player2_active ? FORMAT_SCORE(score_per_player_[1]) : "000000";
out.lives_p2 = match_config_.player2_active ? lives_per_player_[1] : 0;
// Format: "123456 03 LEVEL 01 654321 02"
// Nota: dos espais entre seccions, mantenir ambdós slots siempre visibles
return score_p1 + " " + vides_p1 + " " + Locale::get().text("hud.level") + stage_str + " " + score_p2 + " " + vides_p2;
// Shapes de les naus per a les icones de vides (reutilitza la geometria ja
// carregada de cada Ship).
out.shape_p1 = ships_[0].getShape();
out.shape_p2 = ships_[1].getShape();
// Nivell: etiqueta localitzada + número a 2 dígits (separats per pintar-los
// amb tonalitats distintes).
const uint8_t STAGE_NUM = stage_manager_->getCurrentStage();
out.level_label = Locale::get().text("hud.level");
out.level_value = (STAGE_NUM < 10) ? "0" + std::to_string(STAGE_NUM)
: std::to_string(STAGE_NUM);
return out;
}
// [NEW] Stage system helper methods
@@ -936,7 +1056,7 @@ void GameScene::drawStageMessage(const std::string& message) {
// Render only the partial message (typewriter effect) amb el color
// ambre neon del "PRESS START" del títol — unifica el feel dels missatges.
Vec2 pos = {.x = x, .y = y};
text_.render(partial_message, pos, scale, SPACING, 1.0F, Defaults::Title::Colors::PRESS_START);
text_.render(partial_message, pos, scale, SPACING, 1.0F, STAGE_MESSAGE_COLOR);
}
// ========================================
+24 -3
View File
@@ -9,6 +9,7 @@
#include <string>
#include "core/graphics/border.hpp"
#include "core/graphics/curtain.hpp"
#include "core/graphics/playfield.hpp"
#include "core/graphics/starfield_parallax.hpp"
#include "core/graphics/vector_text.hpp"
@@ -29,6 +30,8 @@
#include "game/stage_system/stage_config.hpp"
#include "game/stage_system/stage_manager.hpp"
#include "game/systems/collision_system.hpp"
#include "game/systems/demo_pilot.hpp"
#include "game/systems/init_hud_animator.hpp"
// Game over state machine
enum class GameOverState : uint8_t {
@@ -41,7 +44,7 @@ enum class GameOverState : uint8_t {
class GameScene final : public Scene {
public:
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
void handleEvent(const SDL_Event& event) override;
@@ -93,6 +96,9 @@ class GameScene final : public Scene {
// Border del playfield (4 línies amb desplaçaments i flash per impactes)
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
std::unique_ptr<StageSystem::StageSystemConfig> stage_config_;
std::unique_ptr<StageSystem::StageManager> stage_manager_;
@@ -100,6 +106,11 @@ class GameScene final : public Scene {
// Control de sons de animación INIT_HUD
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)
// Funciones privades
// 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ó
@@ -128,14 +139,24 @@ class GameScene final : public Scene {
void drawPlayingState();
void drawLevelCompletedState();
// [NEW] Función helper del marcador
[[nodiscard]] auto buildScoreboard() const -> std::string;
// Helper del marcador: construeix les dades (puntuacions, vides i nivell)
// per al render en blocs ancorats per jugador.
[[nodiscard]] auto buildScoreboardData() const -> Systems::InitHud::ScoreboardData;
// Sub-pasos de update() (descompuestos en Fase 9d para reducir
// complejidad cognitiva; cada uno es responsable de una sección).
void stepPhysics(float delta_time);
void stepShootingInput();
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.
// input_blocked: si un overlay (menu de servei / redefinir controls) està
// capturant l'input, no es deixa que l'input trenque la demo (segueix viva).
[[nodiscard]] auto stepDemo(float delta_time, bool input_blocked) -> bool;
void endDemo();
// Devuelven true si el frame debe salir tras esta sección.
[[nodiscard]] auto stepContinueScreen(float delta_time) -> bool;
[[nodiscard]] auto stepGameOver(float delta_time) -> bool;
+19 -3
View File
@@ -55,6 +55,11 @@ LogoScene::LogoScene(SDLManager& sdl, SceneContext& context)
(void)option; // Suprimir warning
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();
}
@@ -171,8 +176,11 @@ void LogoScene::changeState(AnimationState nou_estat) {
std::mt19937 g(rd());
std::shuffle(explosion_order_.begin(), explosion_order_.end(), g);
} else if (nou_estat == AnimationState::POST_EXPLOSION) {
// 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)
<< "\n";
@@ -200,8 +208,12 @@ void LogoScene::updateExplosions(float delta_time) {
FINAL_SCALE, // Escala (lletres a scale final)
SPEED_EXPLOSIO, // Velocidad base
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";
@@ -237,9 +249,13 @@ void LogoScene::update(float delta_time) {
global_progress,
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 (!attract_silent_) {
Audio::get()->playSound(Defaults::Sound::LOGO, Audio::Group::GAME);
}
sound_played_[i] = true;
}
}
+5
View File
@@ -67,6 +67,11 @@ class LogoScene final : public Scene {
// Seguiment de sons de lletres (evitar reproduccions repetides)
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
static constexpr float DURATION_PRE = 1.5F; // Duració PRE_ANIMATION (pantalla negra)
static constexpr float DURATION_ZOOM = 4.0F; // Duració del zoom (segons)
+129 -16
View File
@@ -7,7 +7,6 @@
#include <cfloat>
#include <cmath>
#include <iostream>
#include <numbers>
#include <string>
#include "core/audio/audio.hpp"
@@ -20,6 +19,7 @@
#include "core/rendering/shape_renderer.hpp"
#include "core/system/scene_context.hpp"
#include "core/system/service_menu.hpp"
#include "game/systems/demo_pilot.hpp"
#include "project.h"
using SceneManager::SceneContext;
@@ -36,7 +36,8 @@ namespace {
TitleScene::TitleScene(SDLManager& sdl, SceneContext& context)
: sdl_(sdl),
context_(context),
text_(sdl.getRenderer()) {
text_(sdl.getRenderer()),
curtain_(sdl.getRenderer()) {
std::cout << "SceneType Titol: Inicialitzant...\n";
match_config_.player1_active = false;
@@ -78,7 +79,7 @@ TitleScene::TitleScene(SDLManager& sdl, SceneContext& context)
// Flash que tapa el "pop" final de la nau al VP. Es spawneja al centre
// de pantalla (= projecció del VP) quan ship_animator avisa.
flash_shape_ = Graphics::ShapeLoader::load("title_flash.shp");
flash_shape_ = Graphics::ShapeLoader::load("effect/title_flash.shp");
ship_animator_->setOnShipDisappear([this](int /*player_id*/) {
triggerFlash(Vec2{
.x = static_cast<float>(Defaults::Window::WIDTH) / 2.0F,
@@ -94,8 +95,16 @@ TitleScene::TitleScene(SDLManager& sdl, SceneContext& context)
}
TitleScene::~TitleScene() {
// 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() {
using namespace Graphics;
@@ -267,8 +276,9 @@ void TitleScene::dibuixarPeuTitol(float spacing) const {
// 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_Y = Defaults::Game::HEIGHT / 2.0F;
const float JAILGAMES_S = std::lerp(S::FOOTER_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_jailgames_progress_));
const float COPYRIGHT_S = std::lerp(S::FOOTER_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_copyright_progress_));
// dive_zoom_ (attract) afegeix el zoom del dive per travessar el peu.
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;
for (const auto& letter : letters_jailgames_) {
@@ -276,7 +286,7 @@ void TitleScene::dibuixarPeuTitol(float spacing) const {
.x = SCREEN_CENTRE_X + (JAILGAMES_S * (letter.position.x - SCREEN_CENTRE_X)),
.y = SCREEN_CENTRE_Y + (JAILGAMES_S * (letter.position.y - SCREEN_CENTRE_Y)),
};
Rendering::renderShape(sdl_.getRenderer(), letter.shape, POS, 0.0F, JAILGAMES_RENDER_SCALE, 1.0F, 1.0F, Defaults::Title::Colors::JAILGAMES_LOGO);
Rendering::renderShape(sdl_.getRenderer(), letter.shape, POS, 0.0F, JAILGAMES_RENDER_SCALE, 1.0F, Defaults::Title::Layout::JAILGAMES_BRIGHTNESS, Defaults::Title::Colors::JAILGAMES_LOGO);
}
std::string copyright = Project::COPYRIGHT;
for (char& c : copyright) {
@@ -288,7 +298,7 @@ void TitleScene::dibuixarPeuTitol(float spacing) const {
const float COPY_X = SCREEN_CENTRE_X; // ja al centre
const float COPY_Y = SCREEN_CENTRE_Y + (COPYRIGHT_S * (Y_COPY_FINAL - SCREEN_CENTRE_Y));
const float COPY_RENDER_SCALE = Defaults::Title::Layout::COPYRIGHT_SCALE * COPYRIGHT_S;
text_.renderCentered(copyright, {.x = COPY_X, .y = COPY_Y}, COPY_RENDER_SCALE, spacing, 1.0F, Defaults::Title::Colors::COPYRIGHT);
text_.renderCentered(copyright, {.x = COPY_X, .y = COPY_Y}, COPY_RENDER_SCALE, spacing, Defaults::Title::Layout::COPYRIGHT_BRIGHTNESS, Defaults::Title::Colors::COPYRIGHT);
}
auto TitleScene::isFinished() const -> bool {
@@ -296,6 +306,7 @@ auto TitleScene::isFinished() const -> bool {
}
void TitleScene::update(float delta_time) {
blink_timer_ += delta_time; // fase del parpelleig del PRESS START (reiniciada a les transicions)
if (starfield_) {
starfield_->update(delta_time);
}
@@ -324,6 +335,12 @@ void TitleScene::update(float delta_time) {
case TitleState::BLACK_SCREEN:
updateBlackScreenState(delta_time);
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
@@ -340,6 +357,41 @@ void TitleScene::update(float delta_time) {
handleSkipInput();
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). El comptador
// segueix corrent encara que el menu de servei o l'overlay de redefinir
// controls estiguen oberts: floten sobre el títol i no aturen el fons, així
// que la demo arrenca igualment i les escenes poden canviar amb el menu
// obert. L'input, però, no arriba "a baix": només resetegem el comptador amb
// un botó arcade quan cap overlay l'està capturant.
if (current_state_ == TitleState::MAIN) {
if (!INPUT_BLOCKED && 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) {
@@ -398,6 +450,7 @@ void TitleScene::updateMainState(float delta_time) {
if (!press_start_visible_ && state_time_main_ >= T_PRESS_START_VISIBLE) {
press_start_visible_ = true;
blink_timer_ = 0.0F; // primer parpelleig (lent) complet en aparèixer
}
// L'oscil·lació suau del logo arrenca quan el logo ha aterrat. Així
@@ -442,6 +495,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() {
if (current_state_ != TitleState::STARFIELD_FADE_IN && current_state_ != TitleState::STARFIELD) {
return;
@@ -465,6 +562,7 @@ void TitleScene::handleSkipInput() {
intro_jailgames_progress_ = 1.0F;
intro_copyright_progress_ = 1.0F;
press_start_visible_ = true;
blink_timer_ = 0.0F; // primer parpelleig (lent) complet en saltar la intro
ships_intro_launched_ = true;
if (ship_animator_ != nullptr) {
ship_animator_->setVisible(true);
@@ -496,6 +594,7 @@ void TitleScene::handleStartInput() {
context_.setMatchConfig(match_config_);
current_state_ = TitleState::PLAYER_JOIN_PHASE;
temps_acumulat_ = 0.0F;
blink_timer_ = 0.0F; // primer parpelleig (ràpid) complet en passar a join phase
triggerExitForJoinedPlayers(P1_ABANS, P2_ABANS, "");
@@ -546,7 +645,9 @@ void TitleScene::draw() {
(current_state_ == TitleState::STARFIELD_FADE_IN ||
current_state_ == TitleState::STARFIELD ||
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();
}
drawFlashes();
@@ -555,14 +656,21 @@ void TitleScene::draw() {
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;
}
// 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
// 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_Y = Defaults::Game::HEIGHT / 2.0F;
const float LOGO_RENDER_SCALE = Defaults::Title::Layout::LOGO_SCALE * LOGO_S;
@@ -611,12 +719,14 @@ void TitleScene::draw() {
const float SPACING = Defaults::Title::Layout::TEXT_SPACING;
if (press_start_visible_) {
bool mostrar_text = true;
if (current_state_ == TitleState::PLAYER_JOIN_PHASE) {
const float FASE = temps_acumulat_ * BLINK_FREQUENCY * 2.0F * std::numbers::pi_v<float>;
mostrar_text = (std::sin(FASE) > 0.0F);
}
if (mostrar_text) {
// Parpelleig: lent en aparèixer (MAIN), ràpid en prémer START (join).
// blink_timer_ es reinicia a 0 a cada transició, així el primer mig
// període és sempre visible i complet (no n'agafa un de parcial).
const float BLINK_HZ = (current_state_ == TitleState::PLAYER_JOIN_PHASE)
? Defaults::Title::Layout::PRESS_START_BLINK_HZ_FAST
: Defaults::Title::Layout::PRESS_START_BLINK_HZ_SLOW;
const bool MOSTRAR_TEXT = std::fmod(blink_timer_ * BLINK_HZ, 1.0F) < 0.5F;
if (MOSTRAR_TEXT) {
const std::string MAIN_TEXT = Locale::get().text("title.press_start");
const float MAIN_SCALE = Defaults::Title::Layout::PRESS_START_SCALE;
const float CENTRE_X = Defaults::Game::WIDTH / 2.0F;
@@ -626,6 +736,9 @@ void TitleScene::draw() {
}
dibuixarPeuTitol(SPACING);
// Cortinilla negra (attract): per damunt de tot. No-op si no està activa.
curtain_.draw();
}
auto TitleScene::checkSkipButtonPressed() -> bool {
+26 -2
View File
@@ -18,6 +18,7 @@
#include <vector>
#include "core/graphics/camera3d.hpp"
#include "core/graphics/curtain.hpp"
#include "core/graphics/shape.hpp"
#include "core/graphics/starfield.hpp"
#include "core/graphics/vector_text.hpp"
@@ -46,6 +47,8 @@ class TitleScene final : public Scene {
MAIN,
PLAYER_JOIN_PHASE,
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 {
@@ -60,6 +63,7 @@ class TitleScene final : public Scene {
SceneManager::SceneContext& context_;
GameConfig::MatchConfig match_config_;
Graphics::VectorText text_;
Graphics::Curtain curtain_; // Cortinilla negra en saltar a la demo (attract)
std::unique_ptr<Graphics::Camera3D> camera_;
std::unique_ptr<Graphics::Starfield> starfield_;
std::unique_ptr<Title::ShipAnimator> ship_animator_;
@@ -79,6 +83,12 @@ class TitleScene final : public Scene {
void drawFlashes();
TitleState current_state_{TitleState::STARFIELD_FADE_IN};
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_attack_;
@@ -100,16 +110,23 @@ class TitleScene final : public Scene {
float intro_copyright_progress_{0.0F};
bool press_start_visible_{false};
bool ships_intro_launched_{false};
// Rellotge de fase del parpelleig del "PRESS START". Es reinicia a 0 en cada
// transició (aparició del text i pas a parpelleig ràpid) perquè el primer
// parpelleig siga sempre un període complet, no un de parcial.
float blink_timer_{0.0F};
static constexpr float BRIGHTNESS_STARFIELD = 1.2F;
static constexpr float DURATION_FADE_IN = 3.0F;
static constexpr float DURATION_INIT = 4.0F;
static constexpr float DURATION_TRANSITION = 2.5F;
static constexpr float LETTER_SPACING = 10.0F;
static constexpr float BLINK_FREQUENCY = 3.0F;
static constexpr float DURATION_BLACK_SCREEN = 2.0F;
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_Y = 3.0F;
static constexpr float ORBIT_FREQUENCY_X = 0.8F;
@@ -117,7 +134,7 @@ class TitleScene final : public Scene {
static constexpr float ORBIT_PHASE_OFFSET = 1.57F;
static constexpr float SHADOW_DELAY = 0.5F;
static constexpr float SHADOW_BRIGHTNESS = 0.4F;
static constexpr float SHADOW_BRIGHTNESS = 0.55F;
static constexpr float SHADOW_OFFSET_X = 2.0F;
static constexpr float SHADOW_OFFSET_Y = 2.0F;
@@ -138,6 +155,13 @@ class TitleScene final : public Scene {
void updateMainState(float delta_time);
void updatePlayerJoinPhaseState(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 handleStartInput();
void triggerExitForJoinedPlayers(bool p1_was_active, bool p2_was_active, const char* log_prefix);
+6 -4
View File
@@ -3,7 +3,6 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
@@ -56,9 +55,12 @@ namespace StageSystem {
if (stage_id == 0 || waves.empty()) {
return false;
}
return std::ranges::all_of(waves, [](const WaveConfig& w) {
return w.next.isValid() && !w.spawn.empty();
});
for (const auto& w : waves) {
if (!w.next.isValid() || w.spawn.empty()) {
return false;
}
}
return true;
}
};
+2 -2
View File
@@ -219,8 +219,8 @@ namespace StageSystem {
out = EnemyType::PINWHEEL;
} else if (type_str == "star") {
out = EnemyType::STAR;
} else if (type_str == "big_pentagon") {
out = EnemyType::BIG_PENTAGON;
} else if (type_str == "orb") {
out = EnemyType::ORB;
} else {
return false;
}
@@ -31,6 +31,23 @@ namespace StageSystem {
<< '\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) {
switch (estat_) {
case EstatStage::INIT_HUD:
@@ -25,6 +25,11 @@ namespace StageSystem {
// Lifecycle
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);
// Stage progression
+12 -17
View File
@@ -15,7 +15,7 @@ namespace Systems::Collision {
namespace {
// Trenca una bala en debris (8 fragments de l'octàgon) + so HIT + desactiva.
// Trenca una bala en debris (8 fragments de l'octàgon) + so BULLET_ZAP + desactiva.
// S'invoca des de qualsevol desactivació de bala (impacte amb enemic, amb jugador,
// o sortida del PLAYAREA) per a un feedback visual i sonor consistent.
void breakBullet(Effects::DebrisManager& debris_manager, Bullet& bullet) {
@@ -30,7 +30,7 @@ namespace Systems::Collision {
Vec2{}, // sense herència de velocitat (fragments radials)
0.0F, // sense velocity angular heretada
0.0F, // sense rotació visual heretada
Defaults::Sound::HIT,
Defaults::Sound::BULLET_ZAP,
bullet.getConfig().colors.normal,
Defaults::Physics::Debris::TEMPS_VIDA,
Defaults::Physics::Debris::ACCELERACIO,
@@ -145,6 +145,12 @@ namespace Systems::Collision {
continue;
}
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++) {
// 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 ***
// Regla "cos XOR trossos": l'impuls de la bala s'aplica al cos
// només si el ship sobreviu (fereix). Si el ship mor, el bullet
// va directament als trossos (via tocado) i el cos no rep impuls
// — els trossos ja porten la força de la bala, qualsevol impuls
// afegit al cos seria double-count.
// Mata d'un sol toc (decisió de disseny). El bullet va als trossos
// via tocado; el cos no rep impuls (seria double-count amb la
// velocitat que ja hereten els trossos).
const Vec2 BULLET_VEL = bullet.getBody().velocity;
if (ctx.ships[player_id].isHurt()) {
// 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);
break; // una bala impacta una vegada per frame
}
@@ -234,7 +229,7 @@ namespace Systems::Collision {
processWoundedDeaths(ctx); // expiran ANTES de ser tocadas por bala este frame
detectBulletEnemy(ctx);
// Wounded chain desactivat: era massa fàcil que un enemic ferit topés
// amb el big_pentagon (10 HP) i el matés instantàniament. La regla
// amb l'orb (10 HP) i el matés instantàniament. La regla
// "ferit-toca-sa → ferit" queda permanentment fora.
detectShipEnemy(ctx);
detectBulletPlayer(ctx);
+260
View File
@@ -0,0 +1,260 @@
// 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
// Foc amic: retindre el tret si el company està en la línia de tir.
constexpr float FRIENDLY_BLOCK_RANGE = 1200.0F; // px endavant que es vigilen
constexpr float FRIENDLY_BLOCK_MARGIN = 22.0F; // px: marge sobre el radi del company
// [-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;
}
}
// ¿El company està en la línia de tir? (davant del morro, dins de l'abast i
// a prou poca distància perpendicular de la trajectòria recta de la bala).
// La bala ix en la direcció del morro: (cos, sin)(angle - PI/2).
auto teammateInLineOfFire(const Ship& ship, const Ship& mate) -> bool {
const float NOSE = ship.getAngle() - (PI / 2.0F);
const Vec2 FORWARD{.x = std::cos(NOSE), .y = std::sin(NOSE)};
const Vec2 TO_MATE = mate.getCenter() - ship.getCenter();
const float ALONG = TO_MATE.dot(FORWARD); // distància al llarg del tret
if (ALONG <= 0.0F || ALONG > FRIENDLY_BLOCK_RANGE) {
return false; // darrere de la nau o massa lluny
}
const float PERP2 = TO_MATE.lengthSquared() - (ALONG * ALONG);
const float CLEAR = mate.getCollisionRadius() + FRIENDLY_BLOCK_MARGIN;
return PERP2 < CLEAR * CLEAR;
}
} // namespace
auto DemoPilot::compute(const Ship& ship,
const Ship* teammate,
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, però NO
// si el company està en la línia de tir (i seria víctima vàlida de foc
// amic): es retén el tret sense gastar cooldown, així dispara tan prompte
// com l'altra nau isca de la línia.
if (std::fabs(ERROR) < FIRE_TOLERANCE && fire_cooldown_ <= 0.0F) {
const bool FRIENDLY_BLOCK = (teammate != nullptr) &&
teammate->isActive() && !teammate->isInvulnerable() &&
teammateInLineOfFire(ship, *teammate);
if (!FRIENDLY_BLOCK) {
ctrl.shoot = true;
fire_cooldown_ = FIRE_COOLDOWN;
}
}
}
return ctrl;
}
} // namespace Systems::Demo
+73
View File
@@ -0,0 +1,73 @@
// 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 Ship* teammate, // altra nau (nullptr si no n'hi ha): evita foc amic
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
+100 -9
View File
@@ -3,13 +3,19 @@
#include "game/systems/enemy_event_dispatcher.hpp"
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include "core/defaults.hpp"
#include "core/types.hpp"
#include "game/constants.hpp"
#include "game/entities/bullet.hpp"
#include "game/entities/bullet_config.hpp"
#include "game/entities/bullet_registry.hpp"
#include "game/entities/enemy_ai.hpp"
#include "game/entities/enemy_config.hpp"
#include "game/entities/ship.hpp"
namespace Systems::EnemyEvents {
@@ -25,9 +31,10 @@ namespace Systems::EnemyEvents {
}
// Helper compartit per CREATE_DEBRIS i CREATE_DEBRIS_PARTIAL: única
// crida a explode(), paràmetres alineats; només canvien piece_scale
// (1.0 explosió, 0.3 xip) i el color (cos vs hit-feedback).
void spawnDebrisForEnemy(Systems::Collision::Context& ctx, const Enemy& enemy, const Bullet* bullet, float piece_scale, SDL_Color color) {
// crida a explode(), paràmetres alineats; canvien piece_scale (1.0
// explosió, 0.3 xip), el color (cos vs hit-feedback) i el so
// (ENEMY_EXPLOSION en la mort vs ENEMY_HIT en l'impacte parcial).
void spawnDebrisForEnemy(Systems::Collision::Context& ctx, const Enemy& enemy, const Bullet* bullet, float piece_scale, SDL_Color color, const char* sound) {
constexpr float SPEED_EXPLOSIO = 80.0F;
const Vec2 INHERITED_VEL = enemy.getVelocityVector() *
Defaults::Physics::Debris::ENEMY_VELOCITY_INHERITANCE;
@@ -42,7 +49,7 @@ namespace Systems::EnemyEvents {
INHERITED_VEL,
0.0F,
0.0F,
Defaults::Sound::EXPLOSION,
sound,
color,
Defaults::Physics::Debris::ENEMY_LIFETIME,
Defaults::Physics::Debris::ENEMY_FRICTION,
@@ -72,6 +79,87 @@ namespace Systems::EnemyEvents {
(bullet->getBody().mass * bullet->getConfig().physics.impact_momentum_factor);
enemy.applyImpulse(IMPULSE);
}
auto randFloat01() -> float {
return static_cast<float>(std::rand()) / static_cast<float>(RAND_MAX);
}
// Còpia local de la mateixa primitiva que viu a enemy_ai_system.cpp.
// No s'ha extret a un header compartit perquè és l'únic punt de
// duplicació; si apareix un tercer consumidor, refactoritzar.
auto findNearestShipPosition(const Enemy& enemy) -> const Vec2* {
const Vec2& self = enemy.getCenter();
const Vec2* best = nullptr;
float best_dist_sq = 0.0F;
for (const Ship* ship : enemy.getShips()) {
if (ship == nullptr || !ship->isActive()) {
continue;
}
const Vec2& pos = ship->getCenter();
const Vec2 DELTA = pos - self;
const float DIST_SQ = DELTA.lengthSquared();
if (best == nullptr || DIST_SQ < best_dist_sq) {
best = &pos;
best_dist_sq = DIST_SQ;
}
}
return best;
}
// FIRE_BULLET: paral·lel a doShoot() d'enemy_ai_system.cpp, però disparat
// per esdeveniment (típicament on_hit per a contra-atacs) en lloc de
// periòdicament. owner_id es deriva de l'índex dins ctx.enemies via
// aritmètica de punters (l'array és contigu).
void doFireBullet(Systems::Collision::Context& ctx, const Enemy& enemy, const EnemyAction& action) {
if (action.bullet_config_name.empty()) {
return;
}
const BulletConfig* cfg = BulletRegistry::get(action.bullet_config_name);
if (cfg == nullptr) {
return;
}
Bullet* slot = nullptr;
constexpr std::size_t START = Defaults::Entities::ENEMY_BULLET_START_IDX;
constexpr std::size_t END = START + Defaults::Entities::MAX_ENEMY_BULLETS;
for (std::size_t i = START; i < END; ++i) {
if (!ctx.bullets[i].isActive()) {
slot = &ctx.bullets[i];
break;
}
}
if (slot == nullptr) {
return; // pool d'enemic ple
}
float angle = 0.0F;
if (action.aim_mode == AimMode::AIMED) {
const Vec2* target = findNearestShipPosition(enemy);
if (target == nullptr) {
angle = randFloat01() * 2.0F * Constants::PI;
} else {
const Vec2 TO = *target - enemy.getCenter();
angle = std::atan2(TO.y, TO.x) + (Constants::PI / 2.0F);
}
} else {
angle = randFloat01() * 2.0F * Constants::PI;
}
if (action.jitter_rad > 0.0F) {
angle += (randFloat01() - 0.5F) * 2.0F * action.jitter_rad;
}
// Localitzem l'índex de l'enemic per construir l'owner_id. Evitem
// aritmètica de punters sobre Enemy (tipus polimòrfic — UB si la
// jerarquia canvia); cerca lineal a l'array (mida petita, no és hot path).
std::size_t enemy_index = 0;
for (std::size_t i = 0; i < ctx.enemies.size(); ++i) {
if (&ctx.enemies[i] == &enemy) {
enemy_index = i;
break;
}
}
const auto OWNER = static_cast<uint8_t>(Defaults::Entities::ENEMY_OWNER_BASE + enemy_index);
slot->fire(enemy.getCenter(), angle, OWNER, action.bullet_speed, cfg);
}
} // namespace
void dispatchEvent(Systems::Collision::Context& ctx, Enemy& enemy, EnemyEventType event, uint8_t shooter_id, const Bullet* bullet) {
@@ -120,13 +208,13 @@ namespace Systems::EnemyEvents {
doAddScore(ctx, enemy, shooter_id);
break;
case EnemyActionType::CREATE_DEBRIS:
// Explosió de mort: trossos en color cos (correcte físicament).
spawnDebrisForEnemy(ctx, enemy, bullet, 1.0F, enemy.getConfig().colors.normal);
// Explosió de mort: trossos en color cos + so ENEMY_EXPLOSION.
spawnDebrisForEnemy(ctx, enemy, bullet, 1.0F, enemy.getConfig().colors.normal, Defaults::Sound::ENEMY_EXPLOSION);
break;
case EnemyActionType::CREATE_DEBRIS_PARTIAL:
// Xip d'impacte: trossos en color wounded (daurat) per
// diferenciar-los visualment del cos i marcar "damage".
spawnDebrisForEnemy(ctx, enemy, bullet, Defaults::Enemies::Debris::PARTIAL_PIECE_SCALE, enemy.getConfig().colors.wounded);
// Xip d'impacte: trossos en color wounded (daurat) + so
// ENEMY_HIT, diferent de l'explosió per marcar damage no letal.
spawnDebrisForEnemy(ctx, enemy, bullet, Defaults::Enemies::Debris::PARTIAL_PIECE_SCALE, enemy.getConfig().colors.wounded, Defaults::Sound::ENEMY_HIT);
break;
case EnemyActionType::CREATE_FIREWORKS:
// Burst de mort: línia blanca + glow wounded (daurat) per
@@ -153,6 +241,9 @@ namespace Systems::EnemyEvents {
case EnemyActionType::FLASH:
enemy.triggerFlash();
break;
case EnemyActionType::FIRE_BULLET:
doFireBullet(ctx, enemy, action);
break;
}
}
}
+241 -7
View File
@@ -5,11 +5,14 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <limits>
#include <string>
#include "core/defaults.hpp"
#include "core/defaults/hud.hpp"
#include "core/math/easing.hpp"
#include "core/rendering/line_renderer.hpp"
#include "core/rendering/shape_renderer.hpp"
namespace Systems::InitHud {
@@ -77,24 +80,255 @@ namespace Systems::InitHud {
}
}
void drawScoreboardAnimated(const Graphics::VectorText& text,
const std::string& scoreboard_text,
namespace {
// Nombre de slots de vides: una nau menys que el màxim (la nau en joc
// no es dibuixa; els slots són repuestos). Deriva de MAX_VIDES.
constexpr int NUM_SLOTS = Defaults::Game::MAX_VIDES - 1;
// Pas d'un dígit (amplada + tracking, escalat): és la diferència entre
// l'ample de dos caràcters i el d'un. Marca el ritme de tot el bloc.
auto digitPitch(float scale, float spacing) -> float {
return Graphics::VectorText::getTextWidth("00", scale, spacing) -
Graphics::VectorText::getTextWidth("0", scale, spacing);
}
// Desplaçament vertical (unitats locals) entre el center declarat de la
// shape i el centre real del seu bbox. La nau té center (0,0) però el seu
// bbox no hi està centrat; cal per alinear el centre VISUAL de la nau amb
// la línia del marcador (els dígits sí tenen el center al mig del glif).
auto shapeVerticalOffset(const std::shared_ptr<Graphics::Shape>& shape) -> float {
float min_y = std::numeric_limits<float>::max();
float max_y = std::numeric_limits<float>::lowest();
for (const auto& prim : shape->getPrimitives()) {
for (const auto& point : prim.points) {
min_y = std::min(min_y, point.y);
max_y = std::max(max_y, point.y);
}
}
return ((min_y + max_y) / 2.0F) - shape->getCenter().y;
}
// Mida d'un slot = alçada real del glif del dígit (no la cel·la, que té
// marge vertical: usar la cel·la feia les naus el doble de grans), amb un
// xicotet factor d'ajust perquè la silueta de la nau case amb les xifres.
auto slotSize(float scale) -> float {
return Graphics::VectorText::getGlyphHeight(scale) * Defaults::Hud::LIFE_SLOT_HEIGHT_FACTOR;
}
// Ample del bloc de slots: constant, independent de les vides. NUM_SLOTS
// slots al pas del dígit (l'últim slot ocupa la seva pròpia mida).
auto slotsBlockWidth(float scale, float spacing) -> float {
if (NUM_SLOTS <= 0) {
return 0.0F;
}
return (static_cast<float>(NUM_SLOTS - 1) * digitPitch(scale, spacing)) + slotSize(scale);
}
// Vides com a número de 2 dígits (zeros a l'esquerra).
auto livesDigits(int lives) -> std::string {
const std::string S = std::to_string(lives);
return (lives < 10) ? "0" + S : S;
}
// Ample del bloc de vides segons el mode (constant en ambdós casos).
auto livesBlockWidth(float scale, float spacing) -> float {
if (Defaults::Hud::LIVES_DISPLAY == Defaults::Hud::LivesDisplay::DIGITS) {
return Graphics::VectorText::getTextWidth("00", scale, spacing);
}
return slotsBlockWidth(scale, spacing);
}
// Dibuixa els slots de vides com a naus en miniatura en posicions FIXES.
// Slot amb vida disponible (repuesto) → color encès; slot buit → atenuat.
// Repuestos = vides 1 (la nau en joc no compta com a slot).
void drawSlots(Rendering::Renderer* renderer,
const std::shared_ptr<Graphics::Shape>& shape,
int lives,
SDL_Color bright,
SDL_Color dim,
float x_left,
float center_y,
float scale,
float spacing) {
if (NUM_SLOTS <= 0 || !shape) {
return;
}
const float SIZE = slotSize(scale);
const float PITCH = digitPitch(scale, spacing);
// Escala que ajusta el cercle circumscrit de la shape a la mida del
// slot (mida predictible independent del .shp).
const float RADIUS = shape->getBoundingRadius();
const float ICON_SCALE = (RADIUS > 0.001F) ? (SIZE / (2.0F * RADIUS)) : 1.0F;
// Alinea el centre visual de la nau amb la línia del marcador.
const float OFFSET_Y = shapeVerticalOffset(shape) * ICON_SCALE;
const int FILLED = std::clamp(lives - 1, 0, NUM_SLOTS);
for (int i = 0; i < NUM_SLOTS; i++) {
const SDL_Color COLOR = (i < FILLED) ? bright : dim;
const Vec2 POS = {.x = x_left + (SIZE / 2.0F) + (static_cast<float>(i) * PITCH), .y = center_y - OFFSET_Y};
// glow=false: el marcador es manté net, com els dígits del text.
Rendering::renderShape(renderer, shape, POS, 0.0F, ICON_SCALE, 1.0F, 1.0F, COLOR, 0.0F, 1.0F, false);
}
}
// Pinta la puntuació amb els zeros de farciment previs al primer dígit
// significatiu en to atenuat i la resta en brillant (efecte display de 7
// segments). El dígit menys significatiu SEMPRE va encès: puntuació 0 →
// cinc zeros atenuats + l'últim "0" encès (el marcador no queda mai apagat).
void drawScore(const Graphics::VectorText& text,
const std::string& score,
SDL_Color bright,
SDL_Color dim,
bool active,
float x,
float top_y,
float scale,
float spacing) {
if (score.empty()) {
return;
}
// Jugador inactiu → marcador apagat: tots els dígits atenuats (no té
// "zero punts", simplement no en té). Jugador actiu → l'últim dígit
// sempre encès (puntuació 0 → cinc zeros atenuats + "0" encès).
const SDL_Color REST_COLOR = active ? bright : dim;
const size_t FIRST_SIG = score.find_first_not_of('0');
const size_t SIG = (FIRST_SIG == std::string::npos) ? (score.size() - 1) : FIRST_SIG;
const std::string PREFIX = score.substr(0, SIG);
const std::string REST = score.substr(SIG);
if (!PREFIX.empty()) {
text.render(PREFIX, {.x = x, .y = top_y}, scale, spacing, 1.0F, dim);
// Avança l'amplada del prefix més el buit inter-caràcter que hi
// hauria si fos un sol string (exacte per a qualsevol spacing).
x += Graphics::VectorText::getTextWidth(PREFIX, scale, spacing) + (spacing * scale);
}
if (!REST.empty()) {
text.render(REST, {.x = x, .y = top_y}, scale, spacing, 1.0F, REST_COLOR);
}
}
// Separació punts↔slots dins d'un bloc = un pas de dígit (ritme únic).
auto blockInnerGap(float scale, float spacing) -> float {
return digitPitch(scale, spacing);
}
// Ample (constant) del bloc d'un jugador: 6 dígits + separació + vides.
// No depèn de les vides, així res es recol·loca quan se'n perden.
auto playerBlockWidth(float scale, float spacing) -> float {
return Graphics::VectorText::getTextWidth("000000", scale, spacing) +
blockInnerGap(scale, spacing) + livesBlockWidth(scale, spacing);
}
// Pinta el bloc de vides segons el mode: slots de nau o número de 2 dígits.
void drawLivesBlock(Rendering::Renderer* renderer,
const Graphics::VectorText& text,
const std::shared_ptr<Graphics::Shape>& shape,
int lives,
bool active,
SDL_Color bright,
SDL_Color dim,
float x_left,
float top_y,
float center_y,
float scale,
float spacing) {
if (Defaults::Hud::LIVES_DISPLAY == Defaults::Hud::LivesDisplay::DIGITS) {
// Repuestos = vides 1 (la nau en joc no compta), igual que els
// slots. Mateixa regla de color que el nivell: zeros a l'esquerra
// atenuats, dígit significatiu en endavant encès.
const int SPARES = std::max(0, lives - 1);
drawScore(text, livesDigits(SPARES), bright, dim, active, x_left, top_y, scale, spacing);
return;
}
drawSlots(renderer, shape, lives, bright, dim, x_left, center_y, scale, spacing);
}
// Pinta el bloc d'un jugador "punts vides" amb el seu color (punts amb
// zeros atenuats, vides com a slots de nau o número segons el mode).
// Ancorat a x_left (vora esquerra del bloc), mateix ordre per a P1 i P2
// (no mirrored).
void drawPlayerBlock(Rendering::Renderer* renderer,
const Graphics::VectorText& text,
const std::shared_ptr<Graphics::Shape>& shape,
const std::string& score,
int lives,
bool active,
SDL_Color bright,
SDL_Color dim,
float x_left,
float center_y,
float scale,
float spacing) {
// Jugador inactiu → bloc apagat: es dibuixa igual però tot atenuat
// (punts i vides), com un display físic sense encendre.
const float TOP_Y = center_y - (Graphics::VectorText::getTextHeight(scale) / 2.0F);
const float W_SCORE = Graphics::VectorText::getTextWidth(score, scale, spacing);
float x = x_left;
drawScore(text, score, bright, dim, active, x, TOP_Y, scale, spacing);
x += W_SCORE + blockInnerGap(scale, spacing);
drawLivesBlock(renderer, text, shape, lives, active, bright, dim, x, TOP_Y, center_y, scale, spacing);
}
// Pinta el nivell centrat: etiqueta "NIVELL" encesa i el número com els
// punts (zeros de farciment atenuats, dígit significatiu en endavant encès).
void drawLevel(const Graphics::VectorText& text,
const std::string& label,
const std::string& value,
float top_y,
float scale,
float spacing) {
const float W_LABEL = Graphics::VectorText::getTextWidth(label, scale, spacing);
const float W_VALUE = Graphics::VectorText::getTextWidth(value, scale, spacing);
const float CX = Defaults::Game::WIDTH / 2.0F;
float x = CX - ((W_LABEL + W_VALUE) / 2.0F);
text.render(label, {.x = x, .y = top_y}, scale, spacing, 1.0F, Defaults::Hud::Colors::LEVEL_BRIGHT);
x += W_LABEL;
drawScore(text, value, Defaults::Hud::Colors::LEVEL_BRIGHT, Defaults::Hud::Colors::LEVEL_DIM, true, x, top_y, scale, spacing);
}
} // namespace
void drawScoreboardAt(Rendering::Renderer* renderer,
const Graphics::VectorText& text,
const ScoreboardData& data,
float center_y,
float scale,
float spacing) {
// Fila centrada amb posicions FIXES: [bloc P1] · [NIVELL] · [bloc P2].
// Els blocs tenen ample constant (slots fixos), així NIVELL queda centrat
// i res es recol·loca en perdre vides. Separadors derivats del glif
// (dos espais), com el disseny original.
const float TOP_Y = center_y - (Graphics::VectorText::getTextHeight(scale) / 2.0F);
const float BLOCK_W = playerBlockWidth(scale, spacing);
const float W_LEVEL = Graphics::VectorText::getTextWidth(data.level_label, scale, spacing) +
Graphics::VectorText::getTextWidth(data.level_value, scale, spacing);
const float GAP = Graphics::VectorText::getTextWidth(" ", scale, spacing);
const float TOTAL = BLOCK_W + GAP + W_LEVEL + GAP + BLOCK_W;
float x = (Defaults::Game::WIDTH / 2.0F) - (TOTAL / 2.0F);
drawPlayerBlock(renderer, text, data.shape_p1, data.score_p1, data.lives_p1, data.p1_active, Defaults::Hud::Colors::P1_BRIGHT, Defaults::Hud::Colors::P1_DIM, x, center_y, scale, spacing);
x += BLOCK_W + GAP;
// NIVELL: drawLevel centra a WIDTH/2, que coincideix amb aquest tram.
drawLevel(text, data.level_label, data.level_value, TOP_Y, scale, spacing);
x += W_LEVEL + GAP;
drawPlayerBlock(renderer, text, data.shape_p2, data.score_p2, data.lives_p2, data.p2_active, Defaults::Hud::Colors::P2_BRIGHT, Defaults::Hud::Colors::P2_DIM, x, center_y, scale, spacing);
}
void drawScoreboardAnimated(Rendering::Renderer* renderer,
const Graphics::VectorText& text,
const ScoreboardData& data,
float progress) {
const float EASED = Easing::easeOutQuad(progress);
constexpr float SCALE = Defaults::Hud::SCOREBOARD_TEXT_SCALE;
constexpr float SPACING = Defaults::Hud::SCOREBOARD_TEXT_SPACING;
const SDL_FRect& scoreboard_zone = Defaults::Zones::SCOREBOARD;
const float CENTRE_X = scoreboard_zone.w / 2.0F;
const float Y_FINAL = scoreboard_zone.y + (scoreboard_zone.h / 2.0F);
// Posición inicial: fuera de la pantalla por debajo.
const auto Y_INI = static_cast<float>(Defaults::Game::HEIGHT);
const float Y_ANIM = Y_INI + ((Y_FINAL - Y_INI) * EASED);
text.renderCentered(scoreboard_text,
Vec2{.x = CENTRE_X, .y = Y_ANIM},
SCALE,
SPACING);
drawScoreboardAt(renderer, text, data, Y_ANIM, SCALE, SPACING);
}
} // namespace Systems::InitHud
+36 -4
View File
@@ -13,14 +13,34 @@
#pragma once
#include <memory>
#include <string>
#include "core/graphics/shape.hpp"
#include "core/graphics/vector_text.hpp"
#include "core/rendering/render_context.hpp"
#include "core/types.hpp"
namespace Systems::InitHud {
// Dades del marcador. El render reparteix tres blocs ancorats: bloc P1 a
// l'esquerra i bloc P2 a la dreta (mateix ordre intern "punts vides", no
// mirrored), i el nivell centrat. Cada bloc de jugador es pinta amb el seu
// color (vegeu Defaults::Hud::Colors); el nivell, en verd de sistema.
struct ScoreboardData {
std::string score_p1; // 6 dígits, zeros a l'esquerra
std::string score_p2; // 6 dígits, zeros a l'esquerra
int lives_p1{0}; // vides P1 (icones de nau al render)
int lives_p2{0}; // vides P2
bool p1_active{false}; // jugador actiu? (inactiu → bloc apagat, sense dibuixar)
bool p2_active{false};
std::string level_label; // ex: "NIVELL "
std::string level_value; // ex: "01"
// Shapes de les naus per dibuixar les vides com a icones en miniatura.
std::shared_ptr<Graphics::Shape> shape_p1;
std::shared_ptr<Graphics::Shape> shape_p2;
};
// Convierte un progreso global 0..1 al sub-progreso de un elemento que solo
// se anima en la ventana [ratio_init, ratio_end].
// < ratio_init → 0.0 (no empezó)
@@ -40,10 +60,22 @@ namespace Systems::InitHud {
// 66..100% → línea inferior crece desde los lados hacia el centro.
void drawBordersAnimated(Rendering::Renderer* renderer, float progress);
// Dibuja el scoreboard centrado, subiendo desde fuera de la pantalla
// hasta su posición final con easing.
void drawScoreboardAnimated(const Graphics::VectorText& text,
const std::string& scoreboard_text,
// Dibuixa el marcador en tres blocs ancorats a la fila d'alçada `center_y`:
// bloc P1 a l'esquerra, bloc P2 a la dreta i nivell centrat. Cada bloc amb
// el seu color (Defaults::Hud::Colors). El renderer cal per dibuixar les
// icones de vides (shapes de nau).
void drawScoreboardAt(Rendering::Renderer* renderer,
const Graphics::VectorText& text,
const ScoreboardData& data,
float center_y,
float scale,
float spacing);
// Dibuixa el marcador pujant des de fora de la pantalla fins a la seva
// posició final amb easing. Delega a drawScoreboardAt.
void drawScoreboardAnimated(Rendering::Renderer* renderer,
const Graphics::VectorText& text,
const ScoreboardData& data,
float progress);
} // namespace Systems::InitHud

Some files were not shown because too many files have changed in this diff Show More