Merge branch 'feat/dynamic-hazard-spawn-state'

Spawn on-demand de DynamicHazards via dos sacs a EntityManager:
les mòmies (i futurs hazards mòbils) ara s'instancien just quan
GameScene les "spawneja", no abans. Cinemàtica ROOM_INTRO neta:
mapa buit durant el text, mòmies surten emergint al text-exit
(fase 4, durada 1.0 s), jugadors cauen del cel a continuació.

Estat SPAWN/ACTIVE a DynamicHazard reutilitzable per a futurs
generadors (gotes, roques, etc.) via `EntityManager::spawnActor`.

Refactor col·lateral: EntityManager separa `map_entities_` /
`actors_` / `pending_actors_`. Render mergeja els tres orígens
(amb players). dynamic_casts dispersos a WhipAttack i col·lisió
substituïts per iteracions sobre el sac d'actors.

Bonus: arregla 4 tests latents de test_whip_attack que mai no
havien passat (timing off-by-one al primer update).
This commit is contained in:
JailDesigner committed 2026-05-11 11:31:03 +02:00
commit 9ca09484a6
11 files changed
+407 -78

No files matched your search

+8
View File
@@ -40,6 +40,14 @@ ai:
type: corridor_wanderer type: corridor_wanderer
direction_choice: random direction_choice: random
initial_direction: east initial_direction: east
# Estat SPAWN: la mòmia s'instancia visible al pixel_rect_ inicial just
# quan GameScene crida `spawnPendingActors()` (a la subfase text-exit
# de ROOM_INTRO, fase 4). El `triggerSpawnExit` es dispara llavors
# mateix; passats `duration_s` segons transita a ROAMING i empieza a
# deambular. Sense aquest bloc, default 0 ⇒ ACTIVE immediat.
spawn:
duration_s: 1.0
stats: stats:
speed: 30 speed: 30
damage: 1 damage: 1
+18
View File
@@ -122,6 +122,17 @@ Mummy::Mummy(const fkyaml::node& yaml)
changeDirection(0.0F, 1.0F); 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<float>();
}
enterSpawn(spawn_duration_s);
// Configuració de l'estat HURT: opcional. Si el bloc no apareix, la // Configuració de l'estat HURT: opcional. Si el bloc no apareix, la
// mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la // mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la
// pot ignorar amb `isWhippable() == false` si volem que no reacció. // pot ignorar amb `isWhippable() == false` si volem que no reacció.
@@ -510,6 +521,9 @@ void Mummy::applyChoice(Direction dir) {
} }
void Mummy::onPlayerContact(Player* player) { 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; } if (bus_ == nullptr) { return; }
const int PID = (player != nullptr) ? player->getId() : -1; const int PID = (player != nullptr) ? player->getId() : -1;
bus_->publish(Event{ bus_->publish(Event{
@@ -534,6 +548,10 @@ void Mummy::onPlayerContact(Player* player) {
} }
void Mummy::enterHurt(Direction flee_dir) { 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; state_ = State::HURT;
hurt_remaining_s_ = hurt_config_.duration_s; hurt_remaining_s_ = hurt_config_.duration_s;
hurt_speed_ = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_; hurt_speed_ = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_;
+14 -7
View File
@@ -299,19 +299,22 @@ namespace {
// Tracta una mòmia candidata: si és whippable i la seua caja solapa // Tracta una mòmia candidata: si és whippable i la seua caja solapa
// amb `damage_rect`, la posa a HURT i la marca al set. Extret per // amb `damage_rect`, la posa a HURT i la marca al set. Extret per
// acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar). // acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar).
// Rep un `DynamicHazard*` (l'iterador d'`applyDamage` ja filtra el
// sac d'actors); només cal el dynamic_cast a Mummy per descartar
// altres hermanes (RollingRock i futurs DynamicHazards no whippables).
void hitMummyIfApplicable( void hitMummyIfApplicable(
Entity* e, DynamicHazard* actor,
const SDL_FRect& damage_rect, const SDL_FRect& damage_rect,
WhipAttack::Direction dir, WhipAttack::Direction dir,
std::unordered_set<void*>& already_hit) { std::unordered_set<void*>& already_hit) {
if (e == nullptr || e->isDead()) { return; } if (actor == nullptr || actor->isDead()) { return; }
if (already_hit.contains(static_cast<void*>(e))) { return; } if (already_hit.contains(static_cast<void*>(actor))) { return; }
auto* mummy = dynamic_cast<Mummy*>(e); auto* mummy = dynamic_cast<Mummy*>(actor);
if (mummy == nullptr || !mummy->isWhippable()) { return; } if (mummy == nullptr || !mummy->isWhippable()) { return; }
const auto BOX = mummy->getCollisionRect(); const auto BOX = mummy->getCollisionRect();
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; } if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
mummy->enterHurt(whipDirToMummyDir(dir)); mummy->enterHurt(whipDirToMummyDir(dir));
already_hit.insert(static_cast<void*>(e)); already_hit.insert(static_cast<void*>(actor));
} }
void hitPlayerIfApplicable( void hitPlayerIfApplicable(
@@ -336,8 +339,12 @@ namespace {
void WhipAttack::applyDamage() { void WhipAttack::applyDamage() {
if (entity_manager_ != nullptr) { if (entity_manager_ != nullptr) {
for (auto* e : entity_manager_->getEntities()) { // Només els actors poden rebre el fuetejo (decoracions, sarcs,
hitMummyIfApplicable(e, damage_rect_, direction_, already_hit_); // forats... no són hits del whip). Iterem el sac `actors_`
// directament — sense recórrer tot el mapa per descartar-ne la
// major part.
for (auto* actor : entity_manager_->getActors()) {
hitMummyIfApplicable(actor, damage_rect_, direction_, already_hit_);
} }
} }
if (players_ != nullptr) { if (players_ != nullptr) {
+60 -19
View File
@@ -74,7 +74,19 @@ auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
entity->setEventBus(&bus_); entity->setEventBus(&bus_);
entity->setEntityManager(this); entity->setEntityManager(this);
Entity* raw = entity.get(); Entity* raw = entity.get();
entities_.push_back(std::move(entity)); // Enrutament per categoria semàntica: si l'entitat és (o deriva de)
// DynamicHazard va al sac `actors_`, la resta al sac `map_entities_`.
// Així els consumidors que només volen actors itera el sac petit i
// ens estalviem dynamic_casts dispersos al codi (colisió, whip...).
if (dynamic_cast<DynamicHazard*>(entity.get()) != nullptr) {
// Transferim l'ownership al unique_ptr<DynamicHazard> tipat fort.
// El `release()` consumeix el seu valor com a argument del ctor —
// sense crida nua que el linter pugui marcar com a ignorada.
std::unique_ptr<DynamicHazard> hazard{static_cast<DynamicHazard*>(entity.release())};
actors_.push_back(std::move(hazard));
} else {
map_entities_.push_back(std::move(entity));
}
// cppcheck-suppress returnDanglingLifetime // cppcheck-suppress returnDanglingLifetime
return raw; return raw;
} }
@@ -91,9 +103,15 @@ auto EntityManager::removeEntity(Entity* observer) -> bool {
if (observer == nullptr) { return false; } if (observer == nullptr) { return false; }
const bool WAS_DOOR = (door_ == observer); const bool WAS_DOOR = (door_ == observer);
const bool WAS_SARC = (observer->getName() == "sarcophagus"); const bool WAS_SARC = (observer->getName() == "sarcophagus");
auto it = std::ranges::find_if(entities_, [observer](const auto& up) { return up.get() == observer; }); // Busquem en els dos sacs. El sarc i la door sempre viuen a
if (it == entities_.end()) { return false; } // `map_entities_` per disseny; un actor (DynamicHazard) viu a `actors_`.
entities_.erase(it); if (auto it = std::ranges::find_if(map_entities_, [observer](const auto& up) { return up.get() == observer; }); it != map_entities_.end()) {
map_entities_.erase(it);
} else if (auto it_a = std::ranges::find_if(actors_, [observer](const auto& up) { return up.get() == observer; }); it_a != actors_.end()) {
actors_.erase(it_a);
} else {
return false;
}
if (WAS_DOOR) { door_ = nullptr; } if (WAS_DOOR) { door_ = nullptr; }
if (WAS_SARC && total_sarcs_ > 0) { --total_sarcs_; } if (WAS_SARC && total_sarcs_ > 0) { --total_sarcs_; }
// No tocar `opened_sarcs_` aquí: l'editor congela el joc i no pot // No tocar `opened_sarcs_` aquí: l'editor congela el joc i no pot
@@ -107,7 +125,8 @@ auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcoph
sarc->setEventBus(&bus_); sarc->setEventBus(&bus_);
sarc->setEntityManager(this); sarc->setEntityManager(this);
Sarcophagus* raw = sarc.get(); Sarcophagus* raw = sarc.get();
entities_.push_back(std::move(sarc)); // El sarc és Interactable, viu al sac `map_entities_`.
map_entities_.push_back(std::move(sarc));
++total_sarcs_; ++total_sarcs_;
// cppcheck-suppress returnDanglingLifetime // cppcheck-suppress returnDanglingLifetime
return raw; return raw;
@@ -115,30 +134,34 @@ auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcoph
void EntityManager::update(float delta_time) { void EntityManager::update(float delta_time) {
// ATENCIÓ: si en el futur una entitat publica al bus events que // ATENCIÓ: si en el futur una entitat publica al bus events que
// resulten en `entity_manager_.add()` (push_back a `entities_`), açò // resulten en `entity_manager_.add()` (push_back als sacs), açò
// invalidaria l'iterador de la ranged-for. Avui no passa: els spawns // invalidaria l'iterador de la ranged-for. Avui no passa: els spawns
// runtime venen de fora del cicle d'`update` (p.ex. el polvet del // runtime venen de fora del cicle d'`update` (p.ex. el polvet del
// saltador es spawneja durant `Player::tick`, abans d'aquest mètode). // saltador es spawneja durant `Player::tick`, abans d'aquest mètode).
for (auto& e : entities_) { e->update(delta_time); } for (auto& e : map_entities_) { e->update(delta_time); }
for (auto& a : actors_) { a->update(delta_time); }
// Purga d'entitats marcades mortes aquest frame (efectes acabats, // Purga d'entitats marcades mortes aquest frame (efectes acabats,
// p.ex. polvet d'aterratge). Les persistents (sarcs, momies, etc.) // p.ex. polvet d'aterratge). Les persistents (sarcs, momies, etc.)
// mai no es marquen. // mai no es marquen.
std::erase_if(entities_, [](const auto& e) { return e->isDead(); }); std::erase_if(map_entities_, [](const auto& e) { return e->isDead(); });
std::erase_if(actors_, [](const auto& a) { return a->isDead(); });
} }
void EntityManager::notifyPlayerTilePainted(int col, int row) { void EntityManager::notifyPlayerTilePainted(int col, int row) {
for (auto& e : entities_) { e->notifyTilePainted(col, row); } // Fan-out als dos sacs. Avui només Sarcophagus consumeix (viu a
// map_entities_), però els actors també reben la notificació per
// si en el futur volen reaccionar (cost: una crida virtual buida).
for (auto& e : map_entities_) { e->notifyTilePainted(col, row); }
for (auto& a : actors_) { a->notifyTilePainted(col, row); }
} }
auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* { auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* {
// Cerca lineal sobre `entities_`: cost menyspreable amb poques // Cerca lineal sobre `map_entities_`: els floor decals trigger són
// desenes d'entitats per sala i una crida per frame des dels // exclusivament StaticHazards (Hole; en el futur pinchos plans,
// consumidors actius (Player a l'aterratge, Mòmia a la decisió de // plaques, charcos). Els actors no qualifiquen com a decals — saltem
// direcció). Si esdevé calent, es podria indexar per cel·la del // directament aquest sac, sense `triggersOnOverlap`/`isFloorDecal` per
// tilemap. La doble condició filtra exclusivament decals de sòl que // a cada un. La doble condició filtra dins de map_entities_.
// volen rebre triggers — avui Hole; en el futur pinchos plans, for (const auto& entity : map_entities_) {
// plaques de pressió, charcos.
for (const auto& entity : entities_) {
if (!entity->triggersOnOverlap()) { continue; } if (!entity->triggersOnOverlap()) { continue; }
if (!entity->isFloorDecal()) { continue; } if (!entity->isFloorDecal()) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
@@ -149,9 +172,27 @@ auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Ent
} }
auto EntityManager::getEntities() const -> std::vector<Entity*> { auto EntityManager::getEntities() const -> std::vector<Entity*> {
// Concatenació map + actors per retro-compat (editor, z-sort debug).
// Els callers que ja saben quin sac volen poden cridar
// `getMapEntities` o `getActors` i estalviar la construcció del temp.
std::vector<Entity*> out; std::vector<Entity*> out;
out.reserve(entities_.size()); out.reserve(map_entities_.size() + actors_.size());
std::ranges::transform(entities_, std::back_inserter(out), [](const auto& e) { return e.get(); }); std::ranges::transform(map_entities_, std::back_inserter(out), [](const auto& e) { return e.get(); });
std::ranges::transform(actors_, std::back_inserter(out), [](const auto& a) -> Entity* { return a.get(); });
return out;
}
auto EntityManager::getMapEntities() const -> std::vector<Entity*> {
std::vector<Entity*> out;
out.reserve(map_entities_.size());
std::ranges::transform(map_entities_, std::back_inserter(out), [](const auto& e) { return e.get(); });
return out;
}
auto EntityManager::getActors() const -> std::vector<DynamicHazard*> {
std::vector<DynamicHazard*> out;
out.reserve(actors_.size());
std::ranges::transform(actors_, std::back_inserter(out), [](const auto& a) { return a.get(); });
return out; return out;
} }
+47 -2
View File
@@ -9,6 +9,7 @@
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/entities/hazard.hpp"
class Entity; class Entity;
class Sarcophagus; class Sarcophagus;
@@ -74,7 +75,13 @@ class EntityManager {
// Accés als punters observacionals a les entitats per a consumidors // Accés als punters observacionals a les entitats per a consumidors
// externs que necessiten construir una llista ordenada (z-order). // externs que necessiten construir una llista ordenada (z-order).
// `getEntities()` concatena els dos sacs interns (mapa + actors) per
// a consumidors retro-compat (editor, z-sort debug). `getMapEntities`
// i `getActors` exposen cada sac per separat — útil per a iteracions
// que ja saben quin tipus volen i estalvien filtres O(n).
[[nodiscard]] auto getEntities() const -> std::vector<Entity*>; [[nodiscard]] auto getEntities() const -> std::vector<Entity*>;
[[nodiscard]] auto getMapEntities() const -> std::vector<Entity*>;
[[nodiscard]] auto getActors() const -> std::vector<DynamicHazard*>;
// Comprova contacte del(s) jugador(s) amb entitats `collides()=true`. // Comprova contacte del(s) jugador(s) amb entitats `collides()=true`.
// Si la caixa base del jugador solapa amb la caixa de col·lisió de // Si la caixa base del jugador solapa amb la caixa de col·lisió de
@@ -119,6 +126,23 @@ class EntityManager {
// d'una posició del cursor i el passa. // d'una posició del cursor i el passa.
auto addSpawn(const fkyaml::node& spawn) -> Entity*; auto addSpawn(const fkyaml::node& spawn) -> Entity*;
// Instancia tots els actors que estan en `pending_actors_` (cua que
// `loadFromLevel` omple amb les DynamicHazards declarades al YAML del
// mapa). Cada actor neix en estat SPAWN (per defecte des de la
// plantilla; veure `Mummy::Mummy`) i s'arma immediatament el
// `triggerSpawnExit()` perquè la fase EMERGING corri els seus segons
// de `spawn.duration_s` i transiti a ACTIVE. Vacia la cua. Cridada
// des de GameScene en una subfase concreta de ROOM_INTRO i des de
// `respawnPlayersInstant` (salt debug).
void spawnPendingActors();
// Crea un actor on-demand i el deixa en EMERGING (com a
// `spawnPendingActors` però per a un sol). Pensat per a futurs
// generadors (StaticHazards que produeixen DynamicHazards en runtime,
// p.ex. gotes que cauen, roques que rodolen). Retorna el punter
// observacional o `nullptr` si la plantilla o el factory fallen.
auto spawnActor(const std::string& type, int col, int row) -> DynamicHazard*;
// Reset complet del nivell: destrueix totes les entitats, reseteja // Reset complet del nivell: destrueix totes les entitats, reseteja
// els comptadors interns (sarcòfags oberts, total, exited, flag de // els comptadors interns (sarcòfags oberts, total, exited, flag de
// level_complete) i torna a cridar `loadFromLevel` per regenerar les // level_complete) i torna a cridar `loadFromLevel` per regenerar les
@@ -136,7 +160,8 @@ class EntityManager {
void setExpectedPlayerCount(int n); void setExpectedPlayerCount(int n);
[[nodiscard]] auto getBus() -> EventBus& { return bus_; } [[nodiscard]] auto getBus() -> EventBus& { return bus_; }
[[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); } [[nodiscard]] auto entityCount() const -> std::size_t { return map_entities_.size() + actors_.size(); }
[[nodiscard]] auto pendingActorCount() const -> std::size_t { return pending_actors_.size(); }
[[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; } [[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; }
[[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; } [[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; }
[[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; } [[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; }
@@ -150,7 +175,27 @@ class EntityManager {
private: private:
EventBus bus_; EventBus bus_;
std::vector<std::unique_ptr<Entity>> entities_; // Dos sacs lògics:
// `map_entities_` — decoració, static_hazards (Hole, FloorTrap,
// WallTrap) i interactables (Sarcophagus, Door).
// Cargades al loadFromLevel i persistents durant
// tota la sala.
// `actors_` — DynamicHazards (Mummy, RollingRock, futurs
// enemics). Separats per eliminar dynamic_casts
// i preparar el spawn diferit (commit posterior).
// `add()` enruta automàticament segons sigui o no DynamicHazard.
std::vector<std::unique_ptr<Entity>> map_entities_;
std::vector<std::unique_ptr<DynamicHazard>> actors_;
// Cua d'actors declarats al YAML del mapa però encara no instanciats:
// `loadFromLevel` hi empeny tots els DynamicHazards. `spawnPendingActors`
// o `spawnActor` consumeixen aquesta cua (o creen un actor on-demand)
// i ho buiden. El `fkyaml::node` original es guarda perquè conté la
// posició i qualsevol override del map respecte a la plantilla.
struct PendingActor {
std::string type;
fkyaml::node spawn_node;
};
std::vector<PendingActor> pending_actors_;
Door* door_{nullptr}; Door* door_{nullptr};
std::size_t total_sarcs_{0}; std::size_t total_sarcs_{0};
std::size_t opened_sarcs_{0}; std::size_t opened_sarcs_{0};
+128 -43
View File
@@ -18,6 +18,7 @@
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/entity_loader.hpp" #include "game/entities/entity_loader.hpp"
#include "game/entities/entity_manager.hpp" #include "game/entities/entity_manager.hpp"
#include "game/entities/hazard.hpp"
#include "game/entities/player.hpp" #include "game/entities/player.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
@@ -56,52 +57,64 @@ void EntityManager::renderEntity(const Entity& e) {
} }
void EntityManager::renderAll() { void EntityManager::renderAll() {
for (const auto& e : entities_) { renderEntity(*e); } // Sense ordenació per z: l'ordre és el d'inserció dins de cada sac.
// El render del joc real (GameScene) construeix el seu propi vector
// ordenat per `feet_y`; aquest helper queda per al editor i tests.
for (const auto& e : map_entities_) { renderEntity(*e); }
for (const auto& a : actors_) { renderEntity(*a); }
} }
namespace {
// Filtres "per què aquest jugador no rep contacte d'aquest hazard ara
// mateix": fora de joc, DYING, caient per forat (la transició a mort
// per forat la mana `integrateJump`, no la pot interrompre cap
// hazard), o salt per damunt de l'alçada del cos. Extret a funció
// per acotar la complexitat cognitiva del callsite. Veure la nota a
// `isFallingThroughHole`: si algun dia s'elimina aquest skip, cal
// revisar la cadena DYING(HOLE) → respawn.
auto playerSkipsHazard(const Player& p, int hazard_height_px) -> bool {
if (p.isOutOfPlay() || p.isDying()) { return true; }
if (p.isFallingThroughHole()) { return true; }
return p.isAirborne() && hazard_height_px > 0 && p.getJumpZ() > static_cast<float>(hazard_height_px);
}
auto playerOverlapsEntityBox(const Player& p, const SDL_FRect& box) -> bool {
// Hit-box petita del jugador (`getHitBox`) en lloc de la caja base
// 16×16 — per a "tocar una entitat" volem precisió, no rebot físic.
const auto PBOX = p.getHitBox();
return PBOX.x + PBOX.w > box.x && box.x + box.w > PBOX.x && PBOX.y + PBOX.h > box.y && box.y + box.h > PBOX.y;
}
} // namespace
void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) { void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
if (players.empty()) { return; } if (players.empty()) { return; }
for (const auto& entity : entities_) { // Helper local: aplica el contacte d'una entitat sobre tots els
// Acceptem entitats sòlides (xoc físic) i triggers d'àrea (no // jugadors, amb els filtres per-jugador a `playerSkipsHazard`. Es
// bloquegen però volen rebre `onPlayerContact`). Tanquem el gap // comparteix entre els dos sacs (map_entities_ i actors_).
// latent de FloorTrap/WallTrap (que tenien `collides_=false` i auto apply_contacts = [&players](Entity* entity) {
// mai no es disparaven) i obrim la porta a hazards no-físics
// com `Hole`.
if (!entity->collides() && !entity->triggersOnOverlap()) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
const int E_HEIGHT = entity->getHeight(); const int E_HEIGHT = entity->getHeight();
for (auto* p : players) { for (auto* p : players) {
// Filtrem també DYING: el jugador en cicle de mort no rep if (p == nullptr || playerSkipsHazard(*p, E_HEIGHT)) { continue; }
// contacte amb hazards (un segon toc de mòmia mentre la roda if (playerOverlapsEntityBox(*p, BOX)) { entity->onPlayerContact(p); }
// ja és no-op; cap altra entitat l'ha de fer transicionar).
if (p == nullptr || p->isOutOfPlay() || p->isDying()) { continue; }
// Mentre el jugador cau per un forat, ignorem qualsevol altre
// hazard: la transició a `enterDying()` ja la dispara
// `integrateJump` quan supera `HOLE_FALL_DEPTH`.
//
// NOTA: aquest skip és la defensa que evita que un jugador
// caient per un forat reba contacte d'altres hazards
// "subterranis" durant la caiguda. La cadena de mort per
// forat (`enterDying(HOLE)` → respawn) depén de no ser
// interrompuda per un event `player_hit`/`player_killed`
// mentre `falling_through_hole_=true`. Si el dia que es
// toque aquest skip s'elimina, cal revisar la cadena
// mort/respawn per forat.
if (p->isFallingThroughHole()) { continue; }
// Filtre per altura: si el jugador està en l'aire i està per
// damunt de l'alçada del cos de l'entitat, no hi ha contacte.
// És el mecanisme del salt per esquivar momies, roques, etc.
// Quan height és 0 (entitat sense altura declarada), el filtre
// no es dispara mai i la col·lisió funciona com abans.
if (p->isAirborne() && E_HEIGHT > 0 && p->getJumpZ() > static_cast<float>(E_HEIGHT)) { continue; }
// Contacte actor-actor amb hit-box petita: la caixa base
// 16×16 servia per moure (xoc amb murs), però per a l'event
// "toca una entitat" volem precisió — que la momia mate quan
// els píxels centrals es solapen, no quan els caps es freguen.
const auto PBOX = p->getHitBox();
const bool OVERLAP = PBOX.x + PBOX.w > BOX.x && BOX.x + BOX.w > PBOX.x && PBOX.y + PBOX.h > BOX.y && BOX.y + BOX.h > PBOX.y;
if (OVERLAP) { entity->onPlayerContact(p); }
} }
};
// Sac de mapa: filtre per col·lisió / trigger. Tanquem el gap latent
// de FloorTrap/WallTrap (que tenien `collides_=false` i mai no es
// disparaven) i obrim la porta a hazards no-físics com `Hole`.
for (const auto& entity : map_entities_) {
if (!entity->collides() && !entity->triggersOnOverlap()) { continue; }
apply_contacts(entity.get());
}
// Sac d'actors (DynamicHazards): tots fan dany quan són actius.
// Saltem els que estan en SPAWN: visibles però sense interacció
// durant la fase inicial de ROOM_INTRO i durant futurs spawns en
// runtime. Sense dynamic_cast: el tipus ja és DynamicHazard.
for (const auto& actor : actors_) {
if (actor->isSpawning()) { continue; }
if (!actor->collides() && !actor->triggersOnOverlap()) { continue; }
apply_contacts(actor.get());
} }
} }
@@ -202,6 +215,24 @@ namespace {
return tmpl["anchor"].get_value<std::string>(); return tmpl["anchor"].get_value<std::string>();
} }
// Llig el camp `type:` de la plantilla (la categoria semàntica:
// "decoration", "static_hazard", "interactable", "dynamic_hazard",
// "effect"). Tornarà buit si la plantilla no es pot carregar o no
// declara el camp. S'usa al loader per decidir quin sac rep l'entitat.
auto readTemplateCategory(const std::string& type) -> std::string {
try {
const auto TMPL = EntityLoader::loadTemplate(type);
if (TMPL.contains("type")) { return TMPL["type"].get_value<std::string>(); }
} catch (const std::exception& e) {
// Plantilla absent / parse error: deixem el caller que ho
// gestione (típicament `buildEntity` també fallarà i deixarà
// el seu propi log). Aquí només avisem perquè el silenci
// total amaga problemes de YAML mal formats.
Log::warn("EntityManager", "readTemplateCategory({}) ha fallat: {}", type, e.what());
}
return {};
}
// Coordenades píxel (canvas) d'una cel·la (col, row) del tilemap. El // Coordenades píxel (canvas) d'una cel·la (col, row) del tilemap. El
// grid del tilemap està 16-alineat respecte a `tilemap_origin_*`, que // grid del tilemap està 16-alineat respecte a `tilemap_origin_*`, que
// pot ser negatiu per overscan CPC. // pot ser negatiu per overscan CPC.
@@ -382,7 +413,9 @@ void EntityManager::reload(const std::string& level_yaml_path) {
// subscripcions implícites desapareixen amb la seua vida (cap d'elles // subscripcions implícites desapareixen amb la seua vida (cap d'elles
// se subscriu directament al bus — només publiquen). El bus i les // se subscriu directament al bus — només publiquen). El bus i les
// subscripcions externes (PaintLayer, HUD, GameScene) no es toquen. // subscripcions externes (PaintLayer, HUD, GameScene) no es toquen.
entities_.clear(); map_entities_.clear();
actors_.clear();
pending_actors_.clear();
door_ = nullptr; door_ = nullptr;
total_sarcs_ = 0; total_sarcs_ = 0;
opened_sarcs_ = 0; opened_sarcs_ = 0;
@@ -419,8 +452,21 @@ auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* {
void EntityManager::loadFromLevel(const std::string& level_yaml_path) { void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path); const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path);
const int LOADED = static_cast<int>(std::ranges::count_if(SPAWNS, int loaded = 0;
[this](const fkyaml::node& spawn) { return addSpawn(spawn) != nullptr; })); for (const auto& spawn : SPAWNS) {
if (!spawn.contains("type")) { continue; }
const auto TYPE = spawn["type"].get_value<std::string>();
// Spawn diferit: els DynamicHazards (mòmies, roques, futurs
// enemics) no s'instancien aquí. GameScene crida
// `spawnPendingActors()` al final del text-exit de ROOM_INTRO
// (i, defensivament, en salts debug). La instanciació = la
// seua aparició visual.
if (readTemplateCategory(TYPE) == "dynamic_hazard") {
pending_actors_.push_back(PendingActor{.type = TYPE, .spawn_node = spawn});
continue;
}
if (addSpawn(spawn) != nullptr) { ++loaded; }
}
// Broadcast inicial: marca com a "pintats" tots els tiles ocupats per // Broadcast inicial: marca com a "pintats" tots els tiles ocupats per
// decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...). // decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...).
@@ -432,7 +478,9 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
// perquè tots els sarcòfags ja estan registrats al fan-out (la // perquè tots els sarcòfags ja estan registrats al fan-out (la
// subscripció del manager és al ctor, anterior a qualsevol load). // subscripció del manager és al ctor, anterior a qualsevol load).
int decal_broadcast = 0; int decal_broadcast = 0;
for (const auto& entity : entities_) { // Els decals de sòl (Hole, futurs pinchos plans...) són StaticHazards
// i viuen exclusivament a `map_entities_`. Saltem `actors_` per estalviar.
for (const auto& entity : map_entities_) {
if (!entity->isFloorDecal()) { continue; } if (!entity->isFloorDecal()) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
const auto [COL, ROW] = Map::pixelToTile(BOX.x, BOX.y); const auto [COL, ROW] = Map::pixelToTile(BOX.x, BOX.y);
@@ -447,5 +495,42 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast); Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast);
} }
Log::info("EntityManager", "{} entitats carregades des de {}", LOADED, level_yaml_path); Log::info("EntityManager", "{} map entities + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path);
}
void EntityManager::spawnPendingActors() {
// Itera els pending i els instancia via `addSpawn`. El dispatch d'`add()`
// els enruta a `actors_`. El `triggerSpawnExit()` posterior arma el
// comptador de SPAWN — la mòmia neix ja en EMERGING, sense esperes.
for (const auto& pa : pending_actors_) {
Entity* observer = addSpawn(pa.spawn_node);
if (observer == nullptr) { continue; }
// Per categoria YAML (`type: dynamic_hazard`) sabem que és
// DynamicHazard — el dynamic_cast aquí val zero i evita
// assumptions implícites sobre la jerarquia.
if (auto* dh = dynamic_cast<DynamicHazard*>(observer); dh != nullptr) {
dh->triggerSpawnExit();
}
}
pending_actors_.clear();
}
auto EntityManager::spawnActor(const std::string& type, int col, int row) -> DynamicHazard* {
// Construïm un node de spawn sintètic amb el mínim que `placeGeneric`
// necessita: `type` i `position: [col, row]`. Si el caller vol
// overrides addicionals (orientació inicial, durada de spawn...),
// afegirà una variant amb fkyaml::node pre-construït més endavant.
fkyaml::node spawn;
try {
const std::string YAML_TEXT = "type: " + type + "\nposition: [" + std::to_string(col) + ", " + std::to_string(row) + "]\n";
spawn = fkyaml::node::deserialize(YAML_TEXT.begin(), YAML_TEXT.end());
} catch (const std::exception&) {
Log::error("EntityManager", "spawnActor: no s'ha pogut construir spawn YAML per {}", type);
return nullptr;
}
Entity* observer = addSpawn(spawn);
if (observer == nullptr) { return nullptr; }
auto* dh = dynamic_cast<DynamicHazard*>(observer);
if (dh != nullptr) { dh->triggerSpawnExit(); }
return dh;
} }
+46
View File
@@ -1,5 +1,7 @@
#pragma once #pragma once
#include <cstdint>
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
// Base d'entitats perilloses: al contacte amb el jugador, apliquen dany. // Base d'entitats perilloses: al contacte amb el jugador, apliquen dany.
@@ -56,8 +58,20 @@ class StaticHazard : public Hazard {
// Perill dinàmic: es mou. Velocitat expressada en píxels/segon i una // Perill dinàmic: es mou. Velocitat expressada en píxels/segon i una
// direcció (vec2). Les subclasses decideixen la política de moviment // direcció (vec2). Les subclasses decideixen la política de moviment
// (IA corridor_wanderer, línia recta, etc.) sobreescribint `step(dt)`. // (IA corridor_wanderer, línia recta, etc.) sobreescribint `step(dt)`.
//
// Estat SPAWN: tota DynamicHazard pot estar en SPAWNING (visible, sense
// moviment ni dany) fins que algú extern arme el countdown amb
// `triggerSpawnExit()`. Quan el comptador arriba a zero passa a ACTIVE i
// recupera la lògica normal (`step()` per frame). Per defecte arrenca en
// ACTIVE — backward-compat amb subclasses que encara no usen el mecanisme.
// El filtre central de col·lisió a `EntityManager::checkPlayerCollisions`
// salta les entitats en SPAWN: per això el contacte no fa dany ni dispara
// triggers durant aquesta fase.
class DynamicHazard : public Hazard { class DynamicHazard : public Hazard {
public: public:
enum class SpawnState : std::uint8_t { SPAWNING,
ACTIVE };
DynamicHazard() = default; DynamicHazard() = default;
void setSpeed(float px_per_s) { speed_ = px_per_s; } void setSpeed(float px_per_s) { speed_ = px_per_s; }
@@ -70,6 +84,21 @@ class DynamicHazard : public Hazard {
[[nodiscard]] auto getDirX() const -> float { return dir_x_; } [[nodiscard]] auto getDirX() const -> float { return dir_x_; }
[[nodiscard]] auto getDirY() const -> float { return dir_y_; } [[nodiscard]] auto getDirY() const -> float { return dir_y_; }
// Entra en SPAWNING amb una duració determinada. El comptador NO
// descompta fins que algú crida `triggerSpawnExit()` — l'entitat queda
// visible i quieta esperant indefinidament.
void enterSpawn(float duration_s) {
spawn_state_ = SpawnState::SPAWNING;
spawn_duration_s_ = duration_s;
spawn_remaining_s_ = duration_s;
spawn_exit_armed_ = false;
}
// Arma el comptador del SPAWN. A partir d'aquí, cada `update(dt)`
// descompta `dt` fins arribar a 0 i transitar a ACTIVE.
void triggerSpawnExit() { spawn_exit_armed_ = true; }
[[nodiscard]] auto isSpawning() const -> bool { return spawn_state_ == SpawnState::SPAWNING; }
[[nodiscard]] auto getSpawnDurationS() const -> float { return spawn_duration_s_; }
// Política de moviment per frame. La base desplaça el pixel_rect_ // Política de moviment per frame. La base desplaça el pixel_rect_
// proporcionalment a velocitat i direcció; les subclasses amb IA la // proporcionalment a velocitat i direcció; les subclasses amb IA la
// sobreescriuen per triar nova direcció a les interseccions o rebotar. // sobreescriuen per triar nova direcció a les interseccions o rebotar.
@@ -79,6 +108,19 @@ class DynamicHazard : public Hazard {
} }
void update(float delta_time) override { void update(float delta_time) override {
if (spawn_state_ == SpawnState::SPAWNING) {
// Sense `step()`: l'entitat es queda quieta al pixel_rect_
// inicial. `Entity::update` tic d'animació igual: si en algun
// moment posem un GIF de spawn aquí ja anirà.
Entity::update(delta_time);
if (spawn_exit_armed_) {
spawn_remaining_s_ -= delta_time;
if (spawn_remaining_s_ <= 0.0F) {
spawn_state_ = SpawnState::ACTIVE;
}
}
return;
}
Entity::update(delta_time); Entity::update(delta_time);
step(delta_time); step(delta_time);
} }
@@ -87,4 +129,8 @@ class DynamicHazard : public Hazard {
float speed_{0.0F}; float speed_{0.0F};
float dir_x_{0.0F}; float dir_x_{0.0F};
float dir_y_{0.0F}; float dir_y_{0.0F};
SpawnState spawn_state_{SpawnState::ACTIVE};
float spawn_duration_s_{0.0F};
float spawn_remaining_s_{0.0F};
bool spawn_exit_armed_{false};
}; };
+45 -2
View File
@@ -31,6 +31,7 @@
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/entity_loader.hpp" #include "game/entities/entity_loader.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/entities/hazard.hpp"
#include "game/entities/player.hpp" #include "game/entities/player.hpp"
#include "game/gameplay_config.hpp" #include "game/gameplay_config.hpp"
#include "game/levels/level_manager.hpp" #include "game/levels/level_manager.hpp"
@@ -622,6 +623,7 @@ void GameScene::enterRoomIntro() {
intro_completed_ = false; intro_completed_ = false;
intro_spawn_elapsed_ms_ = 0; intro_spawn_elapsed_ms_ = 0;
players_spawned_in_intro_.fill(false); players_spawned_in_intro_.fill(false);
pending_actors_spawned_ = false;
// Reset visual: arrenquem el FADE_IN d'INTRO i deixem el d'OUTRO // Reset visual: arrenquem el FADE_IN d'INTRO i deixem el d'OUTRO
// inactiu (es re-armarà a `setState(ROOM_OUTRO)` i, dins d'aquell // inactiu (es re-armarà a `setState(ROOM_OUTRO)` i, dins d'aquell
@@ -674,6 +676,12 @@ void GameScene::respawnPlayersInstant() {
state_ = RoomFsm::State::PLAYING; state_ = RoomFsm::State::PLAYING;
intro_completed_ = true; intro_completed_ = true;
transition_ = {}; transition_ = {};
// El salt debug a sala curt-circuit'a la cinemàtica d'INTRO i, amb
// ella, l'spawn diferit dels DynamicHazards que viu a la fase 4
// d'INTRO. Si no els instanciàvem ací també, les mòmies de la sala
// entrada per editor no apareixerien mai.
entity_manager_.spawnPendingActors();
pending_actors_spawned_ = true;
Log::info("ROOM", "post-editor: spawn instantani, estat -> PLAYING"); Log::info("ROOM", "post-editor: spawn instantani, estat -> PLAYING");
} }
#endif #endif
@@ -827,6 +835,15 @@ void GameScene::completeIntro() {
if (intro_completed_) { return; } if (intro_completed_) { return; }
intro_completed_ = true; intro_completed_ = true;
intro_spawn_elapsed_ms_ = 0; intro_spawn_elapsed_ms_ = 0;
// Fallback defensiu: si per qualsevol camí (config de fases amb tots
// els intervals molt curts, salt manual) hem arribat a PLAYING sense
// haver passat per la fase 4 que dispara l'spawn diferit, ho fem ara
// mateix. Sense això, les mòmies pendents quedarien per sempre a
// `pending_actors_` i la sala no tindria enemics.
if (!pending_actors_spawned_) {
entity_manager_.spawnPendingActors();
pending_actors_spawned_ = true;
}
// Spawn dels slots amb delay <= 0 (el cas habitual de P1). Els // Spawn dels slots amb delay <= 0 (el cas habitual de P1). Els
// altres cauen frame a frame dins `updateRoomTransition` a mesura // altres cauen frame a frame dins `updateRoomTransition` a mesura
// que `intro_spawn_elapsed_ms_` supera el seu delay. Així P1 cau // que `intro_spawn_elapsed_ms_` supera el seu delay. Així P1 cau
@@ -863,6 +880,7 @@ void GameScene::setState(RoomFsm::State new_state) {
intro_completed_ = false; intro_completed_ = false;
intro_spawn_elapsed_ms_ = 0; intro_spawn_elapsed_ms_ = 0;
players_spawned_in_intro_.fill(false); players_spawned_in_intro_.fill(false);
pending_actors_spawned_ = false;
} }
auto* audio = Audio::get(); auto* audio = Audio::get();
// PLAYING: arrenca la pista de joc en loop infinit. Netegem el // PLAYING: arrenca la pista de joc en loop infinit. Netegem el
@@ -923,6 +941,23 @@ void GameScene::updateRoomTransition(int delta_ms) {
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms); const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
// ROOM_INTRO: en entrar a la fase 5 (music_tail, immediatament després
// del text-exit) alliberem els DynamicHazards del seu estat SPAWN. Les
// mòmies surten visualment "del terra" just després que el text desapareix
// i abans que caiguin els jugadors — la sala arrenca amb tots els actors
// gairebé alhora (les mòmies amb un petit head-start `spawn.duration_s`).
// ROOM_INTRO, fase 4 = text-exit (el text està desapareixent). Instanciem
// els actors diferits (mòmies) — `EntityManager::spawnPendingActors` els
// crea ja en EMERGING. Visualment, "surten del terra" alhora que el text
// surt de pantalla. Amb spawn.duration_s ~ 1.0 s, queden ACTIVE
// coincidint amb la caiguda dels jugadors a music_tail.
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 4 && !pending_actors_spawned_) {
const std::size_t PENDING = entity_manager_.pendingActorCount();
entity_manager_.spawnPendingActors();
pending_actors_spawned_ = true;
Log::info("ROOM", "INTRO fase 4: spawn diferit de {} actors", PENDING);
}
// OUTRO: arrenquem `outro_fade_` la primera vegada que entrem a la // OUTRO: arrenquem `outro_fade_` la primera vegada que entrem a la
// fase de fade_out (phase 4 del timeline d'OUTRO). Si l'`advance` // fase de fade_out (phase 4 del timeline d'OUTRO). Si l'`advance`
// ha consumit fases buides en aquest mateix frame i ja portem // ha consumit fases buides en aquest mateix frame i ja portem
@@ -1758,10 +1793,18 @@ void GameScene::render() {
}; };
std::vector<Renderable> order; std::vector<Renderable> order;
order.reserve(entity_manager_.entityCount() + players_.size()); order.reserve(entity_manager_.entityCount() + players_.size());
const auto ENTITIES = entity_manager_.getEntities(); // Mergem els dos sacs (map + actors) i els jugadors en un sol
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) { // vector temporal — el `stable_sort` per `feet_y` posterior fa el
// z-order interleaving entre els tres orígens sense importar el
// sac d'on venen.
const auto MAP = entity_manager_.getMapEntities();
std::ranges::transform(MAP, std::back_inserter(order), [](Entity* e) {
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr}; return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
}); });
const auto ACTORS = entity_manager_.getActors();
std::ranges::transform(ACTORS, std::back_inserter(order), [](DynamicHazard* a) -> Renderable {
return Renderable{.feet_y = a->getFeetY(), .entity = a, .player = nullptr};
});
// Tots els jugadors entren al z-sort. Els fora de joc fan no-op a // Tots els jugadors entren al z-sort. Els fora de joc fan no-op a
// `render()`; els actius (inclosos AIRBORNE durant l'spawn aeri) // `render()`; els actius (inclosos AIRBORNE durant l'spawn aeri)
// es pinten amb el seu feet_y normal. La sombra que afecta la // es pinten amb el seu feet_y normal. La sombra que afecta la
+8
View File
@@ -202,6 +202,14 @@ class GameScene : public Scene {
// i a `setState(ROOM_INTRO)` (camí defensiu no canònic). // i a `setState(ROOM_INTRO)` (camí defensiu no canònic).
std::array<bool, 2> players_spawned_in_intro_{false, false}; std::array<bool, 2> players_spawned_in_intro_{false, false};
// Idempotència de la crida a `entity_manager_.spawnPendingActors()`
// dins d'un cicle d'INTRO. Es dispara una sola vegada quan
// ROOM_INTRO entra a la subfase text-exit (fase 4) i, defensivament,
// a `respawnPlayersInstant` i al fallback de `completeIntro` per a
// camins no canònics. Reset a `enterRoomIntro` i a
// `setState(ROOM_INTRO)`.
bool pending_actors_spawned_{false};
// Vista plana de punters crus — la fa servir Player com a llista // Vista plana de punters crus — la fa servir Player com a llista
// d'obstacles mòbils a la callback de col·lisió. Es construeix un sol // d'obstacles mòbils a la callback de col·lisió. Es construeix un sol
// cop al ctor (els punters són estables perquè `players_` no es // cop al ctor (els punters són estables perquè `players_` no es
+13
View File
@@ -69,6 +69,15 @@ namespace {
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__) #define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra).
// Equival al que fa GameScene al final del text-exit de ROOM_INTRO: arma
// el comptador i consumeix la durada amb un update. Tests que operin
// directament sobre HURT/ROAMING l'han de cridar abans.
void exitMummySpawn(Mummy& mummy) {
mummy.triggerSpawnExit();
mummy.update(mummy.getSpawnDurationS() + 0.001F);
}
void testFactory() { void testFactory() {
std::cout << "[T1] factory\n"; std::cout << "[T1] factory\n";
auto& factory = EntityFactory::instance(); auto& factory = EntityFactory::instance();
@@ -442,6 +451,7 @@ namespace {
// Mateixa col·locació que testMummyStep: corredor exterior // Mateixa col·locació que testMummyStep: corredor exterior
// superior, lliure de sarcs, prou marge per fugir cap a la dreta. // superior, lliure de sarcs, prou marge per fugir cap a la dreta.
mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F}); mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
exitMummySpawn(mummy);
// Direcció preferida: dreta. Mòmia ha d'entrar en HURT i avançar // Direcció preferida: dreta. Mòmia ha d'entrar en HURT i avançar
// a la dreta (la velocitat de hurt és més alta que la base). // a la dreta (la velocitat de hurt és més alta que la base).
@@ -474,6 +484,7 @@ namespace {
// la paret i fer-la sortir de HURT. // la paret i fer-la sortir de HURT.
const float RIGHT_WALL = static_cast<float>(Map::playfield_x + Map::playfield_w); const float RIGHT_WALL = static_cast<float>(Map::playfield_x + Map::playfield_w);
mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F}); mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
exitMummySpawn(mummy);
// La direcció preferida (RIGHT) està bloquejada al test // La direcció preferida (RIGHT) està bloquejada al test
// d'`is_open`; entrarem amb una perpendicular o quieta. // d'`is_open`; entrarem amb una perpendicular o quieta.
@@ -517,6 +528,7 @@ namespace {
// expiri el timer (60 px/s · 0.6 s = 36 px de runway). // expiri el timer (60 px/s · 0.6 s = 36 px de runway).
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F}); mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
REQUIRE(mummy.getCollisionRect().x == 40.0F); REQUIRE(mummy.getCollisionRect().x == 40.0F);
exitMummySpawn(mummy);
mummy.enterHurt(Mummy::Direction::WEST); mummy.enterHurt(Mummy::Direction::WEST);
REQUIRE(mummy.getState() == Mummy::State::HURT); REQUIRE(mummy.getState() == Mummy::State::HURT);
@@ -571,6 +583,7 @@ namespace {
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16}); mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
// Mateix runway que T18. // Mateix runway que T18.
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F}); mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
exitMummySpawn(mummy);
mummy.enterHurt(Mummy::Direction::WEST); mummy.enterHurt(Mummy::Direction::WEST);
for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) { for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) {
+20 -5
View File
@@ -32,6 +32,14 @@ namespace {
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__) #define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra). Els
// tests del whip necessiten que la mòmia estigui ACTIVE per reaccionar
// al fueteig — sense això, `enterHurt` torna early per `isSpawning()`.
void exitMummySpawn(Mummy& mummy) {
mummy.triggerSpawnExit();
mummy.update(mummy.getSpawnDurationS() + 0.001F);
}
// Caja de jugador "estàndard" AEE: 16×32 amb cantonada (x, y). // Caja de jugador "estàndard" AEE: 16×32 amb cantonada (x, y).
// El damage_rect del whip s'alinearà al feet box (els últims 16 px), // El damage_rect del whip s'alinearà al feet box (els últims 16 px),
// i el draw_rect respectarà els offsets de la taula del disseny. // i el draw_rect respectarà els offsets de la taula del disseny.
@@ -200,12 +208,15 @@ namespace {
// = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap. // = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap.
mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F}); mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F});
auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned))); auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned)));
exitMummySpawn(*mummy_raw);
auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F); auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F);
whip->setWorldContext(&manager, nullptr); whip->setWorldContext(&manager, nullptr);
// 60 ms → frame 1 (active). active_frames: [1, 2]. // frame_ms=80 al YAML → frame 0 viu [0, 80] ms, frame 1 [80, 160] ms.
whip->update(0.06F); // active_frames=[1, 2], així que cal arribar com a mínim a 80 ms
// perquè `isFrameActive()` retorne true i `applyDamage()` corra.
whip->update(0.09F);
REQUIRE(mummy_raw->getState() == Mummy::State::HURT); REQUIRE(mummy_raw->getState() == Mummy::State::HURT);
} }
@@ -258,12 +269,14 @@ namespace {
// = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap. // = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap.
mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F}); mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F});
auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned))); auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned)));
exitMummySpawn(*mummy_raw);
auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F); auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F);
whip->setWorldContext(&manager, nullptr); whip->setWorldContext(&manager, nullptr);
// Primera passada (frame 1, actiu) → mòmia a HURT. // frame_ms=80 → frame 0 [0, 80] ms; cal ≥ 80 ms per entrar al
whip->update(0.06F); // frame 1 (primer dels active_frames=[1, 2]).
whip->update(0.09F);
REQUIRE(mummy_raw->getState() == Mummy::State::HURT); REQUIRE(mummy_raw->getState() == Mummy::State::HURT);
const float HURT_INITIAL = mummy_raw->getHurtRemainingS(); const float HURT_INITIAL = mummy_raw->getHurtRemainingS();
@@ -275,7 +288,9 @@ namespace {
const float HURT_AFTER_MUMMY_TICK = mummy_raw->getHurtRemainingS(); const float HURT_AFTER_MUMMY_TICK = mummy_raw->getHurtRemainingS();
REQUIRE(HURT_AFTER_MUMMY_TICK < HURT_INITIAL); REQUIRE(HURT_AFTER_MUMMY_TICK < HURT_INITIAL);
whip->update(0.05F); // frame 2, encara actiu // Segon tick del whip: acumulat 0.09 + 0.05 = 0.14 s, encara
// dins del frame 1 ([80, 160] ms). already_hit_ ha de bloquejar.
whip->update(0.05F);
REQUIRE(mummy_raw->getHurtRemainingS() == HURT_AFTER_MUMMY_TICK); REQUIRE(mummy_raw->getHurtRemainingS() == HURT_AFTER_MUMMY_TICK);
} }