feat(entities): andamiatge del sistema d'entitats (classes, factory, bus, YAML, placeholders, smoke tests)

This commit is contained in:
JailDesigner committed 2026-04-21 12:35:19 +02:00
1 parent 984cc820c0
commit 96b71b0f65
48 files changed
+1957

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
#include "game/entities/concrete/door.hpp"
#include <string>
#include <vector>
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
namespace {
// Carrega una animació d'un bloc concret (closed/opening/open) amb
// fallbacks tolerants. Si la clau no existeix, retorna una animació
// d'un sol frame loopant: no fa estat invàlid, simplement no hi ha res
// per reproduir.
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = true;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace
Door::Door(const fkyaml::node& yaml)
: anim_closed_(buildAnimation(yaml, "closed")),
anim_opening_(buildAnimation(yaml, "opening")),
anim_open_(buildAnimation(yaml, "open")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("door");
collides_ = true;
interactable_ = true;
anim_ = anim_closed_;
}
void Door::setAnimation(const AnimationComponent& anim) {
anim_ = anim;
if (anim_.has_value()) { anim_->reset(); }
}
void Door::update(float delta_time) {
Entity::update(delta_time);
// Transició OPENING → OPEN: amb l'animació `opening` configurada com a
// loop=false, `completed()` passa a true quan s'ha recorregut l'últim
// frame. Per a animacions d'un sol frame, el component la considera
// completada immediatament (1 frame = 0 passos), així que la transició
// ocorre al pròxim tick.
if (state_ == State::OPENING && anim_.has_value() && anim_->completed()) {
state_ = State::OPEN;
setAnimation(anim_open_);
}
}
void Door::onPlayerContact(Player* /*player*/) {
if (state_ != State::OPEN || level_complete_emitted_) { return; }
if (bus_ != nullptr) { bus_->publish("level_complete"); }
level_complete_emitted_ = true;
}
void Door::open() {
if (state_ != State::CLOSED) { return; }
state_ = State::OPENING;
setAnimation(anim_opening_);
// Edge case: si `opening` té ≤1 frame i és loop=true, mai es
// completarà. Per força, forcem loop=false del frame únic aquí. Els
// placeholders defineixen `loop: false` explícitament al YAML, així
// que aquest fallback només actua si algú dissenya malament.
if (anim_.has_value() && anim_->frameCount() <= 1 && anim_->loops()) {
anim_ = AnimationComponent({0}, 6.0F, false);
}
}
REGISTER_ENTITY("door", Door)
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <cstdint>
#include "external/fkyaml_node.hpp"
#include "game/entities/animation_component.hpp"
#include "game/entities/interactable.hpp"
// Porta de sortida del nivell. Tres estats:
// CLOSED → inicial; bloqueja el pas.
// OPENING → animació d'obertura en curs. Quan el component acaba →
// transita a OPEN.
// OPEN → accessible. Al contacte amb el jugador publica
// `level_complete` al bus (si n'hi ha).
//
// El que decideix quan s'obri és extern (`EntityManager` comptant sarcs
// oberts) via `open()`. L'animació `opening` es fa servir com a
// temporitzador de transició (s'espera a `completed()`); si només té un
// frame i és loop=false, es completa immediatament al següent tick.
class Door : public Interactable {
public:
enum class State : std::uint8_t { CLOSED,
OPENING,
OPEN };
explicit Door(const fkyaml::node& yaml);
void update(float delta_time) override;
void onPlayerContact(Player* player) override;
// Passa de CLOSED → OPENING. Idempotent: crides posteriors en estat
// OPENING/OPEN són no-op.
void open();
[[nodiscard]] auto getState() const -> State { return state_; }
private:
void setAnimation(const AnimationComponent& anim);
State state_{State::CLOSED};
bool level_complete_emitted_{false};
// Les 3 animacions precalculades al ctor des del YAML. L'activa
// s'assigna a `anim_` (heretat d'Entity) segons l'estat.
AnimationComponent anim_closed_;
AnimationComponent anim_opening_;
AnimationComponent anim_open_;
};
@@ -0,0 +1,29 @@
#include "game/entities/concrete/floor_trap.hpp"
#include <string>
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
FloorTrap::FloorTrap(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("floor_trap");
collides_ = false;
interactable_ = false;
if (yaml.contains("cycle")) {
const auto& c = yaml["cycle"];
const float ACTIVE = c.contains("active_duration") ? c["active_duration"].get_value<float>() : 0.3F;
const float INACTIVE = c.contains("inactive_duration") ? c["inactive_duration"].get_value<float>() : 2.0F;
setCycle(ACTIVE, INACTIVE);
}
if (yaml.contains("stats") && yaml["stats"].contains("damage")) {
setDamage(yaml["stats"]["damage"].get_value<int>());
}
}
void FloorTrap::onPlayerContact(Player* /*player*/) {
if (!isActive()) { return; }
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
}
REGISTER_ENTITY("floor_trap", FloorTrap)
@@ -0,0 +1,14 @@
#pragma once
#include "external/fkyaml_node.hpp"
#include "game/entities/hazard.hpp"
// Trampa de terra: equivalent a WallTrap però dissenyada per viure al
// passadís (forats, picos del terra). El comportament és el mateix; la
// distinció és purament semàntica/visual a efectes d'andamiatge.
class FloorTrap : public StaticHazard {
public:
explicit FloorTrap(const fkyaml::node& yaml);
void onPlayerContact(Player* player) override;
};
+45
View File
@@ -0,0 +1,45 @@
#include "game/entities/concrete/mummy.hpp"
#include <random>
#include <string>
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
namespace {
// RNG local al mòdul: compartit per totes les mòmies (no cal reproducció
// determinista a l'andamiatge). Es reinicialitza amb una llavor fixa
// per facilitar la depuració — si algun dia volem aleatoritat real,
// llegir-la de std::random_device.
auto rng() -> std::mt19937& {
static std::mt19937 k_rng_{1337};
return k_rng_;
}
} // namespace
Mummy::Mummy(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy");
collides_ = true;
interactable_ = false;
// Velocitat i dany des del bloc `stats` (si existeix).
if (yaml.contains("stats")) {
const auto& s = yaml["stats"];
if (s.contains("speed")) { setSpeed(s["speed"].get_value<float>()); }
if (s.contains("damage")) { setDamage(s["damage"].get_value<int>()); }
}
}
auto Mummy::chooseDirection(const std::vector<Direction>& allowed) -> Direction {
if (allowed.empty()) { return Direction::NONE; }
std::uniform_int_distribution<std::size_t> dist(0, allowed.size() - 1);
return allowed[dist(rng())];
}
void Mummy::onPlayerContact(Player* /*player*/) {
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
}
REGISTER_ENTITY("mummy", Mummy)
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <cstdint>
#include <vector>
#include "external/fkyaml_node.hpp"
#include "game/entities/hazard.hpp"
// Mòmia: perill dinàmic que deambula pels passadís. A cada intersecció
// tria una direcció vàlida; l'algoritme per triar-la és un hook virtual
// (`chooseDirection`) que la base implementa com a aleatori uniforme
// sobre les direccions admissibles. Futurs derivats poden sesgar cap al
// jugador sense tocar la lògica de moviment.
//
// Andamiatge: el concepte d'"intersecció" i la validació de direcció
// requereixen conèixer el mapa. De moment l'exposem com un conjunt de
// direccions admissibles que l'EntityManager injecta; la implementació
// real arribarà quan cablegem l'entitat al game loop.
class Mummy : public DynamicHazard {
public:
enum class Direction : std::uint8_t { NONE,
UP,
DOWN,
LEFT,
RIGHT };
explicit Mummy(const fkyaml::node& yaml);
// Hook d'IA. Donat un conjunt de direccions admissibles, decideix la
// següent direcció a prendre. La implementació de la base tria una
// aleatoria; si `allowed` està buit, retorna `NONE`.
virtual auto chooseDirection(const std::vector<Direction>& allowed) -> Direction;
// Contacte amb el jugador: publica `player_hit` amb el dany.
void onPlayerContact(Player* player) override;
};
@@ -0,0 +1,49 @@
#include "game/entities/concrete/rolling_rock.hpp"
#include <string>
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
namespace {
// Tradueix la direcció inicial textual del YAML a un vector 2D. Valors
// no reconeguts → sense moviment (útil per spawn estàtics a tests).
void applyInitialDirection(const std::string& dir, DynamicHazard& rock) {
if (dir == "right") {
rock.setDirection(1.0F, 0.0F);
} else if (dir == "left") {
rock.setDirection(-1.0F, 0.0F);
} else if (dir == "up") {
rock.setDirection(0.0F, -1.0F);
} else if (dir == "down") {
rock.setDirection(0.0F, 1.0F);
} else {
rock.setDirection(0.0F, 0.0F);
}
}
} // namespace
RollingRock::RollingRock(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("rolling_rock");
collides_ = true;
interactable_ = false;
if (yaml.contains("stats")) {
const auto& s = yaml["stats"];
if (s.contains("speed")) { setSpeed(s["speed"].get_value<float>()); }
if (s.contains("damage")) { setDamage(s["damage"].get_value<int>()); }
}
std::string initial = "right";
if (yaml.contains("ai") && yaml["ai"].contains("initial_direction")) {
initial = yaml["ai"]["initial_direction"].get_value<std::string>();
}
applyInitialDirection(initial, *this);
}
void RollingRock::onPlayerContact(Player* /*player*/) {
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
}
REGISTER_ENTITY("rolling_rock", RollingRock)
@@ -0,0 +1,15 @@
#pragma once
#include "external/fkyaml_node.hpp"
#include "game/entities/hazard.hpp"
// Roca rodant: perill dinàmic amb trajectòria en línia recta. La direcció
// inicial ve del bloc `ai:` del YAML (right/left/up/down) i es pot
// sobreescriure per spawn. No canvia per ella mateixa; qui la vulgui
// rebotar/aturar ha de cridar `setDirection(0,0)` externament.
class RollingRock : public DynamicHazard {
public:
explicit RollingRock(const fkyaml::node& yaml);
void onPlayerContact(Player* player) override;
};
@@ -0,0 +1,127 @@
#include "game/entities/concrete/sarcophagus.hpp"
#include <string>
#include <vector>
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
namespace {
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = true;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
// Llig `tile_size: [w, h]` del YAML. Fallback [1,1] si no existeix.
void readTileSize(const fkyaml::node& yaml, int& w, int& h) {
w = 1;
h = 1;
if (!yaml.contains("tile_size")) { return; }
const auto& ts = yaml["tile_size"];
if (ts.is_sequence() && ts.size() == 2) {
w = ts[0].get_value<int>();
h = ts[1].get_value<int>();
}
}
} // namespace
Sarcophagus::Sarcophagus(const fkyaml::node& yaml, int anchor_col, int anchor_row, const BlockedPredicate& is_blocked)
: Sarcophagus(yaml) {
place(anchor_col, anchor_row, is_blocked);
}
Sarcophagus::Sarcophagus(const fkyaml::node& yaml)
: anim_closed_(buildAnimation(yaml, "closed")),
anim_opening_(buildAnimation(yaml, "opening")),
anim_open_(buildAnimation(yaml, "open")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("sarcophagus");
collides_ = true;
interactable_ = true;
readTileSize(yaml, tile_size_w_, tile_size_h_);
anim_ = anim_closed_;
}
void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) {
anchor_col_ = anchor_col;
anchor_row_ = anchor_row;
tile_.col = anchor_col;
tile_.row = anchor_row;
computePerimeter(is_blocked);
visited_.clear();
}
void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) {
perimeter_tiles_.clear();
// El perímetre ortogonal d'un rectangle (w,h) de tiles són les files
// de tiles immediatament N, S, E, O. No s'hi inclouen les cantonades
// diagonals (tile NO, NE, SO, SE), perquè el jugador fa passes pels
// quatre costats i no pot "tallar" per una diagonal: això garanteix
// que el recorregut completa només si passa per cada costat.
//
// - Fila Nord: (col, row-1) per col ∈ [col0, col0+w-1]
// - Fila Sud: (col, row+h) per col ∈ [col0, col0+w-1]
// - Columna O: (col0-1, row) per row ∈ [row0, row0+h-1]
// - Columna E: (col0+w, row) per row ∈ [row0, row0+h-1]
auto maybe_insert = [&](int col, int row) {
if (is_blocked && is_blocked(col, row)) { return; }
perimeter_tiles_.insert({col, row});
};
// Nord i Sud
for (int dc = 0; dc < tile_size_w_; ++dc) {
maybe_insert(anchor_col_ + dc, anchor_row_ - 1);
maybe_insert(anchor_col_ + dc, anchor_row_ + tile_size_h_);
}
// Oest i Est
for (int dr = 0; dr < tile_size_h_; ++dr) {
maybe_insert(anchor_col_ - 1, anchor_row_ + dr);
maybe_insert(anchor_col_ + tile_size_w_, anchor_row_ + dr);
}
}
void Sarcophagus::update(float delta_time) {
Entity::update(delta_time);
if (state_ == State::OPENING && anim_.has_value() && anim_->completed()) {
state_ = State::OPEN;
anim_ = anim_open_;
if (anim_.has_value()) { anim_->reset(); }
if (!opened_event_emitted_ && bus_ != nullptr) {
bus_->publish("sarcophagus_opened", static_cast<Entity*>(this));
opened_event_emitted_ = true;
}
}
}
void Sarcophagus::notifyTileEntered(int col, int row) {
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()) {
state_ = State::OPENING;
anim_ = anim_opening_;
if (anim_.has_value()) { anim_->reset(); }
}
}
REGISTER_ENTITY("sarcophagus", Sarcophagus)
@@ -0,0 +1,86 @@
#pragma once
#include <cstdint>
#include <functional>
#include <unordered_set>
#include "external/fkyaml_node.hpp"
#include "game/entities/animation_component.hpp"
#include "game/entities/interactable.hpp"
// Sarcòfag: rectangle de tiles que el jugador ha de rodar completament pel
// perímetre per "obrir". Tres estats (com la porta):
// CLOSED → inicial; `visited_` s'acumula amb les notificacions.
// OPENING → `visited_ == perimeter_tiles_`; reprodueix animació d'obertura.
// OPEN → emitit l'event `sarcophagus_opened`.
//
// El càlcul de `perimeter_tiles_` es fa al ctor a partir de la posició del
// sarcòfag a la graella del Map (`anchor_col`, `anchor_row`) i de les
// dimensions en tiles (`tile_size_w_`, `tile_size_h_`, llegits del YAML
// plantilla). Es recorren els tiles adjacents per N/S/E/O; els que
// cauen dins d'un altre sarcòfag es descarten (predicat opcional). Si no
// s'aporta el predicat, s'inclouen tots.
class Sarcophagus : public Interactable {
public:
enum class State : std::uint8_t { CLOSED,
OPENING,
OPEN };
// `is_blocked` rep un (col,row) i retorna true si el tile està ocupat
// per un altre sarcòfag o és fora del playfield (és a dir, NO és un
// tile de corredor vàlid). L'usa `EntityManager` passant-li
// `Map::collides` traduït a tiles. Si és nullptr, cap tile es descarta
// i el perímetre es computa purament geomètric (útil als tests).
using BlockedPredicate = std::function<bool(int col, int row)>;
Sarcophagus(const fkyaml::node& yaml, int anchor_col, int anchor_row, const BlockedPredicate& is_blocked);
explicit Sarcophagus(const fkyaml::node& yaml); // variant sense ancoratge (usat pel factory)
// Configura ancoratge i (re)calcula perímetre. Útil quan el factory
// crea l'instància abans de conèixer la posició; el `EntityLoader`
// invoca aquesta funció amb les coordenades del spawn.
void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked);
void update(float delta_time) override;
void notifyTileEntered(int col, int row) override;
[[nodiscard]] auto getState() const -> State { return state_; }
[[nodiscard]] auto getPerimeterSize() const -> std::size_t { return perimeter_tiles_.size(); }
[[nodiscard]] auto getVisitedCount() const -> std::size_t { return visited_.size(); }
// Dimensions del rectangle en tiles (p.ex. 3×1 per al sarcòfag
// estàndard). Exposat per al smoke test i per al render futur.
[[nodiscard]] auto getTileWidth() const -> int { return tile_size_w_; }
[[nodiscard]] auto getTileHeight() const -> int { return tile_size_h_; }
private:
struct TileKey {
int col;
int row;
auto operator==(const TileKey& o) const -> bool { return col == o.col && row == o.row; }
};
struct TileKeyHash {
auto operator()(const TileKey& k) const -> std::size_t {
// Combinació simple; els valors són petits (<100) i el set no és gran.
return (static_cast<std::size_t>(k.col) * 131U) ^ static_cast<std::size_t>(k.row);
}
};
void computePerimeter(const BlockedPredicate& is_blocked);
int tile_size_w_{1};
int tile_size_h_{1};
int anchor_col_{0};
int anchor_row_{0};
std::unordered_set<TileKey, TileKeyHash> perimeter_tiles_;
std::unordered_set<TileKey, TileKeyHash> visited_;
State state_{State::CLOSED};
AnimationComponent anim_closed_;
AnimationComponent anim_opening_;
AnimationComponent anim_open_;
bool opened_event_emitted_{false};
};
+41
View File
@@ -0,0 +1,41 @@
#include "game/entities/concrete/torch.hpp"
#include <string>
#include <vector>
#include "game/entities/entity_factory.hpp"
namespace {
// Ajut: extreu l'animació `idle` del bloc `animations` del YAML si existeix,
// amb valors per defecte que sempre produïxen una animació vàlida (1 frame).
auto buildIdleAnimation(const fkyaml::node& yaml) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = true;
if (yaml.contains("animations") && yaml["animations"].contains("idle")) {
const auto& anim = yaml["animations"]["idle"];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace
Torch::Torch(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("torch");
collides_ = false;
interactable_ = false;
anim_ = buildIdleAnimation(yaml);
}
REGISTER_ENTITY("torch", Torch)
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include "external/fkyaml_node.hpp"
#include "game/entities/decoration.hpp"
// Antorxa decorativa: només anima. Sense col·lisió, sense interacció.
class Torch : public Decoration {
public:
explicit Torch(const fkyaml::node& yaml);
};
@@ -0,0 +1,29 @@
#include "game/entities/concrete/wall_trap.hpp"
#include <string>
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
WallTrap::WallTrap(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("wall_trap");
collides_ = false;
interactable_ = false;
if (yaml.contains("cycle")) {
const auto& c = yaml["cycle"];
const float ACTIVE = c.contains("active_duration") ? c["active_duration"].get_value<float>() : 0.5F;
const float INACTIVE = c.contains("inactive_duration") ? c["inactive_duration"].get_value<float>() : 1.5F;
setCycle(ACTIVE, INACTIVE);
}
if (yaml.contains("stats") && yaml["stats"].contains("damage")) {
setDamage(yaml["stats"]["damage"].get_value<int>());
}
}
void WallTrap::onPlayerContact(Player* /*player*/) {
if (!isActive()) { return; }
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
}
REGISTER_ENTITY("wall_trap", WallTrap)
@@ -0,0 +1,13 @@
#pragma once
#include "external/fkyaml_node.hpp"
#include "game/entities/hazard.hpp"
// Trampa de paret: perill estàtic amb cicle actiu/inactiu (p.ex. punxes
// que surten periòdicament). Contacte → dany només quan `isActive()`.
class WallTrap : public StaticHazard {
public:
explicit WallTrap(const fkyaml::node& yaml);
void onPlayerContact(Player* player) override;
};