Compare commits

...

43 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
34 changed files with 2591 additions and 154 deletions
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -202,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)
+1
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"
+1
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"
+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
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)
+2 -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
+35 -8
View File
@@ -5,6 +5,8 @@
#include <SDL3/SDL.h>
#include <cstdint>
namespace Defaults::Hud {
// Marcador (scoreboard inferior). Usado por GameScene::drawScoreboard()
@@ -12,17 +14,42 @@ namespace Defaults::Hud {
constexpr float SCOREBOARD_TEXT_SCALE = 0.85F;
constexpr float SCOREBOARD_TEXT_SPACING = 0.0F;
// Colors per segment del marcador. Jerarquia per funció (score/vides/nivell)
// + diferenciació de jugador (P1 blanc vs P2 rosa) sense xocar amb els
// colors d'enemics (cyan/roig). Amb alpha=255 el line_renderer usa el color
// directament sense caure al fallback verd (Rendering::DEFAULT_LINE_COLOR).
// 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 {
constexpr SDL_Color SCORE_P1 = {.r = 255, .g = 255, .b = 255, .a = 255}; // blanc
constexpr SDL_Color SCORE_P2 = {.r = 255, .g = 130, .b = 200, .a = 255}; // rosa magenta
constexpr SDL_Color LIVES = {.r = 255, .g = 180, .b = 60, .a = 255}; // ambre / or
constexpr SDL_Color LEVEL = {.r = 155, .g = 255, .b = 175, .a = 255}; // verd sistema
// 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).
+24 -13
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)
@@ -113,10 +113,17 @@ namespace Defaults::Title {
constexpr float COPYRIGHT_LINE_SPACING = 0.0F; // Entre línies copyright (5px)
// Factors de scale
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_SCALE = 0.25F; // Escala del logo JAILGAMES pequeño sobre el copyright
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).
constexpr float JAILGAMES_COPYRIGHT_GAP = 0.015F;
@@ -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
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
// 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 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
+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
+4
View File
@@ -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;
@@ -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;
}
SDL_SubmitGPUCommandBuffer(cmd_buffer_);
cmd_buffer_ = 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
+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.
+5
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();
+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)
+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"
+66 -46
View File
@@ -43,6 +43,11 @@ namespace {
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)
@@ -165,10 +170,10 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
context_.advanceDemoScenario();
stage_manager_->initDemo(SC.stage);
demo_timer_ = DEMO_DURATION;
// Silenciar els SFX durant la demo (la música segueix). Guardem l'estat
// previ per restaurar-lo al destructor sense xafar la preferència de l'usuari.
sound_was_enabled_ = Audio::get()->isSoundEnabled();
Audio::get()->enableSound(false);
// 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
@@ -242,9 +247,14 @@ GameScene::GameScene(SDLManager& sdl, SceneContext& context)
}
GameScene::~GameScene() {
// Si la demo havia silenciat els SFX, restaurar l'estat previ en sortir.
// 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()->enableSound(sound_was_enabled_);
Audio::get()->stopAllSounds();
Audio::get()->silenceGroup(Audio::Group::GAME, false);
}
}
@@ -259,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;
}
@@ -272,7 +288,7 @@ void GameScene::update(float delta_time) {
if (match_config_.mode == GameConfig::Mode::DEMO) {
// Mode demo (attract): salida por input/timeout/muerte + control del pilot.
if (stepDemo(delta_time)) {
if (stepDemo(delta_time, MENU_OPEN)) {
return;
}
} else if (game_over_state_ == GameOverState::NONE) {
@@ -366,11 +382,13 @@ void GameScene::updateShipsControl(float delta_time) {
}
}
auto GameScene::stepDemo(float delta_time) -> bool {
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).
if (Input::get()->checkAnyPlayerAction(DEMO_EXIT_ACTIONS)) {
// 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;
}
@@ -390,8 +408,15 @@ auto GameScene::stepDemo(float delta_time) -> bool {
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,
@@ -814,7 +839,7 @@ void GameScene::drawInitHudState() {
}
if (score_progress > 0.0F) {
Systems::InitHud::drawScoreboardAnimated(text_, buildScoreboardSegments(), score_progress);
Systems::InitHud::drawScoreboardAnimated(sdl_.getRenderer(), text_, buildScoreboardData(), score_progress);
}
if (ship1_progress > 0.0F && match_config_.player1_active && ships_[0].isActive()) {
@@ -933,41 +958,36 @@ void GameScene::drawScoreboard() {
text_.renderCentered(Locale::get().text("demo.banner"), CENTER, SCALE, SPACING);
return;
}
Systems::InitHud::drawScoreboardSegmentsAt(text_, buildScoreboardSegments(), CENTER, SCALE, SPACING);
Systems::InitHud::drawScoreboardAt(sdl_.getRenderer(), text_, buildScoreboardData(), CENTER.y, SCALE, SPACING);
}
auto GameScene::buildScoreboardSegments() const -> Systems::InitHud::ScoreboardSegments {
Systems::InitHud::ScoreboardSegments out;
auto GameScene::buildScoreboardData() const -> Systems::InitHud::ScoreboardData {
Systems::InitHud::ScoreboardData out;
// Puntuació P1 (6 dígits) - zeros si inactiu
if (match_config_.player1_active) {
std::string s = std::to_string(score_per_player_[0]);
out.score_p1 = std::string(6 - std::min(6, static_cast<int>(s.length())), '0') + s;
out.lives_p1 = (lives_per_player_[0] < 10)
? "0" + std::to_string(lives_per_player_[0])
: std::to_string(lives_per_player_[0]);
} else {
out.score_p1 = "000000";
out.lives_p1 = "00";
}
// 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;
};
// Nivell (2 dígits) amb label localitzat
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;
// 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();
const std::string STAGE_STR = (STAGE_NUM < 10) ? "0" + std::to_string(STAGE_NUM)
: std::to_string(STAGE_NUM);
out.level = Locale::get().text("hud.level") + STAGE_STR;
// Puntuació P2 (6 dígits) - zeros si inactiu
if (match_config_.player2_active) {
std::string s = std::to_string(score_per_player_[1]);
out.score_p2 = std::string(6 - std::min(6, static_cast<int>(s.length())), '0') + s;
out.lives_p2 = (lives_per_player_[1] < 10)
? "0" + std::to_string(lives_per_player_[1])
: std::to_string(lives_per_player_[1]);
} else {
out.score_p2 = "000000";
out.lives_p2 = "00";
}
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;
}
@@ -1036,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);
}
// ========================================
+6 -5
View File
@@ -110,7 +110,6 @@ class GameScene final : public Scene {
std::array<Systems::Demo::DemoPilot, 2> demo_pilots_;
std::array<Systems::Demo::Control, 2> demo_ctrls_{}; // Control per nau al frame actual
float demo_timer_{0.0F}; // Temps restant de la demo (→ LOGO)
bool sound_was_enabled_{true}; // Estat dels SFX abans de la demo (per restaurar-lo)
// Funciones privades
// bullet_velocity: velocitat de la bala que ha causat la mort (Vec2{} si no
@@ -140,9 +139,9 @@ class GameScene final : public Scene {
void drawPlayingState();
void drawLevelCompletedState();
// [NEW] Helper del marcador: construeix els 5 segments (score_p1, vides_p1,
// level, score_p2, vides_p2) per a render colorit per segment.
[[nodiscard]] auto buildScoreboardSegments() const -> Systems::InitHud::ScoreboardSegments;
// 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).
@@ -154,7 +153,9 @@ class GameScene final : public Scene {
void updateShipsControl(float delta_time);
// Mode DEMO: gestiona salida (input→título, timeout/muerte→logo) y calcula
// el control + disparo del pilot. Devuelve true si la escena transiciona.
[[nodiscard]] auto stepDemo(float delta_time) -> bool;
// 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;
+24 -14
View File
@@ -7,7 +7,6 @@
#include <cfloat>
#include <cmath>
#include <iostream>
#include <numbers>
#include <string>
#include "core/audio/audio.hpp"
@@ -287,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) {
@@ -299,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 {
@@ -307,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);
}
@@ -358,11 +358,16 @@ void TitleScene::update(float delta_time) {
handleStartInput();
}
// Attract mode: al state MAIN, acumular inactivitat; qualsevol botó
// arcade la reseteja. En esgotar el timeout, saltar a la demo (mode DEMO,
// P1 actiu) sense fer fadeOut de la música (a diferència del START real).
if (current_state_ == TitleState::MAIN && !INPUT_BLOCKED) {
if (Input::get()->checkAnyPlayerAction(ARCADE_BUTTONS, Input::ALLOW_REPEAT)) {
// 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;
@@ -445,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í
@@ -556,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);
@@ -587,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, "");
@@ -711,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;
+5 -2
View File
@@ -110,13 +110,16 @@ 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;
@@ -131,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;
+32 -3
View File
@@ -36,6 +36,10 @@ namespace Systems::Demo {
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;
@@ -136,9 +140,26 @@ namespace Systems::Demo {
}
}
// ¿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,
@@ -218,10 +239,18 @@ namespace Systems::Demo {
std::fabs(ERROR) < FIRE_TOLERANCE) {
ctrl.thrust = true;
}
// Disparar cuando está bien encarada y el cooldown lo permite.
// 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) {
ctrl.shoot = true;
fire_cooldown_ = FIRE_COOLDOWN;
const bool FRIENDLY_BLOCK = (teammate != nullptr) &&
teammate->isActive() && !teammate->isInvulnerable() &&
teammateInLineOfFire(ship, *teammate);
if (!FRIENDLY_BLOCK) {
ctrl.shoot = true;
fire_cooldown_ = FIRE_COOLDOWN;
}
}
}
+1
View File
@@ -57,6 +57,7 @@ namespace Systems::Demo {
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,
+235 -31
View File
@@ -5,12 +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 {
@@ -78,53 +80,255 @@ namespace Systems::InitHud {
}
}
void drawScoreboardSegmentsAt(const Graphics::VectorText& text,
const ScoreboardSegments& segments,
const Vec2& center,
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) {
// Separadors entre segments (preservant el layout legacy: " ", " ", " ", " ").
const float W_SEP1 = Graphics::VectorText::getTextWidth(" ", scale, spacing);
const float W_SEP2 = Graphics::VectorText::getTextWidth(" ", scale, 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;
const float W_SP1 = Graphics::VectorText::getTextWidth(segments.score_p1, scale, spacing);
const float W_LP1 = Graphics::VectorText::getTextWidth(segments.lives_p1, scale, spacing);
const float W_LV = Graphics::VectorText::getTextWidth(segments.level, scale, spacing);
const float W_SP2 = Graphics::VectorText::getTextWidth(segments.score_p2, scale, spacing);
const float W_LP2 = Graphics::VectorText::getTextWidth(segments.lives_p2, scale, spacing);
const float TOTAL = W_SP1 + W_SEP1 + W_LP1 + W_SEP2 + W_LV + W_SEP2 + W_SP2 + W_SEP1 + W_LP2;
const float HEIGHT = Graphics::VectorText::getTextHeight(scale);
const float TOP_Y = center.y - (HEIGHT / 2.0F);
float x = center.x - (TOTAL / 2.0F);
text.render(segments.score_p1, {.x = x, .y = TOP_Y}, scale, spacing, 1.0F, Defaults::Hud::Colors::SCORE_P1);
x += W_SP1 + W_SEP1;
text.render(segments.lives_p1, {.x = x, .y = TOP_Y}, scale, spacing, 1.0F, Defaults::Hud::Colors::LIVES);
x += W_LP1 + W_SEP2;
text.render(segments.level, {.x = x, .y = TOP_Y}, scale, spacing, 1.0F, Defaults::Hud::Colors::LEVEL);
x += W_LV + W_SEP2;
text.render(segments.score_p2, {.x = x, .y = TOP_Y}, scale, spacing, 1.0F, Defaults::Hud::Colors::SCORE_P2);
x += W_SP2 + W_SEP1;
text.render(segments.lives_p2, {.x = x, .y = TOP_Y}, scale, spacing, 1.0F, Defaults::Hud::Colors::LIVES);
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(const Graphics::VectorText& text,
const ScoreboardSegments& segments,
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);
drawScoreboardSegmentsAt(text, segments, {.x = CENTRE_X, .y = Y_ANIM}, SCALE, SPACING);
drawScoreboardAt(renderer, text, data, Y_ANIM, SCALE, SPACING);
}
} // namespace Systems::InitHud
+31 -19
View File
@@ -13,23 +13,32 @@
#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 {
// Segments del marcador. Cada segment es renderitza amb el seu propi color
// (vegeu Defaults::Hud::Colors). El layout final concatena en aquest ordre
// amb separadors d'1, 2, 2, 1 espais respectivament (igual que el legacy).
struct ScoreboardSegments {
std::string score_p1;
std::string lives_p1;
std::string level; // ex: "NIVELL 01"
std::string score_p2;
std::string lives_p2;
// 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
@@ -51,19 +60,22 @@ namespace Systems::InitHud {
// 66..100% → línea inferior crece desde los lados hacia el centro.
void drawBordersAnimated(Rendering::Renderer* renderer, float progress);
// Dibuixa els 5 segments del scoreboard centrats al voltant de `center`,
// cadascun amb el seu color (Defaults::Hud::Colors). Separadors de 1/2/2/1
// espais entre segments per preservar el layout legacy.
void drawScoreboardSegmentsAt(const Graphics::VectorText& text,
const ScoreboardSegments& segments,
const Vec2& center,
// 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 scoreboard centrat, pujant des de fora de la pantalla fins a
// la seva posició final amb easing. Delega a drawScoreboardSegmentsAt.
void drawScoreboardAnimated(const Graphics::VectorText& text,
const ScoreboardSegments& segments,
// 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