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

+42
View File
@@ -30,6 +30,25 @@ endif()
configure_file(${CMAKE_SOURCE_DIR}/source/version.h.in ${CMAKE_BINARY_DIR}/version.h @ONLY)
# Sources comuns del sistema d'entitats: s'afegeixen tant a l'executable
# principal com al smoke test. Mantenir-los en variable separada evita
# duplicació entre els dos targets.
set(ENTITY_SOURCES
source/game/entities/entity.cpp
source/game/entities/animation_component.cpp
source/game/entities/event_bus.cpp
source/game/entities/entity_factory.cpp
source/game/entities/entity_loader.cpp
source/game/entities/entity_manager.cpp
source/game/entities/concrete/torch.cpp
source/game/entities/concrete/sarcophagus.cpp
source/game/entities/concrete/door.cpp
source/game/entities/concrete/mummy.cpp
source/game/entities/concrete/rolling_rock.cpp
source/game/entities/concrete/wall_trap.cpp
source/game/entities/concrete/floor_trap.cpp
)
set(APP_SOURCES
source/main.cpp
source/core/system/director.cpp
@@ -62,6 +81,7 @@ set(APP_SOURCES
source/game/entities/player.cpp
source/game/map/map.cpp
source/game/map/tilemap.cpp
${ENTITY_SOURCES}
)
find_package(SDL3 REQUIRED CONFIG REQUIRED COMPONENTS SDL3)
@@ -222,3 +242,25 @@ if(CPPCHECK_EXE)
else()
message(STATUS "cppcheck no encontrado - target 'cppcheck' no disponible")
endif()
# --- SMOKE TESTS ---
# Executable stand-alone que cobreix el sistema d'entitats. No obri finestra
# ni GPU: pot córrer en qualsevol entorn. `ctest --output-on-failure` el
# dispara des de `build/`.
option(BUILD_TESTS "Build smoke tests" ON)
if(BUILD_TESTS)
add_executable(test_entities_smoke
tests/test_entities_smoke.cpp
${ENTITY_SOURCES}
)
target_include_directories(test_entities_smoke PRIVATE
"${CMAKE_SOURCE_DIR}/source"
"${CMAKE_BINARY_DIR}"
)
target_link_libraries(test_entities_smoke PRIVATE SDL3::SDL3)
target_compile_options(test_entities_smoke PRIVATE -Wall -Wextra -Wpedantic)
enable_testing()
add_test(NAME entities_smoke
COMMAND test_entities_smoke
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
endif()
Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

+18
View File
@@ -0,0 +1,18 @@
name: door
type: interactable
sprite:
path: door.gif
size: [16, 16]
tile_size: [1, 1]
flags:
collides: true
interactable: true
animations:
closed: { frames: [0], fps: 1, loop: true }
opening: { frames: [0], fps: 6, loop: false }
open: { frames: [0], fps: 1, loop: true }
behaviour:
states: [CLOSED, OPENING, OPEN]
initial_state: CLOSED
opens_when: all_sarcophagi_open
on_player_contact_when_open: level_complete
Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

+17
View File
@@ -0,0 +1,17 @@
name: floor_trap
type: static_hazard
sprite:
path: floor_trap.gif
size: [16, 16]
tile_size: [1, 1]
flags:
collides: false
interactable: false
animations:
inactive: { frames: [0], fps: 1, loop: true }
active: { frames: [0], fps: 6, loop: true }
cycle:
active_duration: 0.3
inactive_duration: 2.0
stats:
damage: 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

+18
View File
@@ -0,0 +1,18 @@
name: mummy
type: dynamic_hazard
sprite:
path: mummy.gif
size: [12, 24]
tile_size: [1, 1]
flags:
collides: true
interactable: false
animations:
idle: { frames: [0], fps: 1, loop: true }
walk: { frames: [0], fps: 8, loop: true }
ai:
type: corridor_wanderer
direction_choice: random
stats:
speed: 40
damage: 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

@@ -0,0 +1,17 @@
name: rolling_rock
type: dynamic_hazard
sprite:
path: rolling_rock.gif
size: [16, 16]
tile_size: [1, 1]
flags:
collides: true
interactable: false
animations:
roll: { frames: [0], fps: 10, loop: true }
ai:
type: straight_line
initial_direction: right
stats:
speed: 60
damage: 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 850 B

@@ -0,0 +1,19 @@
name: sarcophagus
type: interactable
sprite:
path: sarcophagus.gif
size: [48, 16]
tile_size: [3, 1]
flags:
collides: true
interactable: true
animations:
closed: { frames: [0], fps: 1, loop: true }
opening: { frames: [0], fps: 6, loop: false }
open: { frames: [0], fps: 1, loop: true }
behaviour:
states: [CLOSED, OPENING, OPEN]
initial_state: CLOSED
on_perimeter_complete: OPENING
on_opening_end: OPEN
emits_event_on_open: sarcophagus_opened
Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

+11
View File
@@ -0,0 +1,11 @@
name: torch
type: decoration
sprite:
path: torch.gif
size: [16, 16]
tile_size: [1, 1]
flags:
collides: false
interactable: false
animations:
idle: { frames: [0], fps: 8, loop: true }
Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

+17
View File
@@ -0,0 +1,17 @@
name: wall_trap
type: static_hazard
sprite:
path: wall_trap.gif
size: [16, 16]
tile_size: [1, 1]
flags:
collides: false
interactable: false
animations:
inactive: { frames: [0], fps: 1, loop: true }
active: { frames: [0], fps: 6, loop: true }
cycle:
active_duration: 0.5
inactive_duration: 1.5
stats:
damage: 1
+9
View File
@@ -63,3 +63,12 @@ data: |
YZD...D...D...D...DTY
YZDDBBDDADDDBCDDDDDTY
YRXXXXXXXXXXXXXXXXXGY
# Spawns d'entitats per a aquest nivell. El carregador de Map ignora aquest
# bloc; només EntityLoader::readSpawns el consulta. Fase actual: andamiatge;
# encara no cablejat al GameScene, així que aquests spawns es llegiran com
# a dades però no instanciaran res al joc real fins que hi arribi la fase
# d'integració. `position` és [col, row] en la graella de sarcòfags
# (Map::grid_cols × Map::grid_rows) per a sarcòfags, o [col, row] en la
# graella de tiles de 16 px per a la resta (to be refinat).
entities: []
+44
View File
@@ -0,0 +1,44 @@
# Nivell mínim per al smoke test d'entitats. Conté la geometria reduïda
# per a poder carregar Map sense errors, però el seu propòsit real són
# els spawns de sota: l'smoke verifica que EntityLoader::readSpawns els
# retorna correctament i que EntityManager pot instanciar-los i wire-ar-los.
tileset: data/tilesets/tomb.gif
tile_size: 16
origin: [-8, 0]
geometry:
grid_cols: 2
grid_rows: 2
sarc_w: 48
sarc_h: 16
pitch_x: 64
pitch_y: 32
hud_h: 24
back_wall_h: 24
side_wall_w: 24
legend:
Y: 1
X: 169
data: |
YYYYYYYYYYYYYY
YXXXXXXXXXXXXY
YXXXXXXXXXXXXY
YXXXXXXXXXXXXY
YXXXXXXXXXXXXY
YXXXXXXXXXXXXY
YYYYYYYYYYYYYY
entities:
- type: torch
position: [1, 1]
- type: sarcophagus
position: [0, 0]
- type: sarcophagus
position: [1, 0]
- type: door
position: [1, 6]
- type: mummy
position: [3, 3]
@@ -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*/) {}
};
+282
View File
@@ -0,0 +1,282 @@
// Smoke tests del sistema d'entitats. Stand-alone, sense frameworks externs.
//
// Tests coberts:
// T1 factory — tots els tipus es registren i es creen.
// T2 loader — llegeix `data/maps/test_entities.yaml` i retorna els spawns.
// T3 event bus — subscribe + publish + payload (entity*, int).
// T4 sarcòfag — perímetre computat correctament; notifyTileEntered
// passa a OPENING quan es visiten tots els tiles.
// T5 manager — 2 sarcs + 1 door: forçant l'obertura dels sarcs, la porta
// s'obri sola; contactar la porta OPEN emet `level_complete`.
// T6 animation — 3 frames @ 10 fps, `tick(0.15)` → frame índex == 1.
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
// T8 decoration — Torch no col·lisiona ni és interactable.
//
// Execució (des de la ruta del repo):
// make build && cd build && ctest --output-on-failure
// O bé:
// ./build/test_entities_smoke
#include <cassert>
#include <cstdio>
#include <iostream>
#include <memory>
#include <string>
#include <unordered_set>
#include <utility>
#include <vector>
#include "game/entities/animation_component.hpp"
#include "game/entities/concrete/door.hpp"
#include "game/entities/concrete/floor_trap.hpp"
#include "game/entities/concrete/mummy.hpp"
#include "game/entities/concrete/rolling_rock.hpp"
#include "game/entities/concrete/sarcophagus.hpp"
#include "game/entities/concrete/torch.hpp"
#include "game/entities/concrete/wall_trap.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_loader.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp"
namespace {
int g_failures = 0;
void check(bool cond, const char* msg, const char* file, int line) {
if (!cond) {
std::cerr << " FAIL: " << msg << " (" << file << ":" << line << ")\n";
++g_failures;
}
}
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
void testFactory() {
std::cout << "[T1] factory\n";
auto& factory = EntityFactory::instance();
const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap",
};
for (const auto& type : TYPES) {
fkyaml::node tmpl = EntityLoader::loadTemplate(type);
auto ent = factory.create(type, tmpl);
REQUIRE(ent != nullptr);
if (ent != nullptr) { REQUIRE(ent->getName() == type); }
}
}
void testLoader() {
std::cout << "[T2] loader\n";
auto spawns = EntityLoader::readSpawns("data/maps/test_entities.yaml");
// Al test_entities.yaml hem declarat 5 spawns.
REQUIRE(spawns.size() == 5);
// Primer spawn és torch.
if (!spawns.empty()) {
REQUIRE(spawns[0]["type"].get_value<std::string>() == "torch");
}
}
void testEventBus() {
std::cout << "[T3] event bus\n";
EventBus bus;
int int_received = -1;
int entity_ptr_flag = 0;
bus.subscribe("a_int", [&](const Event& ev) {
const int* v = std::get_if<int>(&ev.payload);
if (v != nullptr) { int_received = *v; }
});
bus.subscribe("a_ent", [&](const Event& ev) {
if (std::get_if<Entity*>(&ev.payload) != nullptr) { entity_ptr_flag = 1; }
});
bus.publish("a_int", 42);
REQUIRE(int_received == 42);
Entity fake;
bus.publish("a_ent", &fake);
REQUIRE(entity_ptr_flag == 1);
// Publish d'un nom sense subscriptors no ha de crashejar.
bus.publish("desconegut");
// Unsubscribe elimina els handlers.
auto id = bus.subscribe("a_int", [&](const Event&) { int_received = 999; });
bus.unsubscribe(id);
bus.publish("a_int", 7);
REQUIRE(int_received == 7);
}
void testSarcophagusPerimeter() {
std::cout << "[T4] sarcofag perimetre\n";
fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus");
auto sarc = std::make_unique<Sarcophagus>(tmpl);
// Ancorat a (col=1, row=1) amb tile_size=[3,1] → rectangle de tiles
// cobreix (1,1), (2,1), (3,1). Perímetre ortogonal esperat (sense
// BlockedPredicate):
// Nord: (1,0), (2,0), (3,0)
// Sud: (1,2), (2,2), (3,2)
// Oest: (0,1)
// Est: (4,1)
// Total: 8 tiles.
sarc->place(1, 1, {});
REQUIRE(sarc->getPerimeterSize() == 8);
REQUIRE(sarc->getState() == Sarcophagus::State::CLOSED);
// Visitem un tile que NO és al perímetre: res canvia.
sarc->notifyTileEntered(5, 5);
REQUIRE(sarc->getVisitedCount() == 0);
// Visitem tots els del perímetre.
const std::vector<std::pair<int, int>> PERIM = {
{1, 0}, {2, 0}, {3, 0}, {1, 2}, {2, 2}, {3, 2}, {0, 1}, {4, 1},
};
for (const auto& [c, r] : PERIM) { sarc->notifyTileEntered(c, r); }
REQUIRE(sarc->getVisitedCount() == 8);
REQUIRE(sarc->getState() == Sarcophagus::State::OPENING);
// Un update amb l'animació `opening` d'1 frame loop=false completa
// la transició a OPEN.
sarc->update(0.0F);
REQUIRE(sarc->getState() == Sarcophagus::State::OPEN);
}
void testEntityManagerEndToEnd() {
std::cout << "[T5] manager end-to-end\n";
EntityManager manager;
// 2 sarcòfags, 1 porta.
auto sarc1 = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
sarc1->place(0, 0, {});
auto sarc2 = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
sarc2->place(4, 4, {});
Sarcophagus* s1 = manager.addSarcophagus(std::move(sarc1));
Sarcophagus* s2 = manager.addSarcophagus(std::move(sarc2));
REQUIRE(manager.sarcophagusCount() == 2);
auto door = std::make_unique<Door>(EntityLoader::loadTemplate("door"));
Door* door_ptr = door.get();
manager.add(std::move(door));
manager.setDoor(door_ptr);
// Subscriptor local per capturar `level_complete`.
bool level_complete_fired = false;
manager.getBus().subscribe("level_complete", [&](const Event&) {
level_complete_fired = true;
});
// Forcem l'obertura dels dos sarcs visitant els seus perímetres.
auto forceOpen = [](Sarcophagus* s) {
const int COL0 = s->getTilePos().col;
const int ROW0 = s->getTilePos().row;
const int W = s->getTileWidth();
const int H = s->getTileHeight();
for (int dc = 0; dc < W; ++dc) {
s->notifyTileEntered(COL0 + dc, ROW0 - 1);
s->notifyTileEntered(COL0 + dc, ROW0 + H);
}
for (int dr = 0; dr < H; ++dr) {
s->notifyTileEntered(COL0 - 1, ROW0 + dr);
s->notifyTileEntered(COL0 + W, ROW0 + dr);
}
};
forceOpen(s1);
forceOpen(s2);
// Un primer tick: cada sarc transiciona OPENING → OPEN i publica;
// l'handler del manager compta i, en arribar al total, crida
// door->open() — que en aquest mateix tick (la iteració sobre
// entities continua) farà transitar la porta OPENING → OPEN perquè
// l'animació és d'1 frame loop=false.
manager.update(0.0F);
REQUIRE(manager.openedSarcophagusCount() == 2);
REQUIRE(door_ptr->getState() == Door::State::OPEN);
// Contacte amb la porta oberta → level_complete.
door_ptr->onPlayerContact(nullptr);
REQUIRE(level_complete_fired);
// Un segon contacte NO torna a disparar (idempotent).
level_complete_fired = false;
door_ptr->onPlayerContact(nullptr);
REQUIRE(!level_complete_fired);
}
void testAnimationComponent() {
std::cout << "[T6] animation component\n";
AnimationComponent anim({10, 20, 30}, 10.0F, true);
REQUIRE(anim.frameCount() == 3);
REQUIRE(anim.currentFrame() == 10);
anim.tick(0.15F); // 1.5 frames de 10 fps → hauria d'avançar a l'índex 1
REQUIRE(anim.currentFrame() == 20);
anim.tick(0.10F);
REQUIRE(anim.currentFrame() == 30);
// Loop: un tick més i torna al principi.
anim.tick(0.10F);
REQUIRE(anim.currentFrame() == 10);
// Variant no-loop: es para al darrer i queda completed.
AnimationComponent once({1, 2}, 10.0F, false);
once.tick(1.0F); // molts frames; ha d'arribar al final i quedar-se.
REQUIRE(once.currentFrame() == 2);
REQUIRE(once.completed());
// Variant d'1 frame no-loop: completed des del principi.
AnimationComponent single({5}, 1.0F, false);
REQUIRE(single.completed());
}
void testAllTemplatesLoad() {
std::cout << "[T7] plantilles YAML\n";
const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap",
};
for (const auto& type : TYPES) {
try {
fkyaml::node tmpl = EntityLoader::loadTemplate(type);
REQUIRE(tmpl.contains("name"));
REQUIRE(tmpl["name"].get_value<std::string>() == type);
} catch (const std::exception& e) {
std::cerr << " excepció carregant '" << type << "': " << e.what() << '\n';
++g_failures;
}
}
}
void testDecorationFlags() {
std::cout << "[T8] decoration flags\n";
fkyaml::node tmpl = EntityLoader::loadTemplate("torch");
Torch torch(tmpl);
REQUIRE(!torch.collides());
REQUIRE(!torch.interactable());
}
} // namespace
auto main() -> int {
std::cout << "== smoke tests: entity system ==\n";
testFactory();
testLoader();
testEventBus();
testSarcophagusPerimeter();
testEntityManagerEndToEnd();
testAnimationComponent();
testAllTemplatesLoad();
testDecorationFlags();
if (g_failures == 0) {
std::cout << "ALL OK\n";
return 0;
}
std::cerr << g_failures << " smoke check(s) failed\n";
return 1;
}
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Genera GIFs placeholder paletitzats per al sistema d'entitats.
Motor: el GIF loader del joc (source/core/rendering/gif.cpp) treballa amb
GIFs paletitzats (mode P). Aquest script usa Pillow per crear un fitxer
per entitat amb un únic frame de color sòlid distintiu, perquè cada
entitat es distingeixi en pantalla en ausència d'art definitiu.
Sortida: data/entities/<name>/<name>.gif amb les mides especificades.
Execució:
python3 tools/entities/generate_placeholders.py
"""
from __future__ import annotations
import os
from pathlib import Path
from PIL import Image
# (width, height, (r,g,b)) per a cada entitat. L'ordre no importa.
ENTITIES = {
"torch": (16, 16, (255, 165, 0)), # taronja
"door": (16, 16, (101, 67, 33)), # marró fosc
"mummy": (12, 24, (235, 215, 180)), # beige
"sarcophagus": (48, 16, (212, 175, 55)), # daurat (3×1 tiles)
"rolling_rock": (16, 16, (128, 128, 128)), # gris
"wall_trap": (16, 16, (139, 0, 0)), # vermell fosc
"floor_trap": (16, 16, (220, 20, 60)), # vermell clar (crimson)
}
def build_gif(width: int, height: int, rgb: tuple[int, int, int]) -> Image.Image:
"""Crea un GIF paletitzat d'un sol frame amb color sòlid.
Entrada 0 de la paleta = transparent (convenció del motor). El color
del sprite viu a l'entrada 1; la resta de la paleta s'omple amb
negre. Com que només usem un índex visible, l'LZW comprimeix el
frame a una rajola de bytes idèntics.
"""
img = Image.new("P", (width, height), color=1)
palette = bytearray(256 * 3)
# Índex 0: transparent (valor RGB irrellevant; marcat amb transparency).
palette[0:3] = bytes((0, 0, 0))
palette[3:6] = bytes(rgb)
img.putpalette(bytes(palette))
return img
def main() -> int:
repo_root = Path(__file__).resolve().parents[2]
base = repo_root / "data" / "entities"
for name, (width, height, rgb) in ENTITIES.items():
out_dir = base / name
out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / f"{name}.gif"
img = build_gif(width, height, rgb)
# transparency=0 mantén la convenció del motor; optimize=False
# força un GIF amb GCT estàndard (el loader no accepta variants
# minimalistes sense GCT).
img.save(out_file, format="GIF", transparency=0, optimize=False)
size = os.path.getsize(out_file)
print(f" {out_file.relative_to(repo_root)} {width}x{height} {size} bytes")
print(f"OK: {len(ENTITIES)} placeholders escrits a data/entities/")
return 0
if __name__ == "__main__":
raise SystemExit(main())