diff --git a/CMakeLists.txt b/CMakeLists.txt index d7cf101..b772dd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -92,6 +92,8 @@ set(APP_SOURCES source/core/rendering/sprite/moving_sprite.cpp source/core/rendering/sprite/animated_sprite.cpp source/core/rendering/sprite/animated_sprite_parser.cpp + source/core/rendering/effects/heat_haze.cpp + source/core/rendering/transitions/mosaic.cpp source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp source/core/rendering/sdl3gpu/viewport.cpp source/core/rendering/sdl3gpu/pipelines/crtpi_pipeline.cpp diff --git a/data/fonts/ari-w9500-condensed-display.fnt b/data/fonts/ari-w9500-condensed-display.fnt index 7314c59..0f9d018 100644 --- a/data/fonts/ari-w9500-condensed-display.fnt +++ b/data/fonts/ari-w9500-condensed-display.fnt @@ -1,8 +1,8 @@ # Font: ari-w9500-condensed-display — generado desde ari-w9500-condensed-display.ttf size 11 -# Generado con tools/font_gen/font_gen.py +# Generado con font_gen.py box_width 12 -box_height 11 +box_height 14 columns 15 # codepoint_decimal ancho_visual @@ -105,8 +105,8 @@ columns 15 193 8 # Á 200 7 # È 201 7 # É -204 3 # Ì -205 3 # Í +204 4 # Ì +205 4 # Í 210 9 # Ò 211 9 # Ó 217 7 # Ù @@ -118,7 +118,7 @@ columns 15 232 7 # è 233 7 # é 236 4 # ì -237 3 # í +237 4 # í 242 7 # ò 243 7 # ó 249 7 # ù @@ -130,3 +130,4 @@ columns 15 171 10 # « 187 10 # » 183 3 # · +169 10 # © diff --git a/data/fonts/ari-w9500-condensed-display.gif b/data/fonts/ari-w9500-condensed-display.gif index b293b0d..cec3eb8 100644 Binary files a/data/fonts/ari-w9500-condensed-display.gif and b/data/fonts/ari-w9500-condensed-display.gif differ diff --git a/data/fonts/ari-w9500-display.fnt b/data/fonts/ari-w9500-display.fnt index 74dac58..c88e303 100644 --- a/data/fonts/ari-w9500-display.fnt +++ b/data/fonts/ari-w9500-display.fnt @@ -1,8 +1,8 @@ # Font: ari-w9500-display — generado desde ari-w9500-display.ttf size 11 -# Generado con tools/font_gen/font_gen.py +# Generado con font_gen.py box_width 12 -box_height 11 +box_height 14 columns 15 # codepoint_decimal ancho_visual @@ -105,8 +105,8 @@ columns 15 193 9 # Á 200 8 # È 201 8 # É -204 3 # Ì -205 3 # Í +204 4 # Ì +205 4 # Í 210 10 # Ò 211 10 # Ó 217 8 # Ù @@ -118,7 +118,7 @@ columns 15 232 8 # è 233 8 # é 236 4 # ì -237 3 # í +237 4 # í 242 8 # ò 243 8 # ó 249 8 # ù @@ -130,3 +130,4 @@ columns 15 171 10 # « 187 10 # » 183 3 # · +169 10 # © diff --git a/data/fonts/ari-w9500-display.gif b/data/fonts/ari-w9500-display.gif index 11c0d38..8ce9c61 100644 Binary files a/data/fonts/ari-w9500-display.gif and b/data/fonts/ari-w9500-display.gif differ diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index a448e58..2110b92 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -39,6 +39,10 @@ hud: pyramid: "Piramide" room: "Sala" +# Textos de l'escena TITLE. +title: + press_start: "Polsa START per a començar" + # Textos de la transició d'entrada/sortida de sala (FSM de GameScene). # Tant l'INTRO com l'OUTRO es renderitzen com a dues línies separades # (cadascuna amb la seua animació). L'INTRO accepta els placeholders @@ -76,6 +80,7 @@ menu: move_right: "Dreta" jump: "Saltar" attack: "Atacar" + start: "Start" service: "Opcions" gamepad_p1: "Comandament P1" gamepad_p2: "Comandament P2" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index 5971339..062a53b 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -39,6 +39,10 @@ hud: pyramid: "Pyramid" room: "Room" +# Texts of the TITLE scene. +title: + press_start: "Press START to begin" + # Room transition texts (GameScene FSM intro/outro). Both INTRO and # OUTRO render as two independent lines with their own animation. # INTRO uses `{p}` (pyramid) and `{r}` (room) placeholders; OUTRO has @@ -75,6 +79,7 @@ menu: move_right: "Right" jump: "Jump" attack: "Attack" + start: "Start" service: "Options" gamepad_p1: "Gamepad P1" gamepad_p2: "Gamepad P2" diff --git a/data/logo/logo_jailgames.gif b/data/logo/logo_jailgames.gif new file mode 100644 index 0000000..b679aea Binary files /dev/null and b/data/logo/logo_jailgames.gif differ diff --git a/data/logo/logo_jailgames_mini.gif b/data/logo/logo_jailgames_mini.gif new file mode 100644 index 0000000..f4f57ae Binary files /dev/null and b/data/logo/logo_jailgames_mini.gif differ diff --git a/data/logo/logo_since_1998.gif b/data/logo/logo_since_1998.gif new file mode 100644 index 0000000..c0df364 Binary files /dev/null and b/data/logo/logo_since_1998.gif differ diff --git a/data/music/title.ogg b/data/music/title.ogg new file mode 100644 index 0000000..34dae6c Binary files /dev/null and b/data/music/title.ogg differ diff --git a/data/sounds/player_start.wav b/data/sounds/player_start.wav new file mode 100644 index 0000000..e554315 Binary files /dev/null and b/data/sounds/player_start.wav differ diff --git a/data/title/title_bg.gif b/data/title/title_bg.gif new file mode 100644 index 0000000..42c0d74 Binary files /dev/null and b/data/title/title_bg.gif differ diff --git a/data/ui.yaml b/data/ui.yaml index 3cb4cbf..af88a41 100644 --- a/data/ui.yaml +++ b/data/ui.yaml @@ -98,3 +98,50 @@ pause: sounds: menu_select: "menu_select.wav" menu_accept: "menu_accept.wav" + # Disparat quan un jugador prem START a la TITLE (primera pulsació o + # la del segon jugador dins de la finestra de STARTING). + player_start: "player_start.wav" + +# Escena TITLE (placeholder visual mentre no hi haja artwork). +# `bg_color` i `text_color` són índexs sobre la paleta "ui" (uzebox.pal): +# 1 = (36,0,0) gairebé negre amb un mínim de càlid; 31 = (255,109,0) +# taronja pur. `duration_ms` és el timeout abans de saltar a la +# següent escena (skipable amb tecla no reservada). Els dos +# `blink_*_ms` defineixen el cicle del prompt "press start": el text +# es veu durant `visible_ms` i s'amaga durant `hidden_ms` repetint-se. +title: + duration_ms: 30000 + bg_color: 1 + text_color: 31 + copyright_color: 246 + blink_visible_ms: 800 + blink_hidden_ms: 200 + # Fase STARTING (entre la primera pulsació de START i el salt a la + # següent escena). `starting_ms` és la durada fixa; durant aquesta + # fase el parpadeig accelera per `starting_blink_speedup` (2.0 = doble + # de freqüència). Si l'altre jugador prem START dins d'aquesta + # finestra, el comptador es reseteja i s'arrenca amb BOTH. + starting_ms: 3000 + # Durant STARTING el prompt commuta a un cicle simètric 50/50 + # (visible/ocult) amb aquest període total. 200 ms = 5 Hz, prou + # ràpid per llegir-se com a "pulsació confirmada". + starting_blink_period_ms: 200 + # Fade-out estil "mosaic dissolve" durant l'última part de STARTING. + # `fade_out_ms` ha de ser <= starting_ms (els últims X ms del STARTING + # es fan amb el dissolve actiu fins a quedar negre). `fade_out_max_block` + # és la mida màxima de bloc en píxels al final del fade. + fade_out_ms: 800 + fade_out_max_block: 32 + # Fade-out de `title.ogg` en entrar a STARTING. Independent del visual: + # la música pot acabar abans o després del mosaic dissolve. + music_fade_out_ms: 3000 + # Distorsió sinusoidal del fons (efecte de calor del desert). + # `amplitude_px`: desplaçament màxim de cada fila en píxels. + # `wavelength_px`: distància vertical entre dos pics de l'ona. + # `speed_hz`: velocitat amb què avança la fase (cicles per segon). + # Si `enabled` és false, el fons es pinta sense distorsió. + heat_haze: + enabled: true + amplitude_px: 1.5 + wavelength_px: 32.0 + speed_hz: 0.6 diff --git a/source/core/audio/jail_audio.cpp b/source/core/audio/jail_audio.cpp index ef4f1ac..5ad4fb0 100644 --- a/source/core/audio/jail_audio.cpp +++ b/source/core/audio/jail_audio.cpp @@ -127,11 +127,44 @@ namespace Ja { void Engine::updateOutgoingFade() { if (outgoing_music_.stream == nullptr || !outgoing_music_.fade.active) { return; } + // Mentre la fosa està activa, mantenim el stream amb una reserva + // de samples per davant del cursor (mateix patró que pumpMusic + // per al current_music_). Així el stream no es buida ni quan SDL + // drena més ràpid del previst en haver sounds bound a la mateixa + // device. Si l'OGG arriba a EOF, rebobina (la fosa pot ser més + // llarga que la pista). + if (outgoing_music_.music != nullptr && outgoing_music_.music->vorbis != nullptr) { + const Music& music = *outgoing_music_.music; + const int BYTES_PER_SECOND = music.spec.freq * music.spec.channels * MUSIC_BYTES_PER_SAMPLE; + const int LOW_WATER = static_cast(MUSIC_LOW_WATER_SECONDS * static_cast(BYTES_PER_SECOND)); + while (SDL_GetAudioStreamAvailable(outgoing_music_.stream) < LOW_WATER) { + short chunk[MUSIC_CHUNK_SHORTS]; + const int SAMPLES = stb_vorbis_get_samples_short_interleaved( + music.vorbis, + music.spec.channels, + static_cast(chunk), + MUSIC_CHUNK_SHORTS); + if (SAMPLES <= 0) { + stb_vorbis_seek_start(music.vorbis); + continue; + } + const int BYTES = SAMPLES * music.spec.channels * MUSIC_BYTES_PER_SAMPLE; + SDL_PutAudioStreamData(outgoing_music_.stream, static_cast(chunk), BYTES); + } + } + const float PROGRESS = fadeProgress(outgoing_music_.fade); if (PROGRESS >= 1.0F) { SDL_DestroyAudioStream(outgoing_music_.stream); outgoing_music_.stream = nullptr; outgoing_music_.fade.active = false; + // Deixem el Vorbis del Music original en un estat conegut per + // a la pròxima reproducció. (playMusic també fa seek_start, + // però fer-ho ací evita estats intermedis si algú consulta.) + if (outgoing_music_.music != nullptr && outgoing_music_.music->vorbis != nullptr) { + stb_vorbis_seek_start(outgoing_music_.music->vorbis); + } + outgoing_music_.music = nullptr; } else { SDL_SetAudioStreamGain(outgoing_music_.stream, outgoing_music_.fade.initial_volume * (1.0F - PROGRESS)); } @@ -191,7 +224,14 @@ namespace Ja { } preFillOutgoing(current_music_, duration_ms); + outgoing_music_.stream = current_music_->stream; + // Guardem la referència al Music perquè updateOutgoingFade puga + // seguir bombant Vorbis cap al stream durant tota la fosa. NO fem + // seek_start ací: la decompressió ha de continuar des d'on estava + // perquè el so siga continu. El seek_start es farà quan la fosa + // acabe (o quan playMusic la interrompi via stopMusic). + outgoing_music_.music = current_music_; outgoing_music_.fade = { .active = true, .start_time = SDL_GetTicks(), @@ -200,7 +240,6 @@ namespace Ja { }; current_music_->stream = nullptr; current_music_->state = MusicState::STOPPED; - if (current_music_->vorbis != nullptr) { stb_vorbis_seek_start(current_music_->vorbis); } } template @@ -265,6 +304,10 @@ namespace Ja { SDL_DestroyAudioStream(outgoing_music_.stream); outgoing_music_.stream = nullptr; outgoing_music_.fade.active = false; + if (outgoing_music_.music != nullptr && outgoing_music_.music->vorbis != nullptr) { + stb_vorbis_seek_start(outgoing_music_.music->vorbis); + } + outgoing_music_.music = nullptr; } incoming_fade_.active = false; diff --git a/source/core/audio/jail_audio.hpp b/source/core/audio/jail_audio.hpp index 51d026d..85f9900 100644 --- a/source/core/audio/jail_audio.hpp +++ b/source/core/audio/jail_audio.hpp @@ -125,6 +125,13 @@ namespace Ja { struct OutgoingMusic { SDL_AudioStream* stream{nullptr}; + // Referència al Music original perquè updateOutgoingFade puga + // continuar descomprimint des de Vorbis cap al stream durant + // tota la fosa. Sense això, només tenim el pre-fill puntual i + // SDL drena el stream més ràpid del previst quan hi ha sounds + // bound a la mateixa device (~2x), buidant-lo a meitat del + // fade i sentint-se com un tall sec. + Music* music{nullptr}; FadeState fade; }; diff --git a/source/core/input/define_buttons.cpp b/source/core/input/define_buttons.cpp index 4914b0c..e00625a 100644 --- a/source/core/input/define_buttons.cpp +++ b/source/core/input/define_buttons.cpp @@ -62,7 +62,7 @@ void DefineButtons::disable() { } void DefineButtons::resetSequence() { - // Ordre: ATTACK, JUMP, SERVICE — replica l'estructura de + // Ordre: ATTACK, JUMP, START, SERVICE — replica l'estructura de // `clearButtons()` de coffee_crisis_arcade_edition (seqüència ordenada // d'accions amb la SERVICE al final) adaptada al joc actual. La label // de SERVICE va a una clau pròpia (`menu.items.service`) per no @@ -70,6 +70,7 @@ void DefineButtons::resetSequence() { sequence_.clear(); sequence_.push_back({"menu.items.attack", GamepadBindings::Action::ATTACK, static_cast(SDL_GAMEPAD_BUTTON_INVALID)}); sequence_.push_back({"menu.items.jump", GamepadBindings::Action::JUMP, static_cast(SDL_GAMEPAD_BUTTON_INVALID)}); + sequence_.push_back({"menu.items.start", GamepadBindings::Action::START, static_cast(SDL_GAMEPAD_BUTTON_INVALID)}); sequence_.push_back({"menu.items.service", GamepadBindings::Action::SERVICE, static_cast(SDL_GAMEPAD_BUTTON_INVALID)}); index_ = 0; } diff --git a/source/core/input/gamepad_bindings.cpp b/source/core/input/gamepad_bindings.cpp index 197be1e..b2c853d 100644 --- a/source/core/input/gamepad_bindings.cpp +++ b/source/core/input/gamepad_bindings.cpp @@ -24,6 +24,7 @@ namespace { ActionPair{GamepadBindings::Action::JUMP, std::string_view{"jump"}}, ActionPair{GamepadBindings::Action::ATTACK, std::string_view{"attack"}}, ActionPair{GamepadBindings::Action::SERVICE, std::string_view{"service"}}, + ActionPair{GamepadBindings::Action::START, std::string_view{"start"}}, }; using ButtonPair = std::pair; @@ -98,6 +99,8 @@ auto GamepadBindings::defaultButton(Action action) -> int { return static_cast(SDL_GAMEPAD_BUTTON_WEST); case Action::SERVICE: return static_cast(SDL_GAMEPAD_BUTTON_BACK); + case Action::START: + return static_cast(SDL_GAMEPAD_BUTTON_START); } return static_cast(SDL_GAMEPAD_BUTTON_INVALID); } diff --git a/source/core/input/gamepad_bindings.hpp b/source/core/input/gamepad_bindings.hpp index 3136266..eaffbe9 100644 --- a/source/core/input/gamepad_bindings.hpp +++ b/source/core/input/gamepad_bindings.hpp @@ -25,6 +25,7 @@ class GamepadBindings { JUMP, ATTACK, SERVICE, + START, }; // Triggers L2/R2 representats com a "botons virtuals" — replica la diff --git a/source/core/rendering/effects/heat_haze.cpp b/source/core/rendering/effects/heat_haze.cpp new file mode 100644 index 0000000..93dc09c --- /dev/null +++ b/source/core/rendering/effects/heat_haze.cpp @@ -0,0 +1,48 @@ +#include "core/rendering/effects/heat_haze.hpp" + +#include + +#include +#include + +#include "core/rendering/surface.hpp" + +namespace Effects { + + namespace { + constexpr float TWO_PI = 2.0F * std::numbers::pi_v; + } + + HeatHaze::HeatHaze(Params params) + : params_(params) {} + + void HeatHaze::tick(int delta_ms) { + const float DT = static_cast(delta_ms) / 1000.0F; + phase_ = std::fmod(phase_ + (TWO_PI * params_.speed_hz * DT), TWO_PI); + } + + void HeatHaze::render(const Surface& src, int x, int y) const { + // Pas 1 — blit pla de tota la Surface al destí. És la base que + // tapa els marges que les files desplaçades deixarien al + // descobert, evitant haver de demanar al caller un pre-fill. Per + // a fons amb gradient continu (cels, sorres) la costura amb el + // pas 2 no es nota; per a fons d'alt contrast cal moderar + // l'amplitud. + src.render(x, y); + + // Pas 2 — re-blit fila a fila amb desplaçament horitzontal sin. + // Wavelength <=0 dóna divisió absurda: cau a 1 px com a salvaguarda; + // a la pràctica el caller passa un valor >0 vingut d'ui.yaml. + const float WL = (params_.wavelength_px > 0.0F) ? params_.wavelength_px : 1.0F; + const int H = src.getHeight(); + const auto W = static_cast(src.getWidth()); + for (int row = 0; row < H; ++row) { + const float ARG = (TWO_PI * static_cast(row) / WL) + phase_; + const int DX = static_cast(std::lround(params_.amplitude_px * std::sin(ARG))); + if (DX == 0) { continue; } // El pas 1 ja ha pintat aquesta fila on toca. + SDL_FRect row_rect{0.0F, static_cast(row), W, 1.0F}; + src.render(x + DX, y + row, &row_rect); + } + } + +} // namespace Effects diff --git a/source/core/rendering/effects/heat_haze.hpp b/source/core/rendering/effects/heat_haze.hpp new file mode 100644 index 0000000..893e4c7 --- /dev/null +++ b/source/core/rendering/effects/heat_haze.hpp @@ -0,0 +1,37 @@ +#pragma once + +class Surface; + +namespace Effects { + + // Distorsió sinusoidal horitzontal de cada fila: dx(y) = + // amplitude · sin(2π·y/wavelength + phase). La phase avança al + // ritme marcat per `speed_hz` (cicles per segon). Es pinta sobre + // qualsevol Surface — habitualment un fons de canvas — i manté el + // seu propi estat temporal, així dues instàncies poden tenir + // paràmetres i fases independents. + // + // `render()` fa dos passos: primer un blit pla de tota la Surface + // (perquè els marges no quedin descoberts pel desplaçament) i + // després el re-blit fila a fila amb el sin. La costura és + // invisible en fons amb gradient continu i pràcticament invisible + // en altres si l'amplitud és moderada. + class HeatHaze { + public: + struct Params { + float amplitude_px; + float wavelength_px; + float speed_hz; + }; + + explicit HeatHaze(Params params); + + void tick(int delta_ms); + void render(const Surface& src, int x, int y) const; + + private: + Params params_; + float phase_{0.0F}; + }; + +} // namespace Effects diff --git a/source/core/rendering/transitions/mosaic.cpp b/source/core/rendering/transitions/mosaic.cpp new file mode 100644 index 0000000..3afdc36 --- /dev/null +++ b/source/core/rendering/transitions/mosaic.cpp @@ -0,0 +1,71 @@ +#include "core/rendering/transitions/mosaic.hpp" + +#include +#include +#include + +#include "core/rendering/screen.hpp" + +namespace Transitions { + + Mosaic::Mosaic(Params params) + : params_(params) {} + + void Mosaic::begin(int duration_ms) { + duration_ms_ = duration_ms; + elapsed_ms_ = 0; + } + + void Mosaic::tick(int delta_ms) { + if (duration_ms_ <= 0) { return; } + elapsed_ms_ = std::min(elapsed_ms_ + delta_ms, duration_ms_); + } + + auto Mosaic::progress() const -> float { + if (duration_ms_ <= 0) { return 0.0F; } + return std::clamp(static_cast(elapsed_ms_) / static_cast(duration_ms_), 0.0F, 1.0F); + } + + void Mosaic::render() const { + if (duration_ms_ <= 0) { return; } + const float P = progress(); + const int MAX_BLOCK = std::max(1, params_.max_block_size); + // Blocs de mida creixent al llarg del fade. A p=0 → 1 px (sense + // efecte visible); a p=1 → max_block. Es fa cap-amunt amb ceil + // perquè el primer increment arribi aviat (el salt visual de + // 1→2 ja es nota). + const int BLOCK = std::clamp(static_cast(std::ceil(static_cast(MAX_BLOCK) * P)), 1, MAX_BLOCK); + + auto& screen = Screen::get(); + std::uint32_t* canvas = screen.getCanvasArgb(); + const int W = screen.getCanvasWidth(); + const int H = screen.getCanvasHeight(); + + if (BLOCK > 1) { + // Sample-and-fill in-place. Per a cada bloc, mostregem el + // píxel central (les vores poden caure fora del canvas en + // blocs de la última columna/fila → clamp). + for (int by = 0; by < H; by += BLOCK) { + const int Y_END = std::min(by + BLOCK, H); + const int SY = std::min(by + (BLOCK / 2), H - 1); + for (int bx = 0; bx < W; bx += BLOCK) { + const int X_END = std::min(bx + BLOCK, W); + const int SX = std::min(bx + (BLOCK / 2), W - 1); + const std::uint32_t COLOR = canvas[(SY * W) + SX]; + for (int yy = by; yy < Y_END; ++yy) { + std::uint32_t* row = canvas + (static_cast(yy) * W); + std::fill(row + bx, row + X_END, COLOR); + } + } + } + } + + // Fade-to-black quadràtic per damunt: la mascara fosca arriba + // tard (a p=0.5 només 25% d'alpha) perquè es vegin els blocs. + const auto ALPHA = static_cast(std::round(255.0F * P * P)); + if (ALPHA > 0) { + screen.blendRect(0, 0, W, H, 0x00000000U, ALPHA); + } + } + +} // namespace Transitions diff --git a/source/core/rendering/transitions/mosaic.hpp b/source/core/rendering/transitions/mosaic.hpp new file mode 100644 index 0000000..9966691 --- /dev/null +++ b/source/core/rendering/transitions/mosaic.hpp @@ -0,0 +1,45 @@ +#pragma once + +namespace Transitions { + + // Fade-out estil "mosaic dissolve": al llarg del temps els píxels del + // canvas es reagrupen en blocs cada cop més grans (1×1 → max_block_size× + // max_block_size). Cada bloc es pinta amb el píxel central del què + // tenia abans, donant aquell aspecte chunky d'arcade. Al damunt + // s'aplica un fade-to-black quadràtic (alpha = p² × 255) perquè el + // resultat acabi en negre opac. + // + // La classe NO sap res del Screen abans de `render()`. El caller hi + // crida després de pintar la seva escena: l'efecte llegeix els + // píxels actuals del canvas, els rebina i escriu pixels modificats + // al mateix canvas. És, per tant, un post-procés in-place. + class Mosaic { + public: + struct Params { + int max_block_size; + }; + + explicit Mosaic(Params params); + + // Arrenca un fade nou. Si ja n'hi havia un actiu, el reemplaça. + // `duration_ms <= 0` deixa l'efecte inactiu. + void begin(int duration_ms); + + void tick(int delta_ms); + + [[nodiscard]] auto isActive() const -> bool { return duration_ms_ > 0 && elapsed_ms_ < duration_ms_; } + [[nodiscard]] auto isFinished() const -> bool { return duration_ms_ > 0 && elapsed_ms_ >= duration_ms_; } + [[nodiscard]] auto progress() const -> float; + + // Aplica el mosaic + alpha negre sobre el canvas actual de Screen. + // Cridar després del render normal de l'escena, abans de + // `Screen::present()`. + void render() const; + + private: + Params params_; + int duration_ms_{0}; + int elapsed_ms_{0}; + }; + +} // namespace Transitions diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index 4b60c80..7e60531 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -153,6 +153,9 @@ Director::Director() { Resource::Cache::get().loadMusic("room_intro.ogg", "data/music/room_intro.ogg"); Resource::Cache::get().loadMusic("playing.ogg", "data/music/playing.ogg"); Resource::Cache::get().loadMusic("room_outro.ogg", "data/music/room_outro.ogg"); + // Música de la TITLE: arrenca al constructor de l'escena i es fa + // fade-out quan l'escena entra a STARTING. + Resource::Cache::get().loadMusic("title.ogg", "data/music/title.ogg"); // Sons del menú de servei. Els noms venen de `data/ui.yaml` (amb // fallback a `Defaults::UI::MENU_*_SOUND`); el ServiceMenu els @@ -161,6 +164,10 @@ Director::Director() { const auto& menu_accept = UiConfig::get().menuAcceptSound(); Resource::Cache::get().loadSound(menu_select, "data/sounds/" + menu_select); Resource::Cache::get().loadSound(menu_accept, "data/sounds/" + menu_accept); + // So del START a la TITLE: el dispara TitleScene quan un jugador + // prem START (vore enterStarting i el reset del segon jugador). + const auto& player_start = UiConfig::get().playerStartSound(); + Resource::Cache::get().loadSound(player_start, "data/sounds/" + player_start); // Catàleg de presets d'efectes per als sons. Es carrega abans d'Audio // perquè els primers playSoundWith* (al començament del joc) ja @@ -203,6 +210,20 @@ Director::Director() { }; auto menu_title_text = load_menu_font(UiConfig::get().menuTitleFont()); auto menu_items_text = load_menu_font(UiConfig::get().menuItemsFont()); + + // Mini-logo del peu de la TITLE. Es preloda ací per consistència + // amb la resta d'assets d'UI; manté la seua paleta original (no la + // sobreescrivim amb la "ui" perquè el GIF ja porta els colors finals + // del logo). + constexpr const char* MINI_LOGO_KEY = "logo_jailgames_mini.gif"; + Resource::Cache::get().loadSurface(MINI_LOGO_KEY, "data/logo/logo_jailgames_mini.gif"); + auto mini_logo_surface = Resource::Cache::get().getSurface(MINI_LOGO_KEY); + + // Fons sencer de la TITLE (320×240). Igual que el minilogo, conserva + // la seua paleta nadiua perquè el GIF ja porta els colors finals. + constexpr const char* TITLE_BG_KEY = "title_bg.gif"; + Resource::Cache::get().loadSurface(TITLE_BG_KEY, "data/title/title_bg.gif"); + auto title_bg_surface = Resource::Cache::get().getSurface(TITLE_BG_KEY); ServiceMenu::get().init(menu_title_text, menu_items_text, ui_palette); // Overlay hook: Screen::present() cridarà aquesta lambda al principi, @@ -237,22 +258,21 @@ Director::Director() { std::cout << "Director: start_scene=" << DebugConfig::toString(START) << " starting_players=" << DebugConfig::toString(STARTING_PLAYERS) << '\n'; - // El branch Release inicialitza STARTING_PLAYERS amb una constant - // literal i clang considera la captura redundant; en Debug el valor és - // runtime i la captura sí és imprescindible. La lambda viu dins de - // IntroScene, més enllà del scope d'aquest ctor — capturar per - // referència seria UB. - // NOLINTNEXTLINE(clang-diagnostic-unused-lambda-capture) - auto make_game = [STARTING_PLAYERS]() -> std::unique_ptr { + // `make_game` i `make_intro` reben `StartingPlayers` com a paràmetre + // perquè la TitleScene tria al moment quins jugadors entren (P1, P2, + // BOTH) segons la pulsació. La IntroScene ho reenvia a la GameScene. + // El valor de `STARTING_PLAYERS` només s'usa com a punt d'entrada en + // mode Debug quan saltem directament a INTRO o GAME (saltant TITLE). + auto make_game = [](DebugConfig::StartingPlayers players) -> std::unique_ptr { auto cfg = GameScene::defaultConfig(); - cfg.starting_players = STARTING_PLAYERS; + cfg.starting_players = players; return std::make_unique(std::move(cfg)); }; - auto make_intro = [placeholder_text, make_game]() -> std::unique_ptr { - return std::make_unique(placeholder_text, make_game); + auto make_intro = [placeholder_text, make_game](DebugConfig::StartingPlayers players) -> std::unique_ptr { + return std::make_unique(placeholder_text, players, make_game); }; - auto make_title = [placeholder_text, make_intro]() -> std::unique_ptr { - return std::make_unique(placeholder_text, make_intro); + auto make_title = [menu_title_text, placeholder_text, title_bg_surface, mini_logo_surface, make_intro]() -> std::unique_ptr { + return std::make_unique(menu_title_text, placeholder_text, title_bg_surface, mini_logo_surface, make_intro); }; switch (START) { @@ -263,10 +283,10 @@ Director::Director() { current_scene_ = make_title(); break; case DebugConfig::StartScene::INTRO: - current_scene_ = make_intro(); + current_scene_ = make_intro(STARTING_PLAYERS); break; case DebugConfig::StartScene::GAME: - current_scene_ = make_game(); + current_scene_ = make_game(STARTING_PLAYERS); break; } last_ticks_ms_ = SDL_GetTicks(); diff --git a/source/core/ui/ui_config.cpp b/source/core/ui/ui_config.cpp index e7ff25a..54e93f3 100644 --- a/source/core/ui/ui_config.cpp +++ b/source/core/ui/ui_config.cpp @@ -96,8 +96,26 @@ UiConfig::UiConfig() pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS), pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)), pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)), + title_duration_ms_(Defaults::UI::Title::DURATION_MS), + title_bg_color_(Defaults::UI::Title::BG_COLOR), + title_text_color_(Defaults::UI::Title::TEXT_COLOR), + title_copyright_color_(Defaults::UI::Title::COPYRIGHT_COLOR), + title_blink_visible_ms_(Defaults::UI::Title::BLINK_VISIBLE_MS), + title_blink_hidden_ms_(Defaults::UI::Title::BLINK_HIDDEN_MS), + title_starting_ms_(Defaults::UI::Title::STARTING_MS), + title_starting_blink_period_ms_(Defaults::UI::Title::STARTING_BLINK_PERIOD_MS), + title_fade_out_ms_(Defaults::UI::Title::FADE_OUT_MS), + title_fade_out_max_block_(Defaults::UI::Title::FADE_OUT_MAX_BLOCK), + title_music_fade_out_ms_(Defaults::UI::Title::MUSIC_FADE_OUT_MS), + title_heat_haze_{ + .enabled = Defaults::UI::Title::HeatHaze::ENABLED, + .amplitude_px = Defaults::UI::Title::HeatHaze::AMPLITUDE_PX, + .wavelength_px = Defaults::UI::Title::HeatHaze::WAVELENGTH_PX, + .speed_hz = Defaults::UI::Title::HeatHaze::SPEED_HZ, + }, menu_select_sound_(Defaults::UI::MENU_SELECT_SOUND), menu_accept_sound_(Defaults::UI::MENU_ACCEPT_SOUND), + player_start_sound_(Defaults::UI::PLAYER_START_SOUND), hud_font_{.name = Defaults::UI::Font::HUD_NAME, .gif = Defaults::UI::Font::HUD_GIF, .fnt = Defaults::UI::Font::HUD_FNT, .kerning = Defaults::UI::Font::HUD_KERNING}, placeholder_font_{.name = Defaults::UI::Font::PLACEHOLDER_NAME, .gif = Defaults::UI::Font::PLACEHOLDER_GIF, .fnt = Defaults::UI::Font::PLACEHOLDER_FNT, .kerning = Defaults::UI::Font::PLACEHOLDER_KERNING}, room_text_font_{.name = Defaults::UI::Font::ROOM_TEXT_NAME, .gif = Defaults::UI::Font::ROOM_TEXT_GIF, .fnt = Defaults::UI::Font::ROOM_TEXT_FNT, .kerning = Defaults::UI::Font::ROOM_TEXT_KERNING}, @@ -202,6 +220,29 @@ void UiConfig::load(const std::string& file_path) { const auto& s = yaml["sounds"]; readField(s, "menu_select", menu_select_sound_); readField(s, "menu_accept", menu_accept_sound_); + readField(s, "player_start", player_start_sound_); + } + + if (yaml.contains("title")) { + const auto& t = yaml["title"]; + readField(t, "duration_ms", title_duration_ms_); + readColorIndex(t, "bg_color", title_bg_color_); + readColorIndex(t, "text_color", title_text_color_); + readColorIndex(t, "copyright_color", title_copyright_color_); + readField(t, "blink_visible_ms", title_blink_visible_ms_); + readField(t, "blink_hidden_ms", title_blink_hidden_ms_); + readField(t, "starting_ms", title_starting_ms_); + readField(t, "starting_blink_period_ms", title_starting_blink_period_ms_); + readField(t, "fade_out_ms", title_fade_out_ms_); + readField(t, "fade_out_max_block", title_fade_out_max_block_); + readField(t, "music_fade_out_ms", title_music_fade_out_ms_); + if (t.contains("heat_haze")) { + const auto& hh = t["heat_haze"]; + readField(hh, "enabled", title_heat_haze_.enabled); + readField(hh, "amplitude_px", title_heat_haze_.amplitude_px); + readField(hh, "wavelength_px", title_heat_haze_.wavelength_px); + readField(hh, "speed_hz", title_heat_haze_.speed_hz); + } } if (yaml.contains("notifier")) { diff --git a/source/core/ui/ui_config.hpp b/source/core/ui/ui_config.hpp index f8b5508..ddbef1c 100644 --- a/source/core/ui/ui_config.hpp +++ b/source/core/ui/ui_config.hpp @@ -81,6 +81,27 @@ class UiConfig { [[nodiscard]] auto pauseEnterMs() const -> int { return pause_enter_ms_; } [[nodiscard]] auto pauseExitMs() const -> int { return pause_exit_ms_; } + // Escena TITLE (placeholder visual fins que tinguem artwork). + struct TitleHeatHaze { + bool enabled; + float amplitude_px; + float wavelength_px; + float speed_hz; + }; + + [[nodiscard]] auto titleDurationMs() const -> int { return title_duration_ms_; } + [[nodiscard]] auto titleBgColor() const -> Uint8 { return title_bg_color_; } + [[nodiscard]] auto titleTextColor() const -> Uint8 { return title_text_color_; } + [[nodiscard]] auto titleCopyrightColor() const -> Uint8 { return title_copyright_color_; } + [[nodiscard]] auto titleBlinkVisibleMs() const -> int { return title_blink_visible_ms_; } + [[nodiscard]] auto titleBlinkHiddenMs() const -> int { return title_blink_hidden_ms_; } + [[nodiscard]] auto titleStartingMs() const -> int { return title_starting_ms_; } + [[nodiscard]] auto titleStartingBlinkPeriodMs() const -> int { return title_starting_blink_period_ms_; } + [[nodiscard]] auto titleFadeOutMs() const -> int { return title_fade_out_ms_; } + [[nodiscard]] auto titleFadeOutMaxBlock() const -> int { return title_fade_out_max_block_; } + [[nodiscard]] auto titleMusicFadeOutMs() const -> int { return title_music_fade_out_ms_; } + [[nodiscard]] auto titleHeatHaze() const -> const TitleHeatHaze& { return title_heat_haze_; } + // Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa // Easing::linear com a fallback. [[nodiscard]] auto pauseEnterCurve() const -> CurveFn { return pause_enter_curve_; } @@ -96,6 +117,7 @@ class UiConfig { // fitxers viuen a `data/sounds/`). [[nodiscard]] auto menuSelectSound() const -> const std::string& { return menu_select_sound_; } [[nodiscard]] auto menuAcceptSound() const -> const std::string& { return menu_accept_sound_; } + [[nodiscard]] auto playerStartSound() const -> const std::string& { return player_start_sound_; } // Fonts de UI. [[nodiscard]] auto hudFont() const -> const Font& { return hud_font_; } @@ -145,8 +167,22 @@ class UiConfig { CurveFn pause_enter_curve_; CurveFn pause_exit_curve_; + int title_duration_ms_; + Uint8 title_bg_color_; + Uint8 title_text_color_; + Uint8 title_copyright_color_; + int title_blink_visible_ms_; + int title_blink_hidden_ms_; + int title_starting_ms_; + int title_starting_blink_period_ms_; + int title_fade_out_ms_; + int title_fade_out_max_block_; + int title_music_fade_out_ms_; + TitleHeatHaze title_heat_haze_; + std::string menu_select_sound_; std::string menu_accept_sound_; + std::string player_start_sound_; Font hud_font_; Font placeholder_font_; diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index db694fb..b60b451 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -27,6 +27,8 @@ class Player : public AnimatedSprite { SDL_Scancode right; SDL_Scancode jump; SDL_Scancode attack; + // No remapejable des del menú de servei: P1 → "1", P2 → "2". + SDL_Scancode start; }; // Estat d'alt nivell del jugador. Ortogonal a `Facing`/`Cardinal` diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 07b4610..89861fa 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -14,7 +14,6 @@ #include #include "core/audio/audio.hpp" -#include "core/input/gamepad_manager.hpp" #include "core/input/global_inputs.hpp" #include "core/input/key_config.hpp" #include "core/locale/locale.hpp" @@ -348,6 +347,7 @@ GameScene::GameScene(Config config) .right = kb_p1.right, .jump = kb_p1.jump, .attack = kb_p1.attack, + .start = Defaults::KeysGame::START, }; const Player::Controls CONTROLS_P2{ .up = kb_p2.up, @@ -356,6 +356,7 @@ GameScene::GameScene(Config config) .right = kb_p2.right, .jump = kb_p2.jump, .attack = kb_p2.attack, + .start = Defaults::KeysGameP2::START, }; // Spawn lligat a la porta: tots els jugadors apareixen davant del // passadís de sortida. `computeSpawn` deriva les coordenades de @@ -1044,11 +1045,9 @@ void GameScene::handlePauseInput() { if (editor_.active()) { return; } #endif const SDL_Scancode PAUSE_SC = KeyConfig::get().scancode("pause"); - SDL_Gamepad* pad = GamepadManager::get().getPrimary(); for (const auto& event : EventBuffer::events) { const bool KEY_HIT = (event.type == SDL_EVENT_KEY_DOWN && PAUSE_SC != SDL_SCANCODE_UNKNOWN && event.key.scancode == PAUSE_SC); - const bool PAD_HIT = (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && event.gbutton.which == SDL_GetGamepadID(pad) && event.gbutton.button == SDL_GAMEPAD_BUTTON_START); - if (!KEY_HIT && !PAD_HIT) { continue; } + if (!KEY_HIT) { continue; } // Determinem cap a quin estat anem. Si estem quiets (IDLE o // PAUSED) arranquem la transició contrària. Si estem en marxa diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 2adaf83..f20a493 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -58,10 +58,12 @@ class GameScene : public Scene { // crida `setState` (que reseteja la `Transition`). void updateRoomTransition(int delta_ms); - // Consumeix events d'aquest frame (F11 del KeyConfig "pause" o botó - // START del gamepad primari) per disparar les transicions de pausa. - // No alterna un flag binari: inicia (o inverteix mid-flight) un - // "dial" de temps que frena el món amb easing. + // Consumeix events d'aquest frame (F11 del KeyConfig "pause") per + // disparar les transicions de pausa. El mando ja no participa: el + // botó START és ara una acció remapeable per dispositiu i el menú + // de servei fa de pausa quan cal. No alterna un flag binari: inicia + // (o inverteix mid-flight) un "dial" de temps que frena el món amb + // easing. void handlePauseInput(); // Avança la transició activa, recalcula `pause_.scale` i aplica el diff --git a/source/game/scenes/intro_scene.cpp b/source/game/scenes/intro_scene.cpp index d912b44..6f318b6 100644 --- a/source/game/scenes/intro_scene.cpp +++ b/source/game/scenes/intro_scene.cpp @@ -30,10 +30,11 @@ namespace { } // namespace -IntroScene::IntroScene(std::shared_ptr font, SceneFactory next_factory) +IntroScene::IntroScene(std::shared_ptr font, DebugConfig::StartingPlayers starting_players, SceneFactory next_factory) : font_(std::move(font)), + starting_players_(starting_players), next_factory_(std::move(next_factory)) { - std::cout << "[scene] " << SCENE_NAME << " start\n"; + std::cout << "[scene] " << SCENE_NAME << " start (players=" << DebugConfig::toString(starting_players_) << ")\n"; } IntroScene::~IntroScene() = default; @@ -52,7 +53,7 @@ void IntroScene::update(int delta_ms) { }); if ((timer_ms_ <= 0 || SKIPPED) && next_factory_) { - next_scene_ = next_factory_(); + next_scene_ = next_factory_(starting_players_); } Notifier::get().update(delta_ms); diff --git a/source/game/scenes/intro_scene.hpp b/source/game/scenes/intro_scene.hpp index 859b6ea..ec941bb 100644 --- a/source/game/scenes/intro_scene.hpp +++ b/source/game/scenes/intro_scene.hpp @@ -4,19 +4,24 @@ #include #include "core/scene/scene.hpp" +#include "game/debug_config.hpp" class Text; // Escena placeholder de la introducció. Com la resta de placeholders, durada // fixa i skip amb tecla no reservada; quan expira encadena a la factory // injectada (en producció: GameScene). +// +// `starting_players` és el resultat triat a la TitleScene (P1, P2 o BOTH). +// L'IntroScene només el guarda i el reenvia a la seva factory; quan demà +// l'intro tinga contingut real podrà variar segons qui jugue. class IntroScene : public Scene { public: - using SceneFactory = std::function()>; + using SceneFactory = std::function(DebugConfig::StartingPlayers)>; static constexpr int DURATION_MS = 5000; - IntroScene(std::shared_ptr font, SceneFactory next_factory); + IntroScene(std::shared_ptr font, DebugConfig::StartingPlayers starting_players, SceneFactory next_factory); ~IntroScene() override; void update(int delta_ms) override; @@ -24,6 +29,7 @@ class IntroScene : public Scene { private: std::shared_ptr font_; + DebugConfig::StartingPlayers starting_players_; SceneFactory next_factory_; int timer_ms_{DURATION_MS}; }; diff --git a/source/game/scenes/title_scene.cpp b/source/game/scenes/title_scene.cpp index 9f9149c..26bf114 100644 --- a/source/game/scenes/title_scene.cpp +++ b/source/game/scenes/title_scene.cpp @@ -8,32 +8,105 @@ #include #include +#include "core/audio/audio.hpp" +#include "core/input/gamepad_bindings.hpp" +#include "core/input/gamepad_manager.hpp" #include "core/input/global_inputs.hpp" +#include "core/locale/locale.hpp" #include "core/rendering/notifier.hpp" #include "core/rendering/render_info.hpp" #include "core/rendering/screen.hpp" +#include "core/rendering/surface.hpp" #include "core/rendering/text.hpp" +#include "core/resources/resource_cache.hpp" #include "core/system/event_buffer.hpp" #include "core/ui/ui_config.hpp" #include "game/options.hpp" -#include "game/scenes/placeholder_input.hpp" #include "utils/defaults.hpp" #include "utils/defines.hpp" namespace { - constexpr int NAME_Y_OFFSET = -8; - constexpr int TIMER_Y_OFFSET = 8; // TODO: migrar a un logger unificat quan es decideixi (veure INC-03 a // docs/audit-core-rendering.md). const char* const SCENE_NAME = "TITLE"; + // Textos hardcodejats fins que tinguem artwork. Quan apareguen com + // a sprites, aquesta secció desapareix sencera. + const char* const TITLE_LINE_1 = "Aventures en Egipte"; + const char* const TITLE_LINE_2 = "A R C A D E"; + + constexpr int TITLE_Y_PERCENT = 25; + constexpr int PROMPT_Y_PERCENT = 60; + constexpr int COPYRIGHT_Y_PERCENT = 90; + constexpr int LOGO_GAP_PX = 2; // Separació vertical entre el minilogo i el text de copyright. + + // Detecta una pulsació de START dins el frame actual per al jugador + // indicat. Combina events de teclat (scancode hardcoded a Defaults:: + // KeysGame[P2]) i events de gamepad (botó remapejable via + // GamepadBindings, lligat al pad assignat al jugador via Options). + auto pressedStart(int player_id) -> bool { + const SDL_Scancode KB = (player_id == 0) ? Defaults::KeysGame::START : Defaults::KeysGameP2::START; + SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id); + const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0; + const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1 + : Options::controls().gamepad_p2; + const int START_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::START); + + return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) { + if (ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == KB) { return true; } + if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID && static_cast(ev.gbutton.button) == START_BTN) { + return true; + } + return false; + }); + } + + // Separació vertical entre la primera i segona línia del títol. Una + // alçada de línia és l'opció més llegible al canvas de 240 px sense + // entrar a configuració extra; si demà cal afinar-ho, mou-se a + // ui.yaml. + [[nodiscard]] auto titleLineGap(const Text& font) -> int { + return font.getLineHeight(); + } + + // Reproducció del so de START. Centralitzat ací per disparar-lo des + // dels punts on una pulsació té efecte real (entrada a STARTING o + // upgrade a BOTH); l'attract timeout no l'ha de tocar. + void playStartSound() { + if (auto* audio = Audio::get(); audio != nullptr) { + audio->playSound(UiConfig::get().playerStartSound(), Audio::Group::INTERFACE); + } + } + } // namespace -TitleScene::TitleScene(std::shared_ptr font, SceneFactory next_factory) - : font_(std::move(font)), - next_factory_(std::move(next_factory)) { +TitleScene::TitleScene(std::shared_ptr display_font, std::shared_ptr condensed_font, std::shared_ptr background, std::shared_ptr mini_logo, SceneFactory next_factory) + : display_font_(std::move(display_font)), + condensed_font_(std::move(condensed_font)), + background_(std::move(background)), + mini_logo_(std::move(mini_logo)), + next_factory_(std::move(next_factory)), + timer_ms_(UiConfig::get().titleDurationMs()) { std::cout << "[scene] " << SCENE_NAME << " start\n"; + const auto& hh = UiConfig::get().titleHeatHaze(); + if (hh.enabled) { + heat_haze_.emplace(Effects::HeatHaze::Params{ + .amplitude_px = hh.amplitude_px, + .wavelength_px = hh.wavelength_px, + .speed_hz = hh.speed_hz, + }); + } + fade_out_.emplace(Transitions::Mosaic::Params{ + .max_block_size = UiConfig::get().titleFadeOutMaxBlock(), + }); + + // Música de fons en bucle. Es preloda al Director, així que la + // primera crida és immediata. El fade-out arrenca en entrar a + // STARTING (vore enterStarting). + if (auto* audio = Audio::get(); audio != nullptr) { + audio->playMusic("title.ogg", -1); + } } TitleScene::~TitleScene() = default; @@ -45,41 +118,194 @@ void TitleScene::update(int delta_ms) { return; } - timer_ms_ -= delta_ms; + if (heat_haze_) { heat_haze_->tick(delta_ms); } - const bool SKIPPED = std::ranges::any_of(EventBuffer::events, [](const SDL_Event& ev) { - return ev.type == SDL_EVENT_KEY_DOWN && !Placeholder::isReservedScancode(ev.key.scancode); - }); + // Comptador del parpadeig: avança sempre amb `delta_ms` cru. La + // lectura (cicle/visible) la decideix `render()` segons la fase: + // IDLE usa visible_ms / hidden_ms del yaml (asimètric, 80/20 per + // defecte); STARTING usa un cicle simètric 50/50 amb període + // `starting_blink_period_ms`. + blink_elapsed_ms_ += delta_ms; - if ((timer_ms_ <= 0 || SKIPPED) && next_factory_) { - next_scene_ = next_factory_(); + const bool P1_START = pressedStart(0); + const bool P2_START = pressedStart(1); + + switch (phase_) { + case Phase::IDLE: { + timer_ms_ -= delta_ms; + if (P1_START) { + playStartSound(); + enterStarting(DebugConfig::StartingPlayers::P1); + } else if (P2_START) { + playStartSound(); + enterStarting(DebugConfig::StartingPlayers::P2); + } else if (timer_ms_ <= 0) { + // Attract: ningú ha premut. Entrem a STARTING amb BOTH + // perquè el fade-out de transició també hi compti. + // Demà, quan tinguem una DemoScene, ací anirà la + // transició a demo en lloc de a intro. + enterStarting(DebugConfig::StartingPlayers::BOTH); + } + break; + } + case Phase::STARTING: { + starting_timer_ms_ -= delta_ms; + // Si l'altre jugador prem START dins de la finestra (i el + // fade encara no ha arrencat), pugem a BOTH i resetegem el + // comptador. Una vegada el fade ja està actiu, ignorem + // pulsacions per no saltar visualment la transició. + const bool OTHER_PRESSED = + (pending_players_ == DebugConfig::StartingPlayers::P1 && P2_START) || + (pending_players_ == DebugConfig::StartingPlayers::P2 && P1_START); + const bool FADE_LOCKED = fade_out_ && (fade_out_->isActive() || fade_out_->isFinished()); + if (OTHER_PRESSED && !FADE_LOCKED) { + playStartSound(); + pending_players_ = DebugConfig::StartingPlayers::BOTH; + starting_timer_ms_ = UiConfig::get().titleStartingMs(); + } + + // Arrenca el fade quan al comptador li queden <= fade_out_ms, + // i només una vegada (ni en repeticions ni si l'altre jugador + // ha resetejat el timer). + const int FADE_MS = UiConfig::get().titleFadeOutMs(); + if (fade_out_ && !fade_out_->isActive() && !fade_out_->isFinished() && starting_timer_ms_ <= FADE_MS && FADE_MS > 0) { + fade_out_->begin(FADE_MS); + } + if (fade_out_ && fade_out_->isActive()) { fade_out_->tick(delta_ms); } + + if (starting_timer_ms_ <= 0) { + advanceTo(pending_players_); + } + break; + } } Notifier::get().update(delta_ms); RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow())); } -void TitleScene::render() { - constexpr std::uint32_t BLACK_ARGB = 0xFF000000U; - Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, BLACK_ARGB); +void TitleScene::enterStarting(DebugConfig::StartingPlayers players) { + phase_ = Phase::STARTING; + pending_players_ = players; + starting_timer_ms_ = UiConfig::get().titleStartingMs(); + // Resetem el comptador del parpadeig perquè el primer frame de + // STARTING comenci visible — sense això, el modulus amb el nou + // període podria caure mig cicle i donar un primer "off" estrany. + blink_elapsed_ms_ = 0; + // Fade-out de la música. Si l'altre jugador prem START i el timer + // es reseteja, no rearranquem la música: es manté la fosa iniciada + // ací (consistent amb el fade visual, que tampoc es reverteix). + if (auto* audio = Audio::get(); audio != nullptr) { + audio->fadeOutMusic(UiConfig::get().titleMusicFadeOutMs()); + } + std::cout << "[scene] " << SCENE_NAME << " STARTING (players=" << DebugConfig::toString(players) << ")\n"; +} - if (font_) { - const int SECS_LEFT = std::max(1, (timer_ms_ + 999) / 1000); - font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, +void TitleScene::advanceTo(DebugConfig::StartingPlayers players) { + if (next_factory_) { + next_scene_ = next_factory_(players); + } +} + +void TitleScene::render() { + const auto& ui_config = UiConfig::get(); + const auto& palette = Resource::Cache::get().getPalette("ui"); + + // Fons: la imatge si està disponible (cobreix tot el canvas amb la + // seua paleta nadiua), opcionalment amb heat-haze; en cas contrari, + // color pla de la paleta UI com a fallback defensiu. + if (background_) { + if (heat_haze_) { + heat_haze_->render(*background_, 0, 0); + } else { + background_->render(0, 0); + } + } else { + Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, palette[ui_config.titleBgColor()]); + } + + const Uint8 TEXT_COLOR = ui_config.titleTextColor(); + const Uint8 OUTLINE_COLOR = ui_config.titleCopyrightColor(); + constexpr Uint8 OUTLINE_DIST = 1; + constexpr Uint8 STROKE4 = Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG; + + if (display_font_) { + const int TITLE_Y = (GameCanvas::HEIGHT * TITLE_Y_PERCENT) / 100; + display_font_->writeDX(STROKE4, GameCanvas::CENTER_X, - GameCanvas::CENTER_Y + NAME_Y_OFFSET, - SCENE_NAME, - 1, - UiConfig::get().notificationText()); - font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, + TITLE_Y, + TITLE_LINE_1, + display_font_->getDefaultKerning(), + TEXT_COLOR, + OUTLINE_DIST, + OUTLINE_COLOR); + display_font_->writeDX(STROKE4, GameCanvas::CENTER_X, - GameCanvas::CENTER_Y + TIMER_Y_OFFSET, - std::to_string(SECS_LEFT), - 1, - UiConfig::get().notificationText()); + TITLE_Y + titleLineGap(*display_font_), + TITLE_LINE_2, + display_font_->getDefaultKerning(), + TEXT_COLOR, + OUTLINE_DIST, + OUTLINE_COLOR); + } + + if (condensed_font_) { + // Cicle de parpadeig: a IDLE pren visible_ms+hidden_ms del yaml + // (asimètric, 80/20 per defecte); a STARTING commuta a 50/50 amb + // `starting_blink_period_ms` per donar feedback de "polsat". + // Si el cicle val 0 (configuració degenerada), cau a "sempre visible". + int cycle_ms = 0; + int visible_ms = 0; + if (phase_ == Phase::STARTING) { + cycle_ms = std::max(0, ui_config.titleStartingBlinkPeriodMs()); + visible_ms = cycle_ms / 2; + } else { + visible_ms = std::max(0, ui_config.titleBlinkVisibleMs()); + cycle_ms = visible_ms + std::max(0, ui_config.titleBlinkHiddenMs()); + } + const bool VISIBLE = (cycle_ms == 0) || ((blink_elapsed_ms_ % cycle_ms) < visible_ms); + + if (VISIBLE) { + const int PROMPT_Y = (GameCanvas::HEIGHT * PROMPT_Y_PERCENT) / 100; + condensed_font_->writeDX(STROKE4, + GameCanvas::CENTER_X, + PROMPT_Y, + Locale::get().text("title.press_start"), + condensed_font_->getDefaultKerning() - 1, + TEXT_COLOR, + OUTLINE_DIST, + OUTLINE_COLOR); + } + } + + // Footer: minilogo centrat horitzontalment damunt del © de + // copyright. El text va al 90% Y, el logo just damunt amb un petit + // gap (LOGO_GAP_PX). Tots dos blocs són hardcodejats fins que + // tinguem artwork final. + if (condensed_font_) { + const int COPY_Y = (GameCanvas::HEIGHT * COPYRIGHT_Y_PERCENT) / 100; + if (mini_logo_) { + const int LOGO_X = GameCanvas::CENTER_X - (mini_logo_->getWidth() / 2); + const int LOGO_Y = COPY_Y - mini_logo_->getHeight() - LOGO_GAP_PX; + mini_logo_->render(LOGO_X, LOGO_Y); + } + condensed_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, + GameCanvas::CENTER_X, + COPY_Y, + "© 2026 JailDesigner", + condensed_font_->getDefaultKerning() - 1, + ui_config.titleCopyrightColor()); } Notifier::get().render(); + + // El fade-out cobreix l'escena i el Notifier (forma part de la + // transició), però NO el RenderInfo (overlay de debug, sempre + // visible). + if (fade_out_ && (fade_out_->isActive() || fade_out_->isFinished())) { + fade_out_->render(); + } + RenderInfo::get().render(); Screen::get().present(); } diff --git a/source/game/scenes/title_scene.hpp b/source/game/scenes/title_scene.hpp index 19fa3d7..3af0aa8 100644 --- a/source/game/scenes/title_scene.hpp +++ b/source/game/scenes/title_scene.hpp @@ -1,30 +1,56 @@ #pragma once +#include #include #include +#include +#include "core/rendering/effects/heat_haze.hpp" +#include "core/rendering/transitions/mosaic.hpp" #include "core/scene/scene.hpp" +#include "game/debug_config.hpp" +class Surface; class Text; -// Escena placeholder del títol. Mateix comportament que LogoScene — duració -// fixa de 5 s i skip amb tecla no reservada —, però classe separada perquè -// quan divergeixi en contingut real (menú, seleccions, animacions) creixi en -// el seu propi fitxer sense haver de refactoritzar cap base comuna. +// Escena TITLE provisional. Pinta dos blocs de text sobre un fons gairebé +// negre i un prompt "press start" intermitent fins que apareix l'artwork +// real. Tota la cosmètica (colors, durada, ritme del parpadeig) viu a +// data/ui.yaml secció `title:` per poder afinar-la sense recompilar. class TitleScene : public Scene { public: - using SceneFactory = std::function()>; + using SceneFactory = std::function(DebugConfig::StartingPlayers)>; - static constexpr int DURATION_MS = 5000; + // Fases de l'escena. + // - IDLE: animació atràctil. Cap tecla salta; només P1 START o + // P2 START fan transició. El timeout (`title.duration_ms`) + // salta a la següent escena amb BOTH (modus attract). + // - STARTING: comptador fix entre la primera pulsació de START i + // la transició. Si l'altre jugador prem START dins d'aquesta + // finestra, el comptador es reseteja amb `pending_players_=BOTH`. + enum class Phase : std::uint8_t { IDLE, + STARTING }; - TitleScene(std::shared_ptr font, SceneFactory next_factory); + TitleScene(std::shared_ptr display_font, std::shared_ptr condensed_font, std::shared_ptr background, std::shared_ptr mini_logo, SceneFactory next_factory); ~TitleScene() override; void update(int delta_ms) override; void render() override; private: - std::shared_ptr font_; + void enterStarting(DebugConfig::StartingPlayers players); + void advanceTo(DebugConfig::StartingPlayers players); + + std::shared_ptr display_font_; + std::shared_ptr condensed_font_; + std::shared_ptr background_; + std::shared_ptr mini_logo_; SceneFactory next_factory_; - int timer_ms_{DURATION_MS}; + int timer_ms_; + int blink_elapsed_ms_{0}; + std::optional heat_haze_; + Phase phase_{Phase::IDLE}; + int starting_timer_ms_{0}; + DebugConfig::StartingPlayers pending_players_{DebugConfig::StartingPlayers::BOTH}; + std::optional fade_out_; }; diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index 1da9411..75b1f7c 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -79,6 +79,7 @@ namespace Defaults { constexpr SDL_Scancode RIGHT = SDL_SCANCODE_RIGHT; constexpr SDL_Scancode JUMP = SDL_SCANCODE_M; constexpr SDL_Scancode ATTACK = SDL_SCANCODE_N; + constexpr SDL_Scancode START = SDL_SCANCODE_1; constexpr SDL_Scancode EXIT = SDL_SCANCODE_ESCAPE; } // namespace KeysGame @@ -93,6 +94,7 @@ namespace Defaults { constexpr SDL_Scancode RIGHT = SDL_SCANCODE_D; constexpr SDL_Scancode JUMP = SDL_SCANCODE_V; constexpr SDL_Scancode ATTACK = SDL_SCANCODE_C; + constexpr SDL_Scancode START = SDL_SCANCODE_2; } // namespace KeysGameP2 namespace Player { @@ -242,6 +244,62 @@ namespace Defaults { // Els fitxers viuen a `data/sounds/`; només es referencia el nom. constexpr const char* MENU_SELECT_SOUND = "menu_select.wav"; constexpr const char* MENU_ACCEPT_SOUND = "menu_accept.wav"; + + // So disparat quan un jugador prem START a la TITLE (tant la + // primera pulsació que entra a STARTING com la del segon + // jugador que reseteja a BOTH dins de la finestra). Fitxer a + // `data/sounds/`. + constexpr const char* PLAYER_START_SOUND = "player_start.wav"; + + // Escena TITLE (provisional fins que tinguem artwork). Colors + // referits a la paleta "ui" (uzebox.pal): BG_COLOR és el to més + // proper al negre sense ser-ho (índex 1 = 36,0,0); TEXT_COLOR és + // el taronja més pur de la paleta (índex 31 = 255,109,0). Els + // ms del cicle de parpadeig sumen el cicle complet del prompt + // "press start" — 80% visible / 20% ocult per defecte. + namespace Title { + constexpr int DURATION_MS = 30000; + constexpr Uint8 BG_COLOR = 1; + constexpr Uint8 TEXT_COLOR = 31; + constexpr Uint8 COPYRIGHT_COLOR = 246; // (218,218,255) off-white amb un mínim de fred. + constexpr int BLINK_VISIBLE_MS = 800; + constexpr int BLINK_HIDDEN_MS = 200; + + // Fase STARTING: durada fixa entre la primera pulsació de + // START i la transició a la següent escena. Durant aquesta + // fase, el parpadeig del prompt commuta a un cicle simètric + // 50/50 (visible/ocult) amb període `STARTING_BLINK_PERIOD_MS`, + // pensat per a un ritme molt més ràpid que l'animació + // atràctil del IDLE (200 ms = 5 Hz). + constexpr int STARTING_MS = 3000; + constexpr int STARTING_BLINK_PERIOD_MS = 200; + + // Fade-out estil mosaic dissolve dins l'última part de la + // fase STARTING. `FADE_OUT_MS` ha de ser <= STARTING_MS; + // si és més gran, comença ja a t=0 i acaba abans del salt. + // `FADE_OUT_MAX_BLOCK` és la mida màxima de bloc en píxels + // (32 al canvas de 320×240 dóna 10×8 blocs al final). + constexpr int FADE_OUT_MS = 800; + constexpr int FADE_OUT_MAX_BLOCK = 32; + + // Fade-out de la música de la TITLE en entrar a STARTING. + // Independent del visual: no cal que coincidisca amb FADE_OUT_MS + // (la música pot acabar abans/després del mosaic). + constexpr int MUSIC_FADE_OUT_MS = 2000; + + // Distorsió sinusoidal horitzontal de l'imatge de fons + // (efecte de calor del desert). `amplitude_px` és el + // desplaçament màxim per fila; `wavelength_px` la distància + // vertical entre dos pics; `speed_hz` la velocitat amb què + // avança la fase (cicles/segon). Si `enabled` és false el + // fons es pinta sense distorsió. + namespace HeatHaze { + constexpr bool ENABLED = true; + constexpr float AMPLITUDE_PX = 1.5F; + constexpr float WAVELENGTH_PX = 32.0F; + constexpr float SPEED_HZ = 0.6F; + } // namespace HeatHaze + } // namespace Title } // namespace UI #ifdef _DEBUG