Compare commits

...
15 Commits
Author SHA1 Message Date
JailDesigner fa27308897 chore(version): bump 0.41.0 -> 0.42.0 2026-05-11 13:34:01 +02:00
JailDesigner e58eb10d6d Merge branch 'feat/single-bucket-polymorphism'
Refactor del sistema d'entitats per substituir filtres i
dynamic_casts dispersos per dispatch polimòrfic.

Quatre virtuals nous a Entity (`onWhipHit`, `onJustSpawned`,
`onRemoved`, `shouldReceiveContact`) encapsulen dins de cada
subclasse les decisions que abans vivien al manager:
- WhipAttack::applyDamage ja no fa `dynamic_cast<Mummy*>`.
- spawnPendingActors/spawnActor ja no fan `dynamic_cast<DynamicHazard*>`.
- removeEntity ja no compara `getName() == "sarcophagus"`.
- checkPlayerCollisions filtra amb un sol predicat virtual.

Amb el polimorfisme madurat, el sac dual `map_entities_`/`actors_`
introduit a la rama anterior ja no aporta valor: tornem a un sol
`entities_` (manté `pending_actors_` per al spawn diferit). El
manager itera un sol vector i cada entitat decideix què fa.

Cap dynamic_cast nou per a lògica de joc — només queden els
inevitables (validació de factory i ruteig de la Door).
2026-05-11 13:33:46 +02:00
JailDesigner a29ea641ab refactor(em): un sol vector entities_ — polimorfisme per la
discriminació

Reverteix la separació en sacs introduïda al commit 62f4b85 (rama
anterior). Amb els virtuals afegits al commit precedent
(`onWhipHit`, `onJustSpawned`, `shouldReceiveContact`, `onRemoved`),
ja no cal separar `map_entities_` i `actors_`: cada entitat decideix
internament què fa, el manager itera un sol vector i confia en el
dispatch virtual.

Canvis:

- `entities_` torna a ser el contenidor únic. Es desfà el dispatch
  `dynamic_cast<DynamicHazard*>` que `add()` feia per enrutar.
- `addSarcophagus`, `removeEntity`, `update`, `notifyPlayerTilePainted`,
  `findFloorTriggerAt`, `renderAll`, `reload` simplificats: un sol
  bucle, sense distingir categories.
- `checkPlayerCollisions` només recorre `entities_` cridant
  `shouldReceiveContact` polimòrfic — la lògica de "saltar SPAWN"
  viu a `DynamicHazard::shouldReceiveContact`.
- `getMapEntities()` i `getActors()` desapareixen. Es manté
  `getEntities()` com a accessor únic.
- `WhipAttack::applyDamage` itera `getEntities()` cridant `onWhipHit`
  polimòrfic. Les entitats no-whippables retornen false (cost: una
  crida virtual buida) i el callsite no discrimina tipus.
- `GameScene::render` mergeja entities + players (dos transforms en
  comptes de tres) per al z-order.

`pending_actors_` es manté: és un concepte ortogonal — entitats que
encara no s'han instanciat al món fins que GameScene les "spawneja"
al text-exit de ROOM_INTRO.

Beneficis del nou disseny:
- Cap dynamic_cast dispers per a la lògica de joc (només queden els
  inevitables: validació de factory i ruteig de la Door).
- Afegir un nou enemic implica `class X : public DynamicHazard` i
  override dels virtuals rellevants — res al manager.
- Si un sarc, antorxa, o decoració futura volgués reaccionar al
  fueteig (trencar-se, encendre's), basta amb un `onWhipHit` override
  i no cal tocar `WhipAttack::applyDamage`.
2026-05-11 13:14:53 +02:00
JailDesigner 28ff425c89 refactor(entities): polimorfisme per substituir dynamic_casts
Quatre virtuals nous a `Entity` capten decisions que abans vivien al
manager com a discriminacions de tipus:

- `onWhipHit(WhipAttack*)` → bool. Default no-op/false. Override a
  `Mummy` per entrar HURT si és whippable. WhipAttack::applyDamage
  passa de `dynamic_cast<Mummy*>` + `isWhippable()` a una crida
  polimòrfica neta. RollingRock no override → immune al látigo per
  disseny, sense flag.

- `onJustSpawned()`. Default no-op. Override a `DynamicHazard` per
  disparar `triggerSpawnExit()`. `spawnPendingActors` i `spawnActor`
  passen de `dynamic_cast<DynamicHazard*>(observer)->triggerSpawnExit()`
  a `observer->onJustSpawned()`.

- `onRemoved()`. Default no-op. Override a `Sarcophagus` per cridar
  `entity_manager_->onSarcophagusRemoved()`. `removeEntity` passa de
  `getName() == "sarcophagus"` a dispatch polimòrfic.

- `shouldReceiveContact()`. Default `collides() || triggersOnOverlap()`.
  Override a `DynamicHazard` per excloure SPAWN. `checkPlayerCollisions`
  passa de tres condicions inline per a sacs distints a una crida
  unificada.

`whipDirToMummyDir` es mou de whip_attack.cpp a mummy.cpp — el mapping
viu on s'usa (dins de `Mummy::onWhipHit`).

`hitMummyIfApplicable` es renomena a `hitEntityIfApplicable`: ja no
discrimina tipus, aplica `entity->onWhipHit` i marca `already_hit_`
segons el retorn.

Comportament idèntic, tests verds. Aquest commit prepara el següent:
amb el polimorfisme madurat, el sac dual ja no aporta valor i podem
tornar a un únic `entities_`.
2026-05-11 13:01:26 +02:00
JailDesigner e8418f89e0 chore(version): bump 0.40.0 -> 0.41.0 2026-05-11 11:31:24 +02:00
JailDesigner 9ca09484a6 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).
2026-05-11 11:31:03 +02:00
JailDesigner 9291ef6572 test(whip): arregla W4/W6 — first update ha d'arribar al primer active frame
El YAML del whip declara frame_ms=80, així que el frame 0 viu [0, 80] ms
i `active_frames=[1, 2]` comença a 80 ms. Els tests feien `update(0.06F)`
amb el comentari "60 ms → frame 1 (active)" — incorrecte: 60 < 80, encara
al frame 0, sense danyar res. Tres asserts d'`getState() == HURT` mai
no es complien.

