#include "game/entities/concrete/mummy.hpp" #include #include #include #include #include #include #include #include "core/audio/audio.hpp" #include "core/resources/resource_cache.hpp" #include "game/entities/entity_factory.hpp" #include "game/entities/entity_manager.hpp" #include "game/entities/event_bus.hpp" #include "game/entities/player.hpp" #include "game/map/map.hpp" #include "utils/defaults.hpp" namespace { // RNG local al mòdul: compartit per totes les mòmies (no cal reproducció // determinista a l'andamiatge). Es reinicialitza amb una llavor fixa // per facilitar la depuració — si algun dia volem aleatoritat real, // llegir-la de std::random_device. auto rng() -> std::mt19937& { static std::mt19937 k_rng_{1337}; return k_rng_; } // Llegeix un bloc d'animació del YAML amb fallbacks tolerants (mateix // patró que Door/Sarcophagus). Si la clau no apareix, retorna una // animació d'1 frame loopant, neutra — la momia es podrà moure però // visualment es quedarà estàtica. Llegeix `flip_h`/`flip_v` (default // false) i els passa al component perquè el motor sàpiga aplicar-los // al sprite quan aquesta animació estigui activa. auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent { std::vector frames = {0}; float fps = 8.0F; bool loop = true; bool flip_h = false; bool flip_v = false; if (yaml.contains("animations") && yaml["animations"].contains(key)) { const auto& anim = yaml["animations"][key]; if (anim.contains("frames")) { frames.clear(); for (const auto& f : anim["frames"]) { // cppcheck-suppress useStlAlgorithm frames.push_back(f.get_value()); } if (frames.empty()) { frames.push_back(0); } } if (anim.contains("fps")) { fps = anim["fps"].get_value(); } if (anim.contains("loop")) { loop = anim["loop"].get_value(); } if (anim.contains("flip_h")) { flip_h = anim["flip_h"].get_value(); } if (anim.contains("flip_v")) { flip_v = anim["flip_v"].get_value(); } } return {frames, fps, loop, flip_h, flip_v}; } // Busca si el segment [prev, prev+delta] travessa una lane del patró // `base + k·pitch` (k ∈ [0..max_idx]). Retorna el delta que cal aplicar // per arribar exactament a la primera línia travessada. Iterem en el // sentit de la marxa per encertar la MÉS propera quan el frame creua // més d'una lane (cas marginal a velocitats típiques, però pot passar // amb dt grans). Comparació estricta a un costat: no dispara quan la // caixa ja es troba alineada a la lane (hi està després del snap). auto findLaneCross(float prev, float delta, float base, float pitch, int max_idx) -> std::optional { const float NEXT = prev + delta; if (delta > 0.0F) { for (int k = 0; k <= max_idx; ++k) { const float TARGET = base + (static_cast(k) * pitch); if (prev < TARGET && NEXT >= TARGET) { return TARGET - prev; } } } else if (delta < 0.0F) { for (int k = max_idx; k >= 0; --k) { const float TARGET = base + (static_cast(k) * pitch); if (prev > TARGET && NEXT <= TARGET) { return TARGET - prev; } } } return std::nullopt; } } // namespace Mummy::Mummy(const fkyaml::node& yaml) : anim_walk_south_(buildAnimation(yaml, "walk_south")), anim_walk_north_(buildAnimation(yaml, "walk_north")), anim_walk_east_(buildAnimation(yaml, "walk_east")), anim_walk_west_(buildAnimation(yaml, "walk_west")) { name_ = yaml.contains("name") ? yaml["name"].get_value() : std::string("mummy"); collides_ = true; interactable_ = false; // Velocitat i dany des del bloc `stats` (si existeix). El flag // `kills_outright` també hi viu: per defecte true (la mòmia mata // d'un sol toc); posat a false al YAML, el contacte només publica // `player_hit` sense disparar el cicle DYING. if (yaml.contains("stats")) { const auto& s = yaml["stats"]; if (s.contains("speed")) { setSpeed(s["speed"].get_value()); } if (s.contains("damage")) { setDamage(s["damage"].get_value()); } if (s.contains("kills_outright")) { kills_outright_ = s["kills_outright"].get_value(); } } // Direcció inicial llegida del bloc `ai:`. Per defecte est, per // coherència amb el que fa RollingRock. Valors acceptats: // east, west, north, south. Qualsevol altre s'interpreta com a // "sense direcció" (la momia no es mou fins que step() no triï una // direcció). std::string initial = "east"; if (yaml.contains("ai") && yaml["ai"].contains("initial_direction")) { initial = yaml["ai"]["initial_direction"].get_value(); } if (initial == "east") { changeDirection(1.0F, 0.0F); } else if (initial == "west") { changeDirection(-1.0F, 0.0F); } else if (initial == "north") { changeDirection(0.0F, -1.0F); } else if (initial == "south") { changeDirection(0.0F, 1.0F); } // Estat SPAWN inicial: la mòmia naix visible i quieta, esperant que el // GameScene li armi el comptador via `triggerSpawnExit()` (cridat al // final de la subfase text-exit de ROOM_INTRO). Si el YAML no declara // `spawn.duration_s`, default 0 ⇒ al primer update post-trigger passa // directament a ACTIVE. float spawn_duration_s = 0.0F; if (yaml.contains("spawn") && yaml["spawn"].contains("duration_s")) { spawn_duration_s = yaml["spawn"]["duration_s"].get_value(); } enterSpawn(spawn_duration_s); // Configuració de l'estat HURT: opcional. Si el bloc no apareix, la // mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la // pot ignorar amb `isWhippable() == false` si volem que no reacció. if (yaml.contains("hurt")) { const auto& h = yaml["hurt"]; if (h.contains("duration_ms")) { hurt_config_.duration_s = static_cast(h["duration_ms"].get_value()) / 1000.0F; } if (h.contains("speed")) { hurt_config_.speed_px_per_s = h["speed"].get_value(); } if (h.contains("whippable")) { whippable_ = h["whippable"].get_value(); } // So opcional: el basename val tant de clau al Resource::Cache com de // path (`Defaults::Paths::AUDIO_SOUNDS_DIR + key`). Mateix patró que // els sons del Player. Si el camp manca, la clau queda buida i // `enterHurt` no dispara playSound. if (h.contains("sound")) { hurt_config_.sound_key = h["sound"].get_value(); Resource::Cache::get().loadSound(hurt_config_.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + hurt_config_.sound_key); } } } void Mummy::changeDirection(float dx, float dy) { setDirection(dx, dy); // Una animació per cada cardinal — el flip viu al YAML // (`walk_west: flip_h: true`) i el codi només selecciona quina // animació mostrar. Prevalença lateral en diagonals; quan la direcció // queda (0,0) (cul-de-sac amb NONE) no toquem l'animació: la mòmia es // quedarà al frame actual fins que step() li torni a assignar direcció. if (dx > 0.0F) { anim_ = anim_walk_east_; } else if (dx < 0.0F) { anim_ = anim_walk_west_; } else if (dy < 0.0F) { anim_ = anim_walk_north_; } else if (dy > 0.0F) { anim_ = anim_walk_south_; } if (anim_.has_value()) { anim_->reset(); // Propaguem el flip que l'animació declara al YAML al camp // `flip_` que el render llegeix (entity_manager_loadlevel.cpp). flip_ = anim_->flipMode(); } } auto Mummy::chooseDirection(const std::vector& allowed) -> Direction { if (allowed.empty()) { return Direction::NONE; } std::uniform_int_distribution dist(0, allowed.size() - 1); return allowed[dist(rng())]; } auto Mummy::candidateBox(float dx, float dy) const -> SDL_FRect { auto box = getCollisionRect(); box.x += dx; box.y += dy; return box; } void Mummy::snapToPlayfieldEdge(float dx, float dy) { // La caja 16×16 té exactament l'amplada del corredor; si ens aturàvem // a una posició subpíxel dins de l'última cel·la, qualsevol gir // perpendicular col·lidiria amb el sarc adjacent al limit. Per això // reduïm el dx/dy fins a la vora exacta del playfield en l'eix actiu // (l'altre sempre és 0 perquè la momia només es mou en 1D per frame). const auto BOX = getCollisionRect(); float snap_dx = 0.0F; float snap_dy = 0.0F; if (dx > 0.0F) { snap_dx = std::max(0.0F, static_cast(Map::playfield_x + Map::playfield_w) - BOX.x - BOX.w); } else if (dx < 0.0F) { snap_dx = std::min(0.0F, static_cast(Map::playfield_x) - BOX.x); } if (dy > 0.0F) { snap_dy = std::max(0.0F, static_cast(Map::playfield_y + Map::playfield_h) - BOX.y - BOX.h); } else if (dy < 0.0F) { snap_dy = std::min(0.0F, static_cast(Map::playfield_y) - BOX.y); } if (!Map::collides(candidateBox(snap_dx, snap_dy))) { pixel_rect_.x += snap_dx; pixel_rect_.y += snap_dy; } } void Mummy::step(float delta_time) { // 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; // 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 // hi decideix. La mòmia consumeix només la porció de moviment fins al // 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; } else { pixel_rect_.y += *SNAP; } decideAtIntersection(speed_ * delta_time); return; } // Avenç normal: si la caixa candidata és vàlida (dins del playfield // i sense trepitjar cap sarc), s'aplica el moviment i s'acaba el frame. const auto CANDIDATE = candidateBox(DX, DY); if (Map::insidePlayfield(CANDIDATE) && !Map::collides(CANDIDATE)) { pixel_rect_.x += DX; pixel_rect_.y += DY; return; } // Bloqueig per la vora del playfield: amarrem la caja a la vora exacta. if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); } // 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_blocked=*/true); const std::vector* chosen = nullptr; if (!pools.perp.empty()) { chosen = &pools.perp; } else if (!pools.uturn.empty()) { chosen = &pools.uturn; } DirectionPools pools_unfiltered; if (chosen == nullptr) { pools_unfiltered = collectDirectionPools(speed_ * delta_time, /*avoid_blocked=*/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 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 auto [CUR_COL, CUR_ROW] = Map::pixelToTile(LEAD_X, LEAD_Y); // 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 auto TS = static_cast(Map::tile_size); const SDL_FRect TILE_RECT{ .x = (static_cast(CUR_COL) * TS) + static_cast(Map::tilemap_origin_x), .y = (static_cast(CUR_ROW) * TS) + static_cast(Map::tilemap_origin_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) { return findLaneCross(BOX.x, dx, static_cast(Map::playfield_x), static_cast(Map::pitch_x), Map::grid_cols); } return findLaneCross(BOX.y, dy, static_cast(Map::playfield_y), static_cast(Map::pitch_y), Map::grid_rows); } auto Mummy::currentDirection() const -> Direction { if (dir_x_ > 0.0F) { return Direction::EAST; } if (dir_x_ < 0.0F) { return Direction::WEST; } if (dir_y_ > 0.0F) { return Direction::SOUTH; } if (dir_y_ < 0.0F) { return Direction::NORTH; } return Direction::NONE; } void Mummy::decideAtIntersection(float step_px) { // 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_blocked=*/true); // 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 && !isBlockedAheadInDir(dir_x_, dir_y_)) { level1.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; } // 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_blocked=*/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_blocked) const -> DirectionPools { struct Candidate { float dx; float dy; Direction dir; }; const std::array ALL{{ {.dx = step, .dy = 0.0F, .dir = Direction::EAST}, {.dx = -step, .dy = 0.0F, .dir = Direction::WEST}, {.dx = 0.0F, .dy = -step, .dir = Direction::NORTH}, {.dx = 0.0F, .dy = step, .dir = Direction::SOUTH}, }}; DirectionPools pools; for (const auto& c : ALL) { const bool IS_CURRENT = (c.dx * dir_x_ > 0.0F) || (c.dy * dir_y_ > 0.0F); // La direcció actual no forma part del pool: al bloqueig sabem que // està bloquejada; al creuament, qui decideix l'afegeix a banda. if (IS_CURRENT) { continue; } const auto TEST = candidateBox(c.dx, c.dy); if (!Map::insidePlayfield(TEST) || Map::collides(TEST)) { continue; } // Filtre de decals (Hole i futurs decals trigger): comprova el // tile cardinal contigu; si conté un trigger, descartem la // direcció. El caller pot demanar `avoid_blocked=false` com a // fallback quan totes les filtrades estan bloquejades. if (avoid_blocked && isBlockedAheadInDir(c.dx, c.dy)) { continue; } const bool IS_UTURN = (c.dx * dir_x_ < 0.0F) || (c.dy * dir_y_ < 0.0F); if (IS_UTURN) { pools.uturn.push_back(c.dir); } else { pools.perp.push_back(c.dir); } } return pools; } auto Mummy::isBlockedAheadInDir(float dx_signed, float dy_signed) const -> bool { 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`), 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 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 auto [CUR_COL, CUR_ROW] = Map::pixelToTile(CENTER_X, CENTER_Y); const float TILE_X = (static_cast(CUR_COL) * TS) + static_cast(Map::tilemap_origin_x); const float TILE_Y = (static_cast(CUR_ROW) * TS) + static_cast(Map::tilemap_origin_y); const SDL_FRect NEXT{ .x = TILE_X + (SIGN_OF(dx_signed) * TS), .y = TILE_Y + (SIGN_OF(dy_signed) * TS), .w = TS, .h = TS, }; // Ordre per cost creixent: comparació de coordenades, després iteració // sobre sarcs i porta, i finalment cerca a l'index d'entitats. Sense // manager (smoke tests) saltem només l'últim filtre. if (!Map::insidePlayfield(NEXT)) { return true; } if (Map::collides(NEXT)) { return true; } if (entity_manager_ != nullptr && entity_manager_->findFloorTriggerAt(NEXT) != nullptr) { return true; } return false; } void Mummy::applyChoice(Direction dir) { switch (dir) { case Direction::NORTH: changeDirection(0.0F, -1.0F); break; case Direction::SOUTH: changeDirection(0.0F, 1.0F); break; case Direction::WEST: changeDirection(-1.0F, 0.0F); break; case Direction::EAST: changeDirection(1.0F, 0.0F); break; case Direction::NONE: changeDirection(0.0F, 0.0F); break; } } void Mummy::onPlayerContact(Player* player) { // Defensiu: el filtre central a EntityManager::checkPlayerCollisions ja // salta DynamicHazards en SPAWN, però blindem la crida directa. if (isSpawning()) { return; } if (bus_ == nullptr) { return; } const int PID = (player != nullptr) ? player->getId() : -1; bus_->publish(Event{ .name = "player_hit", .payload = getDamage(), .player_id = PID, }); // Path letal: si la mòmia està marcada com a `kills_outright` // (default a Oh Mummy!), notifiquem al GameScene que el jugador // ha de morir. La transició concreta (`Player::enterDying`) la fa // el subscriptor — així mummy.cpp no toca cap símbol del Player més // enllà de la forward-decl, i els tests d'entitats no han d'arrossegar // l'stack pesat del jugador per linkar-se. El subscriptor (GameScene) // és qui resol el `player_id` a la instància concreta. if (kills_outright_) { bus_->publish(Event{ .name = "player_killed", .payload = getDamage(), .player_id = PID, }); } } void Mummy::enterHurt(Direction flee_dir) { // Defensiu: el whip no hauria de reaccionar contra una mòmia en SPAWN // (el filtre de col·lisió la salta), però blindem per si algun camí // futur crida directament. if (isSpawning()) { return; } state_ = State::HURT; hurt_remaining_s_ = hurt_config_.duration_s; hurt_speed_ = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_; if (!hurt_config_.sound_key.empty() && Audio::get() != nullptr) { Audio::get()->playSound(hurt_config_.sound_key, Audio::Group::GAME); } const Direction CHOSEN = chooseHurtDirection(flee_dir); if (CHOSEN == Direction::NONE) { // Cap direcció lliure: la mòmia queda quieta però en HURT durant // tot el timer. Posem dir a 0 perquè `hurtStep` no es mou. setDirection(0.0F, 0.0F); } else { applyChoice(CHOSEN); } } auto Mummy::chooseHurtDirection(Direction preferred) const -> Direction { // Avenç ~1 frame a 60 FPS amb la velocitat de HURT. Tan curt com // perquè un canvi sub-tile no decideixe; suficient perquè un mur // adjacent (o sarc tocant la caja) es detecte. const float TEST_SPEED = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_; const float STEP = TEST_SPEED * (1.0F / 60.0F); auto is_open = [this, STEP](Direction d) -> bool { float dx = 0.0F; float dy = 0.0F; switch (d) { case Direction::NORTH: dy = -STEP; break; case Direction::SOUTH: dy = STEP; break; case Direction::WEST: dx = -STEP; break; case Direction::EAST: dx = STEP; break; case Direction::NONE: return false; } const auto BOX = candidateBox(dx, dy); return Map::insidePlayfield(BOX) && !Map::collides(BOX); }; if (preferred != Direction::NONE && is_open(preferred)) { return preferred; } // Perpendiculars segons la direcció preferida (ordre arbitrari però // consistent: "esquerra del whip" primer). Direction left = Direction::NONE; Direction right = Direction::NONE; switch (preferred) { case Direction::NORTH: left = Direction::WEST; right = Direction::EAST; break; case Direction::SOUTH: left = Direction::EAST; right = Direction::WEST; break; case Direction::WEST: left = Direction::SOUTH; right = Direction::NORTH; break; case Direction::EAST: left = Direction::NORTH; right = Direction::SOUTH; break; case Direction::NONE: return Direction::NONE; } if (is_open(left)) { return left; } if (is_open(right)) { return right; } return Direction::NONE; } void Mummy::hurtStep(float delta_time) { // Si està quieta (totes les direccions bloquejades a `enterHurt`), no // avança ni surt: l'únic camí de sortida és el timer. if (dir_x_ == 0.0F && dir_y_ == 0.0F) { return; } const float DX = dir_x_ * hurt_speed_ * delta_time; const float DY = dir_y_ * hurt_speed_ * delta_time; const auto CANDIDATE = candidateBox(DX, DY); if (Map::insidePlayfield(CANDIDATE) && !Map::collides(CANDIDATE)) { pixel_rect_.x += DX; pixel_rect_.y += DY; return; } // 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; } void Mummy::update(float delta_time) { if (state_ == State::HURT) { // Avancem l'animació inline (no podem cridar `Entity::update` ni // `DynamicHazard::update`: el primer salta una capa virtual i el // segon dispararia `step()` amb la lògica d'IA). Volem només el // tick d'animació + el moviment recta del HURT. if (anim_.has_value()) { anim_->tick(delta_time); } hurtStep(delta_time); hurt_remaining_s_ -= delta_time; if (hurt_remaining_s_ <= 0.0F && state_ == State::HURT) { state_ = State::ROAMING; } return; } DynamicHazard::update(delta_time); } REGISTER_ENTITY("mummy", Mummy)