feat(stats): Player com a font de veritat de vides/score/tresors i sarcs amb recompensa

This commit is contained in:
JailDesigner committed 2026-05-12 19:51:42 +02:00
1 parent d186f66c2c
commit f5d251c33b
14 files changed
+226 -34

No files matched your search

+25 -2
View File
@@ -83,6 +83,15 @@ Sarcophagus::Sarcophagus(const fkyaml::node& yaml)
opening_pitch_spread_ = s["opening_pitch_spread"].get_value<float>();
}
}
// Bloc `reward:` opcional — la recompensa que rebrà el jugador que
// tanqui el sarcòfag. Si no hi és, els camps queden a 0 i el GameScene
// simplement no aplica res al consumir `sarcophagus_opened`.
if (yaml.contains("reward")) {
const auto& r = yaml["reward"];
if (r.contains("points")) { points_reward_ = r["points"].get_value<int>(); }
if (r.contains("treasures")) { treasures_reward_ = r["treasures"].get_value<int>(); }
}
}
void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) {
@@ -135,18 +144,32 @@ void Sarcophagus::update(float delta_time) {
anim_ = anim_open_;
if (anim_.has_value()) { anim_->reset(); }
if (!opened_event_emitted_ && bus_ != nullptr) {
bus_->publish("sarcophagus_opened", static_cast<Entity*>(this));
// Publica amb `player_id = last_closer_player_id_` perquè el
// GameScene pugui atribuir la recompensa al jugador correcte.
// El payload manté el punter al sarc perquè el consumidor
// pugui llegir `getPointsReward()` i `getTreasuresReward()`.
bus_->publish(Event{
.name = "sarcophagus_opened",
.payload = static_cast<Entity*>(this),
.player_id = last_closer_player_id_,
});
opened_event_emitted_ = true;
}
}
}
void Sarcophagus::notifyTilePainted(int col, int row) {
void Sarcophagus::notifyTilePainted(int col, int row, int player_id) {
if (state_ != State::CLOSED) { return; }
const TileKey KEY{.col = col, .row = row};
if (perimeter_tiles_.find(KEY) == perimeter_tiles_.end()) { return; }
visited_.insert(KEY);
if (visited_.size() >= perimeter_tiles_.size()) {
// Atribució: el jugador que pinta el tile que tanca el cercle es
// queda amb la recompensa. Si el broadcast inicial de càrrega
// arribara a tancar un sarc (no hauria de passar perquè els tiles
// d'aquest broadcast són decals/murs, no de corredor), `player_id`
// seria -1 i el GameScene saltarà el repartiment.
last_closer_player_id_ = player_id;
state_ = State::OPENING;
anim_ = anim_opening_;
if (anim_.has_value()) { anim_->reset(); }
+23 -1
View File
@@ -46,7 +46,7 @@ class Sarcophagus : public Interactable {
void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked);
void update(float delta_time) override;
void notifyTilePainted(int col, int row) override;
void notifyTilePainted(int col, int row, int player_id) override;
void onRemoved() override;
[[nodiscard]] auto getState() const -> State { return state_; }
@@ -58,6 +58,17 @@ class Sarcophagus : public Interactable {
[[nodiscard]] auto getTileWidth() const -> int { return tile_size_w_; }
[[nodiscard]] auto getTileHeight() const -> int { return tile_size_h_; }
// Recompensa que es donarà al jugador que pinta el tile que tanca el
// perímetre (el `last_closer_player_id_`). Llegits del YAML del sarc;
// 0/0 si el bloc `reward:` no existeix. El GameScene escolta
// `sarcophagus_opened` i aplica aquests valors al Player corresponent.
[[nodiscard]] auto getPointsReward() const -> int { return points_reward_; }
[[nodiscard]] auto getTreasuresReward() const -> int { return treasures_reward_; }
// Jugador atribuit (qui pinta el tile que dispara CLOSED→OPENING). -1
// si el sarcòfag s'ha tancat per pintades sense atribució (p.ex. el
// broadcast inicial de càrrega) — cas que el GameScene ignora.
[[nodiscard]] auto getLastCloserPlayerId() const -> int { return last_closer_player_id_; }
private:
struct TileKey {
int col;
@@ -94,5 +105,16 @@ class Sarcophagus : public Interactable {
// obertura sorteja [1 - spread, 1 + spread]. 0 ⇒ ratio fix a 1.0.
float opening_pitch_spread_{0.0F};
// Recompenses llegides del YAML (bloc `reward:`). 0/0 si el bloc no
// hi és — el GameScene salta el pas de repartiment quan són 0.
int points_reward_{0};
int treasures_reward_{0};
// Jugador que ha pintat el tile que tanca el perímetre. Es fixa una
// sola vegada, en la transició CLOSED→OPENING dins de notifyTilePainted.
// -1 mentre està a CLOSED, o si la pintada que el tanca no porta
// atribució (player_id == -1 al broadcast inicial).
int last_closer_player_id_{-1};
bool opened_event_emitted_{false};
};
+1 -1
View File
@@ -24,7 +24,7 @@ void Entity::onPlayerContact(Player* /*player*/) {
// No-op per defecte.
}
void Entity::notifyTilePainted(int /*col*/, int /*row*/) {
void Entity::notifyTilePainted(int /*col*/, int /*row*/, int /*player_id*/) {
// No-op per defecte; només `Sarcophagus` l'usa.
}
+6 -2
View File
@@ -84,8 +84,12 @@ class Entity {
// Hook que el `EntityManager` invoca per a cada entitat cada vegada
// que un tile es pinta (via PaintLayer, com a conseqüència d'un
// jugador trepitjant un sòl pintable). Només `Sarcophagus` ho consumeix
// (per marcar perímetre); per defecte no-op.
virtual void notifyTilePainted(int col, int row);
// (per marcar perímetre); per defecte no-op. `player_id` és l'id del
// jugador que ha pintat el tile, o -1 si la pintada ve del broadcast
// inicial de càrrega de nivell (decals + murs, sense atribució). Els
// consumidors han de filtrar per `player_id >= 0` si necessiten
// atribució.
virtual void notifyTilePainted(int col, int row, int player_id);
// L'entitat ha rebut un fueteig amb solapament. Es passa el punter al
// `WhipAttack` perquè la subclasse pugui llegir-ne direcció o atributs
+7 -6
View File
@@ -31,7 +31,7 @@ EntityManager::EntityManager() {
// els sarcòfags puguin actualitzar el seu `visited_`.
tile_sub_id_ = bus_.subscribe("tile_painted", [this](const Event& ev) {
const auto* tile = std::get_if<TilePos>(&ev.payload);
if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row); }
if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row, ev.player_id); }
});
// Subscripció a `player_exited` (publicat per Door en travessar la
@@ -141,12 +141,13 @@ void EntityManager::update(float delta_time) {
std::erase_if(entities_, [](const auto& e) { return e->isDead(); });
}
void EntityManager::notifyPlayerTilePainted(int col, int row) {
void EntityManager::notifyPlayerTilePainted(int col, int row, int player_id) {
// Fan-out polimòrfic a totes les entitats: cada una decideix què fer
// (`Sarcophagus` actualitza el seu perímetre, la resta queden en
// no-op). Cost d'iterar 40 elements amb crida virtual buida ~ 1 µs;
// qui paga el cost guanya simplicitat al callsite.
for (auto& e : entities_) { e->notifyTilePainted(col, row); }
// (`Sarcophagus` actualitza el seu perímetre i recorda qui va tancar
// el cercle, la resta queden en no-op). `player_id` ve del bus
// (`ev.player_id`) — pot ser -1 al broadcast de càrrega inicial de
// nivell, els consumidors filtren si necessiten atribució.
for (auto& e : entities_) { e->notifyTilePainted(col, row, player_id); }
}
auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* {
+5 -4
View File
@@ -21,9 +21,10 @@ class DynamicHazard;
// 2. `update(dt)` propaga el tick a totes.
// 3. Comptador de sarcòfags oberts — quan iguala el total registrat,
// crida `door_->open()` automàticament.
// 4. `notifyPlayerTilePainted(col, row)` — fan-out del hook per a tots
// els sarcòfags (i qualsevol entitat futura que s'hi subscrigui via
// `notifyTilePainted`).
// 4. `notifyPlayerTilePainted(col, row, player_id)` — fan-out del hook
// per a tots els sarcòfags (i qualsevol entitat futura que s'hi
// subscrigui via `notifyTilePainted`). El `player_id` permet
// atribuir l'acció al jugador concret (p.ex. qui tanca un sarc).
//
// NO és un singleton: es crea una instància per nivell. De moment no
// s'usa encara al GameScene (andamiatge pur).
@@ -103,7 +104,7 @@ class EntityManager {
// notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte
// (veure Entity::notifyTilePainted). Invocat com a reacció a
// `tile_painted` al bus (re-publicat pel PaintLayer un cop filtrat).
void notifyPlayerTilePainted(int col, int row);
void notifyPlayerTilePainted(int col, int row, int player_id);
// Carrega els spawns d'un YAML de nivell i els afegeix al manager:
// 1. Llegeix el bloc `entities:` via EntityLoader.
+26 -3
View File
@@ -19,12 +19,21 @@ class Player;
// Event pub/sub minimalista. Hi ha un únic `EventBus` per mundus (propietat
// de l'`EntityManager`); les entitats que el necessiten en reben un punter
// via `Entity::setEventBus`. Aquest sistema cobreix les necessitats
// actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered)
// sense portar-hi senyalització més pesada.
// actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered,
// player_stats_changed) sense portar-hi senyalització més pesada.
//
// Decisió: `std::variant` per al payload — evita any/void* i facilita
// consumir events tipats amb `std::get_if`. Si apareixen més tipus de
// payload, s'amplia aquí sense tocar cridants existents.
//
// `player_stats_changed`: l'únic canal numèric que toca el HUD. El `Player`
// és la font de veritat de vides/score/tresors i el publica cada vegada
// que muta qualsevol d'aquests camps. El HUD el consumeix com a "snapshot
// total" (no com a delta) — sobreescriu la seua caché amb el payload. Així
// qualsevol via que modifique stats al Player (mort per forat, recompensa
// de sarc, etc.) queda automàticament reflectida sense que cada caller
// haja de recordar publicar res addicional. `player_hit` queda només per
// al feedback visual (flash/shake), separat del comptador.
// Tile (col, row) — payload de `tile_painted` (i altres events de tile sense
// metadada extra). No és un `pair<int,int>` per evitar alias ambigus al
@@ -74,9 +83,20 @@ struct WhipSpawnPayload {
float range_px{0.0F};
};
// Snapshot complet dels stats d'un jugador — payload de `player_stats_changed`.
// El Player publica un d'aquests cada vegada que muta vides/score/tresors.
// El HUD el consumeix com a estat absolut (no delta): sobreescriu la seua
// caché amb aquests valors. El `player_id` de l'`Event` indica a quin
// bloc del HUD va dirigit.
struct PlayerStatsPayload {
int lives{0};
int score{0};
int treasures{0};
};
struct Event {
std::string name;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos, WhipSpawnPayload>;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos, WhipSpawnPayload, PlayerStatsPayload>;
Payload payload;
// Jugador que ha originat (o provocat) l'event. -1 = sense jugador
// associat (events del món, com `sarcophagus_opened` des de la pròpia
@@ -129,6 +149,9 @@ class EventBus {
void publish(const std::string& name, const WhipSpawnPayload& whip) {
publish(Event{.name = name, .payload = whip});
}
void publish(const std::string& name, const PlayerStatsPayload& stats) {
publish(Event{.name = name, .payload = stats});
}
private:
struct Subscription {
+37
View File
@@ -1272,6 +1272,37 @@ void Player::applyDyingMovement(float delta_time) {
void Player::resetForJoin() {
lives_remaining_ = death_config.initial_lives;
score_ = 0;
treasures_ = 0;
// Publica l'estat inicial perquè el HUD reompla el bloc en el moment
// de fer join (no només quan el comptador canvia després). Sense açò
// el HUD es quedaria amb la caché prèvia fins al primer event.
publishStatsChanged();
}
void Player::publishStatsChanged() {
if (bus_ == nullptr) { return; }
bus_->publish(Event{
.name = "player_stats_changed",
.payload = PlayerStatsPayload{
.lives = lives_remaining_,
.score = score_,
.treasures = treasures_,
},
.player_id = id_,
});
}
void Player::addScore(int delta) {
if (delta == 0) { return; }
score_ += delta;
publishStatsChanged();
}
void Player::addTreasures(int delta) {
if (delta == 0) { return; }
treasures_ += delta;
publishStatsChanged();
}
void Player::respawnFromDying() {
@@ -1283,6 +1314,12 @@ void Player::respawnFromDying() {
--lives_remaining_;
death_animation_complete_ = false;
respawn_remaining_ms_ = 0.0F;
// Notifica al HUD del nou comptador de vides abans de qualsevol
// transició d'estat. Aquesta crida tanca el bucle de qualsevol via
// de mort (mòmia, traps, rolling rock, FORAT…): totes acaben aquí, i
// ningú més ha de recordar publicar res. Era exactament el punt que
// s'oblidava al camí del forat.
publishStatsChanged();
if (lives_remaining_ <= 0) {
disable();
if (bus_ != nullptr) {
+26
View File
@@ -158,6 +158,21 @@ class Player : public AnimatedSprite {
// xarxa — és únic per dins del procés.
[[nodiscard]] auto getId() const -> int { return id_; }
// Stats que pinta el HUD. El Player n'és la font de veritat; cada
// mutació es publica via `player_stats_changed` al bus (vegeu
// `publishStatsChanged`). Els getters són per a tests i diagnòstic;
// el HUD no els consulta — escolta el bus.
[[nodiscard]] auto getLives() const -> int { return lives_remaining_; }
[[nodiscard]] auto getScore() const -> int { return score_; }
[[nodiscard]] auto getTreasures() const -> int { return treasures_; }
// Acumuladors. Deltas positius i negatius (sense clamp). Publiquen
// `player_stats_changed` només si el delta no és 0, per estalviar
// soroll al bus. Els consumidors típics: GameScene reparteix puntuació
// de sarcs i pickups (futur) cridant aquests mètodes.
void addScore(int delta);
void addTreasures(int delta);
// Accés i mutació de la velocitat intencionada d'aquest frame. El
// GameScene els toca entre `tick` i `applyMovement` per implementar
// col·lisions jugador↔jugador (escalar a push_factor, anul·lar en
@@ -852,6 +867,17 @@ class Player : public AnimatedSprite {
// inicial quan el jugador re-entra mid-partida.
int lives_remaining_{0};
// Puntuació acumulada i nombre de tresors recollits. Es modifiquen
// via `addScore` / `addTreasures` (o reset a `resetForJoin`). El HUD
// els sincronitza via `player_stats_changed`.
int score_{0};
int treasures_{0};
// Publica un snapshot complet (vides/score/tresors) al bus si està
// injectat. Centralitza la generació de l'event perquè cap mutador
// se l'oblide.
void publishStatsChanged();
// Coordenades del spawn de respawn (cantó superior-esquerra del
// sprite). Les assigna el GameScene via `setSpawnPoint`. 0/0 fins que
// el GameScene n'invoque el setter al ctor / `loadRoom`.