373 lines
16 KiB
C++
373 lines
16 KiB
C++
#include "game/ui/hud.hpp"
|
|
|
|
#include <SDL3/SDL.h>
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <variant>
|
|
|
|
#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<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, 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<int>(&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<std::size_t>(BlockId::LEFT)
|
|
: static_cast<std::size_t>(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<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 = 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<std::size_t>(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<float>(b.entry_ms_left) / static_cast<float>(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<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>(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<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));
|
|
|
|
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<std::size_t>(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: "<label>-000000" centrada ---
|
|
const std::string LBL1 = label + SEPARATOR;
|
|
const std::string SCORE = formatScore(p.score);
|
|
const int LBL_W = font_->length(LBL1);
|
|
const int SC_W = font_->lengthMonoDigits(SCORE, MONO_DIGIT_W);
|
|
const int LINE1_W = LBL_W + SC_W;
|
|
int xx = x + ((w - LINE1_W) / 2);
|
|
drawShadowed(*font_, xx, y + LINE1_Y, LBL1, LBL_C, SHD_C);
|
|
xx += LBL_W;
|
|
drawMonoShadowed(*font_, xx, y + LINE1_Y, SCORE, SCORE_C, SHD_C, MONO_DIGIT_W);
|
|
|
|
// --- Línia 2: "<vides>-<n> <tresors>-<n>" centrada ---
|
|
const std::string V_LBL = Locale::get().text("hud.lives") + SEPARATOR;
|
|
const std::string T_LBL = Locale::get().text("hud.treasures") + SEPARATOR;
|
|
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 + V_VW + FIELD_GAP + T_LW + T_VW;
|
|
xx = x + ((w - LINE2_W) / 2);
|
|
drawShadowed(*font_, xx, y + LINE2_Y, V_LBL, LBL_C, SHD_C);
|
|
xx += V_LW;
|
|
drawMonoShadowed(*font_, xx, y + LINE2_Y, V_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
|
|
xx += V_VW + FIELD_GAP;
|
|
drawShadowed(*font_, xx, y + LINE2_Y, T_LBL, LBL_C, SHD_C);
|
|
xx += T_LW;
|
|
drawMonoShadowed(*font_, xx, y + LINE2_Y, T_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
|
|
}
|
|
|
|
void Hud::renderIdleCenter(int x, int y, int w) const {
|
|
const auto& ui = UiConfig::get();
|
|
const Uint8 LBL_C = ui.hudCenterLabel();
|
|
const Uint8 VAL_C = ui.hudCenterValue();
|
|
const Uint8 SHD_C = ui.hudCenterShadow();
|
|
|
|
// --- Línia 1: "<piramide>-<n>" centrada ---
|
|
const std::string PIR_LBL = Locale::get().text("hud.pyramid") + SEPARATOR;
|
|
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 + PV_W;
|
|
int xx = x + ((w - LINE1_W) / 2);
|
|
drawShadowed(*font_, xx, y + LINE1_Y, PIR_LBL, LBL_C, SHD_C);
|
|
xx += PL_W;
|
|
drawMonoShadowed(*font_, xx, y + LINE1_Y, PIR_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
|
|
|
|
// --- Línia 2: "<sala>-<r>" centrada ---
|
|
const std::string SALA_LBL = Locale::get().text("hud.room") + SEPARATOR;
|
|
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 + SV_W;
|
|
xx = x + ((w - LINE2_W) / 2);
|
|
drawShadowed(*font_, xx, y + LINE2_Y, SALA_LBL, LBL_C, SHD_C);
|
|
xx += SL_W;
|
|
drawMonoShadowed(*font_, xx, y + LINE2_Y, SALA_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
|
|
}
|