Canvi a `update(0.09F)`: assegura que el primer tick entra al frame 1
i `applyDamage()` corre. Comentari actualitzat amb el càlcul correcte.
2026-05-11 11:28:51 +02:00
JailDesigner 6f8ab74d6b refactor(whip): applyDamage itera getActors() en lloc de getEntities() 2026-05-11 11:23:14 +02:00
JailDesignerandClaude Opus 4.7 881e0ea1f6 feat(room): mòmies surten emergint quan comença text-exit (fase 4)
Tres canvis coordinats que tanquen la cinemàtica desitjada de
ROOM_INTRO:

1. `spawn.duration_s` de mummy.yaml passa de 0.6 a 1.0 s. Les mòmies
   tarden ~1 segon a "emergir" — temps suficient perquè el jugador
   noti que estan apareixent, no que ja eren a la sala.

2. El disparador es mou de fase 5 (music_tail) a fase 4 (text-exit).
   Ara, just quan el text "PIRÀMIDE X, SALA Y" comença a sortir de
   pantalla, GameScene crida `entity_manager_.spawnPendingActors()`
   i les mòmies s'instancien ja en EMERGING. Visualment apareixen
   alhora que el text desapareix; durant la fase 5 acaben d'emergir
   i a l'entrada a PLAYING ja són ACTIVE — coincidint amb la caiguda
   aèria dels jugadors.

3. `completeIntro` afegeix un fallback: si arribem a PLAYING sense
   haver passat per fase 4 (config patològica, fases molt curtes),
   instanciem els pending igualment per garantir que la sala mai no
   queda sense enemics.

Renomenat: `hazards_spawn_triggered_` → `pending_actors_spawned_`,
nom que reflecteix què representa el flag al model nou.

