feat(hud): marcador de la franja superior amb 3 blocs i animació d'entrada

This commit is contained in:
JailDesigner committed 2026-04-23 23:46:41 +02:00
1 parent 67ecb704d2
commit 82b229222f
6 files changed
+474 -1

No files matched your search

+278
View File
@@ -0,0 +1,278 @@
#include "game/ui/hud.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <string>
#include <utility>
#include <variant>
#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<float>(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<Text> 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<int>(&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<std::size_t>(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<std::size_t>(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<float>(b.entry_ms_left) / static_cast<float>(ENTRY_MS));
const float E = Easing::outCubic(T);
const float REMAIN = 1.0F - E; // 1 al principi → 0 al final
off.x += static_cast<float>(b.entry_offset_x) * REMAIN;
off.y += static_cast<float>(b.entry_offset_y) * REMAIN;
}
// Shake: amplitud decau linealment amb el temps restant.
if (b.shake_ms_left > 0) {
const float DECAY = static_cast<float>(b.shake_ms_left) / static_cast<float>(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<std::size_t>(id);
const auto& b = blocks_[IDX];
const auto OFF = computeOffset(b);
const int X = b.x + static_cast<int>(std::round(OFF.x));
const int Y = b.y + static_cast<int>(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<std::size_t>(player_index)];
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? FLASH_COLOR : VALUE_COLOR;
// --- Línia 1: "<label> 000000" centrada ---
const std::string LBL{label};
const std::string SCORE = formatScore(p.score);
const int LBL_W = font_->length(LBL);
const int SC_W = font_->lengthMonoDigits(SCORE, MONO_DIGIT_W);
const int LINE1_W = LBL_W + LABEL_GAP + SC_W;
int xx = x + ((w - LINE1_W) / 2);
font_->writeColored(xx, y + LINE1_Y, LBL, LABEL_COLOR);
xx += LBL_W + LABEL_GAP;
font_->writeMonoDigits(xx, y + LINE1_Y, SCORE, SCORE_C, MONO_DIGIT_W);
// --- Línia 2: "V <vides> T <tresors>" centrada ---
const std::string V_LBL{"V"};
const std::string T_LBL{"T"};
const std::string V_VAL = std::to_string(std::clamp(p.lives, 0, 9));
const std::string T_VAL = formatTwoDigits(p.treasures);
const int V_LW = font_->length(V_LBL);
const int V_VW = font_->lengthMonoDigits(V_VAL, MONO_DIGIT_W);
const int T_LW = font_->length(T_LBL);
const int T_VW = font_->lengthMonoDigits(T_VAL, MONO_DIGIT_W);
const int LINE2_W = V_LW + LABEL_GAP + V_VW + FIELD_GAP + T_LW + LABEL_GAP + T_VW;
xx = x + ((w - LINE2_W) / 2);
font_->writeColored(xx, y + LINE2_Y, V_LBL, LABEL_COLOR);
xx += V_LW + LABEL_GAP;
font_->writeMonoDigits(xx, y + LINE2_Y, V_VAL, VALUE_COLOR, MONO_DIGIT_W);
xx += V_VW + FIELD_GAP;
font_->writeColored(xx, y + LINE2_Y, T_LBL, LABEL_COLOR);
xx += T_LW + LABEL_GAP;
font_->writeMonoDigits(xx, y + LINE2_Y, T_VAL, VALUE_COLOR, MONO_DIGIT_W);
}
void Hud::renderIdleCenter(int x, int y, int w) const {
// --- Línia 1: "PIRAMIDE <n>" centrada ---
const std::string PIR_LBL{"PIRAMIDE"};
const std::string PIR_VAL = std::to_string(pyramid_);
const int PL_W = font_->length(PIR_LBL);
const int PV_W = font_->lengthMonoDigits(PIR_VAL, MONO_DIGIT_W);
const int LINE1_W = PL_W + LABEL_GAP + PV_W;
int xx = x + ((w - LINE1_W) / 2);
font_->writeColored(xx, y + LINE1_Y, PIR_LBL, LABEL_COLOR);
xx += PL_W + LABEL_GAP;
font_->writeMonoDigits(xx, y + LINE1_Y, PIR_VAL, VALUE_COLOR, MONO_DIGIT_W);
// --- Línia 2: "SALA <r>" centrada ---
const std::string SALA_LBL{"SALA"};
const std::string SALA_VAL = std::to_string(room_);
const int SL_W = font_->length(SALA_LBL);
const int SV_W = font_->lengthMonoDigits(SALA_VAL, MONO_DIGIT_W);
const int LINE2_W = SL_W + LABEL_GAP + SV_W;
xx = x + ((w - LINE2_W) / 2);
font_->writeColored(xx, y + LINE2_Y, SALA_LBL, LABEL_COLOR);
xx += SL_W + LABEL_GAP;
font_->writeMonoDigits(xx, y + LINE2_Y, SALA_VAL, VALUE_COLOR, MONO_DIGIT_W);
}