feat(entities): fase 4 - moviment de la momia amb col·lisió, z-order per peus i contacte amb el jugador

This commit is contained in:
JailDesigner committed 2026-04-21 20:14:47 +02:00
1 parent e6b612d164
commit ee8dc25181
17 files changed
+378 -66

No files matched your search

+102
View File
@@ -1,10 +1,13 @@
#include "game/entities/concrete/mummy.hpp"
#include <array>
#include <random>
#include <string>
#include <utility>
#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<float>()); }
if (s.contains("damage")) { setDamage(s["damage"].get_value<int>()); }
}
// 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<std::string>();
}
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<Direction>& allowed) -> Direction {
@@ -38,6 +59,87 @@ auto Mummy::chooseDirection(const std::vector<Direction>& 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<Candidate, 4> 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<Direction> perp_allowed;
std::vector<Direction> 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()); }
}
+11
View File
@@ -31,6 +31,17 @@ class Mummy : public DynamicHazard {
// aleatoria; si `allowed` està buit, retorna `NONE`.
virtual auto chooseDirection(const std::vector<Direction>& 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;
};
@@ -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()); }
}
@@ -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;
};
+9
View File
@@ -1,5 +1,7 @@
#include "game/entities/entity_manager.hpp"
#include <algorithm>
#include <iterator>
#include <utility>
#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<Entity*> {
std::vector<Entity*> 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
+23 -2
View File
@@ -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<Entity*>;
// 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<Player*>& 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).
@@ -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<int>(RECT.w);
const int FRAME_H = static_cast<int>(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<float>(COL * FRAME_W),
.y = static_cast<float>(ROW * FRAME_H),
.w = RECT.w,
.h = RECT.h,
};
surface->render(static_cast<int>(RECT.x), static_cast<int>(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<int>(RECT.w);
const int FRAME_H = static_cast<int>(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<float>(COL * FRAME_W),
.y = static_cast<float>(ROW * FRAME_H),
.w = RECT.w,
.h = RECT.h,
};
surface->render(static_cast<int>(RECT.x), static_cast<int>(RECT.y), &SRC);
for (const auto& e : entities_) { renderEntity(*e); }
}
void EntityManager::checkPlayerCollisions(const std::vector<Player*>& 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<int>(LOGICAL.y - RENDER.y),
.width = static_cast<int>(LOGICAL.w),
.height = static_cast<int>(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`.
+1 -1
View File
@@ -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<float>(collision_x_offset),
.y = getPosY() + static_cast<float>(collision_y_offset),
+8 -3
View File
@@ -60,10 +60,15 @@ class Player : public AnimatedSprite {
// Map::tilemap_origin_*). Retorna (col, row).
[[nodiscard]] auto getCurrentTile() const -> std::pair<int, int>;
// 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).