From 121558b1fd0dd73b1d3aec6c7d8a401657c3cf54 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Mon, 27 Apr 2026 17:39:50 +0200 Subject: [PATCH] =?UTF-8?q?test(whip):=20smokes=20de=20hitbox,=20dany=20a?= =?UTF-8?q?=20m=C3=B2mia=20i=20alreadyHit;=20tests=20de=20Mummy=20HURT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 23 ++++ tests/test_entities_smoke.cpp | 74 ++++++++++ tests/test_whip_attack.cpp | 208 +++++++++++++++++++++++++++++ tests/whip_attack_player_stubs.cpp | 21 +++ 4 files changed, 326 insertions(+) create mode 100644 tests/test_whip_attack.cpp create mode 100644 tests/whip_attack_player_stubs.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 37febf7..e94f498 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -361,6 +361,29 @@ if(BUILD_TESTS) COMMAND test_entities_smoke WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + # Smoke del WhipAttack. Inclou tot l'stack d'entitats + whip_attack.cpp. + # `whip_attack_player_stubs.cpp` proveeix símbols mínims de Player per + # satisfer el linker sense arrossegar audio/gamepad/screen — els tests + # del whip s'enfoquen a hitbox + Mummy, no a friendly fire. + add_executable(test_whip_attack + tests/test_whip_attack.cpp + tests/whip_attack_player_stubs.cpp + source/core/resources/resource_helper.cpp + source/core/resources/resource_pack.cpp + source/game/map/map.cpp + ${ENTITY_SOURCES_BASE} + source/game/entities/concrete/whip_attack.cpp + ) + target_include_directories(test_whip_attack PRIVATE + "${CMAKE_SOURCE_DIR}/source" + "${CMAKE_BINARY_DIR}" + ) + target_link_libraries(test_whip_attack PRIVATE SDL3::SDL3) + target_compile_options(test_whip_attack PRIVATE -Wall -Wextra -Wpedantic) + add_test(NAME whip_attack + COMMAND test_whip_attack + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + # Smoke del flux d'escenes i del parser de debug.yaml. Stand-alone: # només enllaça els helpers purs (debug_config, placeholder_input, # key_config) perquè la integració completa dels placeholders arrossegaria diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index ff6e938..923f4c3 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -431,6 +431,77 @@ namespace { 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}); + + // 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::RIGHT); + 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(Map::playfield_x + Map::playfield_w); + mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F}); + + // 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::RIGHT); + 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 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 @@ -510,6 +581,9 @@ auto main() -> int { testSarcophagusYaml(); testCollisionBox(); testMummyStep(); + testMummyHurtState(); + testMummyHurtAgainstWall(); + testMummyWhippableFlag(); testTorchAnimation(); testExitedAggregator(); diff --git a/tests/test_whip_attack.cpp b/tests/test_whip_attack.cpp new file mode 100644 index 0000000..a5e572c --- /dev/null +++ b/tests/test_whip_attack.cpp @@ -0,0 +1,208 @@ +// 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__) + + // 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, float spawn_x, float spawn_y, float range_px) + -> std::unique_ptr { + auto yaml = EntityLoader::loadTemplate("whip_attack"); + auto whip = std::make_unique(yaml); + whip->configure(dir, spawn_x, spawn_y, range_px); + return whip; + } + + void testWhipPixelRectPerDirection() { + std::cout << "[W1] WhipAttack pixel_rect per direcció\n"; + Map::loadFrom("data/maps/pyramid.yaml"); + + // Tots els spawns ben dins del playfield perquè el retall no + // afecte la mida visual del sprite (pixel_rect = mida del frame + // sencer). + const float SPAWN_X = static_cast(Map::playfield_x + 64); + const float SPAWN_Y = static_cast(Map::playfield_y + 64); + const float RANGE = 48.0F; + + const auto WE = makeWhip(WhipAttack::Direction::EAST, SPAWN_X, SPAWN_Y, RANGE); + REQUIRE(WE->getPixelRect().w == 48.0F); + REQUIRE(WE->getPixelRect().h == 16.0F); + + const auto WW = makeWhip(WhipAttack::Direction::WEST, SPAWN_X, SPAWN_Y, RANGE); + REQUIRE(WW->getPixelRect().w == 48.0F); + REQUIRE(WW->getPixelRect().h == 16.0F); + + const auto WN = makeWhip(WhipAttack::Direction::NORTH, SPAWN_X, SPAWN_Y, RANGE); + REQUIRE(WN->getPixelRect().w == 16.0F); + REQUIRE(WN->getPixelRect().h == 48.0F); + + const auto WS = makeWhip(WhipAttack::Direction::SOUTH, SPAWN_X, SPAWN_Y, RANGE); + REQUIRE(WS->getPixelRect().w == 16.0F); + REQUIRE(WS->getPixelRect().h == 48.0F); + } + + void testWhipHitboxClippedByWall() { + std::cout << "[W2] hitbox 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 10 px (o menys, segons strict overlap). + const float RIGHT_WALL = static_cast(Map::playfield_x + Map::playfield_w); + const float SPAWN_X = RIGHT_WALL - 10.0F; + const float SPAWN_Y = static_cast(Map::playfield_y + 64); + const float RANGE = 48.0F; + + const auto W = makeWhip(WhipAttack::Direction::EAST, SPAWN_X, SPAWN_Y, RANGE); + const auto DMG = W->getDamageRect(); + // Damage rect estrictament menor que el rang complet. + REQUIRE(DMG.w < RANGE); + // I no més enllà de la paret. + REQUIRE(DMG.x + DMG.w <= RIGHT_WALL); + } + + void testWhipHitboxFreeAtFullRange() { + std::cout << "[W3] hitbox sense bloquejador → longitud completa\n"; + Map::loadFrom("data/maps/pyramid.yaml"); + + // Spawn molt a l'interior i range curt: cap bloquejador a + // l'abast → la longitud efectiva ha de ser RANGE sencer. + const float SPAWN_X = static_cast(Map::playfield_x + 32); + const float SPAWN_Y = static_cast(Map::playfield_y + 96); + const float RANGE = 16.0F; + + const auto W = makeWhip(WhipAttack::Direction::SOUTH, SPAWN_X, SPAWN_Y, 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 i es marca a alreadyHit\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 la mòmia 24 px a la dreta del whip → dins de range_px. + const float SPAWN_X = static_cast(Map::playfield_x + 64); + const float SPAWN_Y = static_cast(Map::playfield_y + 64); + mummy_owned->setPixelRect({.x = SPAWN_X + 24.0F, .y = SPAWN_Y - 12.0F, .w = 12.0F, .h = 24.0F}); + auto* mummy_raw = static_cast(manager.add(std::move(mummy_owned))); + + auto whip = makeWhip(WhipAttack::Direction::EAST, SPAWN_X, SPAWN_Y, 48.0F); + whip->setWorldContext(&manager, nullptr); + + // Avancem l'animació fins arribar a un frame actiu (el YAML + // declara `active_frames: [1, 2]`; cada frame dura 50 ms). + // Amb 1 sol update de 0.06 s passem el frame 0 → 1. + whip->update(0.06F); + + 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}); + const float SPAWN_X = static_cast(Map::playfield_x + 32); + const float SPAWN_Y = static_cast(Map::playfield_y + 64); + // Mòmia 96 px a la dreta → fora del rang de 32. + mummy_owned->setPixelRect({.x = SPAWN_X + 96.0F, .y = SPAWN_Y - 12.0F, .w = 12.0F, .h = 24.0F}); + auto* mummy_raw = static_cast(manager.add(std::move(mummy_owned))); + + auto whip = makeWhip(WhipAttack::Direction::EAST, SPAWN_X, SPAWN_Y, 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}); + const float SPAWN_X = static_cast(Map::playfield_x + 64); + const float SPAWN_Y = static_cast(Map::playfield_y + 64); + mummy_owned->setPixelRect({.x = SPAWN_X + 24.0F, .y = SPAWN_Y - 12.0F, .w = 12.0F, .h = 24.0F}); + auto* mummy_raw = static_cast(manager.add(std::move(mummy_owned))); + + auto whip = makeWhip(WhipAttack::Direction::EAST, SPAWN_X, SPAWN_Y, 48.0F); + whip->setWorldContext(&manager, nullptr); + + // Primera passada: mòmia a HURT (el whip entra al frame 1 + // = actiu en el primer step de 0.06 s). + whip->update(0.06F); + 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); + + whip->update(0.05F); // frame 2, encara actiu + // Si re-hit, enterHurt restauraria el timer; alreadyHit ho impedeix. + REQUIRE(mummy_raw->getHurtRemainingS() == HURT_AFTER_MUMMY_TICK); + } + +} // namespace + +auto main() -> int { + std::cout << "== smoke tests: WhipAttack ==\n"; + + testWhipPixelRectPerDirection(); + 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; +} diff --git a/tests/whip_attack_player_stubs.cpp b/tests/whip_attack_player_stubs.cpp new file mode 100644 index 0000000..cb116a4 --- /dev/null +++ b/tests/whip_attack_player_stubs.cpp @@ -0,0 +1,21 @@ +// Stubs de Player per al binari `test_whip_attack`. +// +// El path de friendly fire del `WhipAttack` referencia +// `Player::enterHurt(Cardinal)` i `Player::getBaseBox() const`. Linkar +// `player.cpp` arrossegaria tot l'stack del jugador (audio, gamepad, +// input, screen, resource_cache…), una explosió de dependències per a +// uns tests que no exerciten aqueix camí. Aquests stubs proporcionen +// els símbols com a no-op perquè el test del whip pugui linkar i centrar +// la cobertura en hitbox + Mummy. +// +// Si algun dia volem cobrir friendly fire, caldrà o bé un binari de test +// amb tot l'stack o invertir la dependència via callable injectable +// (refactor de `WhipAttack::applyDamage`). +#include "game/entities/player.hpp" + +void Player::enterHurt(Cardinal /*preferred*/) { +} + +auto Player::getBaseBox() const -> SDL_FRect { + return SDL_FRect{0.0F, 0.0F, 0.0F, 0.0F}; +}