523 lines
23 KiB
C++
523 lines
23 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/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__)
|
||
|
||
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->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",
|
||
};
|
||
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 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();
|
||
testTorchAnimation();
|
||
testExitedAggregator();
|
||
|
||
if (g_failures == 0) {
|
||
std::cout << "ALL OK\n";
|
||
return 0;
|
||
}
|
||
std::cerr << g_failures << " smoke check(s) failed\n";
|
||
return 1;
|
||
}
|