From e9f99a0542cb644be8359e7c0779bf9c28e2b35e Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 8 May 2026 12:07:18 +0200 Subject: [PATCH] fix(mummy): evita soft-lock per drift sub-pixel a HURT vora del mur --- source/game/entities/concrete/mummy.cpp | 37 ++++++++-- tests/test_entities_smoke.cpp | 96 +++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index ef26502..cb4d554 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -222,9 +222,24 @@ void Mummy::step(float delta_time) { const float DX = dir_x_ * speed_ * delta_time; const float DY = dir_y_ * speed_ * delta_time; - // Si no té direcció (p.ex. YAML sense `initial_direction`), el frame és - // no-op fins que algú la injecte amb `setDirection`. - if (DX == 0.0F && DY == 0.0F) { return; } + // Sense direcció: pot ser un setup degenerat (YAML sense + // `initial_direction`) o el residu d'un HURT que va sortir amb + // `chooseHurtDirection() == NONE` (totes les direccions provades + // bloquejades). En aquest segon cas la mòmia quedaria eternament quieta + // — `setDirection(0,0)` a `enterHurt` no toca `anim_`, així que es + // veuria animant-se sense moure's. Re-decidim com a una intersecció: + // si trobem alguna perpendicular vàlida, la prenem; si no, mitja volta. + // Si tampoc, mantenim (0,0) i tornem a provar el proper frame — + // pràcticament impossible amb la geometria actual, però no enganxa. + if (DX == 0.0F && DY == 0.0F) { + auto pools = collectDirectionPools(speed_ * delta_time, /*avoid_blocked=*/true); + if (!pools.perp.empty()) { + applyChoice(chooseDirection(pools.perp)); + } else if (!pools.uturn.empty()) { + applyChoice(chooseDirection(pools.uturn)); + } + return; + } // Branca creuament: si aquest frame travessa la línia central d'un // corredor perpendicular, amarra la caixa al centre del creuament i @@ -612,8 +627,20 @@ void Mummy::hurtStep(float delta_time) { return; } - // Topa amb mur o sarc: surt del HURT immediatament. La IA normal - // re-decideix al pròxim step (no fem U-turn ni re-orientació aquí). + // Topa amb mur del playfield: amarra la BOX a la vora exacta abans de + // transitar a ROAMING. Sense aquest snap, el drift sub-pixel acumulat a + // la cursa (DX = -hurt_speed·dt rarament és enter al joc real, on dt ve + // de SDL_GetTicks) deixaria la BOX a un offset de 0.x px dins de + // l'última cel·la. Com que la BOX és 16×16 i els passadissos també ho + // són, qualsevol drift fa que la cantonada 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. Sarc-collision durant HURT + // és un cas molt menys habitual (la cursa va sempre per corredor recte); + // si arriba a passar, l'snap al playfield no l'arregla però la branca + // de recovery a `step()` el cobreix. + if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); } + state_ = State::ROAMING; hurt_remaining_s_ = 0.0F; } diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index 584f781..73acf4a 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -496,6 +496,99 @@ namespace { 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); + + 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(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}); + + 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"); @@ -584,6 +677,9 @@ auto main() -> int { testMummyStep(); testMummyHurtState(); testMummyHurtAgainstWall(); + testMummyHurtSnapsAtPlayfieldWall(); + testMummyRoamingRecoversFromZeroDir(); + testMummyHurtCornerNoSoftLock(); testMummyWhippableFlag(); testTorchAnimation(); testExitedAggregator();