El mètode privat `triggerHazardSpawnExit()` desapareix: era un
wrapper que ja només cridava `spawnPendingActors()`. Crida directa
des de `updateRoomTransition`, `respawnPlayersInstant` i
`completeIntro` (fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:40:28 +02:00
JailDesignerandClaude Opus 4.7 6a6a1a52c7 feat(em): spawn diferit de DynamicHazards (pending_actors_)
`loadFromLevel` ja no instancia immediatament les DynamicHazards del
YAML del mapa: les acumula a `pending_actors_` (estructura {type,
spawn_node}). El loader decideix per la categoria semàntica de la
plantilla (`type:` del YAML d'entity: "dynamic_hazard" → pending; la
resta → addSpawn d'instant).

Dues APIs noves consumeixen pending_actors_:
- `spawnPendingActors()` — itera la cua, instancia tots via addSpawn
  i arma `triggerSpawnExit()` a cada un. Vacia la cua. El path
  d'aparició massiva al final del text-exit de ROOM_INTRO.
- `spawnActor(type, col, row)` — instancia un únic actor on-demand
  des d'un spawn YAML sintètic. Pensat per a futurs generadors
  (StaticHazards que produeixen DynamicHazards en runtime: gotes,
  roques, etc.). Cada actor neix ja en EMERGING.

`GameScene::triggerHazardSpawnExit` ara és un wrapper de
`spawnPendingActors()` — el dispar segueix en la subfase actual
(fase 5 de ROOM_INTRO) i a `respawnPlayersInstant`. El canvi de timing
ve en el commit següent.

`reload` també buida pending_actors_. Log: "N map entities + M actors
pending des de PATH" — visible per a diagnòstic.

Sense canvis de comportament observables al joc: les mòmies segueixen
apareixent al final del text-exit, però ara la "aparició" és la seua
instanciació real (abans ja existien quietes des del fade-in).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:35:02 +02:00
JailDesignerandClaude Opus 4.7 62f4b857ef refactor(em): separa entitats en dos sacs (map_entities_/actors_)
EntityManager guanya dues llistes lògiques:
- `map_entities_` — decoració, static_hazards (Hole, FloorTrap, WallTrap)
  i interactables (Sarcophagus, Door). Persistents tota la sala.
- `actors_` — DynamicHazards (Mummy, RollingRock, futurs enemics).
  Tipats com `unique_ptr<DynamicHazard>`, sense dynamic_cast.

`add()` enruta automàticament segons sigui DynamicHazard o no.
`update`, `notifyPlayerTilePainted`, `findFloorTriggerAt`, `removeEntity`,
`reload` i `renderAll` iteren el(s) sac(s) que toca.

`checkPlayerCollisions`: el filtre de SPAWN sobre el sac d'actors ja no
necessita dynamic_cast — `actor->isSpawning()` és directe. El filtre per
floor decals només toca map_entities_ (els decals viuen exclusivament
allà). Filtres per-jugador extrets a helpers per acotar complexitat.

API nova: `getMapEntities()` i `getActors()` exposen cada sac per
separat. `getEntities()` es manté com a concatenació per retro-compat
(editor, z-sort debug).

`GameScene::triggerHazardSpawnExit` itera `getActors()` directe — fora
dynamic_cast.

`GameScene::render` mergeja els tres orígens (map + actors + players)
en un sol vector temporal abans del stable_sort per `feet_y` — mateix
patró que ja s'usava amb players, ara amb una transformació més.

Comportament idèntic: les momies segueixen instanciant-se al
loadFromLevel. La preparació per al spawn diferit ve en el commit
següent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:23:44 +02:00
JailDesignerandClaude Opus 4.7 3b378e2b29 feat(room): allibera DynamicHazards del SPAWN al final del text-INTRO
GameScene ara crida `triggerHazardSpawnExit()` quan ROOM_INTRO entra a
la fase 5 (music_tail, immediatament després del text-exit): itera els
DynamicHazards de la sala i els arma el comptador `spawn.duration_s`.

Així la cinemàtica queda:
  fade-in → text-enter → text-hold → text-exit
    └─> mòmies surten "del terra" (durada `spawn.duration_s` del YAML)
        └─> jugadors cauen del cel (delay player_spawn_delay_ms)

`respawnPlayersInstant` (salt debug a sala) també crida el trigger
perquè les mòmies no quedin congelades quan es curt-circuita la
cinemàtica.

Flag `hazards_spawn_triggered_` garanteix idempotència dins el cicle;
es reseteja a `enterRoomIntro` i al `setState(ROOM_INTRO)` defensiu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:56:14 +02:00
JailDesignerandClaude Opus 4.7 041447eaa4 feat(mummy): naix en SPAWN, llig spawn.duration_s del YAML
La mòmia ara entra a l'estat SPAWN al construir-se: visible al
pixel_rect_ inicial, sense moviment ni dany, esperant que algú extern
(GameScene) li armi el comptador via `triggerSpawnExit()`.

La durada (`spawn.duration_s` al YAML, default 0) controla quan passa
a ROAMING una vegada armat el comptador. Posat a 0.6s a mummy.yaml.

Aquest commit encara no enganxa el trigger: la mòmia es queda quieta
tota la partida — comportament visual intermedi, esperat. El commit
següent posarà el trigger a GameScene al final del text-exit de
ROOM_INTRO.

Tests de smoke i whip ajustats: les mòmies construïdes en stand-alone
necessiten `triggerSpawnExit()` + un update gran abans d'interactuar
amb `enterHurt`/contacto. Helper `exitMummySpawn` afegit a cada fitxer.

Guards defensius a `enterHurt` i `onPlayerContact`: encara que el filtre
central a EntityManager salta DynamicHazards en SPAWN, blindem la
crida directa per a futurs camins (whip propers, scripts...).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:49:23 +02:00
JailDesignerandClaude Opus 4.7 a6b2b02930 feat(hazard): afegeix estat SPAWN a DynamicHazard
DynamicHazard guanya un mecanisme de SPAWN amb dues fases: SPAWNING
(visible, sense moviment ni dany, comptador en pausa) i ACTIVE (lògica
normal). `enterSpawn(duration_s)` deixa l'entitat congelada esperant
indefinidament; `triggerSpawnExit()` arma el comptador i, en arribar a
zero, transita a ACTIVE.

Per defecte les noves DynamicHazard arrenquen en ACTIVE — backward-compat
amb subclasses existents (RollingRock) que encara no usen el mecanisme.

EntityManager::checkPlayerCollisions salta DynamicHazards en SPAWN: no
disparen onPlayerContact, així no fan dany ni triggers durant la fase.

Aquest commit només afegeix la infraestructura — cap subclasse l'usa
encara, comportament del joc inalterat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:43:38 +02:00
JailDesigner b7811180ba feat(game): spawn inicial de ROOM_INTRO sense immunitat 2026-05-11 07:53:56 +02:00
19 changed files with 494 additions and 92 deletions

No files matched your search

+8
View File
@@ -40,6 +40,14 @@ ai:
type: corridor_wanderer
direction_choice: random
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:
speed: 30
damage: 1
+46
View File
@@ -10,6 +10,7 @@
#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"
@@ -65,6 +66,24 @@ namespace {
// 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) {
@@ -122,6 +141,17 @@ Mummy::Mummy(const fkyaml::node& yaml)
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
// mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la
// pot ignorar amb `isWhippable() == false` si volem que no reacció.
@@ -509,7 +539,19 @@ void Mummy::applyChoice(Direction dir) {
}
}
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{
@@ -534,6 +576,10 @@ void Mummy::onPlayerContact(Player* player) {
}
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_;
+6
View File
@@ -64,6 +64,12 @@ class Mummy : public DynamicHazard {
// Contacte amb el jugador: publica `player_hit` amb el dany.
void onPlayerContact(Player* player) override;
// Contacte amb el fueteig: si la mòmia és whippable, entra a HURT amb
// la direcció derivada del whip. Retorna true si efectivament ha
// rebut dany — el WhipAttack ho usa per al `already_hit_`. Substitueix
// el `dynamic_cast<Mummy*>` que feia el whip abans per discriminar.
auto onWhipHit(WhipAttack* attack) -> bool override;
// Activa l'estat HURT amb la direcció de fuga preferida `flee_dir`.
// Si està bloquejada, prova perpendiculars; si totes estan bloquejades,
// la mòmia queda quieta però en HURT durant tot el timer.
@@ -4,6 +4,7 @@
#include <vector>
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp"
namespace {
@@ -124,4 +125,16 @@ void Sarcophagus::notifyTilePainted(int col, int row) {
}
}
void Sarcophagus::onRemoved() {
// El manager comptabilitza els sarcòfags actius en `total_sarcs_` per
// saber quan ha d'obrir la porta automàticament. Quan el editor
// n'esborra un, cal informar-lo: sense aquesta crida, `total_sarcs_`
// quedaria descompassat i la porta no s'obriria mai. Substitueix el
// `getName() == "sarcophagus"` que el manager feia abans —
// polimorfisme net, no comparació de strings.
if (entity_manager_ != nullptr) {
entity_manager_->onSarcophagusRemoved();
}
}
REGISTER_ENTITY("sarcophagus", Sarcophagus)
@@ -43,6 +43,7 @@ class Sarcophagus : public Interactable {
void update(float delta_time) override;
void notifyTilePainted(int col, int row) override;
void onRemoved() override;
[[nodiscard]] auto getState() const -> State { return state_; }
[[nodiscard]] auto getPerimeterSize() const -> std::size_t { return perimeter_tiles_.size(); }
+27 -28
View File
@@ -262,19 +262,10 @@ void WhipAttack::update(float delta_time) {
namespace {
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;
}
// (El mapping WhipAttack::Direction → Mummy::Direction viu ara a
// mummy.cpp, on s'usa des de `Mummy::onWhipHit`. Aquí només queda el
// mapping a Player::Cardinal — usat per al friendly-fire entre
// jugadors, que no és polimòrfic via Entity.)
auto whipDirToPlayerCardinal(WhipAttack::Direction d) -> Player::Cardinal {
switch (d) {
@@ -296,22 +287,24 @@ namespace {
return STATE != Player::State::HURT && STATE != Player::State::EXITED && STATE != Player::State::DISABLED;
}
// 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
// acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar).
void hitMummyIfApplicable(
Entity* e,
// Tracta una entitat candidata: si la seva caixa solapa amb
// `damage_rect`, delega la decisió a `entity->onWhipHit(attack)`.
// Cada entitat decideix què fer (Mummy entra HURT, Torch retorna
// false sense efecte, RollingRock no override → no-op). El retorn
// true marca el set `already_hit` per evitar segons impactes dins del
// mateix swing. Polimorfisme net — sense dynamic_cast.
void hitEntityIfApplicable(
Entity* entity,
const SDL_FRect& damage_rect,
WhipAttack::Direction dir,
WhipAttack* attack,
std::unordered_set<void*>& already_hit) {
if (e == nullptr || e->isDead()) { return; }
if (already_hit.contains(static_cast<void*>(e))) { return; }
auto* mummy = dynamic_cast<Mummy*>(e);
if (mummy == nullptr || !mummy->isWhippable()) { return; }
const auto BOX = mummy->getCollisionRect();
if (entity == nullptr || entity->isDead()) { return; }
if (already_hit.contains(static_cast<void*>(entity))) { return; }
const auto BOX = entity->getCollisionRect();
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
mummy->enterHurt(whipDirToMummyDir(dir));
already_hit.insert(static_cast<void*>(e));
if (entity->onWhipHit(attack)) {
already_hit.insert(static_cast<void*>(entity));
}
}
void hitPlayerIfApplicable(
@@ -336,8 +329,14 @@ namespace {
void WhipAttack::applyDamage() {
if (entity_manager_ != nullptr) {
for (auto* e : entity_manager_->getEntities()) {
hitMummyIfApplicable(e, damage_rect_, direction_, already_hit_);
// Iterem totes les entitats. La discriminació la fa cada una via
// `onWhipHit`: Mummy entra HURT si és whippable, la resta retorna
// false (no-op). El cost d'iterar antorxes/sarcs/forats és una
// crida virtual buida cada una — menyspreable comparat amb la
// claredat del callsite. Si demà un sarc vol trencar-se al
// fueteig, només cal override de `onWhipHit` allà.
for (auto* entity : entity_manager_->getEntities()) {
hitEntityIfApplicable(entity, damage_rect_, this, already_hit_);
}
}
if (players_ != nullptr) {
+25
View File
@@ -28,6 +28,31 @@ void Entity::notifyTilePainted(int /*col*/, int /*row*/) {
// No-op per defecte; només `Sarcophagus` l'usa.
}
auto Entity::onWhipHit(WhipAttack* /*attack*/) -> bool {
// No-op per defecte; les entitats no-whippables (Torch, Hole, Sarc,
// Door, RollingRock...) retornen `false`. `Mummy` n'és l'override
// efectiu. El caller usa el retorn per marcar `already_hit_` —
// entitats que no han rebut dany NO bloquegen futurs frames del whip.
return false;
}
void Entity::onJustSpawned() {
// No-op per defecte. `DynamicHazard` override per disparar el seu
// `triggerSpawnExit` (anima la fase EMERGING just després de néixer).
}
void Entity::onRemoved() {
// No-op per defecte. `Sarcophagus` override per decrementar el
// comptador `total_sarcs_` del manager.
}
auto Entity::shouldReceiveContact() const -> bool {
// Default: l'entitat rep contacte si és sòlida (`collides_`) o si és
// un trigger d'àrea (`triggers_on_overlap_`). `DynamicHazard` afegeix
// el filtre de SPAWN — mentre està emergint, ignora contactes.
return collides_ || triggers_on_overlap_;
}
auto Entity::getCollisionRect() const -> SDL_FRect {
if (!collision_box_.has_value()) { return pixel_rect_; }
return {
+33
View File
@@ -19,6 +19,7 @@ class Player;
class EventBus;
class Surface;
class EntityManager;
class WhipAttack;
// Entitat genèrica del món. Actua com a dada en comú per a tots els actors
// del joc (decoratius, interactables, perills mòbils i estàtics): manté
@@ -86,6 +87,38 @@ class Entity {
// (per marcar perímetre); per defecte no-op.
virtual void notifyTilePainted(int col, int row);
// L'entitat ha rebut un fueteig amb solapament. Es passa el punter al
// `WhipAttack` perquè la subclasse pugui llegir-ne direcció o atributs
// (mateix patró que `onPlayerContact(Player*)`). Retorna `true` si
// efectivament ha aplicat dany — el caller en marca el set
// `already_hit_` per evitar segons impactes dins del mateix swing.
// Default: no fa res, retorna `false`. Override a `Mummy` (whippable)
// i a futurs enemics whippables. `RollingRock` no override → roca
// immune al látigo per defecte.
virtual auto onWhipHit(WhipAttack* attack) -> bool;
// Hook just després de néixer al món. El manager el crida quan
// `addSpawn` / `spawnActor` / `spawnPendingActors` instancien una
// entitat. La base no fa res; `DynamicHazard` override per disparar
// el seu `triggerSpawnExit` (anima la fase EMERGING). Substitueix el
// `dynamic_cast<DynamicHazard*>(observer)->triggerSpawnExit()` que el
// manager feia abans — ara el dispatch és polimòrfic.
virtual void onJustSpawned();
// Hook just abans que el manager esborri l'entitat (via
// `removeEntity`, típicament al editor). Permet a subclasses
// notificar al manager o publicar events per mantenir invariants
// (p.ex. `Sarcophagus` decrementa `total_sarcs_`). Default no-op.
virtual void onRemoved();
// Decisió polimòrfica de si aquesta entitat ha de rebre el callback
// `onPlayerContact` aquest frame. La base permet contacte si `collides_`
// o `triggers_on_overlap_` estan posats. Subclasses poden afegir
// condicions transitòries (p.ex. `DynamicHazard` retorna `false` mentre
// està en SPAWN). Centralitza el filtre que abans vivia al manager
// amb dynamic_casts/isSpawning explícits.
[[nodiscard]] virtual auto shouldReceiveContact() const -> bool;
[[nodiscard]] auto getName() const -> const std::string& { return name_; }
[[nodiscard]] auto getTilePos() const -> TilePos { return tile_; }
[[nodiscard]] auto getPixelRect() const -> SDL_FRect { return pixel_rect_; }
+14 -9
View File
@@ -90,12 +90,15 @@ void EntityManager::setDoor(Door* door) {
auto EntityManager::removeEntity(Entity* observer) -> bool {
if (observer == nullptr) { return false; }
const bool WAS_DOOR = (door_ == observer);
const bool WAS_SARC = (observer->getName() == "sarcophagus");
// `onRemoved` és polimòrfic: cada subclasse decideix què cal
// notificar quan se l'esborra. Avui només Sarcophagus l'override per
// decrementar el seu comptador. La door es detecta per comparació de
// punter (és única, no cal RTTI).
observer->onRemoved();
auto it = std::ranges::find_if(entities_, [observer](const auto& up) { return up.get() == observer; });
if (it == entities_.end()) { return false; }
entities_.erase(it);
if (WAS_DOOR) { door_ = nullptr; }
if (WAS_SARC && total_sarcs_ > 0) { --total_sarcs_; }
// No tocar `opened_sarcs_` aquí: l'editor congela el joc i no pot
// disparar-se cap esdeveniment derivat. Quan el GameScene faça el
// reload posterior, els comptadors es re-deriven d'inici.
@@ -127,17 +130,19 @@ void EntityManager::update(float delta_time) {
}
void EntityManager::notifyPlayerTilePainted(int col, int row) {
// Fan-out polimòrfic a totes les entitats: cada una decideix què fer
// (`Sarcophagus` actualitza el seu perímetre, la resta queden en
// no-op). Cost d'iterar 40 elements amb crida virtual buida ~ 1 µs;
// qui paga el cost guanya simplicitat al callsite.
for (auto& e : entities_) { e->notifyTilePainted(col, row); }
}
auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* {
// Cerca lineal sobre `entities_`: cost menyspreable amb poques
// desenes d'entitats per sala i una crida per frame des dels
// consumidors actius (Player a l'aterratge, Mòmia a la decisió de
// direcció). Si esdevé calent, es podria indexar per cel·la del
// tilemap. La doble condició filtra exclusivament decals de sòl que
// volen rebre triggers — avui Hole; en el futur pinchos plans,
// plaques de pressió, charcos.
// Cerca lineal: només `Hole` (i futurs decals trigger) compleixen la
// doble condició `triggersOnOverlap` + `isFloorDecal`. Costos típics
// (poques desenes d'entitats) menyspreables, una crida per frame des
// dels consumidors actius (Player a l'aterratge, Mòmia a la decisió
// de direcció).
for (const auto& entity : entities_) {
if (!entity->triggersOnOverlap()) { continue; }
if (!entity->isFloorDecal()) { continue; }
+49 -1
View File
@@ -14,6 +14,7 @@ class Entity;
class Sarcophagus;
class Door;
class Player;
class DynamicHazard;
// Contenidor i coordinador d'entitats d'un nivell. Responsabilitats:
// 1. `add` registra una entitat, la connecta al bus i manté propietat.
@@ -73,7 +74,11 @@ class EntityManager {
static void renderEntity(const Entity& entity);
// Accés als punters observacionals a les entitats per a consumidors
// externs que necessiten construir una llista ordenada (z-order).
// externs que necessiten iterar (z-order, aplicar dany, etc.). El
// polimorfisme dins de cada entitat fa la discriminació: l'iterador
// recorre tot i les entitats que no hagin de respondre tornen no-op
// (p.ex. Torch::onWhipHit retorna false sense efecte). Sense sacs
// separats — el `if (entity_is_X)` viu dins de l'entitat, no del caller.
[[nodiscard]] auto getEntities() const -> std::vector<Entity*>;
// Comprova contacte del(s) jugador(s) amb entitats `collides()=true`.
@@ -119,6 +124,23 @@ class EntityManager {
// d'una posició del cursor i el passa.
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
// els comptadors interns (sarcòfags oberts, total, exited, flag de
// level_complete) i torna a cridar `loadFromLevel` per regenerar les
@@ -137,6 +159,7 @@ class EntityManager {
[[nodiscard]] auto getBus() -> EventBus& { return bus_; }
[[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); }
[[nodiscard]] auto pendingActorCount() const -> std::size_t { return pending_actors_.size(); }
[[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; }
[[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; }
[[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; }
@@ -148,9 +171,34 @@ class EntityManager {
// sarcs. No-op si la porta ja està oberta o si el nivell no en té.
void forceOpenDoor();
// Hook que `Sarcophagus::onRemoved` crida per decrementar el
// comptador `total_sarcs_`. Públic perquè el sarcòfag és la única
// entitat que necessita aquest manteniment, però l'API es manté
// genèrica: si en el futur Door volgués notificar quelcom semblant,
// afegim el seu propi callback.
void onSarcophagusRemoved() {
if (total_sarcs_ > 0) { --total_sarcs_; }
}
private:
EventBus bus_;
// Un sol contenidor lògic per a totes les entitats instanciades. La
// discriminació de tipus la fan els virtuals (`onPlayerContact`,
// `onWhipHit`, `shouldReceiveContact`, `notifyTilePainted`...) — el
// manager itera i cada entitat decideix què fa. Hi conviuen
// decoracions, static_hazards, interactables i dynamic_hazards
// (actors).
std::vector<std::unique_ptr<Entity>> entities_;
// 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};
std::size_t total_sarcs_{0};
std::size_t opened_sarcs_{0};
+114 -40
View File
@@ -18,6 +18,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_loader.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/hazard.hpp"
#include "game/entities/player.hpp"
#include "game/map/map.hpp"
#include "utils/defaults.hpp"
@@ -56,52 +57,56 @@ void EntityManager::renderEntity(const Entity& e) {
}
void EntityManager::renderAll() {
// Sense ordenació per z: l'ordre és el d'inserció al vector. 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 : entities_) { renderEntity(*e); }
}
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) {
if (players.empty()) { return; }
for (const auto& entity : entities_) {
// Acceptem entitats sòlides (xoc físic) i triggers d'àrea (no
// bloquegen però volen rebre `onPlayerContact`). 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`.
if (!entity->collides() && !entity->triggersOnOverlap()) { continue; }
// Helper local: aplica el contacte d'una entitat sobre tots els
// jugadors, amb els filtres per-jugador a `playerSkipsHazard`. Es
// comparteix entre els dos sacs (map_entities_ i actors_).
auto apply_contacts = [&players](Entity* entity) {
const auto BOX = entity->getCollisionRect();
const int E_HEIGHT = entity->getHeight();
for (auto* p : players) {
// Filtrem també DYING: el jugador en cicle de mort no rep
// contacte amb hazards (un segon toc de mòmia mentre la roda
// 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); }
if (p == nullptr || playerSkipsHazard(*p, E_HEIGHT)) { continue; }
if (playerOverlapsEntityBox(*p, BOX)) { entity->onPlayerContact(p); }
}
};
// Filtre polimòrfic unificat: cada entitat decideix si ha de rebre
// contacte (`shouldReceiveContact`). Per defecte equival a `collides()
// || triggersOnOverlap()`; `DynamicHazard` afegeix el filtre de SPAWN
// (mentre està emergint torna false). El callsite no fa dynamic_cast
// ni distingeix categories.
for (const auto& entity : entities_) {
if (!entity->shouldReceiveContact()) { continue; }
apply_contacts(entity.get());
}
}
@@ -202,6 +207,24 @@ namespace {
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
// grid del tilemap està 16-alineat respecte a `tilemap_origin_*`, que
// pot ser negatiu per overscan CPC.
@@ -383,6 +406,7 @@ void EntityManager::reload(const std::string& level_yaml_path) {
// se subscriu directament al bus — només publiquen). El bus i les
// subscripcions externes (PaintLayer, HUD, GameScene) no es toquen.
entities_.clear();
pending_actors_.clear();
door_ = nullptr;
total_sarcs_ = 0;
opened_sarcs_ = 0;
@@ -419,8 +443,21 @@ auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* {
void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path);
const int LOADED = static_cast<int>(std::ranges::count_if(SPAWNS,
[this](const fkyaml::node& spawn) { return addSpawn(spawn) != nullptr; }));
int loaded = 0;
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
// decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...).
@@ -432,6 +469,8 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
// perquè tots els sarcòfags ja estan registrats al fan-out (la
// subscripció del manager és al ctor, anterior a qualsevol load).
int decal_broadcast = 0;
// Els decals de sòl (Hole, futurs pinchos plans...) són StaticHazards;
// iterem tot `entities_` i el filtre per `isFloorDecal()` els tria.
for (const auto& entity : entities_) {
if (!entity->isFloorDecal()) { continue; }
const auto BOX = entity->getCollisionRect();
@@ -447,5 +486,40 @@ 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", "{} entitats carregades des de {}", LOADED, level_yaml_path);
Log::info("EntityManager", "{} entitats + {} 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_`. `onJustSpawned()` és polimòrfic: la base
// no fa res, DynamicHazard l'override per disparar `triggerSpawnExit()`
// i entrar a EMERGING. Sense dynamic_cast: la decisió viu dins de
// cada entitat.
for (const auto& pa : pending_actors_) {
Entity* observer = addSpawn(pa.spawn_node);
if (observer != nullptr) { observer->onJustSpawned(); }
}
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; }
// `onJustSpawned` polimòrfic: per a DynamicHazards arma EMERGING; per
// a un type erroni (no dynamic_hazard) queda no-op i retornem nullptr
// mitjançant el cast final, deixant la signatura `DynamicHazard*` neta.
observer->onJustSpawned();
return dynamic_cast<DynamicHazard*>(observer);
}
+60
View File
@@ -1,5 +1,7 @@
#pragma once
#include <cstdint>
#include "game/entities/entity.hpp"
// 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
// direcció (vec2). Les subclasses decideixen la política de moviment
// (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 {
public:
enum class SpawnState : std::uint8_t { SPAWNING,
ACTIVE };
DynamicHazard() = default;
void setSpeed(float px_per_s) { speed_ = px_per_s; }
@@ -70,6 +84,35 @@ class DynamicHazard : public Hazard {
[[nodiscard]] auto getDirX() const -> float { return dir_x_; }
[[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_; }
// Just després d'instanciar-se al món (via spawnPendingActors /
// spawnActor), l'actor entra automàticament en la fase EMERGING:
// arma el comptador i comença l'animació de sortida. Substitueix el
// dynamic_cast que el manager feia abans per cridar `triggerSpawnExit`.
void onJustSpawned() override { triggerSpawnExit(); }
// Mentre està en SPAWN (HIDDEN/EMERGING), l'actor NO rep contacte
// ni amb el jugador ni amb attacks: visible però "no juga". Quan
// passa a ACTIVE, recupera el comportament estàndard d'Entity.
[[nodiscard]] auto shouldReceiveContact() const -> bool override {
if (isSpawning()) { return false; }
return Entity::shouldReceiveContact();
}
// Política de moviment per frame. La base desplaça el pixel_rect_
// proporcionalment a velocitat i direcció; les subclasses amb IA la
// sobreescriuen per triar nova direcció a les interseccions o rebotar.
@@ -79,6 +122,19 @@ class DynamicHazard : public Hazard {
}
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);
step(delta_time);
}
@@ -87,4 +143,8 @@ class DynamicHazard : public Hazard {
float speed_{0.0F};
float dir_x_{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};
};
+7 -4
View File
@@ -1589,10 +1589,13 @@ void Player::respawn(float x, float y) {
setPosX(x);
setPosY(y);
// Immunitat post-spawn: armem la finestra de gràcia amb la durada del
// YAML. Cobreix l'aparició inicial (`spawnFromAir` → `respawn`) i el
// respawn post-mort (`respawnFromDying` → `respawn`). Si el bloc
// `immunity:` manca al YAML, `duration_ms` és 0 i `setImmunity` és
// no-op — comportament idèntic al d'abans.
// YAML. Cobreix el join mid-partida (`joinPlayer` → `spawnFromAir` →
// `respawn`) i el respawn post-mort (`respawnFromDying` → `respawn`).
// L'spawn inicial de ROOM_INTRO també hi passa, però `triggerPlayerSpawn`
// crida `setImmunity(0)` immediatament després per cancel·lar-la (els
// enemics ja estan apareixent i el jugador no ha de tindre gràcia).
// Si el bloc `immunity:` manca al YAML, `duration_ms` és 0 i el timer
// queda a 0 — sense gràcia, comportament idèntic al d'abans.
setImmunity(immunity_config.duration_ms);
}
+4 -4
View File
@@ -331,11 +331,11 @@ class Player : public AnimatedSprite {
// sortida lògica del món. Idempotent: una segona crida reescriu el
// timer (no acumula). Visualment, el render alterna la paleta del
// sprite amb una paleta plana cada `immunity_config.blink_period_ms`
// ms. Si `duration_ms <= 0` és no-op (manera de cancel·lar
// explícitament la immunitat seria `setImmunity(0)` — equivalent al
// comportament natural quan el timer s'esgota).
// ms. `setImmunity(0)` cancel·la la immunitat activa (sobreescriu el
// timer a 0) — equivalent al comportament natural quan el timer
// s'esgota. Valors negatius es clampejen a 0.
void setImmunity(float duration_ms) {
if (duration_ms > 0.0F) { immunity_remaining_ms_ = duration_ms; }
immunity_remaining_ms_ = duration_ms > 0.0F ? duration_ms : 0.0F;
}
// Cert mentre el jugador té immunitat activa. Comprovat per
+45
View File
@@ -31,6 +31,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_loader.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/hazard.hpp"
#include "game/entities/player.hpp"
#include "game/gameplay_config.hpp"
#include "game/levels/level_manager.hpp"
@@ -622,6 +623,7 @@ void GameScene::enterRoomIntro() {
intro_completed_ = false;
intro_spawn_elapsed_ms_ = 0;
players_spawned_in_intro_.fill(false);
pending_actors_spawned_ = false;
// 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
@@ -674,6 +676,12 @@ void GameScene::respawnPlayersInstant() {
state_ = RoomFsm::State::PLAYING;
intro_completed_ = true;
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");
}
#endif
@@ -701,6 +709,14 @@ void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
// pot canviar de geometria de porta entre cicles.
players_[slot_index]->setSpawnPoint(x, SPAWN.y);
players_[slot_index]->spawnFromAir(x, SPAWN.y);
// Spawn inicial sense gràcia: l'aparició coincideix amb el moment
// en què comencen a aparèixer els enemics, i la finestra d'immunitat
// que arma `respawn` deixaria al jugador immune just quan ja hauria
// de poder ser tocat. Cancel·lem el timer recent armat. Els altres
// camins (`joinPlayer` mid-partida i `respawnFromDying`) sí que
// mantenen la immunitat: són situacions on el jugador apareix enmig
// de l'acció ja en curs i necessita la finestra de gràcia.
players_[slot_index]->setImmunity(0.0F);
// Arma el reverb del primer LAND com a part de la cinemàtica
// d'INTRO. Una sola vegada per spawn aeri: el respawn post-mort
// (que no passa per ací) cau al camí sec. Si el preset està buit
@@ -819,6 +835,15 @@ void GameScene::completeIntro() {
if (intro_completed_) { return; }
intro_completed_ = true;
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
// altres cauen frame a frame dins `updateRoomTransition` a mesura
// que `intro_spawn_elapsed_ms_` supera el seu delay. Així P1 cau
@@ -855,6 +880,7 @@ void GameScene::setState(RoomFsm::State new_state) {
intro_completed_ = false;
intro_spawn_elapsed_ms_ = 0;
players_spawned_in_intro_.fill(false);
pending_actors_spawned_ = false;
}
auto* audio = Audio::get();
// PLAYING: arrenca la pista de joc en loop infinit. Netegem el
@@ -915,6 +941,23 @@ void GameScene::updateRoomTransition(int 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
// fase de fade_out (phase 4 del timeline d'OUTRO). Si l'`advance`
// ha consumit fases buides en aquest mateix frame i ja portem
@@ -1750,6 +1793,8 @@ void GameScene::render() {
};
std::vector<Renderable> order;
order.reserve(entity_manager_.entityCount() + players_.size());
// Mergem entitats i jugadors en un sol vector temporal — el
// `stable_sort` per `feet_y` posterior fa el z-order interleaving.
const auto ENTITIES = entity_manager_.getEntities();
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
+8
View File
@@ -202,6 +202,14 @@ class GameScene : public Scene {
// i a `setState(ROOM_INTRO)` (camí defensiu no canònic).
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
// 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
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.40.0"; // Single source of truth — Makefile el grep
constexpr const char* VERSION = "0.42.0"; // Single source of truth — Makefile el grep
} // namespace Texts
namespace GameCanvas {
+13
View File
@@ -69,6 +69,15 @@ namespace {
#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() {
std::cout << "[T1] factory\n";
auto& factory = EntityFactory::instance();
@@ -442,6 +451,7 @@ namespace {
// Mateixa col·locació que testMummyStep: corredor exterior
// 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});
exitMummySpawn(mummy);
// 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).
@@ -474,6 +484,7 @@ namespace {
// la paret i fer-la sortir de HURT.
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});
exitMummySpawn(mummy);
// La direcció preferida (RIGHT) està bloquejada al test
// 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).
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
REQUIRE(mummy.getCollisionRect().x == 40.0F);
exitMummySpawn(mummy);
mummy.enterHurt(Mummy::Direction::WEST);
REQUIRE(mummy.getState() == Mummy::State::HURT);
@@ -571,6 +583,7 @@ namespace {
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
// Mateix runway que T18.
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
exitMummySpawn(mummy);
mummy.enterHurt(Mummy::Direction::WEST);
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__)
// 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).
// 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.
@@ -200,12 +208,15 @@ namespace {
// = 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});
auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned)));
exitMummySpawn(*mummy_raw);
auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F);
whip->setWorldContext(&manager, nullptr);
// 60 ms → frame 1 (active). active_frames: [1, 2].
whip->update(0.06F);
// frame_ms=80 al YAML → frame 0 viu [0, 80] ms, frame 1 [80, 160] ms.
// 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);
}
@@ -258,12 +269,14 @@ namespace {
// = 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});
auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned)));
exitMummySpawn(*mummy_raw);
auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F);
whip->setWorldContext(&manager, nullptr);
// Primera passada (frame 1, actiu) → mòmia a HURT.
whip->update(0.06F);
// frame_ms=80 → frame 0 [0, 80] ms; cal ≥ 80 ms per entrar al
// frame 1 (primer dels active_frames=[1, 2]).
whip->update(0.09F);
REQUIRE(mummy_raw->getState() == Mummy::State::HURT);
const float HURT_INITIAL = mummy_raw->getHurtRemainingS();
@@ -275,7 +288,9 @@ namespace {
const float HURT_AFTER_MUMMY_TICK = mummy_raw->getHurtRemainingS();
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);
}