From 6d4f606eeba2e8dcabb192feaca9d2a574f33d1e Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Thu, 30 Apr 2026 18:30:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(mummy):=20evita=20forats=20per=20detecci?= =?UTF-8?q?=C3=B3=20reactiva=20al=20canvi=20de=20tile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/maps/p1/r1.yaml | 22 +-- source/game/entities/concrete/mummy.cpp | 181 +++++++++++++++++++----- source/game/entities/concrete/mummy.hpp | 19 +++ 3 files changed, 176 insertions(+), 46 deletions(-) diff --git a/data/maps/p1/r1.yaml b/data/maps/p1/r1.yaml index 3555088..eac3d3a 100644 --- a/data/maps/p1/r1.yaml +++ b/data/maps/p1/r1.yaml @@ -90,6 +90,7 @@ entities: position: [12, 2] - type: torch position: [16, 2] + - type: sarcophagus sarc_position: [0, 0] - type: sarcophagus @@ -130,17 +131,22 @@ entities: sarc_position: [2, 4] - type: sarcophagus sarc_position: [3, 4] + - type: door - # Sense `position`: la porta sempre viu al mur trasser. La columna - # transitable la dicta `geometry.door_col`; el render i la caixa - # de col·lisió es deriven d'aquesta dada al loadFromLevel. + + - type: mummy position: [10, 7] - type: mummy position: [7, 9] - # Forat de prova: tile de sòl pla a la galeria superior, 2 tiles a - # l'est del punt d'aterratge del jugador (door_col=10). Lluny dels - # sarcs i de les momies; accessible caminant cap a l'est tot just - # després del spawn. + - type: hole - position: [12, 3] + position: [11, 5] + - type: hole + position: [4, 7] + - type: hole + position: [8, 11] + - type: hole + position: [15, 11] + - type: hole + position: [15, 5] diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index 5612428..40e08ab 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -198,7 +199,15 @@ void Mummy::snapToPlayfieldEdge(float dx, float dy) { } void Mummy::step(float delta_time) { - // Desplaçament nominal en aquest frame segons la direcció actual. + // Detecció reactiva al canvi de tile: si la mòmia ha entrat aquest + // tick a un tile nou amb un decal trigger (Hole), dispara mitja volta + // abans de calcular el desplaçament — així el frame ja consumeix la + // nova direcció sense esperar al següent. La primera crida només + // registra el tile de spawn (lazy init). + reactToCurrentTile(); + + // Desplaçament nominal en aquest frame segons la direcció actual + // (potser ja invertida per `reactToCurrentTile`). const float DX = dir_x_ * speed_ * delta_time; const float DY = dir_y_ * speed_ * delta_time; @@ -212,6 +221,9 @@ void Mummy::step(float delta_time) { // centre; el remanent queda per al pròxim frame, ja amb la (potser // nova) direcció aplicada. Així la caixa 16×16 queda sempre alineada // a la graella de corredors i els girs no xoquen per drift sub-píxel. + // L'evitació de forats en pasillos rectes la cobreix + // `reactToCurrentTile` (al començament d'aquest mètode), no aquesta + // branca — aquí només es decideix entre direccions geomètriques. if (const auto SNAP = detectLaneCrossing(DX, DY); SNAP.has_value()) { if (DX != 0.0F) { pixel_rect_.x += *SNAP; @@ -234,21 +246,89 @@ void Mummy::step(float delta_time) { // Bloqueig per la vora del playfield: amarrem la caja a la vora exacta. if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); } - // Cal girar. Preferim perpendiculars; només fem U-turn si no hi ha cap - // altra opció (comportament típic d'Oh Mummy!: no es recula sense motiu). - // Primer intentem amb el filtre de decals (Hole) actiu; si totes les - // alternatives apunten a un decal trigger, fallback al pool sense filtre - // — millor caure al forat que quedar-se paralitzada en una sala mal - // dissenyada (la mòmia no mor pels forats). + // Cal girar. Cascada de tres nivells: + // Nivell 1 — perpendiculars sense decal (preferides; el comportament + // "natural" d'Oh Mummy!: girar abans que recular). + // Nivell 2 — mitja volta sense decal (rescat: callejón sense salida o + // tots els girs porten a forat). Preferim recular abans + // que caure al forat — un forat al costat NO ha de fer + // que la mòmia salti per damunt seu cap a una perpendicular + // amb forat només perquè la mitja volta era l'única neta. + // Nivell 3 — pool sense filtre de decals (cas degenerat: rodejada de + // forats). Permet caure al forat com a últim recurs; les + // mòmies no moren pels forats, així que és comportament + // tolerable en sales mal dissenyades. + // TODO(refactor): mateixa cascada a `decideAtIntersection` amb la + // variant "afegir recta a Nivell 1". Factoritzable + // quan tinga sentit (3+ callsites). auto pools = collectDirectionPools(speed_ * delta_time, /*avoid_decals=*/true); - if (pools.perp.empty() && pools.uturn.empty()) { - pools = collectDirectionPools(speed_ * delta_time, /*avoid_decals=*/false); + const std::vector* chosen = nullptr; + if (!pools.perp.empty()) { + chosen = &pools.perp; + } else if (!pools.uturn.empty()) { + chosen = &pools.uturn; } - const auto& pool = !pools.perp.empty() ? pools.perp : pools.uturn; - applyChoice(chooseDirection(pool)); + DirectionPools pools_unfiltered; + if (chosen == nullptr) { + pools_unfiltered = collectDirectionPools(speed_ * delta_time, /*avoid_decals=*/false); + if (!pools_unfiltered.perp.empty()) { + chosen = &pools_unfiltered.perp; + } else if (!pools_unfiltered.uturn.empty()) { + chosen = &pools_unfiltered.uturn; + } + } + applyChoice((chosen != nullptr) ? chooseDirection(*chosen) : Direction::NONE); // No avancem aquest frame: la direcció nova s'aplicarà al pròxim step. } +void Mummy::reactToCurrentTile() { + // Calculem el tile que toca la **vora davantera** de la BOX en la + // direcció de marxa, no el centre. Així la mitja volta es dispara + // tan aviat com el primer píxel del cos de la mòmia entra al tile- + // forat ("asoma el peu"), no quan ja hi ha mig cos a dins. Per a una + // direcció positiva en un eix, la vora davantera és el costat major + // de l'AABB (`x + w - 1`, exclusive→inclusive convention en píxels); + // per a una direcció negativa, el menor (`x`); per a zero, qualsevol + // (no hi ha avenç en aquest eix). + const auto BOX = getCollisionRect(); + const auto TS = static_cast(Map::tile_size); + const auto ORIG_X = static_cast(Map::tilemap_origin_x); + const auto ORIG_Y = static_cast(Map::tilemap_origin_y); + const float LEAD_X = (dir_x_ > 0.0F) ? (BOX.x + BOX.w - 1.0F) : BOX.x; + const float LEAD_Y = (dir_y_ > 0.0F) ? (BOX.y + BOX.h - 1.0F) : BOX.y; + const int CUR_COL = static_cast(std::floor((LEAD_X - ORIG_X) / TS)); + const int CUR_ROW = static_cast(std::floor((LEAD_Y - ORIG_Y) / TS)); + + // Sense canvi de tile no hi ha res a fer — això filtra també tots els + // ticks intra-tile (la majoria, ~16 frames per tile a velocitats típiques). + if (CUR_COL == last_tile_col_ && CUR_ROW == last_tile_row_) { return; } + + // Primera crida després del spawn: registrem el tile sense reaccionar. + // Així no disparem mitja volta espuria si la mòmia spawneja sobre un + // decal (defensa contra mapes mal dissenyats) o si pixel_rect_ encara + // no estava posat al constructor. + const bool FIRST_TIME = (last_tile_col_ == INT_MIN || last_tile_row_ == INT_MIN); + last_tile_col_ = CUR_COL; + last_tile_row_ = CUR_ROW; + if (FIRST_TIME) { return; } + + // Sense manager injectat (smoke tests), no podem consultar decals. + if (entity_manager_ == nullptr) { return; } + + // Si el tile nou conté un decal trigger (Hole o futur pinchos plans, + // plaques, etc.), mitja volta immediata. `changeDirection` també + // selecciona l'animació cardinal corresponent. + const SDL_FRect TILE_RECT{ + .x = (static_cast(CUR_COL) * TS) + ORIG_X, + .y = (static_cast(CUR_ROW) * TS) + ORIG_Y, + .w = TS, + .h = TS, + }; + if (entity_manager_->findFloorTriggerAt(TILE_RECT) != nullptr) { + changeDirection(-dir_x_, -dir_y_); + } +} + auto Mummy::detectLaneCrossing(float dx, float dy) const -> std::optional { const auto BOX = getCollisionRect(); if (dx != 0.0F) { @@ -266,37 +346,51 @@ auto Mummy::currentDirection() const -> Direction { } void Mummy::decideAtIntersection(float step_px) { - // Pool base: perpendiculars lliures (collectDirectionPools ja exclou la - // direcció actual i la mitja volta va al seu propi calaix, que ací - // ignorem — mai fem U-turn a un creuament). Primera passa amb filtre - // de decals (Hole) actiu. + // Cascada de tres nivells (paral·lela a la branca de bloqueig de + // `step`). Aquí el Nivell 1 inclou la recta com a candidata addicional + // (a un creuament la mòmia pot continuar recta si segueix lliure i no + // apunta a un decal); el Nivell 2 admet la mitja volta — abans no es + // permetia mai en creuaments, però és preferible recular que caure al + // forat quan totes les altres surten a decal. + // TODO(refactor): cascada paral·lela a la branca de bloqueig de `step`. auto pools = collectDirectionPools(step_px, /*avoid_decals=*/true); - std::vector pool = std::move(pools.perp); - // Afegim la recta si segueix essent lliure. A un creuament "net" ho és - // sempre (la mòmia hi ha arribat per aquest corredor), però als creua- - // ments de la vora del playfield la recta pot estar al límit i no - // permetre un pas més — en aquest cas la descartem. També la descartem - // si apunta a un decal trigger: aquesta és la regla "evita forats" en - // mode "seguir recte". + // Nivell 1: perpendiculars (filtrades) + recta si lliure i sense decal. + std::vector level1 = std::move(pools.perp); const auto STRAIGHT_BOX = candidateBox(dir_x_ * step_px, dir_y_ * step_px); const bool STRAIGHT_OPEN = Map::insidePlayfield(STRAIGHT_BOX) && !Map::collides(STRAIGHT_BOX); if (STRAIGHT_OPEN && !isDecalAheadInDir(dir_x_, dir_y_)) { - pool.push_back(currentDirection()); + level1.push_back(currentDirection()); } - // Fallback: si totes les opcions filtrades porten a decal o estan - // bloquejades, repetim el pool sense filtre i tornem a afegir la recta - // si és lliure (encara que vaja a forat). Així la mòmia mai es queda - // paralitzada en un creuament rodejat de forats. - if (pool.empty()) { - auto pools_unfiltered = collectDirectionPools(step_px, /*avoid_decals=*/false); - pool = std::move(pools_unfiltered.perp); - if (STRAIGHT_OPEN) { pool.push_back(currentDirection()); } + // Nivell 2: mitja volta sense decal — rescat quan Nivell 1 ja no té cap + // opció neta. Un forat al final del passadís ha de fer recular la + // mòmia, no convertir-se en l'opció triada per defecte. + const std::vector* chosen = nullptr; + if (!level1.empty()) { + chosen = &level1; + } else if (!pools.uturn.empty()) { + chosen = &pools.uturn; } - if (pool.empty()) { return; } - applyChoice(chooseDirection(pool)); + // Nivell 3: cas degenerat (rodejada de forats). Pool sense filtre amb + // perpendiculars, recta si lliure i mitja volta. Permet caure al forat + // com a últim recurs. + DirectionPools pools_unfiltered; + std::vector level3; + if (chosen == nullptr) { + pools_unfiltered = collectDirectionPools(step_px, /*avoid_decals=*/false); + level3 = std::move(pools_unfiltered.perp); + if (STRAIGHT_OPEN) { level3.push_back(currentDirection()); } + if (!level3.empty()) { + chosen = &level3; + } else if (!pools_unfiltered.uturn.empty()) { + chosen = &pools_unfiltered.uturn; + } + } + + if (chosen == nullptr) { return; } + applyChoice(chooseDirection(*chosen)); } auto Mummy::collectDirectionPools(float step, bool avoid_decals) const -> DirectionPools { @@ -340,19 +434,30 @@ auto Mummy::isDecalAheadInDir(float dx_signed, float dy_signed) const -> bool { if (entity_manager_ == nullptr) { return false; } if (dx_signed == 0.0F && dy_signed == 0.0F) { return false; } // El rect a consultar és el AABB del tile cardinal contigu (mida - // `Map::tile_size`), NO el AABB de la mòmia desplaçat un pas — així - // captura "si la mòmia estiguera centrada en aquell tile, hi pisaria - // un decal?" amb marge. + // `Map::tile_size`), ancorat al tile actual de la mòmia. Calculem el + // tile a partir del **centre** de la BOX, no de la cantonada superior- + // esquerra: amb una BOX de mida `tile_size`, la cantonada esquerra + // entra al tile següent un sub-píxel abans que el cos de la mòmia hi + // sigui majoritàriament. Si féssim servir `BOX.x` directament, una + // mòmia a `BOX.x = 199.5` (cobrint [199.5, 215.5], 0.5 px al tile 12 + // i 15.5 px al tile 13) seria classificada al tile 12 i giraria un + // tile abans del veritablement adjacent al decal. const auto BOX = getCollisionRect(); const auto TS = static_cast(Map::tile_size); + const auto ORIG_X = static_cast(Map::tilemap_origin_x); + const auto ORIG_Y = static_cast(Map::tilemap_origin_y); const auto SIGN_OF = [](float v) -> float { if (v > 0.0F) { return 1.0F; } if (v < 0.0F) { return -1.0F; } return 0.0F; }; + const float CENTER_X = BOX.x + (BOX.w * 0.5F); + const float CENTER_Y = BOX.y + (BOX.h * 0.5F); + const float TILE_X = (std::floor((CENTER_X - ORIG_X) / TS) * TS) + ORIG_X; + const float TILE_Y = (std::floor((CENTER_Y - ORIG_Y) / TS) * TS) + ORIG_Y; const SDL_FRect NEXT{ - .x = BOX.x + (SIGN_OF(dx_signed) * TS), - .y = BOX.y + (SIGN_OF(dy_signed) * TS), + .x = TILE_X + (SIGN_OF(dx_signed) * TS), + .y = TILE_Y + (SIGN_OF(dy_signed) * TS), .w = TS, .h = TS, }; diff --git a/source/game/entities/concrete/mummy.hpp b/source/game/entities/concrete/mummy.hpp index d408cca..41505ab 100644 --- a/source/game/entities/concrete/mummy.hpp +++ b/source/game/entities/concrete/mummy.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -143,6 +144,16 @@ class Mummy : public DynamicHazard { // totes ho estan, retorna `NONE` (la mòmia queda quieta en HURT). [[nodiscard]] auto chooseHurtDirection(Direction preferred) const -> Direction; + // Detecció reactiva al canvi de tile (substitueix el lookahead sub-tile + // entre creuaments). Es crida al principi de cada `step()`. Si el tile + // que ocupa la mòmia (calculat des del centre de la BOX) ha canviat + // respecte a l'última crida, comprova si el tile nou conté un decal + // trigger; si el conté, dispara mitja volta immediata. La primera crida + // (last_tile_col_ == INT_MIN) només registra el tile actual sense + // reaccionar — evita un falç positiu si la mòmia spawneja sobre un + // decal o si el setup encara no és estable. + void reactToCurrentTile(); + // Animacions carregades del YAML, una per cada cardinal. La variant // `walk_west` reaprofita els frames de l'est però amb `flip_h: true` // declarat al YAML — el motor d'animació aplica el flip al sprite @@ -166,4 +177,12 @@ class Mummy : public DynamicHazard { // bloc `stats.kills_outright` del YAML; default true per coherència // amb el comportament d'Oh Mummy! (les mòmies maten). bool kills_outright_{true}; + + // Tile que ocupava la mòmia a l'última crida a `reactToCurrentTile`. + // Inicialitzats a `INT_MIN` perquè la primera crida (lazy init: no + // podem fer-ho al constructor perquè `pixel_rect_` encara no té + // posició; setPixelRect del manager arriba després) registra el tile + // de spawn sense reaccionar. + int last_tile_col_{INT_MIN}; + int last_tile_row_{INT_MIN}; };