merge: feat/scene-title — TITLE com a escena jugable amb música, SFX i FSM IDLE/STARTING

This commit is contained in:
JailDesigner committed 2026-05-06 18:15:17 +02:00
commit 25ce524470
34 files changed
+809 -75

No files matched your search

+2
View File
@@ -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
+6 -5
View File
@@ -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 # ©
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+6 -5
View File
@@ -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 # ©
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+5
View File
@@ -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"
+5
View File
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+47
View File
@@ -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
+44 -1
View File
@@ -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<int>(MUSIC_LOW_WATER_SECONDS * static_cast<float>(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<short*>(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<const void*>(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 <typename Fn>
@@ -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;
+7
View File
@@ -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;
};
+2 -1
View File
@@ -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<int>(SDL_GAMEPAD_BUTTON_INVALID)});
sequence_.push_back({"menu.items.jump", GamepadBindings::Action::JUMP, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)});
sequence_.push_back({"menu.items.start", GamepadBindings::Action::START, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)});
sequence_.push_back({"menu.items.service", GamepadBindings::Action::SERVICE, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)});
index_ = 0;
}
+3
View File
@@ -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<int, std::string_view>;
@@ -98,6 +99,8 @@ auto GamepadBindings::defaultButton(Action action) -> int {
return static_cast<int>(SDL_GAMEPAD_BUTTON_WEST);
case Action::SERVICE:
return static_cast<int>(SDL_GAMEPAD_BUTTON_BACK);
case Action::START:
return static_cast<int>(SDL_GAMEPAD_BUTTON_START);
}
return static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID);
}
+1
View File
@@ -25,6 +25,7 @@ class GamepadBindings {
JUMP,
ATTACK,
SERVICE,
START,
};
// Triggers L2/R2 representats com a "botons virtuals" — replica la
@@ -0,0 +1,48 @@
#include "core/rendering/effects/heat_haze.hpp"
#include <SDL3/SDL.h>
#include <cmath>
#include <numbers>
#include "core/rendering/surface.hpp"
namespace Effects {
namespace {
constexpr float TWO_PI = 2.0F * std::numbers::pi_v<float>;
}
HeatHaze::HeatHaze(Params params)
: params_(params) {}
void HeatHaze::tick(int delta_ms) {
const float DT = static_cast<float>(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<float>(src.getWidth());
for (int row = 0; row < H; ++row) {
const float ARG = (TWO_PI * static_cast<float>(row) / WL) + phase_;
const int DX = static_cast<int>(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<float>(row), W, 1.0F};
src.render(x + DX, y + row, &row_rect);
}
}
} // namespace Effects
@@ -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
@@ -0,0 +1,71 @@
#include "core/rendering/transitions/mosaic.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#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<float>(elapsed_ms_) / static_cast<float>(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<int>(std::ceil(static_cast<float>(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<std::ptrdiff_t>(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::uint8_t>(std::round(255.0F * P * P));
if (ALPHA > 0) {
screen.blendRect(0, 0, W, H, 0x00000000U, ALPHA);
}
}
} // namespace Transitions
@@ -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
+34 -14
View File
@@ -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<Scene> {
// `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<Scene> {
auto cfg = GameScene::defaultConfig();
cfg.starting_players = STARTING_PLAYERS;
cfg.starting_players = players;
return std::make_unique<GameScene>(std::move(cfg));
};
auto make_intro = [placeholder_text, make_game]() -> std::unique_ptr<Scene> {
return std::make_unique<IntroScene>(placeholder_text, make_game);
auto make_intro = [placeholder_text, make_game](DebugConfig::StartingPlayers players) -> std::unique_ptr<Scene> {
return std::make_unique<IntroScene>(placeholder_text, players, make_game);
};
auto make_title = [placeholder_text, make_intro]() -> std::unique_ptr<Scene> {
return std::make_unique<TitleScene>(placeholder_text, make_intro);
auto make_title = [menu_title_text, placeholder_text, title_bg_surface, mini_logo_surface, make_intro]() -> std::unique_ptr<Scene> {
return std::make_unique<TitleScene>(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();
+41
View File
@@ -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")) {
+36
View File
@@ -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_;
+2
View File
@@ -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`
+3 -4
View File
@@ -14,7 +14,6 @@
#include <vector>
#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
+6 -4
View File
@@ -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
+4 -3
View File
@@ -30,10 +30,11 @@ namespace {
} // namespace
IntroScene::IntroScene(std::shared_ptr<Text> font, SceneFactory next_factory)
IntroScene::IntroScene(std::shared_ptr<Text> 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);
+8 -2
View File
@@ -4,19 +4,24 @@
#include <memory>
#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<std::unique_ptr<Scene>()>;
using SceneFactory = std::function<std::unique_ptr<Scene>(DebugConfig::StartingPlayers)>;
static constexpr int DURATION_MS = 5000;
IntroScene(std::shared_ptr<Text> font, SceneFactory next_factory);
IntroScene(std::shared_ptr<Text> 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<Text> font_;
DebugConfig::StartingPlayers starting_players_;
SceneFactory next_factory_;
int timer_ms_{DURATION_MS};
};
+253 -27
View File
@@ -8,32 +8,105 @@
#include <string>
#include <utility>
#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<int>(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<Text> font, SceneFactory next_factory)
: font_(std::move(font)),
next_factory_(std::move(next_factory)) {
TitleScene::TitleScene(std::shared_ptr<Text> display_font, std::shared_ptr<Text> condensed_font, std::shared_ptr<Surface> background, std::shared_ptr<Surface> 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();
}
+35 -9
View File
@@ -1,30 +1,56 @@
#pragma once
#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
#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<std::unique_ptr<Scene>()>;
using SceneFactory = std::function<std::unique_ptr<Scene>(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<Text> font, SceneFactory next_factory);
TitleScene(std::shared_ptr<Text> display_font, std::shared_ptr<Text> condensed_font, std::shared_ptr<Surface> background, std::shared_ptr<Surface> mini_logo, SceneFactory next_factory);
~TitleScene() override;
void update(int delta_ms) override;
void render() override;
private:
std::shared_ptr<Text> font_;
void enterStarting(DebugConfig::StartingPlayers players);
void advanceTo(DebugConfig::StartingPlayers players);
std::shared_ptr<Text> display_font_;
std::shared_ptr<Text> condensed_font_;
std::shared_ptr<Surface> background_;
std::shared_ptr<Surface> mini_logo_;
SceneFactory next_factory_;
int timer_ms_{DURATION_MS};
int timer_ms_;
int blink_elapsed_ms_{0};
std::optional<Effects::HeatHaze> heat_haze_;
Phase phase_{Phase::IDLE};
int starting_timer_ms_{0};
DebugConfig::StartingPlayers pending_players_{DebugConfig::StartingPlayers::BOTH};
std::optional<Transitions::Mosaic> fade_out_;
};
+58
View File
@@ -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