708 lines
32 KiB
C++
708 lines
32 KiB
C++
// 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; notifyTilePainted
|
||
// 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. El contacte de la porta amb un jugador
|
||
// nul·l és no-op segur (la porta ja no emet `level_complete`
|
||
// directament; ho fa EntityManager comptant `player_exited`).
|
||
// 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.
|
||
// T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame.
|
||
// T11 sarcofag — tile_size [3,1] llegit; perímetre 8; falsa perspectiva:
|
||
// el sprite.height no canvia la caixa lògica 48×16.
|
||
// T12 collision — `Entity::CollisionBox` encastada al sprite: la caixa
|
||
// retornada coincideix amb les dimensions del bloc
|
||
// `collision:` del YAML; sense caixa, coincideix amb el
|
||
// sprite sencer (pixel_rect).
|
||
// T13 mummy step — camina en línia recta pel playfield i gira en topar
|
||
// un mur (insidePlayfield=false) o sarc (Map::collides).
|
||
// T14 exited aggregator — EntityManager compta `player_exited` i emet
|
||
// `level_complete` quan iguala `expected_players`. Verifica
|
||
// idempotència (3r event no re-emet) i reset del comptador
|
||
// via `setExpectedPlayerCount` (cicle següent torna a emetre).
|
||
//
|
||
// 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/hole.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"
|
||
#include "game/map/map.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__)
|
||
|
||
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra
|
||
// amb l'animació `raise` activa). Tickem amb dt gran fins sortir de
|
||
// SPAWNING — mateix path que `Mummy::update` executa al final de
|
||
// l'animació en partida real. El for limita el bucle defensivament.
|
||
void exitMummySpawn(Mummy& mummy) {
|
||
for (int i = 0; i < 32 && mummy.isSpawning(); ++i) {
|
||
mummy.update(0.5F);
|
||
}
|
||
}
|
||
|
||
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", "hole",
|
||
};
|
||
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->notifyTilePainted(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->notifyTilePainted(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);
|
||
|
||
// 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->notifyTilePainted(COL0 + dc, ROW0 - 1);
|
||
s->notifyTilePainted(COL0 + dc, ROW0 + H);
|
||
}
|
||
for (int dr = 0; dr < H; ++dr) {
|
||
s->notifyTilePainted(COL0 - 1, ROW0 + dr);
|
||
s->notifyTilePainted(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 sense jugador (nullptr): no-op segur. La porta ja no
|
||
// emet `level_complete` directament — ara és EntityManager qui
|
||
// l'emet quan tots els jugadors han eixit. La cobertura
|
||
// d'aquest agregat viu al test específic d'EntityManager
|
||
// (test_game_fsm) perquè requereix instanciar Player, fora
|
||
// d'abast d'aquest smoke purament d'entitats.
|
||
bool level_complete_fired = false;
|
||
manager.getBus().subscribe("level_complete", [&](const Event&) {
|
||
level_complete_fired = true;
|
||
});
|
||
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", "hole",
|
||
};
|
||
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());
|
||
}
|
||
|
||
void testLoadFromLevel() {
|
||
std::cout << "[T9] pyramid level load (globals + spawns + tile math + bus)\n";
|
||
// 1) Map::loadFrom carrega les tres globals noves (tile_size, origen).
|
||
Map::loadFrom("data/maps/pyramid.yaml");
|
||
REQUIRE(Map::tile_size == 16);
|
||
REQUIRE(Map::tilemap_origin_x == -8);
|
||
REQUIRE(Map::tilemap_origin_y == 0);
|
||
|
||
// 2) El pyramid.yaml conté spawns dels tipus esperats. Comptem al
|
||
// menys un de cada tipus que el nivell ha de tenir sí o sí (no
|
||
// fixem quants torches exactes perquè és decoratiu i pot
|
||
// variar). Així afegir decoració al YAML no trenca el smoke.
|
||
const auto SPAWNS = EntityLoader::readSpawns("data/maps/pyramid.yaml");
|
||
REQUIRE(!SPAWNS.empty());
|
||
int torches = 0;
|
||
int sarcs = 0;
|
||
int doors = 0;
|
||
int mummies = 0;
|
||
for (const auto& s : SPAWNS) {
|
||
const auto T = s["type"].get_value<std::string>();
|
||
if (T == "torch") { ++torches; }
|
||
else if (T == "sarcophagus") { ++sarcs; }
|
||
else if (T == "door") { ++doors; }
|
||
else if (T == "mummy") { ++mummies; }
|
||
}
|
||
REQUIRE(torches >= 1);
|
||
REQUIRE(sarcs >= 1);
|
||
REQUIRE(doors >= 1);
|
||
REQUIRE(mummies >= 1);
|
||
|
||
// 3) La conversió sarc-grid (0,0) → tile anchor (3,4) (i.e. la
|
||
// fórmula que usa loadFromLevel). Sarc rect = (40, 64, 48, 16).
|
||
const auto RECT = Map::getSarcRect(0, 0);
|
||
REQUIRE(static_cast<int>(RECT.x) == 40);
|
||
REQUIRE(static_cast<int>(RECT.y) == 64);
|
||
const int ANCHOR_COL = (static_cast<int>(RECT.x) - Map::tilemap_origin_x) / Map::tile_size;
|
||
const int ANCHOR_ROW = (static_cast<int>(RECT.y) - Map::tilemap_origin_y) / Map::tile_size;
|
||
REQUIRE(ANCHOR_COL == 3);
|
||
REQUIRE(ANCHOR_ROW == 4);
|
||
|
||
// 4) Fan-out del bus: un sarcòfag ancorat a (3,4) mida 3×1 rep via
|
||
// tile_painted una notificació sobre cada tile del seu perímetre
|
||
// → state canvia. Publiquem directament `tile_painted` (sense
|
||
// passar pel PaintLayer) per aïllar el fan-out de l'EntityManager.
|
||
EntityManager manager;
|
||
auto sarc = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
|
||
sarc->place(3, 4, {});
|
||
Sarcophagus* sarc_raw = manager.addSarcophagus(std::move(sarc));
|
||
REQUIRE(sarc_raw->getPerimeterSize() == 8);
|
||
// Les 8 tiles del perímetre: N (3..5,3), S (3..5,5), O (2,4), E (6,4).
|
||
const std::pair<int, int> PERIM[] = {
|
||
{3, 3}, {4, 3}, {5, 3},
|
||
{3, 5}, {4, 5}, {5, 5},
|
||
{2, 4}, {6, 4},
|
||
};
|
||
for (const auto& [c, r] : PERIM) {
|
||
manager.getBus().publish("tile_painted", TilePos{.col = c, .row = r});
|
||
}
|
||
REQUIRE(sarc_raw->getState() == Sarcophagus::State::OPENING);
|
||
}
|
||
|
||
void testSarcophagusYaml() {
|
||
std::cout << "[T11] sarcofag YAML + falsa perspectiva\n";
|
||
// El template ha de declarar tile_size [3,1]: el footprint lògic
|
||
// són els 48×16 inferiors del sprite, encara que el sprite sencer
|
||
// fa 48×32 (16 px de "alçada" per damunt, sense col·lisió).
|
||
fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus");
|
||
REQUIRE(tmpl.contains("tile_size"));
|
||
REQUIRE(tmpl["tile_size"][0].get_value<int>() == 3);
|
||
REQUIRE(tmpl["tile_size"][1].get_value<int>() == 1);
|
||
REQUIRE(tmpl["sprite"]["size"][0].get_value<int>() == 48);
|
||
REQUIRE(tmpl["sprite"]["size"][1].get_value<int>() == 32);
|
||
|
||
// Amb tile_size [3,1], un sarc a ancoratge (3,4) conserva els 8
|
||
// tiles de perímetre (no es converteix en 10 per culpa del [3,2]
|
||
// antic). Això blinda la geometria contra regressions al YAML.
|
||
auto sarc = std::make_unique<Sarcophagus>(tmpl);
|
||
sarc->place(3, 4, {});
|
||
REQUIRE(sarc->getPerimeterSize() == 8);
|
||
}
|
||
|
||
void testCollisionBox() {
|
||
std::cout << "[T12] collision box encastada al sprite\n";
|
||
// Torch sense bloc `collision:` → getCollisionRect == pixel_rect.
|
||
fkyaml::node torch_tmpl = EntityLoader::loadTemplate("torch");
|
||
Torch torch(torch_tmpl);
|
||
torch.setPixelRect({.x = 100.0F, .y = 200.0F, .w = 16.0F, .h = 16.0F});
|
||
const auto TRECT = torch.getCollisionRect();
|
||
REQUIRE(static_cast<int>(TRECT.w) == 16);
|
||
REQUIRE(static_cast<int>(TRECT.h) == 16);
|
||
REQUIRE(static_cast<int>(TRECT.x) == 100);
|
||
REQUIRE(static_cast<int>(TRECT.y) == 200);
|
||
|
||
// Mummy amb `collision: { size: [12, 12] }` i sprite 12×24 → caixa
|
||
// auto-centrada bottom dels peus: offset_x = (12-12)/2 = 0,
|
||
// offset_y = 24 - 12 = 12. Amb pixel_rect a (100, 200, 12, 24),
|
||
// la caixa ha de sortir a (100, 212, 12, 12) i feet_y = 224.
|
||
fkyaml::node mummy_tmpl = EntityLoader::loadTemplate("mummy");
|
||
Mummy mummy(mummy_tmpl);
|
||
// Apliquem el CollisionBox com fa el loader (readCollisionBox)
|
||
// amb sprite 12×24, offset calculat per defecte.
|
||
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
|
||
mummy.setPixelRect({.x = 100.0F, .y = 200.0F, .w = 12.0F, .h = 24.0F});
|
||
const auto MRECT = mummy.getCollisionRect();
|
||
REQUIRE(static_cast<int>(MRECT.x) == 100);
|
||
REQUIRE(static_cast<int>(MRECT.y) == 212);
|
||
REQUIRE(static_cast<int>(MRECT.w) == 12);
|
||
REQUIRE(static_cast<int>(MRECT.h) == 12);
|
||
REQUIRE(static_cast<int>(mummy.getFeetY()) == 224);
|
||
}
|
||
|
||
void testMummyStep() {
|
||
std::cout << "[T13] momia step amb col·lisió i gir\n";
|
||
// Cal Map::loadFrom (T9 ja el carrega, però el rerrumigo ací per
|
||
// aïllar la intenció del test).
|
||
Map::loadFrom("data/maps/pyramid.yaml");
|
||
|
||
// Momia al corredor exterior superior: playfield_y=48, alçada del
|
||
// corredor exterior = outer_corridor (16). Així el base box 12×12
|
||
// queda a y∈[52, 64] aprox. Collision box: offset_y=12 (sprite
|
||
// 12×24 amb peus 12×12 a baix). pixel_rect.x/y triat perquè els
|
||
// peus quedin al tile centrat (4, 3) del tilemap → peus a
|
||
// (col*16-8, row*16+0) i alineats bottom del tile.
|
||
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||
Mummy mummy(tmpl);
|
||
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
|
||
// Posem els peus ben dins d'un corredor horitzontal lliure. El
|
||
// corredor superior exterior va de y=48 a y=64 (alçada 16). Amb
|
||
// collision_y=12, sprite 12×24 → pixel_rect.y perquè feetY=64.
|
||
// feetY = pixel_rect.y + offset_y + h = pixel_rect.y + 24 → pixel_rect.y = 40.
|
||
// X: fora d'un sarc; (x=60) és al corredor extern abans dels sarcs.
|
||
mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
|
||
const auto BEFORE = mummy.getPixelRect();
|
||
|
||
// Velocitat llegida del YAML és 40 px/s. A 0.1s hauria d'avançar
|
||
// 4 px en X (direcció inicial right=+1,0). Com que està en un
|
||
// corredor lliure, el step s'aplica.
|
||
mummy.step(0.1F);
|
||
const auto AFTER = mummy.getPixelRect();
|
||
REQUIRE(AFTER.x > BEFORE.x);
|
||
REQUIRE(AFTER.y == BEFORE.y);
|
||
|
||
// Posem la momia contra la paret dreta del playfield: avançar un
|
||
// frame la trauria fora → el step ha de girar. No assumim cap nova
|
||
// direcció concreta (perpendiculars lliures poden ser vàlides); el
|
||
// contracte és "ja no continua cap a la dreta".
|
||
const float RIGHT_WALL = static_cast<float>(Map::playfield_x + Map::playfield_w);
|
||
mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
|
||
const auto POS_BEFORE = mummy.getPixelRect();
|
||
mummy.step(0.1F);
|
||
const auto POS_AFTER = mummy.getPixelRect();
|
||
// No ha avançat cap a la dreta en el frame de gir.
|
||
REQUIRE(POS_AFTER.x == POS_BEFORE.x);
|
||
REQUIRE(POS_AFTER.y == POS_BEFORE.y);
|
||
// I ja no apunta cap a la dreta (ha triat perpendicular o U-turn).
|
||
REQUIRE(mummy.getDirX() <= 0.0F);
|
||
}
|
||
|
||
void testMummyHurtState() {
|
||
std::cout << "[T15] mummy enterHurt → fugida + recuperació\n";
|
||
Map::loadFrom("data/maps/pyramid.yaml");
|
||
|
||
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||
Mummy mummy(tmpl);
|
||
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
|
||
// Mateixa col·locació que testMummyStep: corredor exterior
|
||
// superior, lliure de sarcs, prou marge per fugir cap a la dreta.
|
||
mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
|
||
exitMummySpawn(mummy);
|
||
|
||
// Direcció preferida: dreta. Mòmia ha d'entrar en HURT i avançar
|
||
// a la dreta (la velocitat de hurt és més alta que la base).
|
||
mummy.enterHurt(Mummy::Direction::EAST);
|
||
REQUIRE(mummy.getState() == Mummy::State::HURT);
|
||
REQUIRE(mummy.getDirX() > 0.0F);
|
||
|
||
const auto BEFORE = mummy.getPixelRect();
|
||
mummy.update(0.05F); // 50 ms: hauria d'avançar
|
||
const auto AFTER = mummy.getPixelRect();
|
||
REQUIRE(AFTER.x > BEFORE.x);
|
||
REQUIRE(mummy.getState() == Mummy::State::HURT);
|
||
|
||
// Avancem fins esgotar el timer (`hurt.duration_ms` = 600 ms al
|
||
// YAML → 0.6 s; sumem 1 s amb un sol step gran per garantir-ho).
|
||
mummy.update(1.0F);
|
||
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
|
||
}
|
||
|
||
void testMummyHurtAgainstWall() {
|
||
std::cout << "[T16] mummy HURT contra mur → surt abans del timer\n";
|
||
Map::loadFrom("data/maps/pyramid.yaml");
|
||
|
||
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||
Mummy mummy(tmpl);
|
||
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
|
||
|
||
// Posicionem la mòmia tocant la paret dreta del playfield i la
|
||
// fem fugir cap a la dreta: el primer pas hauria de topar amb
|
||
// la paret i fer-la sortir de HURT.
|
||
const float RIGHT_WALL = static_cast<float>(Map::playfield_x + Map::playfield_w);
|
||
mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
|
||
exitMummySpawn(mummy);
|
||
|
||
// La direcció preferida (RIGHT) està bloquejada al test
|
||
// d'`is_open`; entrarem amb una perpendicular o quieta.
|
||
// L'objecte de la prova és que `state_` torne a ROAMING quan
|
||
// el moviment topa: ho forcem fent que `enterHurt` triï igual
|
||
// RIGHT i topem en cridar `update`. Per aconseguir-ho posicionem
|
||
// la mòmia al límit i forcem la direcció via `setDirection`
|
||
// ABANS d'`enterHurt`. Però com `enterHurt` reescriu la
|
||
// direcció, el camí més directe és:
|
||
// 1) `enterHurt(RIGHT)` → si is_open(RIGHT) val false, intenta
|
||
// perpendiculars; si totes fallen, queda quieta.
|
||
// 2) Si està quieta, el HURT tampoc avança i només mor pel
|
||
// timer. Aquesta variant del test NO comprova la sortida
|
||
// per col·lisió; només verifica que entrar a HURT a la
|
||
// paret no peta.
|
||
mummy.enterHurt(Mummy::Direction::EAST);
|
||
REQUIRE(mummy.getState() == Mummy::State::HURT);
|
||
// Update llarg → expira el timer i torna a ROAMING.
|
||
mummy.update(1.0F);
|
||
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
|
||
}
|
||
|
||
void testMummyHurtSnapsAtPlayfieldWall() {
|
||
std::cout << "[T18] mummy HURT amarra a la vora del playfield en topar\n";
|
||
// Regressió: amb dt no-exacte (joc real → SDL_GetTicks ms) el drift
|
||
// sub-pixel de la cursa HURT deixava la BOX a 0.x px dins de l'última
|
||
// cel·la quan topava amb el mur. Com que la BOX 16×16 cabe exacta al
|
||
// corredor 16×16, qualsevol drift fa que la cantonada perpendicular
|
||
// sobresurti cap als sarcs adjacents — i el `chooseHurtDirection` del
|
||
// proper fueteig classifica les perpendiculars com a bloquejades per
|
||
// micro-overlap, retornant NONE i deixant la mòmia eternament quieta
|
||
// amb l'animació de córrer activa. El fix és snap a la vora exacta
|
||
// dins de `hurtStep` abans de transitar a ROAMING.
|
||
Map::loadFrom("data/maps/pyramid.yaml");
|
||
|
||
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||
Mummy mummy(tmpl);
|
||
// Caixa real del joc (YAML): collision 16×16 als peus del sprite 16×32.
|
||
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
|
||
// Comencem prou a prop del mur perquè la cursa hi arribi abans que
|
||
// expiri el timer (60 px/s · 0.6 s = 36 px de runway).
|
||
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
|
||
REQUIRE(mummy.getCollisionRect().x == 40.0F);
|
||
exitMummySpawn(mummy);
|
||
|
||
mummy.enterHurt(Mummy::Direction::WEST);
|
||
REQUIRE(mummy.getState() == Mummy::State::HURT);
|
||
REQUIRE(mummy.getDirX() == -1.0F);
|
||
|
||
// Avancem amb dt no-divisor de 1 (joc real) fins que topa el mur.
|
||
for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) {
|
||
mummy.update(0.0173F);
|
||
}
|
||
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
|
||
// El fix garanteix snap exacte a playfield_x = 24.
|
||
REQUIRE(mummy.getCollisionRect().x == static_cast<float>(Map::playfield_x));
|
||
}
|
||
|
||
void testMummyRoamingRecoversFromZeroDir() {
|
||
std::cout << "[T19] mummy ROAMING amb dir=(0,0) re-decideix la propera step()\n";
|
||
// Xarxa de seguretat: si per qualsevol via la mòmia entra a ROAMING
|
||
// amb dir_x_/dir_y_ a zero, el `step()` anterior feia early return i
|
||
// es quedava quieta per sempre. Ara re-decideix com a una intersecció
|
||
// si troba alguna direcció vàlida.
|
||
Map::loadFrom("data/maps/pyramid.yaml");
|
||
|
||
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||
Mummy mummy(tmpl);
|
||
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
|
||
// Posicionem-la a un creuament aliniat (interior, lluny de murs i
|
||
// sarcs problemàtics): vertical x=88 ∩ horitzontal y=80.
|
||
mummy.setPixelRect({.x = 88.0F, .y = 64.0F, .w = 16.0F, .h = 32.0F});
|
||
// Forcem dir=(0,0) sense passar per HURT — simula l'estat patològic.
|
||
mummy.setDirection(0.0F, 0.0F);
|
||
REQUIRE(mummy.getDirX() == 0.0F);
|
||
REQUIRE(mummy.getDirY() == 0.0F);
|
||
|
||
// Un sol step amb dt típic ha d'assignar alguna direcció no-zero.
|
||
mummy.step(0.0167F);
|
||
const bool RECOVERED = mummy.getDirX() != 0.0F || mummy.getDirY() != 0.0F;
|
||
REQUIRE(RECOVERED);
|
||
}
|
||
|
||
void testMummyHurtCornerNoSoftLock() {
|
||
std::cout << "[T20] mummy HURT contra cantonada + re-whip no fa soft-lock\n";
|
||
// Integració: cursa HURT cap a una cantonada del playfield, després
|
||
// fueteig immediat al mateix lloc. Amb el bug, la mòmia quedava amb
|
||
// dir=(0,0) i no es movia per molts fueteigs que rebés. Amb el fix
|
||
// (snap a hurtStep + recovery a step), o bé `chooseHurtDirection`
|
||
// troba una direcció lliure des de la posició aliniada, o bé el
|
||
// proper step la rescata. En cap cas pot quedar permanentment quieta.
|
||
Map::loadFrom("data/maps/pyramid.yaml");
|
||
|
||
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||
Mummy mummy(tmpl);
|
||
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
|
||
// Mateix runway que T18.
|
||
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
|
||
exitMummySpawn(mummy);
|
||
|
||
mummy.enterHurt(Mummy::Direction::WEST);
|
||
for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) {
|
||
mummy.update(0.0173F);
|
||
}
|
||
// Fueteig immediat: enterHurt al moment exacte de quedar al mur.
|
||
mummy.enterHurt(Mummy::Direction::WEST);
|
||
|
||
// Després d'una desena de frames, la mòmia s'ha de moure ja sigui via
|
||
// la fugida HURT (perpendicular trobada) o via recovery a ROAMING.
|
||
const auto BEFORE = mummy.getPixelRect();
|
||
for (int i = 0; i < 60; ++i) { mummy.update(0.0173F); }
|
||
const auto AFTER = mummy.getPixelRect();
|
||
const float DX_TOTAL = AFTER.x - BEFORE.x;
|
||
const float DY_TOTAL = AFTER.y - BEFORE.y;
|
||
REQUIRE((DX_TOTAL != 0.0F) || (DY_TOTAL != 0.0F));
|
||
}
|
||
|
||
void testMummyWhippableFlag() {
|
||
std::cout << "[T17] mummy YAML llig `whippable` correctament\n";
|
||
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||
Mummy mummy(tmpl);
|
||
REQUIRE(mummy.isWhippable());
|
||
}
|
||
|
||
void testTorchAnimation() {
|
||
std::cout << "[T10] torch animation wired from yaml\n";
|
||
// Regressió: el torch.yaml declarava `frames: [0]` i el render no
|
||
// consultava `currentFrame()`, així que una antorxa animada no es
|
||
// veia viva. Ara el YAML ha de portar 4 frames i l'Entity::update
|
||
// ha d'avançar el frame via el component d'animació.
|
||
fkyaml::node tmpl = EntityLoader::loadTemplate("torch");
|
||
Torch torch(tmpl);
|
||
const auto& ANIM = torch.getAnimation();
|
||
REQUIRE(ANIM.has_value());
|
||
REQUIRE(ANIM->frameCount() == 4);
|
||
REQUIRE(ANIM->currentFrame() == 0);
|
||
|
||
// Amb fps=8, el pas entre frames és 0.125s. Un tick d'aquest valor
|
||
// ha de fer saltar current_ a 1 (i, com que `frames: [0..3]`, el
|
||
// valor retornat pel currentFrame coincideix amb l'índex).
|
||
torch.update(0.125F);
|
||
REQUIRE(torch.getAnimation()->currentFrame() == 1);
|
||
torch.update(0.125F);
|
||
REQUIRE(torch.getAnimation()->currentFrame() == 2);
|
||
}
|
||
|
||
void testExitedAggregator() {
|
||
std::cout << "[T14] EntityManager: player_exited → level_complete\n";
|
||
EntityManager manager;
|
||
|
||
// Comptador inicialitzat per a 2 jugadors. Sense aquesta crida,
|
||
// l'agregador no faria res (expected_players_ = 0).
|
||
manager.setExpectedPlayerCount(2);
|
||
|
||
int level_complete_count = 0;
|
||
manager.getBus().subscribe("level_complete", [&](const Event& ev) {
|
||
++level_complete_count;
|
||
// Event d'agregat: no atribuïble a un jugador concret.
|
||
REQUIRE(ev.player_id == -1);
|
||
});
|
||
|
||
// Primer player_exited: comptador a 1 < 2, no emet encara.
|
||
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
|
||
REQUIRE(manager.exitedPlayerCount() == 1);
|
||
REQUIRE(level_complete_count == 0);
|
||
|
||
// Segon player_exited: comptador a 2 == expected, emet una vegada.
|
||
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 1});
|
||
REQUIRE(manager.exitedPlayerCount() == 2);
|
||
REQUIRE(level_complete_count == 1);
|
||
|
||
// Un tercer player_exited (no hauria de passar amb 2 jugadors,
|
||
// però defensa contra publicacions extra): no torna a emetre.
|
||
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
|
||
REQUIRE(level_complete_count == 1);
|
||
|
||
// setExpectedPlayerCount torna a obrir el cicle (això simularia
|
||
// el que fa GameScene després d'un reload de sala). El comptador
|
||
// d'EXITED es reinicia a 0 i el flag d'emissió també.
|
||
manager.setExpectedPlayerCount(2);
|
||
REQUIRE(manager.exitedPlayerCount() == 0);
|
||
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
|
||
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 1});
|
||
REQUIRE(level_complete_count == 2); // segon cicle ha emès una vegada més
|
||
}
|
||
|
||
} // namespace
|
||
|
||
auto main() -> int {
|
||
std::cout << "== smoke tests: entity system ==\n";
|
||
|
||
testFactory();
|
||
testLoader();
|
||
testEventBus();
|
||
testSarcophagusPerimeter();
|
||
testEntityManagerEndToEnd();
|
||
testAnimationComponent();
|
||
testAllTemplatesLoad();
|
||
testDecorationFlags();
|
||
testLoadFromLevel();
|
||
testSarcophagusYaml();
|
||
testCollisionBox();
|
||
testMummyStep();
|
||
testMummyHurtState();
|
||
testMummyHurtAgainstWall();
|
||
testMummyHurtSnapsAtPlayfieldWall();
|
||
testMummyRoamingRecoversFromZeroDir();
|
||
testMummyHurtCornerNoSoftLock();
|
||
testMummyWhippableFlag();
|
||
testTorchAnimation();
|
||
testExitedAggregator();
|
||
|
||
if (g_failures == 0) {
|
||
std::cout << "ALL OK\n";
|
||
return 0;
|
||
}
|
||
std::cerr << g_failures << " smoke check(s) failed\n";
|
||
return 1;
|
||
}
|