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:
1 parent
e6b612d164
commit
ee8dc25181
17 files changed
+364
-52
No files matched your search
@@ -0,0 +1 @@
|
||||
{"sessionId":"5c7d4a59-4494-40ef-94c8-3a11d7560eff","pid":88259,"acquiredAt":1776794655719}
|
||||
@@ -20,6 +20,7 @@ animations:
|
||||
ai:
|
||||
type: corridor_wanderer
|
||||
direction_choice: random
|
||||
initial_direction: right
|
||||
stats:
|
||||
speed: 40
|
||||
damage: 1
|
||||
+18
-13
@@ -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]
|
||||
+33
-9
@@ -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.
|
||||
@@ -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()); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,12 +33,11 @@
|
||||
// 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::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();
|
||||
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);
|
||||
@@ -53,6 +53,23 @@ void EntityManager::renderAll() {
|
||||
};
|
||||
surface->render(static_cast<int>(RECT.x), static_cast<int>(RECT.y), &SRC);
|
||||
}
|
||||
|
||||
void EntityManager::renderAll() {
|
||||
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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -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`.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -100,4 +100,8 @@ namespace Map {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto insidePlayfield(const SDL_FRect& box) -> bool {
|
||||
return box.x >= static_cast<float>(playfield_x) && box.y >= static_cast<float>(playfield_y) && box.x + box.w <= static_cast<float>(playfield_x + playfield_w) && box.y + box.h <= static_cast<float>(playfield_y + playfield_h);
|
||||
}
|
||||
|
||||
} // namespace Map
|
||||
@@ -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
|
||||
@@ -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*> 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<Player*> 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();
|
||||
// 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<Renderable> 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};
|
||||
});
|
||||
for (auto* player : sorted) { player->render(); }
|
||||
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();
|
||||
|
||||
@@ -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<int>(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<float>(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) {
|
||||
|
||||
Reference in new issue
Block a user