feat(entities): andamiatge del sistema d'entitats (classes, factory, bus, YAML, placeholders, smoke tests)
No files matched your search
@@ -30,6 +30,25 @@ endif()
|
|||||||
|
|
||||||
configure_file(${CMAKE_SOURCE_DIR}/source/version.h.in ${CMAKE_BINARY_DIR}/version.h @ONLY)
|
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
|
set(APP_SOURCES
|
||||||
source/main.cpp
|
source/main.cpp
|
||||||
source/core/system/director.cpp
|
source/core/system/director.cpp
|
||||||
@@ -62,6 +81,7 @@ set(APP_SOURCES
|
|||||||
source/game/entities/player.cpp
|
source/game/entities/player.cpp
|
||||||
source/game/map/map.cpp
|
source/game/map/map.cpp
|
||||||
source/game/map/tilemap.cpp
|
source/game/map/tilemap.cpp
|
||||||
|
${ENTITY_SOURCES}
|
||||||
)
|
)
|
||||||
|
|
||||||
find_package(SDL3 REQUIRED CONFIG REQUIRED COMPONENTS SDL3)
|
find_package(SDL3 REQUIRED CONFIG REQUIRED COMPONENTS SDL3)
|
||||||
@@ -222,3 +242,25 @@ if(CPPCHECK_EXE)
|
|||||||
else()
|
else()
|
||||||
message(STATUS "cppcheck no encontrado - target 'cppcheck' no disponible")
|
message(STATUS "cppcheck no encontrado - target 'cppcheck' no disponible")
|
||||||
endif()
|
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()
|
||||||
|
After Width: | Height: | Size: 832 B |
@@ -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
|
||||||
|
After Width: | Height: | Size: 832 B |
@@ -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
|
||||||
|
After Width: | Height: | Size: 833 B |
@@ -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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
After Width: | Height: | Size: 832 B |
@@ -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 }
|
||||||
|
After Width: | Height: | Size: 832 B |
@@ -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
|
||||||
@@ -63,3 +63,12 @@ data: |
|
|||||||
YZD...D...D...D...DTY
|
YZD...D...D...D...DTY
|
||||||
YZDDBBDDADDDBCDDDDDTY
|
YZDDBBDDADDDBCDDDDDTY
|
||||||
YRXXXXXXXXXXXXXXXXXGY
|
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: []
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) */ \
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
@@ -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*/) {}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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())
|
||||||