// Smoke tests del WhipAttack: hitbox per direcció, retall contra murs i // sarcs, aplicació de dany a mòmies whippable, alreadyHit. Friendly // fire (Player↔Player) NO es cobreix aquí — Player viu enllaçat amb // stubs sense estat real (veure whip_attack_player_stubs.cpp). // // Convencions iguals a test_entities_smoke: framework-less, REQUIRE // macro, comptador `g_failures`. Execució: `./test_whip_attack` des de // build/, working_dir = arrel del repo. #include #include #include #include #include #include "game/entities/concrete/mummy.hpp" #include "game/entities/concrete/whip_attack.hpp" #include "game/entities/entity_loader.hpp" #include "game/entities/entity_manager.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). Els tests del whip necessiten que la // mòmia estigui ACTIVE per reaccionar al fueteig — sense això, // `enterHurt` torna early per `isSpawning()`. Tickem amb dt gran fins // sortir de SPAWNING (mateix path real, només accelerat). void exitMummySpawn(Mummy& mummy) { for (int i = 0; i < 32 && mummy.isSpawning(); ++i) { mummy.update(0.5F); } } // Caja de jugador "estàndard" AEE: 16×32 amb cantonada (x, y). // El damage_rect del whip s'alinearà al feet box (els últims 16 px), // i el draw_rect respectarà els offsets de la taula del disseny. auto playerBox(float x, float y) -> SDL_FRect { return SDL_FRect{.x = x, .y = y, .w = 16.0F, .h = 32.0F}; } // Spawneja un WhipAttack ben configurat per al test, sense surface. // Els tests no exerciten render, així que `surface_=nullptr` és OK. auto makeWhip(WhipAttack::Direction dir, const SDL_FRect& owner_box, float range_px) -> std::unique_ptr { auto yaml = EntityLoader::loadTemplate("whip_attack"); auto whip = std::make_unique(yaml); whip->configure(dir, owner_box, range_px); return whip; } void testWhipDrawRectPerDirection() { std::cout << "[W1] WhipAttack draw_rect per direcció\n"; Map::loadFrom("data/maps/pyramid.yaml"); // Owner ben dins del playfield perquè el draw_rect (no es retalla) // tinga la mida nominal completa. // Posicionem el jugador amb la collision (bottom 16×16) al // corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la // columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect // cap al sarc-buit corredor i no es retalla per sarcs. const float OX = static_cast(Map::playfield_x + 64); const float OY = static_cast(Map::playfield_y + 80); const auto BOX = playerBox(OX, OY); const float RANGE = 48.0F; const auto WE = makeWhip(WhipAttack::Direction::EAST, BOX, RANGE); REQUIRE(WE->getPixelRect().w == 80.0F); REQUIRE(WE->getPixelRect().h == 32.0F); const auto WW = makeWhip(WhipAttack::Direction::WEST, BOX, RANGE); REQUIRE(WW->getPixelRect().w == 80.0F); REQUIRE(WW->getPixelRect().h == 32.0F); const auto WN = makeWhip(WhipAttack::Direction::NORTH, BOX, RANGE); REQUIRE(WN->getPixelRect().w == 16.0F); REQUIRE(WN->getPixelRect().h == 96.0F); const auto WS = makeWhip(WhipAttack::Direction::SOUTH, BOX, RANGE); REQUIRE(WS->getPixelRect().w == 16.0F); REQUIRE(WS->getPixelRect().h == 96.0F); } void testWhipDrawRectOffsets() { std::cout << "[W7] offsets del draw_rect per direcció (taula del disseny)\n"; Map::loadFrom("data/maps/pyramid.yaml"); // Posicionem el jugador amb la collision (bottom 16×16) al // corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la // columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect // cap al sarc-buit corredor i no es retalla per sarcs. const float OX = static_cast(Map::playfield_x + 64); const float OY = static_cast(Map::playfield_y + 80); const auto BOX = playerBox(OX, OY); const float RANGE = 48.0F; // E: (player.x - 16, player.y), 80×32. const auto WE = makeWhip(WhipAttack::Direction::EAST, BOX, RANGE); REQUIRE(WE->getPixelRect().x == OX - 16.0F); REQUIRE(WE->getPixelRect().y == OY); // W: (player.x - 48, player.y), 80×32. const auto WW = makeWhip(WhipAttack::Direction::WEST, BOX, RANGE); REQUIRE(WW->getPixelRect().x == OX - 48.0F); REQUIRE(WW->getPixelRect().y == OY); // N: (player.x, player.y - 48), 16×96. const auto WN = makeWhip(WhipAttack::Direction::NORTH, BOX, RANGE); REQUIRE(WN->getPixelRect().x == OX); REQUIRE(WN->getPixelRect().y == OY - 48.0F); // S: (player.x, player.y - 16), 16×96. const auto WS = makeWhip(WhipAttack::Direction::SOUTH, BOX, RANGE); REQUIRE(WS->getPixelRect().x == OX); REQUIRE(WS->getPixelRect().y == OY - 16.0F); } void testWhipFeetYDelegation() { std::cout << "[W8] feet_y delegat a la base de l'owner_box\n"; Map::loadFrom("data/maps/pyramid.yaml"); // Posicionem el jugador amb la collision (bottom 16×16) al // corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la // columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect // cap al sarc-buit corredor i no es retalla per sarcs. const float OX = static_cast(Map::playfield_x + 64); const float OY = static_cast(Map::playfield_y + 80); const auto BOX = playerBox(OX, OY); const float EXPECTED_FEET_Y = OY + 32.0F; // base inferior del 16×32 // Independent de la direcció: el feet_y és el del jugador. for (auto dir : {WhipAttack::Direction::EAST, WhipAttack::Direction::WEST, WhipAttack::Direction::NORTH, WhipAttack::Direction::SOUTH}) { const auto W = makeWhip(dir, BOX, 48.0F); REQUIRE(W->getFeetY() == EXPECTED_FEET_Y); } } void testWhipHitboxClippedByWall() { std::cout << "[W2] damage_rect retallat per la paret del playfield\n"; Map::loadFrom("data/maps/pyramid.yaml"); // Llancem un E des d'una posició a 10 px de la paret dreta del // playfield, amb un range de 48 px → la hitbox efectiva ha de // quedar acotada a < 48 i no més enllà del mur. const float RIGHT_WALL = static_cast(Map::playfield_x + Map::playfield_w); const float OX = RIGHT_WALL - 16.0F - 10.0F; // l'owner mateix dins const float OY = static_cast(Map::playfield_y + 96); const auto BOX = playerBox(OX, OY); const float RANGE = 48.0F; const auto W = makeWhip(WhipAttack::Direction::EAST, BOX, RANGE); const auto DMG = W->getDamageRect(); REQUIRE(DMG.w < RANGE); REQUIRE(DMG.x + DMG.w <= RIGHT_WALL); } void testWhipHitboxFreeAtFullRange() { std::cout << "[W3] damage_rect sense bloquejador → longitud completa\n"; Map::loadFrom("data/maps/pyramid.yaml"); // Owner ben dins del playfield, range curt → 16 px lliures fins // a topar amb res. La longitud efectiva = RANGE. const float OX = static_cast(Map::playfield_x + 32); const float OY = static_cast(Map::playfield_y + 96); const auto BOX = playerBox(OX, OY); const float RANGE = 16.0F; const auto W = makeWhip(WhipAttack::Direction::SOUTH, BOX, RANGE); const auto DMG = W->getDamageRect(); REQUIRE(DMG.h == RANGE); REQUIRE(DMG.w == 16.0F); } void testWhipHitsMummyInRange() { std::cout << "[W4] mòmia en rang entra a HURT\n"; Map::loadFrom("data/maps/pyramid.yaml"); EntityManager manager; auto mummy_yaml = EntityLoader::loadTemplate("mummy"); auto mummy_owned = std::make_unique(mummy_yaml); mummy_owned->setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); // Owner i mòmia al mateix corredor; mòmia 24 px a la dreta del // jugador → dins del damage_rect (48 px de rang). // Posicionem el jugador amb la collision (bottom 16×16) al // corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la // columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect // cap al sarc-buit corredor i no es retalla per sarcs. const float OX = static_cast(Map::playfield_x + 64); const float OY = static_cast(Map::playfield_y + 80); const auto BOX = playerBox(OX, OY); // Posicionem la mòmia perquè la seua collision box solape amb // la zona on cau el damage_rect (els últims 16 px verticals // del player, és a dir y ∈ [OY+16, OY+32]). // Volem que la collision (offset_y=12, height=12) solape amb el // damage_rect del whip (a y ∈ [OY+16, OY+32]). Posant pixel_rect.y // = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap. mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F}); auto* mummy_raw = static_cast(manager.add(std::move(mummy_owned))); exitMummySpawn(*mummy_raw); auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F); whip->setWorldContext(&manager, nullptr); // frame_ms=80 al YAML → frame 0 viu [0, 80] ms, frame 1 [80, 160] ms. // active_frames=[1, 2], així que cal arribar com a mínim a 80 ms // perquè `isFrameActive()` retorne true i `applyDamage()` corra. whip->update(0.09F); REQUIRE(mummy_raw->getState() == Mummy::State::HURT); } void testWhipNoHitOutOfRange() { std::cout << "[W5] mòmia fora del rang no rep dany\n"; Map::loadFrom("data/maps/pyramid.yaml"); EntityManager manager; auto mummy_yaml = EntityLoader::loadTemplate("mummy"); auto mummy_owned = std::make_unique(mummy_yaml); mummy_owned->setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); // Mateixa col·locació corredor 2-3 que W4: el damage_rect cap // íntegre al sarc-buit; només l'eix x distingirà "dins" de "fora". const float OX = static_cast(Map::playfield_x + 64); const float OY = static_cast(Map::playfield_y + 80); const auto BOX = playerBox(OX, OY); // Mòmia 96 px a la dreta de l'owner → fora d'un range de 32. // Vertically aligned with the damage band perquè el test només // depenga de l'eix horitzontal (no d'una falla d'alineament Y). mummy_owned->setPixelRect({.x = OX + 96.0F, .y = OY, .w = 12.0F, .h = 24.0F}); auto* mummy_raw = static_cast(manager.add(std::move(mummy_owned))); auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 32.0F); whip->setWorldContext(&manager, nullptr); whip->update(0.06F); REQUIRE(mummy_raw->getState() == Mummy::State::ROAMING); } void testWhipSingleHitPerSwing() { std::cout << "[W6] una mòmia rep un sol impacte per swing\n"; Map::loadFrom("data/maps/pyramid.yaml"); EntityManager manager; auto mummy_yaml = EntityLoader::loadTemplate("mummy"); auto mummy_owned = std::make_unique(mummy_yaml); mummy_owned->setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); // Posicionem el jugador amb la collision (bottom 16×16) al // corredor entre files 2 i 3 de sarcs (y∈[144,160]) i a la // columna entre cols 0 i 1 (x∈[88,104]) — així el damage_rect // cap al sarc-buit corredor i no es retalla per sarcs. const float OX = static_cast(Map::playfield_x + 64); const float OY = static_cast(Map::playfield_y + 80); const auto BOX = playerBox(OX, OY); // Volem que la collision (offset_y=12, height=12) solape amb el // damage_rect del whip (a y ∈ [OY+16, OY+32]). Posant pixel_rect.y // = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap. mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F}); auto* mummy_raw = static_cast(manager.add(std::move(mummy_owned))); exitMummySpawn(*mummy_raw); auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F); whip->setWorldContext(&manager, nullptr); // frame_ms=80 → frame 0 [0, 80] ms; cal ≥ 80 ms per entrar al // frame 1 (primer dels active_frames=[1, 2]). whip->update(0.09F); REQUIRE(mummy_raw->getState() == Mummy::State::HURT); const float HURT_INITIAL = mummy_raw->getHurtRemainingS(); // Avancem la mòmia (descompta el seu timer) i tornem a tickar el // whip mentre l'animació segueix activa: si `already_hit_` // funciona, el segon hit es bloqueja i el timer de la mòmia NO // es reseteja al valor inicial. mummy_raw->update(0.05F); const float HURT_AFTER_MUMMY_TICK = mummy_raw->getHurtRemainingS(); REQUIRE(HURT_AFTER_MUMMY_TICK < HURT_INITIAL); // Segon tick del whip: acumulat 0.09 + 0.05 = 0.14 s, encara // dins del frame 1 ([80, 160] ms). already_hit_ ha de bloquejar. whip->update(0.05F); REQUIRE(mummy_raw->getHurtRemainingS() == HURT_AFTER_MUMMY_TICK); } } // namespace auto main() -> int { std::cout << "== smoke tests: WhipAttack ==\n"; testWhipDrawRectPerDirection(); testWhipDrawRectOffsets(); testWhipFeetYDelegation(); testWhipHitboxClippedByWall(); testWhipHitboxFreeAtFullRange(); testWhipHitsMummyInRange(); testWhipNoHitOutOfRange(); testWhipSingleHitPerSwing(); if (g_failures == 0) { std::cout << "ALL OK\n"; return EXIT_SUCCESS; } std::cerr << g_failures << " failures\n"; return EXIT_FAILURE; }