#include "game/entities/concrete/mummy.hpp" #include #include #include #include #include #include #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" 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); } // 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(); } } } 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) { // Desplaçament nominal en aquest frame segons la direcció actual. 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; } // 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. 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. 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). 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 auto& pool = !pools.perp.empty() ? pools.perp : pools.uturn; applyChoice(chooseDirection(pool)); // No avancem aquest frame: la direcció nova s'aplicarà al pròxim step. } 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) { // 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. 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". 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()); } // 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()); } } if (pool.empty()) { return; } applyChoice(chooseDirection(pool)); } auto Mummy::collectDirectionPools(float step, bool avoid_decals) 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_decals=false` com a // fallback quan totes les filtrades estan bloquejades. if (avoid_decals && isDecalAheadInDir(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::isDecalAheadInDir(float dx_signed, float dy_signed) const -> bool { // Sense manager injectat (smoke tests sense `EntityManager::add`), el // filtre és no-op: la mòmia es comporta exactament com abans del feature. 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. 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 SDL_FRect NEXT{ .x = BOX.x + (SIGN_OF(dx_signed) * TS), .y = BOX.y + (SIGN_OF(dy_signed) * TS), .w = TS, .h = TS, }; return entity_manager_->findFloorTriggerAt(NEXT) != nullptr; } 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) { 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) { 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_; 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 o sarc: surt del HURT immediatament. La IA normal // re-decideix al pròxim step (no fem U-turn ni re-orientació aquí). 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)