#include "game/ui/hud.hpp" #include #include #include #include #include #include #include #include #include "core/locale/locale.hpp" #include "core/rendering/screen.hpp" #include "core/rendering/text.hpp" #include "core/resources/resource_cache.hpp" #include "core/ui/ui_config.hpp" #include "utils/defaults.hpp" #include "utils/defines.hpp" #include "utils/easing.hpp" #include "utils/log.hpp" namespace { // Els colors del HUD viuen a data/config/ui.yaml (colors.hud_label/value/flash) // via UiConfig. Les etiquetes ("P1", "V", "T") usen un to apagat per // crear jerarquia visual respecte als valors; el flash temporal usa un // to càlid que contrasta amb el valor normal. // Amplada nominal d'una cel·la de dígit. Imposem monoespaiat als // comptadors per evitar jitter visible quan canvien valors (Notifier // ho fa igual amb `writeMonoDigits`). constexpr int MONO_DIGIT_W = 6; // Separador que s'enganxa al final de l'etiqueta abans del valor // ("P1 000000", "Vides 3"...). Canviant aquesta cadena aquí es // controla el look-and-feel de totes les línies del HUD. constexpr const char* SEPARATOR = " "; // Separació extra entre camps dins d'una mateixa línia. constexpr int FIELD_GAP = 6; // px entre "Vides 3" i "Tres. 00" // Desplaçament de l'ombra del text en píxels (sota-dreta). Petit // perquè la font del HUD és 8 px d'alçada — qualsevol valor més // gran tapa el següent caràcter. constexpr int SHADOW_DX = 1; constexpr int SHADOW_DY = 1; // Helpers per pintar text amb ombra. Primer la sombra desplaçada, // després el text principal al damunt — l'ordre garanteix que la // sombra mai cobreix el text. El kerning queda al default del // Text (1 px), igual que la resta del HUD. void drawShadowed(Text& font, int x, int y, const std::string& s, Uint8 color, Uint8 shadow) { font.writeColored(x + SHADOW_DX, y + SHADOW_DY, s, shadow); font.writeColored(x, y, s, color); } void drawMonoShadowed(Text& font, int x, int y, const std::string& s, Uint8 color, Uint8 shadow, int cell_w) { font.writeMonoDigits(x + SHADOW_DX, y + SHADOW_DY, s, shadow, cell_w); font.writeMonoDigits(x, y, s, color, cell_w); } // Format zero-padded a 6 dígits per a la puntuació. auto formatScore(int v) -> std::string { const int CLAMPED = std::clamp(v, 0, 999999); std::string s = std::to_string(CLAMPED); return std::string(6 - s.size(), '0') + s; } // Format zero-padded a 2 dígits per a comptadors petits (tresors, // sales, vides quan en tenim moltes). auto formatTwoDigits(int v) -> std::string { const int CLAMPED = std::clamp(v, 0, 99); std::string s = std::to_string(CLAMPED); return std::string(2 - s.size(), '0') + s; } // Vibració suau per al shake: dues sinusoides desfasades amb // freqüències lleugerament diferents per a evitar el patró regular // que faria una sola sinusoide. La fase ve dels ms restants — així // queda determinista per frame, sense necessitat d'un PRNG. auto shakeOffset(float intensity, int phase_ms) -> SDL_FPoint { const float ANG = static_cast(phase_ms) * 0.07F; return SDL_FPoint{ .x = std::cos(ANG) * intensity, .y = std::sin(ANG * 1.31F) * intensity, }; } } // namespace Hud::Hud(EventBus& bus, std::shared_ptr font, int initial_lives) : bus_(bus), font_(std::move(font)), initial_lives_(initial_lives) { // Geometria fixa dels tres blocs. Sumen exactament GameCanvas::WIDTH. // La durada de l'animació d'entrada ve de data/config/ui.yaml via UiConfig. const int ENTRY_MS = UiConfig::get().hudEntryMs(); blocks_[0] = Block{ .x = 0, .y = 0, .w = LEFT_W, .h = HEIGHT_PX, .entry_ms_left = ENTRY_MS, .entry_offset_x = -LEFT_W, .entry_offset_y = 0, }; blocks_[1] = Block{ .x = LEFT_W, .y = 0, .w = CENTER_W, .h = HEIGHT_PX, .entry_ms_left = ENTRY_MS, .entry_offset_x = 0, .entry_offset_y = -HEIGHT_PX, }; blocks_[2] = Block{ .x = LEFT_W + CENTER_W, .y = 0, .w = RIGHT_W, .h = HEIGHT_PX, .entry_ms_left = ENTRY_MS, .entry_offset_x = RIGHT_W, .entry_offset_y = 0, }; // Naixem amb tots dos slots inactius. El GameScene crida // `activatePlayer(...)` per als que arrenquen en partida segons // `starting_players` (un, l'altre, o tots dos). Els no activats // mostraran el prompt "POLSA START / PER JUGAR" via `setJoinPrompt` // fins que el jugador faci join. Les vides s'inicialitzen al valor // del YAML del jugador — sense això, canviar `lives:` no es // reflectiria al marcador. players_[0].active = false; players_[0].lives = initial_lives_; players_[1].active = false; players_[1].lives = initial_lives_; // Subscripcions al bus de l'EntityManager. De moment només `player_hit` // afecta el HUD visualment: decrementa vides, dispara flash i shake al // bloc del jugador. Quan els events evolucionin per portar `player_id` // i puntuacions, ampliarem aquí sense tocar callers. subs_.push_back(bus_.subscribe("player_hit", [this](const Event& ev) { const auto* dmg = std::get_if(&ev.payload); // Dispatch pel `player_id` de l'event. 0 = P1 (bloc LEFT), 1 = P2 // (bloc RIGHT). Si l'event arriba sense id (-1, event world-wide) // fem fallback a P1 per no perdre'l — a la pràctica tots els // publishers de `player_hit` actuals sí que porten id. const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U; if (!players_[IDX].active) { return; } if (players_[IDX].lives > 0) { --players_[IDX].lives; } const std::size_t BLK_IDX = (IDX == 0) ? static_cast(BlockId::LEFT) : static_cast(BlockId::RIGHT); auto& blk = blocks_[BLK_IDX]; blk.flash_ms_left = UiConfig::get().hudFlashMs(); blk.shake_ms_left = UiConfig::get().hudShakeMs(); blk.shake_intensity = 1.5F; Log::info("HUD", "player_hit p{} dmg={} lives={}", IDX + 1, dmg != nullptr ? *dmg : 0, players_[IDX].lives); })); subs_.push_back(bus_.subscribe("level_complete", [](const Event& /*ev*/) { // TODO: capturar `this` i cridar setMode(*, Mode::Win) per als // tres blocs quan el mode Win estiga implementat. Log::info("HUD", "level_complete (mode Win pendent)"); })); // `player_disabled`: el jugador acaba d'esgotar les seves vides al // final del cicle DYING. Tornem el bloc al render inactiu — el // prompt textual el fixa GameScene cada frame via `setJoinPrompt`, // així que ací només cal apagar `active`. Els camps lives/score/ // treasures es preserven per si en una versió futura volem mostrar // el resultat final abans de tornar a zero. subs_.push_back(bus_.subscribe("player_disabled", [this](const Event& ev) { const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U; players_[IDX].active = false; Log::info("HUD", "player_disabled p{}", IDX + 1); })); Log::info("HUD", "init: 3 blocks ready (entry {} ms)", ENTRY_MS); } Hud::~Hud() { for (auto id : subs_) { bus_.unsubscribe(id); } } void Hud::update(int delta_ms) { for (auto& b : blocks_) { if (b.entry_ms_left > 0) { b.entry_ms_left = std::max(0, b.entry_ms_left - delta_ms); } if (b.shake_ms_left > 0) { b.shake_ms_left = std::max(0, b.shake_ms_left - delta_ms); } if (b.flash_ms_left > 0) { b.flash_ms_left = std::max(0, b.flash_ms_left - delta_ms); } } } void Hud::setMode(BlockId block, Mode mode) { blocks_[static_cast(block)].mode = mode; } void Hud::setPyramidRoom(int pyramid, int room) { pyramid_ = pyramid; room_ = room; } void Hud::activatePlayer(int player_one_based) { if (player_one_based < 1 || player_one_based > 2) { return; } auto& p = players_[static_cast(player_one_based - 1)]; p.active = true; p.lives = initial_lives_; p.score = 0; p.treasures = 0; p.join_prompt = JoinPromptMode::NONE; } void Hud::setJoinPrompt(int player_one_based, JoinPromptMode mode) { if (player_one_based < 1 || player_one_based > 2) { return; } players_[static_cast(player_one_based - 1)].join_prompt = mode; } auto Hud::computeOffset(const Block& b) -> SDL_FPoint { SDL_FPoint off{.x = 0.0F, .y = 0.0F}; // Animació d'entrada: t va de 0 (recent iniciat) a 1 (acabada). Amb // outCubic, el bloc arrenca ràpid i s'atura suaument al seu lloc. if (b.entry_ms_left > 0) { const float T = 1.0F - (static_cast(b.entry_ms_left) / static_cast(UiConfig::get().hudEntryMs())); const float E = Easing::outCubic(T); const float REMAIN = 1.0F - E; // 1 al principi → 0 al final off.x += static_cast(b.entry_offset_x) * REMAIN; off.y += static_cast(b.entry_offset_y) * REMAIN; } // Shake: amplitud decau linealment amb el temps restant. if (b.shake_ms_left > 0) { const float DECAY = static_cast(b.shake_ms_left) / static_cast(UiConfig::get().hudShakeMs()); const auto JITTER = shakeOffset(b.shake_intensity * DECAY, b.shake_ms_left); off.x += JITTER.x; off.y += JITTER.y; } return off; } void Hud::render() const { // Backdrop de la franja del HUD. Es pinta abans dels tres blocs perquè, // quan un bloc es mou (entry slide o shake), la zona que deixa al // descobert segueix mostrant el mateix color — en lloc del tilemap de // sota, que causaria un trencament visual. if (UiConfig::get().hudBgEnabled()) { const auto& pal = Resource::Cache::get().getPalette("ui"); Screen::get().fillRect(0, 0, LEFT_W + CENTER_W + RIGHT_W, HEIGHT_PX, pal[UiConfig::get().hudBg()]); } renderBlock(BlockId::LEFT); renderBlock(BlockId::CENTER); renderBlock(BlockId::RIGHT); } void Hud::renderBlock(BlockId id) const { const auto IDX = static_cast(id); const auto& b = blocks_[IDX]; const auto OFF = computeOffset(b); const int X = b.x + static_cast(std::round(OFF.x)); const int Y = b.y + static_cast(std::round(OFF.y)); switch (b.mode) { case Mode::IDLE: if (id == BlockId::LEFT) { renderIdleSide(X, Y, b.w, b, 0, Locale::get().text("hud.p1")); } else if (id == BlockId::RIGHT) { renderIdleSide(X, Y, b.w, b, 1, Locale::get().text("hud.p2")); } else { renderIdleCenter(X, Y, b.w); } break; case Mode::DYING: // TODO: animació de "vida perduda". case Mode::COUNTDOWN: // TODO: 3-2-1 abans de respawn. case Mode::WIN: // TODO: celebració al `level_complete`. case Mode::EMPTY: // Deixa el bloc amb només el fons. break; } } void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index, const std::string& label) const { const auto& p = players_[static_cast(player_index)]; // Família de color del bloc: P1 (vermell-crema-vermell-fosc) per al // jugador 0, P2 (cian-blau-blau-marí) per al jugador 1. Mantenir el // mateix matís a label/value/shadow dóna jerarquia visual coherent // entre línies sense empastar (la sombra és un to fosc del mateix // matís → efecte embossing). const auto& ui = UiConfig::get(); const Uint8 LBL_C = (player_index == 0) ? ui.hudP1Label() : ui.hudP2Label(); const Uint8 VAL_C = (player_index == 0) ? ui.hudP1Value() : ui.hudP2Value(); const Uint8 SHD_C = (player_index == 0) ? ui.hudP1Shadow() : ui.hudP2Shadow(); // Slot inactiu amb prompt actiu: substituïm el render de zeros pel // missatge "PULSA START / PARA JUGAR" o "ESPERA / PARA JUGAR" segons // el mode fixat per la GameScene cada frame. El render de zeros // queda com a fallback (mode NONE o slot actiu). if (!p.active && p.join_prompt != JoinPromptMode::NONE) { const bool CAN_JOIN = (p.join_prompt == JoinPromptMode::CAN_JOIN); const std::string L1 = Locale::get().text( CAN_JOIN ? "hud.join_prompt_can_join_l1" : "hud.join_prompt_wait_l1"); const std::string L2 = Locale::get().text( CAN_JOIN ? "hud.join_prompt_can_join_l2" : "hud.join_prompt_wait_l2"); const int L1_W = font_->length(L1); const int L2_W = font_->length(L2); const int X1 = x + ((w - L1_W) / 2); const int X2 = x + ((w - L2_W) / 2); drawShadowed(*font_, X1, y + LINE1_Y, L1, LBL_C, SHD_C); drawShadowed(*font_, X2, y + LINE2_Y, L2, VAL_C, SHD_C); return; } const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? ui.hudFlash() : VAL_C; // --- Línia 1: "