diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..f636c8d --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"5c7d4a59-4494-40ef-94c8-3a11d7560eff","pid":88259,"acquiredAt":1776794655719} \ No newline at end of file diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index 52c831b..4068151 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -20,6 +20,7 @@ animations: ai: type: corridor_wanderer direction_choice: random + initial_direction: right stats: speed: 40 damage: 1 diff --git a/data/player/player.yaml b/data/player/player.yaml index 95c140f..bb24c87 100644 --- a/data/player/player.yaml +++ b/data/player/player.yaml @@ -12,21 +12,26 @@ collision: base_size: 12 animations: + - name: stand_down + frames: [0] + + - name: stand_up + frames: [4] + - name: stand - frames: [1] + frames: [8] + + - name: walk_down + speed: 0.07 + loopFrom: 0 + frames: [0, 1, 2, 3] + + - name: walk_up + speed: 0.07 + loopFrom: 0 + frames: [4, 5, 6, 7] - name: walk speed: 0.07 loopFrom: 0 - frames: [2, 3, 4, 1] - - - name: turn_walk - speed: [0.1, 0.07, 0.07, 0.07, 0.07] - loopFrom: 1 - frames: [0, 2, 3, 4, 1] - - - name: jump - frames: [6] - - - name: jump_peak - frames: [5] + frames: [8, 9, 10, 11] diff --git a/docs/entities-system.md b/docs/entities-system.md index 14b2c60..8682c46 100644 --- a/docs/entities-system.md +++ b/docs/entities-system.md @@ -185,17 +185,17 @@ Es respecta tant a la fase 1 com a la 2. | ID | Descripció | Estat | |---|---|---| -| ENT-IA-01 | **Moviment viu de la momia**: el `chooseDirection` està al seu lloc però el `step` no itera interseccions ni valida direcció contra el mapa. La momia es dibuixa estàtica. | DEFERRED | +| ENT-IA-01 | **Moviment viu de la momia**: `Mummy::step` camina en línia recta validant `Map::collides` + `Map::insidePlayfield` a cada frame. En topar, recull perpendiculars lliures (evitant U-turn llevat de cul-de-sac) i crida `chooseDirection`. Cobert per T13. | DONE | | ENT-IA-02 | **IA de la momia amb bias cap al jugador** (weighted_towards_player). Hook `chooseDirection` deixat preparat; només cal una subclasse/override. | DEFERRED | -| ENT-HAZ-01 | **Trampes actives amb dany real al jugador**. `onPlayerContact` publica `player_hit` però encara no hi ha sistema de vides. Caldrà cablejar vides al Player. | DEFERRED | -| ENT-ROCK-01 | **Col·lisió de la roca amb sarcs/murs**. `DynamicHazard::step` desplaça sense validar `Map::collides`. Una roca rodant a tota velocitat travessaria sarcs. | DEFERRED | +| ENT-HAZ-01 | **Trampes actives amb dany real al jugador**. `onPlayerContact` publica `player_hit` via `EntityManager::checkPlayerCollisions`, però encara no hi ha sistema de vides. Caldrà cablejar vides al Player. | DEFERRED | +| ENT-ROCK-01 | **Col·lisió de la roca amb sarcs/murs**. `RollingRock::step` valida `Map::collides`/`insidePlayfield` i s'atura quan bloqueja (setDirection 0,0). No rebota per disseny. | DONE | | ENT-DOOR-01 | **Retorn a la pantalla de títol al `level_complete`**. Ara només log a stdout. Pendent cablejar la transició de Scene. | DEFERRED | ### Render i geometria | ID | Descripció | Estat | |---|---|---| -| REND-01 | **Z-order fi** entre entitats i Player (interleaving per `feetY`). Ara les entitats sempre queden sota el Player. Veure comentari a [game_scene.cpp:109-110](../source/game/scenes/game_scene.cpp#L109-L110). | DEFERRED | +| REND-01 | **Z-order fi** entre entitats i Player: `GameScene::render` construeix una llista {Entity*, Player*, feetY}, la ordena amb `stable_sort` ascendent i dibuixa per ordre. Els sprites amb peus més amunt queden darrere dels que tenen peus més avall. | DONE | | REND-02 | **Animacions multi-frame**. `renderAll` clipa el frame actual via `src_rect` calculat amb `Entity::getAnimation()->currentFrame()` sobre una graella left-to-right / top-to-bottom; columnes deduïdes de `surface->getWidth() / pixel_rect.w`. Así un strip 1×N, N×1 o N×M funciona igual. Torch primer consumidor real (4 frames @ 8 fps). | DONE | | REND-03 | **Paleta compartida** amb el tilemap. Cada GIF placeholder té la seua paleta pròpia (Pillow); funciona perquè `Surface` desa paleta per surface. Amb l'art real pot caldre unificar per evitar mismatch cromàtic. | DEFERRED | @@ -220,14 +220,38 @@ Es respecta tant a la fase 1 com a la 2. | ID | Descripció | Estat | |---|---|---| | TST-01 | **Test d'integració amb `loadFromLevel` real**: el smoke actual evita el Cache per no arrossegar dependències. Un test separat amb el Cache enllaçat podria verificar el blit, però demana instanciar Screen/Canvas. | WONTFIX (l'smoke d'arrencada sota `xvfb-run` ja cobreix el camí viu) | -| TST-02 | **Test de moviment de la momia** (quan ENT-IA-01 estigui fet). Pendent juntament amb la IA. | DEFERRED | +| TST-02 | **Test de moviment de la momia**: T13 exercita step lliure (avança) i step contra paret (gira sense avançar). | DONE | --- -## 6. Estat global +## 6. Fase 4 — Moviment i interacció (DONE) -- **Andamiatge i integració bàsica**: DONE. Els 5 actors del `pyramid.yaml` es carreguen, s'assignen surfaces i es renderitzen estàticament sobre el mapa. El bus connecta Player ↔ sarcs ↔ porta. -- **Comportament dinàmic (IA, dany, transicions de scene)**: DEFERRED a iteracions posteriors. El hooks existeixen, falta omplir la lògica viva. -- **Smoke tests**: 9/9 passant; regla durable de cobrir amb smoke tota lògica no trivial respectada a les dues fases. +Tanca els hooks d'IA i col·lisió que quedaven apuntalats a les fases 1–3. + +- **`Mummy::step`** implementat: desplaçament recte validant playfield i + sarcs; gir a cul-de-sac o intersecció via `chooseDirection`. +- **`RollingRock::step`** igual, però s'atura en lloc de girar. +- **`Entity::getCollisionRect()` + `getFeetY()`**: caixa AABB encastada al + sprite (falsa perspectiva, opcional via bloc `collision:` del YAML). +- **Anchor `feet`** al loader: `position: [col, row]` és el tile dels peus; + el sprite es centra en X i bottom-aligned en Y. +- **Sarcòfag amb caixa lògica**: `placeSarcophagus` aplica un `CollisionBox` + que retalla la meitat superior del sprite (alçada) perquè no dispari + falsos contactes. +- **`Map::insidePlayfield(box)`**: valida la caixa candidata dels `step`. +- **`EntityManager::checkPlayerCollisions(players)`**: detecta solapament + base-box del player amb `getCollisionRect` de cada entitat amb + `collides()=true`, i crida `onPlayerContact`. Cablejat al `GameScene::update`. +- **Z-order**: `GameScene::render` ordena {entitats, players} per `getFeetY()` + ascendent i dibuixa en aquell ordre (interleaved). +- **`Player::getBaseBox()`** exposat com a API pública. +- **Smoke tests**: T12 (CollisionBox), T13 (Mummy::step), T11 (sarc YAML), + T10 (torch anim). Total 14/14. + +## 7. Estat global + +- **Andamiatge, integració, render animat, falsa perspectiva, moviment**: DONE. Els 26 actors del `pyramid.yaml` (4 torches, 20 sarcs, 1 door, 1 mummy) es carreguen, animen, es col·loquen amb l'anchor correcte, col·lisionen segons caixa lògica i el z-order interleava per peus. +- **Pendents majors**: ENT-HAZ-01 (sistema de vides), ENT-DOOR-01 (transició d'escena), ENT-IA-02 (bias IA), PER-01 (perímetre amb blocked_predicate quan els sarcs siguin veïns). +- **Smoke tests**: 14/14 passant; regla durable de cobrir amb smoke tota lògica no trivial respectada a totes les fases. El document és viu: cada tasca nova hauria d'afegir-s'hi (entrada amb ID, descripció, estat) al fer-la o ajornar-la. diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index efa395b..06638e4 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -1,10 +1,13 @@ #include "game/entities/concrete/mummy.hpp" +#include #include #include +#include #include "game/entities/entity_factory.hpp" #include "game/entities/event_bus.hpp" +#include "game/map/map.hpp" namespace { @@ -30,6 +33,24 @@ Mummy::Mummy(const fkyaml::node& yaml) { if (s.contains("speed")) { setSpeed(s["speed"].get_value()); } if (s.contains("damage")) { setDamage(s["damage"].get_value()); } } + + // Direcció inicial llegida del bloc `ai:`. Per defecte dreta, per + // coherència amb el que fa RollingRock. Valors acceptats: right, left, + // up, down. Qualsevol altre s'interpreta com a "sense direcció" (la + // momia no es mou fins que step() no triï una direcció). + std::string initial = "right"; + if (yaml.contains("ai") && yaml["ai"].contains("initial_direction")) { + initial = yaml["ai"]["initial_direction"].get_value(); + } + if (initial == "right") { + setDirection(1.0F, 0.0F); + } else if (initial == "left") { + setDirection(-1.0F, 0.0F); + } else if (initial == "up") { + setDirection(0.0F, -1.0F); + } else if (initial == "down") { + setDirection(0.0F, 1.0F); + } } auto Mummy::chooseDirection(const std::vector& allowed) -> Direction { @@ -38,6 +59,87 @@ auto Mummy::chooseDirection(const std::vector& allowed) -> Direction return allowed[dist(rng())]; } +auto Mummy::candidateBox(float dx, float dy) const -> SDL_FRect { + auto box = getCollisionRect(); + box.x += dx; + box.y += dy; + return box; +} + +void Mummy::step(float delta_time) { + // Desplaçament nominal en aquest frame segons la direcció actual. + const float DX = dir_x_ * speed_ * delta_time; + const float DY = dir_y_ * speed_ * delta_time; + + // Si no té direcció (p.ex. YAML sense `initial_direction`), el frame és + // no-op fins que algú la injecte amb `setDirection`. + if (DX == 0.0F && DY == 0.0F) { return; } + + // Prova d'avançar. Si la caixa candidata és vàlida (dins del playfield + // i sense trepitjar cap sarc), s'aplica el moviment i s'acaba el frame. + const auto CANDIDATE = candidateBox(DX, DY); + if (Map::insidePlayfield(CANDIDATE) && !Map::collides(CANDIDATE)) { + pixel_rect_.x += DX; + pixel_rect_.y += DY; + return; + } + + // Bloqueig: cal girar. Recollim les direccions perpendiculars a l'actual + // que tinguin un pròxim tram lliure. Evitem U-turn llevat que no hi hagi + // cap alternativa (comportament típic d'Oh Mummy!: no es recula sense + // motiu). Si la direcció perpendicular també està bloquejada, no s'hi + // inclou; si totes les perpendiculars estan bloquejades, afegim l'opcio + // oposada com a últim recurs (cul-de-sac). + const float STEP = speed_ * delta_time; + struct Candidate { + float dx; + float dy; + Direction dir; + }; + const std::array ALL{{ + {.dx = STEP, .dy = 0.0F, .dir = Direction::RIGHT}, + {.dx = -STEP, .dy = 0.0F, .dir = Direction::LEFT}, + {.dx = 0.0F, .dy = -STEP, .dir = Direction::UP}, + {.dx = 0.0F, .dy = STEP, .dir = Direction::DOWN}, + }}; + + std::vector perp_allowed; + std::vector uturn_allowed; + for (const auto& c : ALL) { + const bool IS_CURRENT = (c.dx > 0.0F && dir_x_ > 0.0F) || (c.dx < 0.0F && dir_x_ < 0.0F) || (c.dy > 0.0F && dir_y_ > 0.0F) || (c.dy < 0.0F && dir_y_ < 0.0F); + const bool IS_UTURN = (c.dx > 0.0F && dir_x_ < 0.0F) || (c.dx < 0.0F && dir_x_ > 0.0F) || (c.dy > 0.0F && dir_y_ < 0.0F) || (c.dy < 0.0F && dir_y_ > 0.0F); + if (IS_CURRENT) { continue; } // ja hem vist que està bloquejada + const auto TEST = candidateBox(c.dx, c.dy); + if (!Map::insidePlayfield(TEST) || Map::collides(TEST)) { continue; } + if (IS_UTURN) { + uturn_allowed.push_back(c.dir); + } else { + perp_allowed.push_back(c.dir); + } + } + + const auto& pool = !perp_allowed.empty() ? perp_allowed : uturn_allowed; + const auto CHOICE = chooseDirection(pool); + switch (CHOICE) { + case Direction::UP: + setDirection(0.0F, -1.0F); + break; + case Direction::DOWN: + setDirection(0.0F, 1.0F); + break; + case Direction::LEFT: + setDirection(-1.0F, 0.0F); + break; + case Direction::RIGHT: + setDirection(1.0F, 0.0F); + break; + case Direction::NONE: + setDirection(0.0F, 0.0F); + break; + } + // No avancem aquest frame: la direcció nova s'aplicarà al pròxim step. +} + void Mummy::onPlayerContact(Player* /*player*/) { if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); } } diff --git a/source/game/entities/concrete/mummy.hpp b/source/game/entities/concrete/mummy.hpp index 30a73e2..5006665 100644 --- a/source/game/entities/concrete/mummy.hpp +++ b/source/game/entities/concrete/mummy.hpp @@ -31,6 +31,17 @@ class Mummy : public DynamicHazard { // aleatoria; si `allowed` està buit, retorna `NONE`. virtual auto chooseDirection(const std::vector& allowed) -> Direction; + // Política de moviment "corridor_wanderer": camina recte fins que el + // pròxim tram la bloquejaria (mur del playfield o sarc). Quan troba un + // obstacle, recull les direccions perpendiculars vàlides (evitant + // U-turn llevat que no hi hagi alternativa) i crida `chooseDirection` + // per decidir-ne una. No consumeix moviment el frame de gir. + void step(float delta_time) override; + // Contacte amb el jugador: publica `player_hit` amb el dany. void onPlayerContact(Player* player) override; + + private: + // Caixa de col·lisió candidata si l'entitat es desplaçara (dx, dy). + [[nodiscard]] auto candidateBox(float dx, float dy) const -> SDL_FRect; }; diff --git a/source/game/entities/concrete/rolling_rock.cpp b/source/game/entities/concrete/rolling_rock.cpp index dea68f7..212df64 100644 --- a/source/game/entities/concrete/rolling_rock.cpp +++ b/source/game/entities/concrete/rolling_rock.cpp @@ -4,6 +4,7 @@ #include "game/entities/entity_factory.hpp" #include "game/entities/event_bus.hpp" +#include "game/map/map.hpp" namespace { @@ -42,6 +43,23 @@ RollingRock::RollingRock(const fkyaml::node& yaml) { applyInitialDirection(initial, *this); } +void RollingRock::step(float delta_time) { + const float DX = dir_x_ * speed_ * delta_time; + const float DY = dir_y_ * speed_ * delta_time; + if (DX == 0.0F && DY == 0.0F) { return; } + + auto candidate = getCollisionRect(); + candidate.x += DX; + candidate.y += DY; + if (Map::insidePlayfield(candidate) && !Map::collides(candidate)) { + pixel_rect_.x += DX; + pixel_rect_.y += DY; + return; + } + // Bloqueig: aturada definitiva (la roca no rebota). + setDirection(0.0F, 0.0F); +} + void RollingRock::onPlayerContact(Player* /*player*/) { if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); } } diff --git a/source/game/entities/concrete/rolling_rock.hpp b/source/game/entities/concrete/rolling_rock.hpp index 4b896b7..85fc9e4 100644 --- a/source/game/entities/concrete/rolling_rock.hpp +++ b/source/game/entities/concrete/rolling_rock.hpp @@ -11,5 +11,12 @@ class RollingRock : public DynamicHazard { public: explicit RollingRock(const fkyaml::node& yaml); + // Avança en línia recta fins a la primera col·lisió: si la caixa + // candidata sortiria del playfield o trepitjaria un sarc, s'atura + // (direcció → 0,0) i l'actualització es manté en aquella posició fins + // que un agent extern la torni a empènyer. No rebota per decisió + // de disseny: la roca és trampa estàtica disfressada de dinàmica. + void step(float delta_time) override; + void onPlayerContact(Player* player) override; }; diff --git a/source/game/entities/entity_manager.cpp b/source/game/entities/entity_manager.cpp index ba5a23d..2b62629 100644 --- a/source/game/entities/entity_manager.cpp +++ b/source/game/entities/entity_manager.cpp @@ -1,5 +1,7 @@ #include "game/entities/entity_manager.hpp" +#include +#include #include #include "game/entities/concrete/door.hpp" @@ -68,6 +70,13 @@ void EntityManager::notifyPlayerEnteredTile(int col, int row) { for (auto& e : entities_) { e->notifyTileEntered(col, row); } } +auto EntityManager::getEntities() const -> std::vector { + std::vector out; + out.reserve(entities_.size()); + std::ranges::transform(entities_, std::back_inserter(out), [](const auto& e) { return e.get(); }); + return out; +} + // NOTA: `renderAll` es defineix a `entity_manager_loadlevel.cpp`. Conté // blits via `Surface::render`, que dragaria el pipeline complet de render // (GIF, Canvas, Screen, àudio...) al smoke test. Separar-lo manté el smoke diff --git a/source/game/entities/entity_manager.hpp b/source/game/entities/entity_manager.hpp index 968b39d..2cb9559 100644 --- a/source/game/entities/entity_manager.hpp +++ b/source/game/entities/entity_manager.hpp @@ -10,6 +10,7 @@ class Entity; class Sarcophagus; class Door; +class Player; // Contenidor i coordinador d'entitats d'un nivell. Responsabilitats: // 1. `add` registra una entitat, la connecta al bus i manté propietat. @@ -49,10 +50,30 @@ class EntityManager { void update(float delta_time); // Dibuix de totes les entitats. Sense ordenació per z: la crida dura - // l'ordre d'inserció. El z-order fi (interleaving amb el Player per - // feetY) queda per a una tasca posterior. + // l'ordre d'inserció. GameScene fa el z-order interleaving amb els + // players directament via `renderEntity`; `renderAll` queda com a + // dreçera quan no hi ha players al render (editor, tests). void renderAll(); + // Render d'una única entitat (mateixa lògica de clipping per frame + // que renderAll). Exposat perquè GameScene pugui interleavar entitats + // i players per `getFeetY()` (REND-01). Estàtic perquè la lògica de + // render no depèn de cap estat del manager — ara com ara, l'entitat + // porta la seua surface, animació i pixel_rect. + 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). + [[nodiscard]] auto getEntities() const -> std::vector; + + // Comprova contacte del(s) jugador(s) amb entitats `collides()=true`. + // Si la caixa base del jugador solapa amb la caixa de col·lisió de + // l'entitat, crida `entity->onPlayerContact(player)`. No fa res si + // la llista està buida. Es crida des de GameScene cada frame, després + // de `update`, perquè la resposta (p.ex. `player_hit`, `level_complete`) + // reflecteixi l'estat del frame actual. + void checkPlayerCollisions(const std::vector& players); + // Fan-out del hook de moviment de jugador: per a cada sarcòfag, // cridar notifyTileEntered. Les entitats no-sarcòfag ho ignoren per // defecte (veure Entity::notifyTileEntered). diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 0752795..8c4c6f8 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -19,6 +19,7 @@ #include "game/entities/entity_factory.hpp" #include "game/entities/entity_loader.hpp" #include "game/entities/entity_manager.hpp" +#include "game/entities/player.hpp" #include "game/map/map.hpp" // Render centralitzat al manager per confinar la dependència cap a @@ -32,26 +33,42 @@ // surface per l'amplada del frame — així un GIF de 4 frames pot venir com // a 1×4, 4×1 o 2×2 indistintament. `renderAll` clipa la cel·la del frame // actual amb un `src_rect`. Entitats sense animació es dibuixen al frame 0. +void EntityManager::renderEntity(const Entity& e) { + const auto& surface = e.getSurface(); + if (surface == nullptr) { return; } + const auto RECT = e.getPixelRect(); + const auto& anim = e.getAnimation(); + const int FRAME = anim.has_value() ? anim->currentFrame() : 0; + const int FRAME_W = static_cast(RECT.w); + const int FRAME_H = static_cast(RECT.h); + const int COLS = (FRAME_W > 0) ? (surface->getWidth() / FRAME_W) : 1; + const int COLS_SAFE = (COLS > 0) ? COLS : 1; + const int COL = FRAME % COLS_SAFE; + const int ROW = FRAME / COLS_SAFE; + const SDL_FRect SRC{ + .x = static_cast(COL * FRAME_W), + .y = static_cast(ROW * FRAME_H), + .w = RECT.w, + .h = RECT.h, + }; + surface->render(static_cast(RECT.x), static_cast(RECT.y), &SRC); +} + void EntityManager::renderAll() { - for (const auto& e : entities_) { - const auto& surface = e->getSurface(); - if (surface == nullptr) { continue; } - const auto RECT = e->getPixelRect(); - const auto& anim = e->getAnimation(); - const int FRAME = anim.has_value() ? anim->currentFrame() : 0; - const int FRAME_W = static_cast(RECT.w); - const int FRAME_H = static_cast(RECT.h); - const int COLS = (FRAME_W > 0) ? (surface->getWidth() / FRAME_W) : 1; - const int COLS_SAFE = (COLS > 0) ? COLS : 1; - const int COL = FRAME % COLS_SAFE; - const int ROW = FRAME / COLS_SAFE; - const SDL_FRect SRC{ - .x = static_cast(COL * FRAME_W), - .y = static_cast(ROW * FRAME_H), - .w = RECT.w, - .h = RECT.h, - }; - surface->render(static_cast(RECT.x), static_cast(RECT.y), &SRC); + for (const auto& e : entities_) { renderEntity(*e); } +} + +void EntityManager::checkPlayerCollisions(const std::vector& players) { + if (players.empty()) { return; } + for (const auto& entity : entities_) { + if (!entity->collides()) { continue; } + const auto BOX = entity->getCollisionRect(); + for (auto* p : players) { + if (p == nullptr) { continue; } + const auto PBOX = p->getBaseBox(); + 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); } + } } } @@ -209,6 +226,17 @@ namespace { }; entity->setPixelRect(RENDER); + // La caixa de col·lisió del sarc és la lògica (48×16 inferior), no el + // rect del sprite sencer: si un jugador passa just per damunt (pels + // 16 px superiors del sprite, que són "alçada" sense cos), no s'ha + // de disparar cap contacte. Offset relatiu al render rect. + entity->setCollisionBox({ + .offset_x = 0, + .offset_y = static_cast(LOGICAL.y - RENDER.y), + .width = static_cast(LOGICAL.w), + .height = static_cast(LOGICAL.h), + }); + // Downcast segur: la factory ens garanteix que "sarcophagus" crea // un `Sarcophagus`. Usant el retorn de `release` al ctor del nou // `unique_ptr` evitem el lint `bugprone-unused-return-value`. diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index a2bddc2..d80c09f 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -151,7 +151,7 @@ void Player::clampToPlayfield() { setPosY(std::clamp(getPosY(), MIN_Y, MAX_Y)); } -auto Player::baseBox() const -> SDL_FRect { +auto Player::getBaseBox() const -> SDL_FRect { return { .x = getPosX() + static_cast(collision_x_offset), .y = getPosY() + static_cast(collision_y_offset), diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index a3f2f1f..d5c2aeb 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -60,10 +60,15 @@ class Player : public AnimatedSprite { // Map::tilemap_origin_*). Retorna (col, row). [[nodiscard]] auto getCurrentTile() const -> std::pair; + // Rectangle de col·lisió del jugador, en coordenades del canvas. Usat + // pel mapa de col·lisions i per les entitats que comproven contacte + // amb el jugador (momies, trampes, porta). + [[nodiscard]] auto getBaseBox() const -> SDL_FRect; + private: - // Rectangle de col·lisió del jugador, usat pel mapa de colisions i com - // a referència per al Z-order. - [[nodiscard]] auto baseBox() const -> SDL_FRect; + // Alias privat històric. Mantingut perquè viu dins de l'implementació + // (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`. + [[nodiscard]] auto baseBox() const -> SDL_FRect { return getBaseBox(); } // Clamp de la caixa de col·lisió al playfield (no al sprite sencer: // el cap pot sobresortir pel mur superior sense afectar el joc). diff --git a/source/game/map/map.cpp b/source/game/map/map.cpp index 62a6060..a10c3ca 100644 --- a/source/game/map/map.cpp +++ b/source/game/map/map.cpp @@ -100,4 +100,8 @@ namespace Map { return false; } + auto insidePlayfield(const SDL_FRect& box) -> bool { + return box.x >= static_cast(playfield_x) && box.y >= static_cast(playfield_y) && box.x + box.w <= static_cast(playfield_x + playfield_w) && box.y + box.h <= static_cast(playfield_y + playfield_h); + } + } // namespace Map diff --git a/source/game/map/map.hpp b/source/game/map/map.hpp index d9fee62..19b8cd0 100644 --- a/source/game/map/map.hpp +++ b/source/game/map/map.hpp @@ -67,4 +67,10 @@ namespace Map { // amb cap sarcòfag. [[nodiscard]] auto collides(const SDL_FRect& base_box) -> bool; + // true si la caixa `box` queda completament dins del playfield (zona + // interior, sense tocar els murs exteriors). El Player ja té clamp + // propi que aplica el mateix constrenyiment; això serveix a les + // entitats dinàmiques per validar posicions candidates del seu step. + [[nodiscard]] auto insidePlayfield(const SDL_FRect& box) -> bool; + } // namespace Map diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 275723f..a9e65b1 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -17,6 +17,7 @@ #include "core/rendering/render_info.hpp" #include "core/rendering/screen.hpp" #include "core/resources/resource_cache.hpp" +#include "game/entities/entity.hpp" #include "game/entities/event_bus.hpp" #include "game/entities/player.hpp" #include "game/map/map.hpp" @@ -123,6 +124,17 @@ void GameScene::update(int delta_ms) { // als sarcòfags via la subscripció del bus, i aquest `update` pot // aplicar la transició OPENING → OPEN en el mateix frame. entity_manager_.update(DT); + + // Detecció de contacte player↔entitat després que totes les entitats + // hagin mogut aquest frame. La porta (si està oberta) emet + // `level_complete`; la momia/trampa emet `player_hit`. Els sarcs, encara + // que tinguen collides=true, tenen la caixa lògica fora del camí del + // jugador (gràcies a Map::collides al Player::update), així que aquí + // no generen falsos contactes. + std::vector player_ptrs; + player_ptrs.reserve(players_.size()); + std::ranges::transform(players_, std::back_inserter(player_ptrs), [](const auto& p) { return p.get(); }); + entity_manager_.checkPlayerCollisions(player_ptrs); RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW)); Notifier::get().update(delta_ms); } @@ -137,25 +149,33 @@ void GameScene::render() { // col·lisions del jugador. tilemap_.render(); - // Entitats (sarcòfags, porta, momia, antorxa...) dibuixades per sobre - // dels tiles però sota dels players. Z-order fi (interleaving per feetY - // amb els sarcs) queda per a una tasca posterior; "sin pulido". - entity_manager_.renderAll(); - - // Amb un sol jugador, l'ordre és irrellevant i ens estalviem el vector - // temporal. Quan n'hi haja més d'un (o quan els sarcs siguen sprites) - // caldrà ordenar per `getFeetY()` ascendent i interleaveing amb les - // files de sarcs — l'ordre ascendent manté els més al fons darrere. - if (players_.size() <= 1) { - for (auto& player : players_) { player->render(); } - } else { - std::vector sorted; - sorted.reserve(players_.size()); - std::ranges::transform(players_, std::back_inserter(sorted), [](const auto& p) { return p.get(); }); - std::ranges::sort(sorted, [](const Player* a, const Player* b) { - return a->getFeetY() < b->getFeetY(); - }); - for (auto* player : sorted) { player->render(); } + // Z-order interleaving entitats ↔ players per `getFeetY()` ascendent: + // els actors amb els peus més amunt queden dibuixats primer (al fons), + // els més avall al davant. Així un sarc situat per darrere d'un player + // queda tapat per ell, i un sarc a davant el tapa a ell. + struct Renderable { + float feet_y; + Entity* entity; // nullable; si és nullptr, s'usa `player`. + Player* player; // nullable; si és nullptr, s'usa `entity`. + }; + std::vector order; + order.reserve(entity_manager_.entityCount() + players_.size()); + 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}; + }); + std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) { + return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()}; + }); + std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) { + return a.feet_y < b.feet_y; + }); + for (const auto& r : order) { + if (r.entity != nullptr) { + EntityManager::renderEntity(*r.entity); + } else if (r.player != nullptr) { + r.player->render(); + } } Notifier::get().render(); diff --git a/tests/test_entities_smoke.cpp b/tests/test_entities_smoke.cpp index f5ca255..f82bbd7 100644 --- a/tests/test_entities_smoke.cpp +++ b/tests/test_entities_smoke.cpp @@ -18,6 +18,8 @@ // retornada coincideix amb les dimensions del bloc // `collision:` del YAML; sense caixa, coincideix amb el // sprite sencer (pixel_rect). +// T13 mummy step — camina en línia recta pel playfield i gira en topar +// un mur (insidePlayfield=false) o sarc (Map::collides). // // Execució (des de la ruta del repo): // make build && cd build && ctest --output-on-failure @@ -376,6 +378,53 @@ namespace { REQUIRE(static_cast(mummy.getFeetY()) == 224); } + void testMummyStep() { + std::cout << "[T13] momia step amb col·lisió i gir\n"; + // Cal Map::loadFrom (T9 ja el carrega, però el rerrumigo ací per + // aïllar la intenció del test). + Map::loadFrom("data/maps/pyramid.yaml"); + + // Momia al corredor exterior superior: playfield_y=48, alçada del + // corredor exterior = outer_corridor (16). Així el base box 12×12 + // queda a y∈[52, 64] aprox. Collision box: offset_y=12 (sprite + // 12×24 amb peus 12×12 a baix). pixel_rect.x/y triat perquè els + // peus quedin al tile centrat (4, 3) del tilemap → peus a + // (col*16-8, row*16+0) i alineats bottom del tile. + fkyaml::node tmpl = EntityLoader::loadTemplate("mummy"); + Mummy mummy(tmpl); + mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12}); + // Posem els peus ben dins d'un corredor horitzontal lliure. El + // corredor superior exterior va de y=48 a y=64 (alçada 16). Amb + // collision_y=12, sprite 12×24 → pixel_rect.y perquè feetY=64. + // feetY = pixel_rect.y + offset_y + h = pixel_rect.y + 24 → pixel_rect.y = 40. + // X: fora d'un sarc; (x=60) és al corredor extern abans dels sarcs. + mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F}); + const auto BEFORE = mummy.getPixelRect(); + + // Velocitat llegida del YAML és 40 px/s. A 0.1s hauria d'avançar + // 4 px en X (direcció inicial right=+1,0). Com que està en un + // corredor lliure, el step s'aplica. + mummy.step(0.1F); + const auto AFTER = mummy.getPixelRect(); + REQUIRE(AFTER.x > BEFORE.x); + REQUIRE(AFTER.y == BEFORE.y); + + // Posem la momia contra la paret dreta del playfield: avançar un + // frame la trauria fora → el step ha de girar. No assumim cap nova + // direcció concreta (perpendiculars lliures poden ser vàlides); el + // contracte és "ja no continua cap a la dreta". + const float RIGHT_WALL = static_cast(Map::playfield_x + Map::playfield_w); + mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F}); + const auto POS_BEFORE = mummy.getPixelRect(); + mummy.step(0.1F); + const auto POS_AFTER = mummy.getPixelRect(); + // No ha avançat cap a la dreta en el frame de gir. + REQUIRE(POS_AFTER.x == POS_BEFORE.x); + REQUIRE(POS_AFTER.y == POS_BEFORE.y); + // I ja no apunta cap a la dreta (ha triat perpendicular o U-turn). + REQUIRE(mummy.getDirX() <= 0.0F); + } + void testTorchAnimation() { std::cout << "[T10] torch animation wired from yaml\n"; // Regressió: el torch.yaml declarava `frames: [0]` i el render no @@ -414,6 +463,7 @@ auto main() -> int { testLoadFromLevel(); testSarcophagusYaml(); testCollisionBox(); + testMummyStep(); testTorchAnimation(); if (g_failures == 0) {