diff --git a/CMakeLists.txt b/CMakeLists.txt index 7efa628..26dfeea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,7 @@ set(APP_SOURCES source/game/options.cpp source/game/options_apply.cpp source/game/ui/service_menu.cpp + source/game/ui/hud.cpp source/game/scenes/placeholder_input.cpp source/game/scenes/logo_scene.cpp source/game/scenes/title_scene.cpp diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 1b2d104..694e2b9 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -29,6 +29,7 @@ #include "game/entities/player.hpp" #include "game/map/map.hpp" #include "game/options.hpp" +#include "game/ui/hud.hpp" #include "game/ui/ui_palette.hpp" #include "utils/defaults.hpp" #include "utils/defines.hpp" @@ -139,6 +140,16 @@ GameScene::GameScene(Config config) // sarcòfags passa per `tile_painted`. players_.back()->setEventBus(&entity_manager_.getBus()); + // Marcador (HUD) de la franja superior. Reutilitza la font 8bithud i + // la paleta "ui" ja precarregades pel Director — no toca disc des + // d'aquí. Subscrit al bus per reaccionar a `player_hit`, + // `level_complete`, etc. amb shake/flash i futurs canvis de mode. + const std::string HUD_FONT_GIF_KEY = std::string(Defaults::UI::Font::HUD_NAME) + ".gif"; + auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY); + auto hud_font = std::make_shared(hud_font_surface, Defaults::UI::Font::HUD_FNT); + hud_font->setDefaultKerning(Defaults::UI::Font::HUD_KERNING); + hud_ = std::make_unique(entity_manager_.getBus(), hud_font, Resource::Cache::get().getPalette("ui")); + // Música de fons en bucle infinit. El track ja està al Resource::Cache // (preload al Director), així que playMusic només mou el punter cap a // jail_audio i obri l'stream. Audio::get() està garantit no-null: @@ -236,6 +247,11 @@ void GameScene::update(int delta_ms) { editor_.update(delta_ms, tilemap_); #endif + // El HUD continua avançant les seues animacions encara que el món + // estigui congelat (pausa o editor): així l'animació d'entrada acaba + // sola i quan es despausa ja està al seu lloc, igual que el Notifier. + if (hud_) { hud_->update(delta_ms); } + RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW)); Notifier::get().update(delta_ms); } @@ -291,6 +307,11 @@ void GameScene::render() { r.player->render(); } } + // HUD: a sobre dels actors perquè la franja superior tape qualsevol + // sprite que (durant l'animació d'entrada) puga sortir més amunt + // del playfield. S'amaga en mode editor (l'editor edita el tilemap; + // el HUD distrauria) però segueix actualitzant-se per fora. + if (hud_) { hud_->render(); } } #ifdef _DEBUG diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 346f717..e576181 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -13,6 +13,7 @@ #include "game/editor/editor.hpp" #endif +class Hud; class Player; class Text; @@ -63,6 +64,14 @@ class GameScene : public Scene { // dos membres per garantir l'ordre invers a la destrucció. std::unique_ptr paint_layer_; + // Marcador de la franja superior (24 px). `unique_ptr` per controlar + // l'ordre de construcció/destrucció igual que `paint_layer_`: es crea + // al ctor un cop `entity_manager_` ja té el bus, i es destrueix abans + // que `entity_manager_` (on viu el bus al qual està subscrit). + // Declarat després d'`entity_manager_` per garantir l'ordre invers a + // la destrucció. + std::unique_ptr hud_; + // Pausa de gameplay. Quan `paused_` és true, l'update de jugadors i // entitats es salta (el món es congela); l'input d'UI (GlobalInputs, // Notifier, RenderInfo) continua per no perdre hotkeys ni l'overlay de diff --git a/source/game/ui/hud.cpp b/source/game/ui/hud.cpp new file mode 100644 index 0000000..de1a27e --- /dev/null +++ b/source/game/ui/hud.cpp @@ -0,0 +1,278 @@ +#include "game/ui/hud.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/rendering/screen.hpp" +#include "core/rendering/text.hpp" +#include "game/ui/ui_palette.hpp" +#include "utils/defaults.hpp" +#include "utils/defines.hpp" +#include "utils/easing.hpp" + +namespace { + + // Colors HUD reaprofitats de la paleta UI: les etiquetes ("P1", "V", + // "T") usen un to apagat per crear jerarquia visual respecte als + // valors. El flash temporal usa un to càlid (groc del títol del menú). + constexpr Uint8 LABEL_COLOR = UiColor::MENU_LABEL; // 47 + constexpr Uint8 VALUE_COLOR = UiColor::MENU_VALUE; // 197 + constexpr Uint8 BG_COLOR = UiColor::MENU_BG; // 40 + constexpr Uint8 FLASH_COLOR = Defaults::UI::MENU_TITLE; // 213 + + // 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; + + // Separadors visuals entre etiquetes i valors. + constexpr int LABEL_GAP = 2; // px entre "P1" i "000000" + constexpr int FIELD_GAP = 6; // px entre "V 3" i "T 00" + + // 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, const Palette& ui_palette) + : bus_(bus), + font_(std::move(font)), + ui_palette_(ui_palette) { + // Geometria fixa dels tres blocs. Sumen exactament GameCanvas::WIDTH. + blocks_[0] = Block{ + .x = 0, + .y = 0, + .w = LEFT_W, + .h = HEIGHT_PX, + .entry_offset_x = -LEFT_W, + .entry_offset_y = 0, + }; + blocks_[1] = Block{ + .x = LEFT_W, + .y = 0, + .w = CENTER_W, + .h = HEIGHT_PX, + .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_offset_x = RIGHT_W, + .entry_offset_y = 0, + }; + + // Per defecte, P1 actiu (sempre n'hi ha al menys un); P2 inactiu fins + // que un segon Player s'uneix mitjançant `activatePlayer(2)`. + players_[0].active = true; + players_[1].active = false; + + // 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); + // TODO: quan l'event porte player_id, fer dispatch al bloc real. + // Mentrestant, apliquem el hit a P1 (consistent amb el log del + // GameScene actual, que tampoc desambigua el jugador). + constexpr std::size_t IDX = 0; + if (players_[IDX].lives > 0) { --players_[IDX].lives; } + auto& blk = blocks_[IDX]; // P1 → bloc esquerre + blk.flash_ms_left = FLASH_MS; + blk.shake_ms_left = SHAKE_MS; + blk.shake_intensity = 1.5F; + std::cout << "[HUD] player_hit dmg=" << (dmg != nullptr ? *dmg : 0) + << " lives=" << players_[IDX].lives << '\n'; + })); + + 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. + std::cout << "[HUD] level_complete (mode Win pendent)\n"; + })); + + std::cout << "[HUD] init: 3 blocks ready (entry " << ENTRY_MS << " ms)\n"; +} + +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 = DEFAULT_LIVES; + p.score = 0; + p.treasures = 0; +} + +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(ENTRY_MS)); + 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(SHAKE_MS); + 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 { + 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)); + + // Fons sòlid del bloc (mateixa convenció que `Notifier::render`): + // ARGB directe al canvas, sense tocar surfaces paletades. + Screen::get().fillRect(X, Y, b.w, b.h, ui_palette_[BG_COLOR]); + + switch (b.mode) { + case Mode::IDLE: + if (id == BlockId::LEFT) { + renderIdleSide(X, Y, b.w, b, 0, "P1"); + } else if (id == BlockId::RIGHT) { + renderIdleSide(X, Y, b.w, b, 1, "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 char* label) const { + const auto& p = players_[static_cast(player_index)]; + const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? FLASH_COLOR : VALUE_COLOR; + + // --- Línia 1: "