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

@@ -0,0 +1,48 @@
#include "game/entities/animation_component.hpp"
#include <cmath>
#include <utility>
AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool loop)
: frames_(std::move(frames)),
fps_(fps > 0.0F ? fps : 1.0F),
loop_(loop) {
// Una animació d'1 sol frame amb loop=false és una animació que ja s'ha
// acabat de reproduir: no hi ha cap transició per mostrar. Marcant-la
// com a completada des de l'inici permet als consumidors (p.ex. Door)
// usar `completed()` com a temporitzador de transició d'estat.
if (frames_.size() <= 1 && !loop_) { completed_ = true; }
}
void AnimationComponent::tick(float delta_time) {
if (frames_.size() <= 1 || completed_) { return; }
accumulator_ += delta_time;
const float FRAME_TIME = 1.0F / fps_;
while (accumulator_ >= FRAME_TIME) {
accumulator_ -= FRAME_TIME;
++current_;
if (static_cast<std::size_t>(current_) >= frames_.size()) {
if (loop_) {
current_ = 0;
} else {
current_ = static_cast<int>(frames_.size()) - 1;
completed_ = true;
return;
}
}
}
}
void AnimationComponent::reset() {
current_ = 0;
accumulator_ = 0.0F;
// Invariant: animacions d'1 frame no-loop neixen completades i tornen
// a aquest estat després de reset (no hi ha progressió que desfer).
completed_ = (frames_.size() <= 1 && !loop_);
}
auto AnimationComponent::currentFrame() const -> int {
if (frames_.empty()) { return 0; }
return frames_[static_cast<std::size_t>(current_)];
}
@@ -0,0 +1,47 @@
#pragma once
#include <cstddef>
#include <vector>
// Component reutilitzable d'animació: qualsevol entitat que necessiti
// reproduir una seqüència de frames l'instancia. Es manté com a camp
// opcional dins d'Entity (veure `entity.hpp`) i NO hereta de cap classe
// del pipeline de sprites existent: això manté les entitats estàtiques
// (hazards invisibles, triggers) lliures de l'estat d'animació.
//
// Els frames s'expressen com a índexs enters dins d'un spritesheet. Quan
// algun dia aterri l'art definitiu, el renderer consultarà `currentFrame()`
// i traduirà l'índex al rectangle del sprite corresponent. De moment els
// placeholders tenen un únic frame i `currentFrame()` retorna sempre 0.
class AnimationComponent {
public:
AnimationComponent() = default;
AnimationComponent(std::vector<int> frames, float fps, bool loop);
// Avanç del temps. Si `frames_` està buit o té un sol element, no fa
// res. En mode loop, `current_frame_` rebota a 0 en passar del final;
// en mode no-loop, es queda al darrer frame i `completed()` passa a
// true.
void tick(float delta_time);
// Torna al primer frame i reinicia l'acumulador. Útil en transicions
// d'estat (p.ex. Sarcòfag: CLOSED → OPENING reinicia l'animació).
void reset();
// Índex actual dins `frames_`. Si `frames_` està buit retorna 0 com
// a fallback tolerant (el loader ja ha emès warning si hi havia
// frames fora de rang; aquí preferim no crashejar).
[[nodiscard]] auto currentFrame() const -> int;
[[nodiscard]] auto frameCount() const -> std::size_t { return frames_.size(); }
[[nodiscard]] auto completed() const -> bool { return completed_; }
[[nodiscard]] auto loops() const -> bool { return loop_; }
private:
std::vector<int> frames_;
float fps_{1.0F};
bool loop_{true};
int current_{0};
float accumulator_{0.0F};
bool completed_{false};
};
+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;
};
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "game/entities/entity.hpp"
// Entitat purament decorativa: sense col·lisió, sense interacció. Només
// actualitza el seu component d'animació (si en té). El `collides_=false`
// / `interactable_=false` ja és el valor per defecte d'`Entity`, però ho
// expressem explícitament aquí per documentació.
class Decoration : public Entity {
public:
using Entity::Entity;
Decoration() {
collides_ = false;
interactable_ = false;
}
};
+26
View File
@@ -0,0 +1,26 @@
#include "game/entities/entity.hpp"
#include <utility>
Entity::Entity(std::string name, TilePos tile, SDL_FRect pixel_rect)
: name_(std::move(name)),
tile_(tile),
pixel_rect_(pixel_rect) {}
void Entity::update(float delta_time) {
if (anim_.has_value()) { anim_->tick(delta_time); }
}
void Entity::render() {
// Andamiatge: el render real es cablejarà quan s'integri al GameScene.
// Les subclasses poden sobreescriure si volen lògica especial (p.ex.
// no dibuixar-se en estat inactiu); de moment la base no fa res.
}
void Entity::onPlayerContact(Player* /*player*/) {
// No-op per defecte.
}
void Entity::notifyTileEntered(int /*col*/, int /*row*/) {
// No-op per defecte; només `Sarcophagus` l'usa.
}
+89
View File
@@ -0,0 +1,89 @@
#pragma once
#include <SDL3/SDL.h>
#include <optional>
#include <string>
#include "game/entities/animation_component.hpp"
// Forward-declaracions: mantenim aquest header lleuger. `Player` viu a
// `source/game/entities/player.hpp` i només es referencia per referència
// dins `onPlayerContact`; `EventBus` és opcional a cada entitat i viu al
// seu header propi.
class Player;
class EventBus;
// Entitat genèrica del món. Actua com a dada en comú per a tots els actors
// del joc (decoratius, interactables, perills mòbils i estàtics): manté
// posició en tiles i píxels, un component d'animació opcional i flags de
// comportament (collides, interactable). Les subclasses intermèdies
// afegeixen semàntica (veure `decoration.hpp`, `interactable.hpp`,
// `hazard.hpp`) i les concretes materialitzen cada tipus (Sarcophagus,
// Torch, Mummy, etc.).
//
// Decisió de disseny: `Entity` NO hereta d'`AnimatedSprite`. L'animació és
// un component (composició), no una herència, per dues raons: (a) no totes
// les entitats en necessiten (trampes invisibles, triggers), i (b) lligar
// el cicle de vida al de `MovingSprite` imposaria semàntica de moviment
// contínu a actors estàtics. Quan s'hi cablege el render real, es podrà
// optar per tenir un `Sprite` intern també com a component opcional.
class Entity {
public:
struct TilePos {
int col{0};
int row{0};
};
Entity() = default;
Entity(std::string name, TilePos tile, SDL_FRect pixel_rect);
virtual ~Entity() = default;
Entity(const Entity&) = delete;
Entity(Entity&&) = delete;
auto operator=(const Entity&) -> Entity& = delete;
auto operator=(Entity&&) -> Entity& = delete;
// Avanç lògic per frame. La base només avança l'animació si existeix;
// les subclasses poden afegir IA, cicles, moviment, etc.
virtual void update(float delta_time);
// Render a la pantalla. La base és no-op: l'andamiatge no cableja encara
// amb el `Screen` real; un cop integrat al `GameScene`, les subclasses
// o un mòdul de render dedicat dibuixaran sprites segons
// `anim_.currentFrame()`.
virtual void render();
// El jugador ha xocat amb aquesta entitat (col·lisió o proximitat).
// Per defecte no fa res; `Hazard` aplica dany, `Door` oberta publica
// `"level_complete"`, etc. Es passa per punter (nullable) perquè els
// smoke tests poden disparar el contacte sense un Player real, i
// perquè en el futur podria disparar-se per triggers d'àrea sense
// jugador específic.
virtual void onPlayerContact(Player* player);
// Hook que el `EntityManager` invoca per a cada entitat cada vegada
// que el jugador entra en un tile nou. Només `Sarcophagus` ho consumeix
// (per marcar perímetre visitat); per defecte no-op.
virtual void notifyTileEntered(int col, int row);
[[nodiscard]] auto getName() const -> const std::string& { return name_; }
[[nodiscard]] auto getTilePos() const -> TilePos { return tile_; }
[[nodiscard]] auto getPixelRect() const -> SDL_FRect { return pixel_rect_; }
[[nodiscard]] auto collides() const -> bool { return collides_; }
[[nodiscard]] auto interactable() const -> bool { return interactable_; }
// Associar el bus una sola vegada (propietat externa, p.ex.
// `EntityManager`). Entitats que no publiquen events (Torch, trampes
// estàtiques de moment) el poden ignorar.
void setEventBus(EventBus* bus) { bus_ = bus; }
protected:
std::string name_;
TilePos tile_{};
SDL_FRect pixel_rect_{};
std::optional<AnimationComponent> anim_;
bool collides_{false};
bool interactable_{false};
EventBus* bus_{nullptr};
};
+29
View File
@@ -0,0 +1,29 @@
#include "game/entities/entity_factory.hpp"
#include <utility>
#include <vector>
#include "game/entities/entity.hpp" // tipus complet per a unique_ptr<Entity>
auto EntityFactory::instance() -> EntityFactory& {
static EntityFactory k_factory_;
return k_factory_;
}
void EntityFactory::registerCreator(const std::string& name, Creator creator) {
creators_[name] = std::move(creator);
}
auto EntityFactory::create(const std::string& name, const fkyaml::node& yaml) const
-> std::unique_ptr<Entity> {
auto it = creators_.find(name);
if (it == creators_.end()) { return nullptr; }
return it->second(yaml);
}
auto EntityFactory::registeredNames() const -> std::vector<std::string> {
std::vector<std::string> names;
names.reserve(creators_.size());
for (const auto& [name, _] : creators_) { names.push_back(name); }
return names;
}
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
#include "external/fkyaml_node.hpp"
class Entity;
// Factory amb registre obert a extensió: cada `concrete/*.cpp` declara un
// registrador estàtic amb `REGISTER_ENTITY(name, Class)` que s'executa
// abans de `main`. Afegir un tipus nou d'entitat = nova classe + nova
// crida `REGISTER_ENTITY`, sense tocar el factory.
//
// El `Creator` rep el `fkyaml::node` de la plantilla de l'entitat (no el
// spawn). És el constructor de la classe concreta qui extreu els camps
// que li interessin (sprite.path, animations, stats, etc.).
class EntityFactory {
public:
using Creator = std::function<std::unique_ptr<Entity>(const fkyaml::node&)>;
static auto instance() -> EntityFactory&;
// Idempotent per nom: si s'invoca dues vegades amb el mateix nom,
// la segona sobreescriu (útil en tests que re-registren; no és
// especialment defensiu perquè els registradors són d'execució única).
void registerCreator(const std::string& name, Creator creator);
// Retorna nullptr si el nom no té registrada cap creator.
[[nodiscard]] auto create(const std::string& name, const fkyaml::node& yaml) const
-> std::unique_ptr<Entity>;
// Diagnòstic: llista de noms registrats (per ordre d'inserció no
// garantit). Útil al smoke per comprovar que tots els concrets
// s'han auto-registrat.
[[nodiscard]] auto registeredNames() const -> std::vector<std::string>;
private:
EntityFactory() = default;
std::unordered_map<std::string, Creator> creators_;
};
// La macro genera un objecte anònim dins un namespace anònim: el seu
// constructor s'executa abans de `main` i registra la factory. El nom del
// símbol és únic per `CLASS` perquè el preprocessor el concatena; així
// dues entitats diferents no xoquen.
//
// IMPORTANT: perquè el registrador es cridi, el `.cpp` que el conté ha
// d'estar enllaçat directament a l'executable (no dins una STATIC lib
// sense `--whole-archive`). Tots els `concrete/*.cpp` entren a APP_SOURCES.
#define REGISTER_ENTITY(NAME, CLASS) \
namespace { \
struct CLASS##_Registrar { \
CLASS##_Registrar() { \
EntityFactory::instance().registerCreator( \
NAME, \
[](const fkyaml::node& yaml) -> std::unique_ptr<Entity> { \
return std::make_unique<CLASS>(yaml); \
}); \
} \
}; \
const CLASS##_Registrar g_##CLASS##_registrar; /* NOLINT(cert-err58-cpp) */ \
}
+68
View File
@@ -0,0 +1,68 @@
#include "game/entities/entity_loader.hpp"
#include <fstream>
#include <stdexcept>
#include <unordered_map>
#include "game/entities/entity.hpp"
#include "game/entities/entity_factory.hpp"
namespace EntityLoader {
namespace {
// Cache de plantilles parsejades. La primera crida llegeix disc;
// les següents reben una còpia del node cacheat. fkyaml::node és
// cost-barat de copiar (vector de shared_ptr internament).
auto templateCache() -> std::unordered_map<std::string, fkyaml::node>& {
static std::unordered_map<std::string, fkyaml::node> k_cache_;
return k_cache_;
}
auto templatePath(const std::string& name) -> std::string {
return "data/entities/" + name + "/" + name + ".yaml";
}
} // namespace
auto loadTemplate(const std::string& name) -> fkyaml::node {
auto& cache = templateCache();
auto it = cache.find(name);
if (it != cache.end()) { return it->second; }
const std::string PATH = templatePath(name);
std::ifstream file(PATH);
if (!file.is_open()) {
throw std::runtime_error("EntityLoader: no s'ha pogut obrir " + PATH);
}
fkyaml::node node = fkyaml::node::deserialize(file);
cache.emplace(name, node);
return node;
}
auto readSpawns(const std::string& level_yaml_path) -> std::vector<fkyaml::node> {
std::ifstream file(level_yaml_path);
if (!file.is_open()) {
throw std::runtime_error("EntityLoader: no s'ha pogut obrir " + level_yaml_path);
}
fkyaml::node root = fkyaml::node::deserialize(file);
std::vector<fkyaml::node> spawns;
if (!root.contains("entities")) { return spawns; }
const auto& list = root["entities"];
if (!list.is_sequence()) { return spawns; }
spawns.reserve(list.size());
// cppcheck-suppress useStlAlgorithm
for (const auto& item : list) { spawns.push_back(item); }
return spawns;
}
auto createByType(const std::string& type) -> std::unique_ptr<Entity> {
try {
fkyaml::node tmpl = loadTemplate(type);
return EntityFactory::instance().create(type, tmpl);
} catch (const std::exception&) {
return nullptr;
}
}
} // namespace EntityLoader
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "external/fkyaml_node.hpp"
class Entity;
// Utilitats estàtiques per carregar plantilles d'entitats i llistes de
// spawns. No té estat propi; tot és lliure d'instància. El Cache de
// plantilles YAML viu dins el loader (estàtic de funció): si el mateix
// tipus s'instancia N vegades, el YAML només es llegeix una.
namespace EntityLoader {
// Carrega la plantilla d'una entitat (p.ex. "torch") llegint
// `data/entities/<name>/<name>.yaml`. Llança `std::runtime_error` si
// el fitxer no existeix o no parseja. El node retornat és una còpia
// del que hi ha al cache — és barat, però si algun perfil ho exigís
// es podria retornar `const&`.
auto loadTemplate(const std::string& name) -> fkyaml::node;
// Llegeix el bloc `entities:` d'un YAML de nivell i retorna la llista
// de spawns en cru. Cada spawn és un node amb (com a mínim) `type` i
// `position: [col, row]`. L'EntityManager decideix com interpretar-lo.
auto readSpawns(const std::string& level_yaml_path) -> std::vector<fkyaml::node>;
// Crea una entitat del tipus demanat via la EntityFactory usant la
// plantilla corresponent. Si no hi ha creator registrat o falla
// l'accés al YAML, retorna nullptr.
auto createByType(const std::string& type) -> std::unique_ptr<Entity>;
} // namespace EntityLoader
+55
View File
@@ -0,0 +1,55 @@
#include "game/entities/entity_manager.hpp"
#include <utility>
#include "game/entities/concrete/door.hpp"
#include "game/entities/concrete/sarcophagus.hpp"
#include "game/entities/entity.hpp"
EntityManager::EntityManager() {
// Subscripció al bus: cada sarcòfag obert incrementa el comptador;
// quan iguala el total registrat, obrim la porta. La subscripció
// sobreviu tot el nivell.
sarc_sub_id_ = bus_.subscribe("sarcophagus_opened", [this](const Event& /*ev*/) {
++opened_sarcs_;
if (total_sarcs_ > 0 && opened_sarcs_ >= total_sarcs_ && door_ != nullptr) {
door_->open();
}
});
}
EntityManager::~EntityManager() {
bus_.unsubscribe(sarc_sub_id_);
}
auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
if (entity == nullptr) { return nullptr; }
entity->setEventBus(&bus_);
Entity* raw = entity.get();
entities_.push_back(std::move(entity));
// cppcheck-suppress returnDanglingLifetime
return raw;
}
void EntityManager::setDoor(Door* door) {
door_ = door;
if (door_ != nullptr) { door_->setEventBus(&bus_); }
}
auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcophagus* {
if (sarc == nullptr) { return nullptr; }
sarc->setEventBus(&bus_);
Sarcophagus* raw = sarc.get();
entities_.push_back(std::move(sarc));
++total_sarcs_;
// cppcheck-suppress returnDanglingLifetime
return raw;
}
void EntityManager::update(float delta_time) {
for (auto& e : entities_) { e->update(delta_time); }
}
void EntityManager::notifyPlayerEnteredTile(int col, int row) {
for (auto& e : entities_) { e->notifyTileEntered(col, row); }
}
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <cstddef>
#include <memory>
#include <vector>
#include "game/entities/event_bus.hpp"
class Entity;
class Sarcophagus;
class Door;
// Contenidor i coordinador d'entitats d'un nivell. Responsabilitats:
// 1. `add` registra una entitat, la connecta al bus i manté propietat.
// 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. `notifyPlayerEnteredTile(col, row)` — fan-out del hook per a tots
// els sarcòfags (i qualsevol entitat futura que s'hi subscrigui via
// `notifyTileEntered`).
//
// NO és un singleton: es crea una instància per nivell. De moment no
// s'usa encara al GameScene (andamiatge pur).
class EntityManager {
public:
EntityManager();
~EntityManager();
EntityManager(const EntityManager&) = delete;
EntityManager(EntityManager&&) = delete;
auto operator=(const EntityManager&) -> EntityManager& = delete;
auto operator=(EntityManager&&) -> EntityManager& = delete;
// Registre d'una entitat. L'ownership és del manager; retorna
// l'observador per conveniència (no eximeix que el manager la destrueixi).
auto add(std::unique_ptr<Entity> entity) -> Entity*;
// Marca una entitat com a porta principal del nivell. N'hi ha només
// una. Si ja n'hi ha una assignada, la substitueix (útil a tests).
void setDoor(Door* door);
// Marca una entitat com a sarcòfag — si no es passa per `add` amb
// type=sarcophagus no es compta al total. Útil en combinació amb
// `add` quan es fa addSarcophagus(unique_ptr<Sarcophagus>).
auto addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcophagus*;
// Tick de totes les entitats.
void update(float delta_time);
// Fan-out del hook de moviment de jugador: per a cada sarcòfag,
// cridar notifyTileEntered. Les entitats no-sarcòfag ho ignoren per
// defecte (veure Entity::notifyTileEntered).
void notifyPlayerEnteredTile(int col, int row);
[[nodiscard]] auto getBus() -> EventBus& { return bus_; }
[[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); }
[[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; }
[[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; }
private:
EventBus bus_;
std::vector<std::unique_ptr<Entity>> entities_;
Door* door_{nullptr};
std::size_t total_sarcs_{0};
std::size_t opened_sarcs_{0};
EventBus::SubId sarc_sub_id_{0};
};
+31
View File
@@ -0,0 +1,31 @@
#include "game/entities/event_bus.hpp"
#include <algorithm>
#include <utility>
auto EventBus::subscribe(const std::string& name, Handler handler) -> SubId {
const SubId ID = next_id_++;
subs_[name].push_back(Subscription{.id = ID, .handler = std::move(handler)});
return ID;
}
void EventBus::unsubscribe(SubId id) {
// Cerca lineal: els subscriptors per esdeveniment són pocs (un gestor
// de nivell, una HUD). Si algun dia creix, es pot passar a unordered_map
// per id, però ara la simplicitat és més valuosa.
for (auto& [name, vec] : subs_) {
auto removed = std::ranges::remove_if(vec, [id](const Subscription& s) { return s.id == id; });
vec.erase(removed.begin(), removed.end());
}
}
void EventBus::publish(const Event& event) {
auto it = subs_.find(event.name);
if (it == subs_.end()) { return; }
// Còpia del vector: un handler podria publicar un altre event i fer
// créixer el subs_ del mateix nom; iterar sobre la còpia evita
// invalidar iteradors. És barat: Subscription és petit i els handlers
// s'usen sovint una vegada.
const auto SUBS = it->second;
for (const auto& sub : SUBS) { sub.handler(event); }
}
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <utility>
#include <variant>
#include <vector>
// Forward: els events poden dur un punter a `Entity` (p. ex. quin sarcòfag
// s'ha obert). No cal incloure el header complet aquí.
class Entity;
// 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) 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.
struct Event {
std::string name;
using Payload = std::variant<std::monostate, int, std::string, Entity*>;
Payload payload;
};
class EventBus {
public:
using Handler = std::function<void(const Event&)>;
using SubId = std::size_t;
EventBus() = default;
EventBus(const EventBus&) = delete;
EventBus(EventBus&&) = delete;
auto operator=(const EventBus&) -> EventBus& = delete;
auto operator=(EventBus&&) -> EventBus& = delete;
~EventBus() = default;
// Retorna un identificador únic per poder-se donar de baixa amb
// `unsubscribe`. Els identificadors s'assignen globalment i no es
// reutilitzen fins a overflow (acceptable per a la vida d'un nivell).
auto subscribe(const std::string& name, Handler handler) -> SubId;
void unsubscribe(SubId id);
// Publica a tots els subscriptors registrats per al nom. Si no n'hi ha
// cap, no fa res.
void publish(const Event& event);
// Forma ergonòmica: construeix un Event amb nom + payload opcional.
void publish(const std::string& name) {
publish(Event{.name = name, .payload = std::monostate{}});
}
void publish(const std::string& name, Entity* entity) {
publish(Event{.name = name, .payload = entity});
}
void publish(const std::string& name, int value) {
publish(Event{.name = name, .payload = value});
}
private:
struct Subscription {
SubId id;
Handler handler;
};
std::unordered_map<std::string, std::vector<Subscription>> subs_;
std::uint64_t next_id_{1};
};
+90
View File
@@ -0,0 +1,90 @@
#pragma once
#include "game/entities/entity.hpp"
// Base d'entitats perilloses: al contacte amb el jugador, apliquen dany.
// De moment `Player` no té HP exposat; l'andamiatge deixa el hook llest
// (`damage_` + `onPlayerContact` publicant un event `"player_hit"`) perquè
// la lògica real de vides es cable-je quan s'integri al GameScene.
class Hazard : public Entity {
public:
Hazard() { collides_ = true; }
[[nodiscard]] auto getDamage() const -> int { return damage_; }
void setDamage(int d) { damage_ = d; }
protected:
int damage_{1};
};
// Perill estàtic: sempre al mateix tile. Suport opcional de cicle
// actiu/inactiu (trampes que oscil·len). Quan `active_=false`, el contacte
// no fa dany (però la detecció de col·lisió es manté: pot bloquejar o no
// segons la configuració).
class StaticHazard : public Hazard {
public:
StaticHazard() = default;
void setCycle(float active_s, float inactive_s) {
active_duration_ = active_s;
inactive_duration_ = inactive_s;
has_cycle_ = (active_s > 0.0F) || (inactive_s > 0.0F);
}
void update(float delta_time) override {
Entity::update(delta_time);
if (!has_cycle_) { return; }
timer_ += delta_time;
const float PERIOD = active_ ? active_duration_ : inactive_duration_;
if (timer_ >= PERIOD) {
timer_ -= PERIOD;
active_ = !active_;
}
}
[[nodiscard]] auto isActive() const -> bool { return active_; }
protected:
bool has_cycle_{false};
bool active_{true};
float active_duration_{0.0F};
float inactive_duration_{0.0F};
float timer_{0.0F};
};
// Perill dinàmic: es mou. Velocitat expressada en píxels/segon i una
// direcció (vec2). Les subclasses decideixen la política de moviment
// (IA corridor_wanderer, línia recta, etc.) sobreescribint `step(dt)`.
class DynamicHazard : public Hazard {
public:
DynamicHazard() = default;
void setSpeed(float px_per_s) { speed_ = px_per_s; }
[[nodiscard]] auto getSpeed() const -> float { return speed_; }
void setDirection(float dx, float dy) {
dir_x_ = dx;
dir_y_ = dy;
}
[[nodiscard]] auto getDirX() const -> float { return dir_x_; }
[[nodiscard]] auto getDirY() const -> float { return dir_y_; }
// Política de moviment per frame. La base desplaça el pixel_rect_
// proporcionalment a velocitat i direcció; les subclasses amb IA la
// sobreescriuen per triar nova direcció a les interseccions o rebotar.
virtual void step(float delta_time) {
pixel_rect_.x += dir_x_ * speed_ * delta_time;
pixel_rect_.y += dir_y_ * speed_ * delta_time;
}
void update(float delta_time) override {
Entity::update(delta_time);
step(delta_time);
}
protected:
float speed_{0.0F};
float dir_x_{0.0F};
float dir_y_{0.0F};
};
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "game/entities/entity.hpp"
// Entitat amb estat observable i/o mètode d'interacció explícita. Exemples:
// `Sarcophagus` (estat CLOSED/OPENING/OPEN), `Door` (estat igual). Les
// classes filles implementen `interact(Player&)` si responen a una acció
// directa; altres canvien d'estat per observació del món (Sarcòfag:
// perímetre visitat).
class Interactable : public Entity {
public:
using Entity::Entity;
Interactable() {
collides_ = true;
interactable_ = true;
}
// Crida explícita d'interacció: per defecte no-op. Les subclasses que
// tinguin un botó "accionar" (Door oberta, p.ex.) ho sobreescriuen.
virtual void interact(Player* /*player*/) {}
};