706 lines
30 KiB
C++
706 lines
30 KiB
C++
#include "game/entities/concrete/mummy.hpp"
|
||
|
||
#include <algorithm>
|
||
#include <array>
|
||
#include <cmath>
|
||
#include <optional>
|
||
#include <random>
|
||
#include <string>
|
||
#include <utility>
|
||
|
||
#include "core/audio/audio.hpp"
|
||
#include "core/resources/resource_cache.hpp"
|
||
#include "game/entities/concrete/whip_attack.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_;
|
||
}
|
||
|
||
// 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).
|
||
// Mapatge WhipAttack::Direction → Mummy::Direction. Viuen amb noms i
|
||
// valors paral·lels però són dos enums distints (cada classe modela el
|
||
// seu propi domini). Conversió 1-a-1; `NONE` no apareix com a possible
|
||
// sortida del whip, però la deixem per coherència defensiva.
|
||
auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction {
|
||
switch (d) {
|
||
case WhipAttack::Direction::NORTH:
|
||
return Mummy::Direction::NORTH;
|
||
case WhipAttack::Direction::SOUTH:
|
||
return Mummy::Direction::SOUTH;
|
||
case WhipAttack::Direction::EAST:
|
||
return Mummy::Direction::EAST;
|
||
case WhipAttack::Direction::WEST:
|
||
return Mummy::Direction::WEST;
|
||
}
|
||
return Mummy::Direction::NONE;
|
||
}
|
||
|
||
auto findLaneCross(float prev, float delta, float base, float pitch, int max_idx) -> std::optional<float> {
|
||
const float NEXT = prev + delta;
|
||
if (delta > 0.0F) {
|
||
for (int k = 0; k <= max_idx; ++k) {
|
||
const float TARGET = base + (static_cast<float>(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<float>(k) * pitch);
|
||
if (prev > TARGET && NEXT <= TARGET) { return TARGET - prev; }
|
||
}
|
||
}
|
||
return std::nullopt;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
Mummy::Mummy(const fkyaml::node& yaml)
|
||
: anim_walk_south_(AnimationComponent::fromYaml(yaml, "walk_south")),
|
||
anim_walk_north_(AnimationComponent::fromYaml(yaml, "walk_north")),
|
||
anim_walk_east_(AnimationComponent::fromYaml(yaml, "walk_east")),
|
||
anim_walk_west_(AnimationComponent::fromYaml(yaml, "walk_west")),
|
||
anim_raise_(AnimationComponent::fromYaml(yaml, "raise")) {
|
||
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : 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<float>()); }
|
||
if (s.contains("damage")) { setDamage(s["damage"].get_value<int>()); }
|
||
if (s.contains("kills_outright")) { kills_outright_ = s["kills_outright"].get_value<bool>(); }
|
||
}
|
||
|
||
// 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<std::string>();
|
||
}
|
||
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);
|
||
}
|
||
|
||
// L'estat SPAWNING amb l'animació `raise` s'arma a `onJustSpawned`,
|
||
// no al ctor. Així el manager pot instanciar la mòmia sense disparar
|
||
// la cinemàtica (cas del mode editor) i veure-la ja en ROAMING amb
|
||
// la `walk_*` triada per `changeDirection` més amunt.
|
||
|
||
// 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<float>(h["duration_ms"].get_value<int>()) / 1000.0F;
|
||
}
|
||
if (h.contains("speed")) {
|
||
hurt_config_.speed_px_per_s = h["speed"].get_value<float>();
|
||
}
|
||
if (h.contains("whippable")) {
|
||
whippable_ = h["whippable"].get_value<bool>();
|
||
}
|
||
// 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<std::string>();
|
||
Resource::Cache::get().loadSound(hurt_config_.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + hurt_config_.sound_key);
|
||
}
|
||
}
|
||
}
|
||
|
||
void Mummy::onJustSpawned() {
|
||
// Cinemàtica d'aparició: la mòmia "s'aixeca" reproduint `raise`
|
||
// (16 frames no-loop). El countdown del DynamicHazard NO s'utilitza
|
||
// (passem 0): `Mummy::update` controla la transició a ACTIVE per
|
||
// `anim_->completed()`. Cap crida a la base perquè el `triggerSpawnExit`
|
||
// del DynamicHazard arma aquest mateix countdown que aquí no toca.
|
||
enterSpawn(0.0F);
|
||
anim_ = anim_raise_;
|
||
if (anim_.has_value()) {
|
||
anim_->reset();
|
||
flip_ = anim_->flipMode();
|
||
}
|
||
}
|
||
|
||
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<Direction>& allowed) -> Direction {
|
||
if (allowed.empty()) { return Direction::NONE; }
|
||
std::uniform_int_distribution<std::size_t> 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<float>(Map::playfield_x + Map::playfield_w) - BOX.x - BOX.w);
|
||
} else if (dx < 0.0F) {
|
||
snap_dx = std::min(0.0F, static_cast<float>(Map::playfield_x) - BOX.x);
|
||
}
|
||
if (dy > 0.0F) {
|
||
snap_dy = std::max(0.0F, static_cast<float>(Map::playfield_y + Map::playfield_h) - BOX.y - BOX.h);
|
||
} else if (dy < 0.0F) {
|
||
snap_dy = std::min(0.0F, static_cast<float>(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<Direction>* 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<float>(Map::tile_size);
|
||
const SDL_FRect TILE_RECT{
|
||
.x = (static_cast<float>(CUR_COL) * TS) + static_cast<float>(Map::tilemap_origin_x),
|
||
.y = (static_cast<float>(CUR_ROW) * TS) + static_cast<float>(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<float> {
|
||
const auto BOX = getCollisionRect();
|
||
if (dx != 0.0F) {
|
||
return findLaneCross(BOX.x, dx, static_cast<float>(Map::playfield_x), static_cast<float>(Map::pitch_x), Map::grid_cols);
|
||
}
|
||
return findLaneCross(BOX.y, dy, static_cast<float>(Map::playfield_y), static_cast<float>(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<Direction> 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<Direction>* 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<Direction> 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<Candidate, 4> 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<float>(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<float>(CUR_COL) * TS) + static_cast<float>(Map::tilemap_origin_x);
|
||
const float TILE_Y = (static_cast<float>(CUR_ROW) * TS) + static_cast<float>(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;
|
||
}
|
||
}
|
||
|
||
auto Mummy::onWhipHit(WhipAttack* attack) -> bool {
|
||
// Defensiu: el filtre `shouldReceiveContact` ja salta DynamicHazards en
|
||
// SPAWN, però blindem aquesta crida si arribara per un camí no central.
|
||
if (isSpawning()) { return false; }
|
||
if (!whippable_ || attack == nullptr) { return false; }
|
||
enterHurt(whipDirToMummyDir(attack->getDirection()));
|
||
return true;
|
||
}
|
||
|
||
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 (isSpawning()) {
|
||
// SPAWN dirigit per animació: la `raise` és no-loop, així que
|
||
// `completed()` ens marca el final. Mentre dura, NO cridem
|
||
// `DynamicHazard::update`: la base hi té el countdown i ja
|
||
// hauria saltat a ACTIVE (passem `enterSpawn(0)`), i el `step()`
|
||
// d'IA mouria la mòmia abans de "aixecar-se". En completar,
|
||
// tornem a la `walk_*` que toca segons la direcció ja triada
|
||
// al ctor (initial_direction de l'IA).
|
||
if (anim_.has_value()) { anim_->tick(delta_time); }
|
||
if (anim_.has_value() && anim_->completed()) {
|
||
endSpawn();
|
||
changeDirection(getDirX(), getDirY());
|
||
}
|
||
return;
|
||
}
|
||
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)
|