From 96b71b0f65548cfbc550942aa1082508f79ef2ac Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 21 Apr 2026 12:35:19 +0200 Subject: [PATCH] feat(entities): andamiatge del sistema d'entitats (classes, factory, bus, YAML, placeholders, smoke tests) --- CMakeLists.txt | 42 +++ data/entities/door/door.gif | Bin 0 -> 832 bytes data/entities/door/door.yaml | 18 ++ data/entities/floor_trap/floor_trap.gif | Bin 0 -> 832 bytes data/entities/floor_trap/floor_trap.yaml | 17 ++ data/entities/mummy/mummy.gif | Bin 0 -> 833 bytes data/entities/mummy/mummy.yaml | 18 ++ data/entities/rolling_rock/rolling_rock.gif | Bin 0 -> 832 bytes data/entities/rolling_rock/rolling_rock.yaml | 17 ++ data/entities/sarcophagus/sarcophagus.gif | Bin 0 -> 850 bytes data/entities/sarcophagus/sarcophagus.yaml | 19 ++ data/entities/torch/torch.gif | Bin 0 -> 832 bytes data/entities/torch/torch.yaml | 11 + data/entities/wall_trap/wall_trap.gif | Bin 0 -> 832 bytes data/entities/wall_trap/wall_trap.yaml | 17 ++ data/maps/pyramid.yaml | 9 + data/maps/test_entities.yaml | 44 +++ source/game/entities/animation_component.cpp | 48 +++ source/game/entities/animation_component.hpp | 47 +++ source/game/entities/concrete/door.cpp | 85 ++++++ source/game/entities/concrete/door.hpp | 48 +++ source/game/entities/concrete/floor_trap.cpp | 29 ++ source/game/entities/concrete/floor_trap.hpp | 14 + source/game/entities/concrete/mummy.cpp | 45 +++ source/game/entities/concrete/mummy.hpp | 36 +++ .../game/entities/concrete/rolling_rock.cpp | 49 +++ .../game/entities/concrete/rolling_rock.hpp | 15 + source/game/entities/concrete/sarcophagus.cpp | 127 ++++++++ source/game/entities/concrete/sarcophagus.hpp | 86 ++++++ source/game/entities/concrete/torch.cpp | 41 +++ source/game/entities/concrete/torch.hpp | 10 + source/game/entities/concrete/wall_trap.cpp | 29 ++ source/game/entities/concrete/wall_trap.hpp | 13 + source/game/entities/decoration.hpp | 17 ++ source/game/entities/entity.cpp | 26 ++ source/game/entities/entity.hpp | 89 ++++++ source/game/entities/entity_factory.cpp | 29 ++ source/game/entities/entity_factory.hpp | 65 ++++ source/game/entities/entity_loader.cpp | 68 +++++ source/game/entities/entity_loader.hpp | 34 +++ source/game/entities/entity_manager.cpp | 55 ++++ source/game/entities/entity_manager.hpp | 67 +++++ source/game/entities/event_bus.cpp | 31 ++ source/game/entities/event_bus.hpp | 72 +++++ source/game/entities/hazard.hpp | 90 ++++++ source/game/entities/interactable.hpp | 22 ++ tests/test_entities_smoke.cpp | 282 ++++++++++++++++++ tools/entities/generate_placeholders.py | 76 +++++ 48 files changed, 1957 insertions(+) create mode 100644 data/entities/door/door.gif create mode 100644 data/entities/door/door.yaml create mode 100644 data/entities/floor_trap/floor_trap.gif create mode 100644 data/entities/floor_trap/floor_trap.yaml create mode 100644 data/entities/mummy/mummy.gif create mode 100644 data/entities/mummy/mummy.yaml create mode 100644 data/entities/rolling_rock/rolling_rock.gif create mode 100644 data/entities/rolling_rock/rolling_rock.yaml create mode 100644 data/entities/sarcophagus/sarcophagus.gif create mode 100644 data/entities/sarcophagus/sarcophagus.yaml create mode 100644 data/entities/torch/torch.gif create mode 100644 data/entities/torch/torch.yaml create mode 100644 data/entities/wall_trap/wall_trap.gif create mode 100644 data/entities/wall_trap/wall_trap.yaml create mode 100644 data/maps/test_entities.yaml create mode 100644 source/game/entities/animation_component.cpp create mode 100644 source/game/entities/animation_component.hpp create mode 100644 source/game/entities/concrete/door.cpp create mode 100644 source/game/entities/concrete/door.hpp create mode 100644 source/game/entities/concrete/floor_trap.cpp create mode 100644 source/game/entities/concrete/floor_trap.hpp create mode 100644 source/game/entities/concrete/mummy.cpp create mode 100644 source/game/entities/concrete/mummy.hpp create mode 100644 source/game/entities/concrete/rolling_rock.cpp create mode 100644 source/game/entities/concrete/rolling_rock.hpp create mode 100644 source/game/entities/concrete/sarcophagus.cpp create mode 100644 source/game/entities/concrete/sarcophagus.hpp create mode 100644 source/game/entities/concrete/torch.cpp create mode 100644 source/game/entities/concrete/torch.hpp create mode 100644 source/game/entities/concrete/wall_trap.cpp create mode 100644 source/game/entities/concrete/wall_trap.hpp create mode 100644 source/game/entities/decoration.hpp create mode 100644 source/game/entities/entity.cpp create mode 100644 source/game/entities/entity.hpp create mode 100644 source/game/entities/entity_factory.cpp create mode 100644 source/game/entities/entity_factory.hpp create mode 100644 source/game/entities/entity_loader.cpp create mode 100644 source/game/entities/entity_loader.hpp create mode 100644 source/game/entities/entity_manager.cpp create mode 100644 source/game/entities/entity_manager.hpp create mode 100644 source/game/entities/event_bus.cpp create mode 100644 source/game/entities/event_bus.hpp create mode 100644 source/game/entities/hazard.hpp create mode 100644 source/game/entities/interactable.hpp create mode 100644 tests/test_entities_smoke.cpp create mode 100644 tools/entities/generate_placeholders.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 7973383..3905d98 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/data/entities/door/door.gif b/data/entities/door/door.gif new file mode 100644 index 0000000000000000000000000000000000000000..b28dbf00fa79ffca25d3a9d3ed6f8029225677b0 GIT binary patch literal 832 zcmZ?wbhEHb6krfwXlDR{RA)snJxYv*z=#V0#h)yUAW!Rn2vDAI;E-it=8*B&u;5@b bhp<-6i46-6w+kqH&GFc{=x8G&BZD;n`G^oF literal 0 HcmV?d00001 diff --git a/data/entities/door/door.yaml b/data/entities/door/door.yaml new file mode 100644 index 0000000..26bf55f --- /dev/null +++ b/data/entities/door/door.yaml @@ -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 diff --git a/data/entities/floor_trap/floor_trap.gif b/data/entities/floor_trap/floor_trap.gif new file mode 100644 index 0000000000000000000000000000000000000000..b0c03742201389c26ba30ab40352b061820cdb7a GIT binary patch literal 832 zcmZ?wbhEHb6krfwXlDR{J0dn b4q>gB6B`yDZWmDYn&Yu?(a}amMh0sDH7^i~ literal 0 HcmV?d00001 diff --git a/data/entities/floor_trap/floor_trap.yaml b/data/entities/floor_trap/floor_trap.yaml new file mode 100644 index 0000000..e7b2aa1 --- /dev/null +++ b/data/entities/floor_trap/floor_trap.yaml @@ -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 diff --git a/data/entities/mummy/mummy.gif b/data/entities/mummy/mummy.gif new file mode 100644 index 0000000000000000000000000000000000000000..88d5c45e6fe7dc05ea59fe9db39078aee2307e2b GIT binary patch literal 833 zcmZ?wbhEHb5XlDR{E9=d{^e8bJ0wXR26o0ZXf;_DQB0zbLCpIiR+%BN(HOFJ)qNCjs##whxY+QW2U%|Oc#&gq>lf8_L4AuY>{~C1w literal 0 HcmV?d00001 diff --git a/data/entities/sarcophagus/sarcophagus.yaml b/data/entities/sarcophagus/sarcophagus.yaml new file mode 100644 index 0000000..fd18029 --- /dev/null +++ b/data/entities/sarcophagus/sarcophagus.yaml @@ -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 diff --git a/data/entities/torch/torch.gif b/data/entities/torch/torch.gif new file mode 100644 index 0000000000000000000000000000000000000000..e3538320975ac374ac0624877de09113e5aded0f GIT binary patch literal 832 zcmZ?wbhEHb6krfwXlDR{|4YHtC@~rWBQFFLf3h%we60f_KzYJ}LzaP=L&jsnf`iQ* a!dfvWHY_~cE}-l+$7AE7qm7J=4Aua%r4a%E literal 0 HcmV?d00001 diff --git a/data/entities/torch/torch.yaml b/data/entities/torch/torch.yaml new file mode 100644 index 0000000..aa26a0e --- /dev/null +++ b/data/entities/torch/torch.yaml @@ -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 } diff --git a/data/entities/wall_trap/wall_trap.gif b/data/entities/wall_trap/wall_trap.gif new file mode 100644 index 0000000000000000000000000000000000000000..f31598e10cd7fca7fd0fe24cae8bb126b8f6132e GIT binary patch literal 832 zcmZ?wbhEHb6krfwXlDR{ZZJ7YjE2An3<1TTEQ}y;>wpMQo^arhWnkuz@z}87U^9oX ZR?LYF3lFyoD0|KE*tqCuBO@b&H2^ei59t5^ literal 0 HcmV?d00001 diff --git a/data/entities/wall_trap/wall_trap.yaml b/data/entities/wall_trap/wall_trap.yaml new file mode 100644 index 0000000..2ef051b --- /dev/null +++ b/data/entities/wall_trap/wall_trap.yaml @@ -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 diff --git a/data/maps/pyramid.yaml b/data/maps/pyramid.yaml index dc7fd10..c04dc92 100644 --- a/data/maps/pyramid.yaml +++ b/data/maps/pyramid.yaml @@ -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: [] diff --git a/data/maps/test_entities.yaml b/data/maps/test_entities.yaml new file mode 100644 index 0000000..86163fa --- /dev/null +++ b/data/maps/test_entities.yaml @@ -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] diff --git a/source/game/entities/animation_component.cpp b/source/game/entities/animation_component.cpp new file mode 100644 index 0000000..65ba3a2 --- /dev/null +++ b/source/game/entities/animation_component.cpp @@ -0,0 +1,48 @@ +#include "game/entities/animation_component.hpp" + +#include +#include + +AnimationComponent::AnimationComponent(std::vector 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(current_) >= frames_.size()) { + if (loop_) { + current_ = 0; + } else { + current_ = static_cast(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(current_)]; +} diff --git a/source/game/entities/animation_component.hpp b/source/game/entities/animation_component.hpp new file mode 100644 index 0000000..11f851f --- /dev/null +++ b/source/game/entities/animation_component.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +// 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 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 frames_; + float fps_{1.0F}; + bool loop_{true}; + int current_{0}; + float accumulator_{0.0F}; + bool completed_{false}; +}; diff --git a/source/game/entities/concrete/door.cpp b/source/game/entities/concrete/door.cpp new file mode 100644 index 0000000..3d172f2 --- /dev/null +++ b/source/game/entities/concrete/door.cpp @@ -0,0 +1,85 @@ +#include "game/entities/concrete/door.hpp" + +#include +#include + +#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 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()); + } + if (frames.empty()) { frames.push_back(0); } + } + if (anim.contains("fps")) { fps = anim["fps"].get_value(); } + if (anim.contains("loop")) { loop = anim["loop"].get_value(); } + } + 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("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) diff --git a/source/game/entities/concrete/door.hpp b/source/game/entities/concrete/door.hpp new file mode 100644 index 0000000..f3fa9f2 --- /dev/null +++ b/source/game/entities/concrete/door.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include + +#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_; +}; diff --git a/source/game/entities/concrete/floor_trap.cpp b/source/game/entities/concrete/floor_trap.cpp new file mode 100644 index 0000000..4457765 --- /dev/null +++ b/source/game/entities/concrete/floor_trap.cpp @@ -0,0 +1,29 @@ +#include "game/entities/concrete/floor_trap.hpp" + +#include + +#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("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() : 0.3F; + const float INACTIVE = c.contains("inactive_duration") ? c["inactive_duration"].get_value() : 2.0F; + setCycle(ACTIVE, INACTIVE); + } + if (yaml.contains("stats") && yaml["stats"].contains("damage")) { + setDamage(yaml["stats"]["damage"].get_value()); + } +} + +void FloorTrap::onPlayerContact(Player* /*player*/) { + if (!isActive()) { return; } + if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); } +} + +REGISTER_ENTITY("floor_trap", FloorTrap) diff --git a/source/game/entities/concrete/floor_trap.hpp b/source/game/entities/concrete/floor_trap.hpp new file mode 100644 index 0000000..47304cb --- /dev/null +++ b/source/game/entities/concrete/floor_trap.hpp @@ -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; +}; diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp new file mode 100644 index 0000000..efa395b --- /dev/null +++ b/source/game/entities/concrete/mummy.cpp @@ -0,0 +1,45 @@ +#include "game/entities/concrete/mummy.hpp" + +#include +#include + +#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("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()); } + if (s.contains("damage")) { setDamage(s["damage"].get_value()); } + } +} + +auto Mummy::chooseDirection(const std::vector& allowed) -> Direction { + if (allowed.empty()) { return Direction::NONE; } + std::uniform_int_distribution 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) diff --git a/source/game/entities/concrete/mummy.hpp b/source/game/entities/concrete/mummy.hpp new file mode 100644 index 0000000..30a73e2 --- /dev/null +++ b/source/game/entities/concrete/mummy.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#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& allowed) -> Direction; + + // Contacte amb el jugador: publica `player_hit` amb el dany. + void onPlayerContact(Player* player) override; +}; diff --git a/source/game/entities/concrete/rolling_rock.cpp b/source/game/entities/concrete/rolling_rock.cpp new file mode 100644 index 0000000..dea68f7 --- /dev/null +++ b/source/game/entities/concrete/rolling_rock.cpp @@ -0,0 +1,49 @@ +#include "game/entities/concrete/rolling_rock.hpp" + +#include + +#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("rolling_rock"); + collides_ = true; + interactable_ = false; + + if (yaml.contains("stats")) { + const auto& s = yaml["stats"]; + if (s.contains("speed")) { setSpeed(s["speed"].get_value()); } + if (s.contains("damage")) { setDamage(s["damage"].get_value()); } + } + std::string initial = "right"; + if (yaml.contains("ai") && yaml["ai"].contains("initial_direction")) { + initial = yaml["ai"]["initial_direction"].get_value(); + } + applyInitialDirection(initial, *this); +} + +void RollingRock::onPlayerContact(Player* /*player*/) { + if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); } +} + +REGISTER_ENTITY("rolling_rock", RollingRock) diff --git a/source/game/entities/concrete/rolling_rock.hpp b/source/game/entities/concrete/rolling_rock.hpp new file mode 100644 index 0000000..4b896b7 --- /dev/null +++ b/source/game/entities/concrete/rolling_rock.hpp @@ -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; +}; diff --git a/source/game/entities/concrete/sarcophagus.cpp b/source/game/entities/concrete/sarcophagus.cpp new file mode 100644 index 0000000..bfdcdb6 --- /dev/null +++ b/source/game/entities/concrete/sarcophagus.cpp @@ -0,0 +1,127 @@ +#include "game/entities/concrete/sarcophagus.hpp" + +#include +#include + +#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 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()); + } + if (frames.empty()) { frames.push_back(0); } + } + if (anim.contains("fps")) { fps = anim["fps"].get_value(); } + if (anim.contains("loop")) { loop = anim["loop"].get_value(); } + } + 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(); + h = ts[1].get_value(); + } + } + +} // 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("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(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) diff --git a/source/game/entities/concrete/sarcophagus.hpp b/source/game/entities/concrete/sarcophagus.hpp new file mode 100644 index 0000000..5cc5754 --- /dev/null +++ b/source/game/entities/concrete/sarcophagus.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include + +#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; + + 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(k.col) * 131U) ^ static_cast(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 perimeter_tiles_; + std::unordered_set visited_; + + State state_{State::CLOSED}; + + AnimationComponent anim_closed_; + AnimationComponent anim_opening_; + AnimationComponent anim_open_; + + bool opened_event_emitted_{false}; +}; diff --git a/source/game/entities/concrete/torch.cpp b/source/game/entities/concrete/torch.cpp new file mode 100644 index 0000000..8702cee --- /dev/null +++ b/source/game/entities/concrete/torch.cpp @@ -0,0 +1,41 @@ +#include "game/entities/concrete/torch.hpp" + +#include +#include + +#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 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()); + } + if (frames.empty()) { frames.push_back(0); } + } + if (anim.contains("fps")) { fps = anim["fps"].get_value(); } + if (anim.contains("loop")) { loop = anim["loop"].get_value(); } + } + return {frames, fps, loop}; + } + +} // namespace + +Torch::Torch(const fkyaml::node& yaml) { + name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("torch"); + collides_ = false; + interactable_ = false; + anim_ = buildIdleAnimation(yaml); +} + +REGISTER_ENTITY("torch", Torch) diff --git a/source/game/entities/concrete/torch.hpp b/source/game/entities/concrete/torch.hpp new file mode 100644 index 0000000..f76de2c --- /dev/null +++ b/source/game/entities/concrete/torch.hpp @@ -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); +}; diff --git a/source/game/entities/concrete/wall_trap.cpp b/source/game/entities/concrete/wall_trap.cpp new file mode 100644 index 0000000..43345d7 --- /dev/null +++ b/source/game/entities/concrete/wall_trap.cpp @@ -0,0 +1,29 @@ +#include "game/entities/concrete/wall_trap.hpp" + +#include + +#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("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() : 0.5F; + const float INACTIVE = c.contains("inactive_duration") ? c["inactive_duration"].get_value() : 1.5F; + setCycle(ACTIVE, INACTIVE); + } + if (yaml.contains("stats") && yaml["stats"].contains("damage")) { + setDamage(yaml["stats"]["damage"].get_value()); + } +} + +void WallTrap::onPlayerContact(Player* /*player*/) { + if (!isActive()) { return; } + if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); } +} + +REGISTER_ENTITY("wall_trap", WallTrap) diff --git a/source/game/entities/concrete/wall_trap.hpp b/source/game/entities/concrete/wall_trap.hpp new file mode 100644 index 0000000..f720752 --- /dev/null +++ b/source/game/entities/concrete/wall_trap.hpp @@ -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; +}; diff --git a/source/game/entities/decoration.hpp b/source/game/entities/decoration.hpp new file mode 100644 index 0000000..a6a7f15 --- /dev/null +++ b/source/game/entities/decoration.hpp @@ -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; + } +}; diff --git a/source/game/entities/entity.cpp b/source/game/entities/entity.cpp new file mode 100644 index 0000000..8190863 --- /dev/null +++ b/source/game/entities/entity.cpp @@ -0,0 +1,26 @@ +#include "game/entities/entity.hpp" + +#include + +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. +} diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp new file mode 100644 index 0000000..40c4d2f --- /dev/null +++ b/source/game/entities/entity.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include + +#include +#include + +#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 anim_; + bool collides_{false}; + bool interactable_{false}; + EventBus* bus_{nullptr}; +}; diff --git a/source/game/entities/entity_factory.cpp b/source/game/entities/entity_factory.cpp new file mode 100644 index 0000000..dcc56e9 --- /dev/null +++ b/source/game/entities/entity_factory.cpp @@ -0,0 +1,29 @@ +#include "game/entities/entity_factory.hpp" + +#include +#include + +#include "game/entities/entity.hpp" // tipus complet per a unique_ptr + +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 { + auto it = creators_.find(name); + if (it == creators_.end()) { return nullptr; } + return it->second(yaml); +} + +auto EntityFactory::registeredNames() const -> std::vector { + std::vector names; + names.reserve(creators_.size()); + for (const auto& [name, _] : creators_) { names.push_back(name); } + return names; +} diff --git a/source/game/entities/entity_factory.hpp b/source/game/entities/entity_factory.hpp new file mode 100644 index 0000000..601d195 --- /dev/null +++ b/source/game/entities/entity_factory.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include + +#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(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; + + // 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; + + private: + EntityFactory() = default; + std::unordered_map 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 { \ + return std::make_unique(yaml); \ + }); \ + } \ + }; \ + const CLASS##_Registrar g_##CLASS##_registrar; /* NOLINT(cert-err58-cpp) */ \ + } diff --git a/source/game/entities/entity_loader.cpp b/source/game/entities/entity_loader.cpp new file mode 100644 index 0000000..8bdfa59 --- /dev/null +++ b/source/game/entities/entity_loader.cpp @@ -0,0 +1,68 @@ +#include "game/entities/entity_loader.hpp" + +#include +#include +#include + +#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& { + static std::unordered_map 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 { + 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 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 { + try { + fkyaml::node tmpl = loadTemplate(type); + return EntityFactory::instance().create(type, tmpl); + } catch (const std::exception&) { + return nullptr; + } + } + +} // namespace EntityLoader diff --git a/source/game/entities/entity_loader.hpp b/source/game/entities/entity_loader.hpp new file mode 100644 index 0000000..2858d85 --- /dev/null +++ b/source/game/entities/entity_loader.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +#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//.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; + + // 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; + +} // namespace EntityLoader diff --git a/source/game/entities/entity_manager.cpp b/source/game/entities/entity_manager.cpp new file mode 100644 index 0000000..a803603 --- /dev/null +++ b/source/game/entities/entity_manager.cpp @@ -0,0 +1,55 @@ +#include "game/entities/entity_manager.hpp" + +#include + +#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* { + 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 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); } +} diff --git a/source/game/entities/entity_manager.hpp b/source/game/entities/entity_manager.hpp new file mode 100644 index 0000000..01a7443 --- /dev/null +++ b/source/game/entities/entity_manager.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include + +#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*; + + // 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). + auto addSarcophagus(std::unique_ptr 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> entities_; + Door* door_{nullptr}; + std::size_t total_sarcs_{0}; + std::size_t opened_sarcs_{0}; + EventBus::SubId sarc_sub_id_{0}; +}; diff --git a/source/game/entities/event_bus.cpp b/source/game/entities/event_bus.cpp new file mode 100644 index 0000000..c6e14bf --- /dev/null +++ b/source/game/entities/event_bus.cpp @@ -0,0 +1,31 @@ +#include "game/entities/event_bus.hpp" + +#include +#include + +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); } +} diff --git a/source/game/entities/event_bus.hpp b/source/game/entities/event_bus.hpp new file mode 100644 index 0000000..ce1a841 --- /dev/null +++ b/source/game/entities/event_bus.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +// 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; + Payload payload; +}; + +class EventBus { + public: + using Handler = std::function; + 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> subs_; + std::uint64_t next_id_{1}; +}; diff --git a/source/game/entities/hazard.hpp b/source/game/entities/hazard.hpp new file mode 100644 index 0000000..79c5cce --- /dev/null +++ b/source/game/entities/hazard.hpp @@ -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}; +}; diff --git a/source/game/entities/interactable.hpp b/source/game/entities/interactable.hpp new file mode 100644 index 0000000..f1a9d36 --- /dev/null +++ b/source/game/entities/interactable.hpp @@ -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*/) {} +}; diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp new file mode 100644 index 0000000..b47cbfc --- /dev/null +++ b/tests/test_entities_smoke.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +#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 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() == "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(&ev.payload); + if (v != nullptr) { int_received = *v; } + }); + bus.subscribe("a_ent", [&](const Event& ev) { + if (std::get_if(&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(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> 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(EntityLoader::loadTemplate("sarcophagus")); + sarc1->place(0, 0, {}); + auto sarc2 = std::make_unique(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(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 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() == 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; +} diff --git a/tools/entities/generate_placeholders.py b/tools/entities/generate_placeholders.py new file mode 100644 index 0000000..5f01709 --- /dev/null +++ b/tools/entities/generate_placeholders.py @@ -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//.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())