feat(mummy): evita forats per detecció reactiva al canvi de tile

This commit is contained in:
JailDesigner committed 2026-04-30 18:30:03 +02:00
1 parent 1ccd444aad
commit 6d4f606eeb
3 files changed
+176 -46

No files matched your search

+143 -38
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <optional>
#include <random>
#include <string>
@@ -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<Direction>* 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<float>(Map::tile_size);
const auto ORIG_X = static_cast<float>(Map::tilemap_origin_x);
const auto ORIG_Y = static_cast<float>(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<int>(std::floor((LEAD_X - ORIG_X) / TS));
const int CUR_ROW = static_cast<int>(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<float>(CUR_COL) * TS) + ORIG_X,
.y = (static_cast<float>(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<float> {
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<Direction> 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<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 && !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<Direction>* 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<Direction> 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<float>(Map::tile_size);
const auto ORIG_X = static_cast<float>(Map::tilemap_origin_x);
const auto ORIG_Y = static_cast<float>(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,
};
+19
View File
@@ -1,5 +1,6 @@
#pragma once
#include <climits>
#include <cstdint>
#include <optional>
#include <vector>
@@ -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};
};