Compare commits

...
4 Commits
22 changed files with 661 additions and 67 deletions

No files matched your search

+1
View File
@@ -14,3 +14,4 @@ _aee_2026
.cache/
compile_commands.json
.DS_Store
.claude/
+8
View File
@@ -4,6 +4,13 @@ sprite:
path: mummy.gif
size: [12, 24]
tile_size: [1, 1]
# La momia usa falsa perspectiva: el sprite fa 24 px d'alt però la caixa
# AABB només ocupa els 12×12 de baix (els "peus"). L'spawn `position` del
# map és el tile dels peus: el sprite es centra horitzontalment i la
# seua vora inferior s'alinea amb la vora inferior del tile.
anchor: feet
collision:
size: [12, 12]
flags:
collides: true
interactable: false
@@ -13,6 +20,7 @@ animations:
ai:
type: corridor_wanderer
direction_choice: random
initial_direction: right
stats:
speed: 40
damage: 1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 828 B

After

Width:  |  Height:  |  Size: 1.8 KiB

+18 -13
View File
@@ -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]
+49 -9
View File
@@ -4,6 +4,22 @@ Registre del disseny, els treballs fets i els pendents per al sistema d'entitats
Estats possibles: `DONE`, `WIP`, `DEFERRED`, `WONTFIX`.
## Convenció de col·lisió i anchors (fase 3)
- **`sprite.size`** (YAML entitat): mida en píxels del frame de render.
- **`tile_size`** (YAML entitat): footprint lògic en tiles. Només el
consumeix Sarcophagus per al perímetre; a la resta és declaratiu.
- **`collision: { size: [w, h], offset: [x, y] }`** (opcional al YAML
entitat): sub-rect de col·lisió dins del sprite. Si `offset` falta, es
calcula automàticament bottom-center (falsa perspectiva típica).
Sense bloc, `Entity::getCollisionRect()` = `pixel_rect_` sencer.
- **`anchor`** (opcional al YAML entitat): `top_left` (per defecte) o
`feet`. `feet` vol dir que el `position: [col, row]` del map és el
tile dels peus; el sprite es centra horitzontalment sobre el tile i
s'alinea la vora inferior del sprite amb la vora inferior del tile.
Els sarcòfags tenen bottom-align hardcoded i no usen `anchor`.
---
## 1. Context i objectiu inicial
@@ -169,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 |
@@ -204,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.
+78
View File
@@ -0,0 +1,78 @@
# Animacions i direccionalitat del jugador
Documenta els afegits d'aquesta sessió a `source/game/entities/player.*`: sis
animacions direccionals amb `facing_` persistent, regla de flip només per al
lateral i transició automàtica a `stand` quan la col·lisió bloqueja el
moviment. Tot el canvi és local al `Player`; no s'ha tocat l'EntityManager
ni el bus.
## 1. YAML — sis animacions
El fitxer `data/player/player.yaml` defineix ara sis animacions, agrupades
en tres files de 4 frames sobre `player.gif` (convenció habitual: d'esquerra
a dreta i de dalt a baix):
| Nom | Frames | Descripció |
|---------------|------------|----------------------------|
| `stand_down` | `[0]` | parat mirant cap avall |
| `walk_down` | `[0..3]` | caminant cap avall |
| `stand_up` | `[4]` | parat mirant cap amunt |
| `walk_up` | `[4..7]` | caminant cap amunt |
| `stand` | `[8]` | parat lateral |
| `walk` | `[8..11]` | caminant lateral |
Les laterals (`stand` i `walk`) ja existien; les variants `_up` i `_down`
són noves i permeten distingir visualment la direcció de moviment.
## 2. `Facing` persistent
S'ha afegit l'enum privat `Facing { SIDE, UP, DOWN }` amb el membre
`facing_{SIDE}`. La tria es fa a cada `update`, amb prevalença lateral
per si un dia es permetessin diagonals:
- `vx != 0` → `SIDE` (inclou diagonals).
- `vx == 0 && vy < 0` → `UP`.
- `vx == 0 && vy > 0` → `DOWN`.
- Cap input → es conserva l'últim `facing_` perquè l'animació `stand_*`
coincideixi amb l'última direcció caminada.
## 3. Regla de flip
- `SIDE`: el sprite es flipeja horitzontalment si `vx < 0`, i es restaura
a `FLIP_NONE` si `vx > 0`. El sprite original mira a la dreta.
- `UP` / `DOWN`: el flip es força explícitament a `FLIP_NONE`. Els sprites
frontals/dorsals no s'han d'espillar només perquè l'últim lateral havia
estat cap a l'esquerra.
## 4. `stand` automàtic a la col·lisió
Quan el jugador prem una tecla però la col·lisió (mapa o sarcòfag) li
impedeix avançar, l'animació `walk*` s'atura i es força la variant
`stand_*` corresponent al facing. L'algorisme:
1. Al principi del frame es captura `FRAME_START_X` / `FRAME_START_Y`.
2. S'aplica snap al passadís perpendicular si hi ha pur moviment H o V.
3. `AnimatedSprite::update` integra la velocitat; la col·lisió
eix-separada reverteix cada eix si `Map::collides(baseBox())`.
4. Després de `clampToPlayfield`, es compara la posició final amb el
valor capturat al pas 1. Si és idèntica, `MOVED = false` i es tria
`stand` / `stand_up` / `stand_down` segons `facing_`.
5. Si hi ha hagut moviment real (incloent-hi snap), s'aplica la variant
`walk*` del mateix facing.
D'aquesta manera, xocar contra un mur o un sarcòfag fa que el jugador es
quede immòbil en compte d'animar el caminat "en el lloc". El facing
segueix reflectint la intenció (on vol anar), no el resultat, perquè
l'`stand_up` d'empentar-se contra un mur ha de mostrar el personatge
mirant amunt.
## 5. Estat
- DONE: sis animacions al YAML, `facing_` persistent, flip condicionat,
`stand` automàtic a la col·lisió.
- Build i smoke tests (14/14) verdes.
- Sense smoke test dedicat: la lògica està entrellaçada amb teclat i
`Map::collides`; l'smoke actual de `Player` no cobreix teclat simulat.
- Pendent (ajornat): si algun dia es vol feedback auditiu de wall-bump
(so en xocar), la condició natural és `!MOVED && (vx != 0 || vy != 0)`
al final d'`update`.
+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;
};
+15
View File
@@ -26,3 +26,18 @@ void Entity::onPlayerContact(Player* /*player*/) {
void Entity::notifyTileEntered(int /*col*/, int /*row*/) {
// No-op per defecte; només `Sarcophagus` l'usa.
}
auto Entity::getCollisionRect() const -> SDL_FRect {
if (!collision_box_.has_value()) { return pixel_rect_; }
return {
.x = pixel_rect_.x + static_cast<float>(collision_box_->offset_x),
.y = pixel_rect_.y + static_cast<float>(collision_box_->offset_y),
.w = static_cast<float>(collision_box_->width),
.h = static_cast<float>(collision_box_->height),
};
}
auto Entity::getFeetY() const -> float {
const auto RECT = getCollisionRect();
return RECT.y + RECT.h;
}
+29
View File
@@ -37,6 +37,18 @@ class Entity {
int row{0};
};
// Sub-rectangle de col·lisió dins de `pixel_rect_`. Pensat per a
// personatges amb falsa perspectiva (sprite alt, caixa AABB petita
// encastada a baix) — mateixa convenció que el Player. `offset_x/y`
// són relatius a la cantonada superior-esquerra del sprite. Si l'entitat
// no té `CollisionBox`, `getCollisionRect()` retorna tot el `pixel_rect_`.
struct CollisionBox {
int offset_x{0};
int offset_y{0};
int width{0};
int height{0};
};
Entity() = default;
Entity(std::string name, TilePos tile, SDL_FRect pixel_rect);
virtual ~Entity() = default;
@@ -100,11 +112,28 @@ class Entity {
// Entitats sense animació retornen `nullopt` → frame 0 per defecte.
[[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; }
// Caixa de col·lisió: per defecte és el `pixel_rect_` sencer (entitats
// AABB-aligned). Si s'ha configurat un `CollisionBox`, retorna el
// sub-rect dins del sprite — vida útil: validar col·lisions amb el
// jugador o el mapa sense que la caixa inclogui "l'alçada" del sprite.
[[nodiscard]] auto getCollisionRect() const -> SDL_FRect;
// Y dels peus: la vora inferior de la caixa de col·lisió. La usarà el
// Z-order per ordenar entitats i jugador de menys a més profunditat
// (REND-01, quan s'implemente).
[[nodiscard]] auto getFeetY() const -> float;
// El loader li assigna una caixa explícita llegida del YAML; sense
// aquesta crida, l'entitat queda sense `CollisionBox` i la caixa de
// col·lisió coincideix amb el sprite.
void setCollisionBox(CollisionBox box) { collision_box_ = box; }
protected:
std::string name_;
TilePos tile_{};
SDL_FRect pixel_rect_{};
std::optional<AnimationComponent> anim_;
std::optional<CollisionBox> collision_box_;
std::shared_ptr<Surface> surface_{nullptr};
bool collides_{false};
bool interactable_{false};
+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,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 {
@@ -88,6 +105,41 @@ namespace {
return info;
}
// Bloc `collision: { size: [w, h], offset: [x, y] }` de la plantilla
// d'entitat. `offset` és opcional: si no apareix, s'entén "la caixa
// queda bottom-center del sprite" (convenció de falsa perspectiva).
// Retorna nullopt si no hi ha bloc, i l'entitat es queda sense caixa
// explícita (getCollisionRect torna tot el sprite).
auto readCollisionBox(const fkyaml::node& tmpl, float sprite_w, float sprite_h) -> std::optional<Entity::CollisionBox> {
if (!tmpl.contains("collision")) { return std::nullopt; }
const auto& c = tmpl["collision"];
if (!c.contains("size")) { return std::nullopt; }
const auto& sz = c["size"];
if (!sz.is_sequence() || sz.size() != 2) { return std::nullopt; }
const int W = sz[0].get_value<int>();
const int H = sz[1].get_value<int>();
int off_x = static_cast<int>((sprite_w - static_cast<float>(W)) / 2.0F);
int off_y = static_cast<int>(sprite_h - static_cast<float>(H));
if (c.contains("offset")) {
const auto& o = c["offset"];
if (o.is_sequence() && o.size() == 2) {
off_x = o[0].get_value<int>();
off_y = o[1].get_value<int>();
}
}
return Entity::CollisionBox{.offset_x = off_x, .offset_y = off_y, .width = W, .height = H};
}
// Ancoratge d'spawn. "feet" vol dir que el `position: [col, row]` del
// map és el tile dels peus: el sprite es centra horitzontalment dins
// del tile i s'alinea la seua vora inferior amb la vora inferior del
// tile. "top_left" (per defecte) posa el sprite amb el cantonal
// superior-esquerre a la cantonada superior-esquerra del tile.
auto readAnchor(const fkyaml::node& tmpl) -> std::string {
if (!tmpl.contains("anchor")) { return "top_left"; }
return tmpl["anchor"].get_value<std::string>();
}
// 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.
@@ -115,7 +167,10 @@ namespace {
// Crea l'entitat per factory i li adjunta la surface si n'hi ha. Retorna
// nullptr si la plantilla no es pot carregar o si el tipus no està
// registrat al factory — el loop principal ja imprimeix el missatge.
auto buildEntity(const std::string& type, SpriteInfo& sprite_out) -> std::unique_ptr<Entity> {
// També llegeix el bloc `collision:` de la plantilla (si existeix) i
// el bota a l'entitat, i retorna l'`anchor` per a que el loader sàpiga
// com col·locar el sprite a l'spawn ("feet" o "top_left").
auto buildEntity(const std::string& type, SpriteInfo& sprite_out, std::string& anchor_out) -> std::unique_ptr<Entity> {
fkyaml::node tmpl;
try {
tmpl = EntityLoader::loadTemplate(type);
@@ -133,6 +188,10 @@ namespace {
Resource::Cache::get().loadSurface(sprite_out.key, sprite_out.full_path);
entity->setSurface(Resource::Cache::get().getSurface(sprite_out.key));
}
if (const auto BOX = readCollisionBox(tmpl, sprite_out.width, sprite_out.height); BOX.has_value()) {
entity->setCollisionBox(*BOX);
}
anchor_out = readAnchor(tmpl);
return entity;
}
@@ -167,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`.
@@ -180,14 +250,26 @@ namespace {
return true;
}
auto placeGeneric(EntityManager& manager, std::unique_ptr<Entity> entity, const std::string& type, const SpriteInfo& sprite, const fkyaml::node& spawn) -> bool {
auto placeGeneric(EntityManager& manager, std::unique_ptr<Entity> entity, const std::string& type, const SpriteInfo& sprite, const std::string& anchor, const fkyaml::node& spawn) -> bool {
if (!spawn.contains("position")) {
std::cerr << "EntityManager: " << type << " sense position\n";
return false;
}
const auto& p = spawn["position"];
const auto PX = tileToPixel(p[0].get_value<int>(), p[1].get_value<int>());
entity->setPixelRect({.x = PX.x, .y = PX.y, .w = sprite.width, .h = sprite.height});
// Anchor "feet": el tile [col,row] és el tile dels peus. El sprite
// es centra horitzontalment sobre el tile i la seua vora inferior
// s'alinea amb la vora inferior del tile. Els píxels que sobresurten
// (alçada del sprite per damunt del tile dels peus) queden "per
// sobre" sense afectar cap col·lisió — falsa perspectiva.
float rect_x = PX.x;
float rect_y = PX.y;
if (anchor == "feet") {
rect_x = PX.x + (static_cast<float>(Map::tile_size) - sprite.width) / 2.0F;
rect_y = PX.y + static_cast<float>(Map::tile_size) - sprite.height;
}
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
if (type == "door") {
auto* door_ptr = dynamic_cast<Door*>(entity.get());
@@ -209,12 +291,13 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
const auto TYPE = spawn["type"].get_value<std::string>();
SpriteInfo sprite;
auto entity = buildEntity(TYPE, sprite);
std::string anchor;
auto entity = buildEntity(TYPE, sprite, anchor);
if (entity == nullptr) { continue; }
const bool OK = (TYPE == "sarcophagus")
? placeSarcophagus(*this, std::move(entity), sprite, spawn)
: placeGeneric(*this, std::move(entity), TYPE, sprite, spawn);
: placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn);
if (OK) { ++loaded; }
}
+42 -10
View File
@@ -66,24 +66,38 @@ void Player::update(float delta_time) {
setVelX(vx);
setVelY(vy);
// El sprite original mira a la dreta; flipem només si es mou a l'esquerra
// i restaurem si torna a la dreta. Moviments purament verticals conserven
// l'últim flip perquè el jugador no "gire" sense motiu.
if (vx < 0.0F) {
setFlip(SDL_FLIP_HORIZONTAL);
} else if (vx > 0.0F) {
// Tria de facing amb prevalença lateral: si hi ha component horitzontal
// (inclòs el diagonal), l'animació és lateral i el flip segueix el signe
// de `vx`. Només en moviment purament vertical passem a UP/DOWN, i en
// aquest cas el flip es força a NONE perquè els sprites frontals no
// s'han d'espillar segons l'últim lateral. En aturar-se conservem
// `facing_` per a l'animació stand corresponent.
if (vx != 0.0F) {
facing_ = Facing::SIDE;
setFlip(vx < 0.0F ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE);
} else if (vy < 0.0F) {
facing_ = Facing::UP;
setFlip(SDL_FLIP_NONE);
} else if (vy > 0.0F) {
facing_ = Facing::DOWN;
setFlip(SDL_FLIP_NONE);
}
const bool MOVING = vx != 0.0F || vy != 0.0F;
setCurrentAnimation(MOVING ? "walk" : "stand");
// Snap al passadís: si només hi ha moviment horitzontal (o vertical) i
// la caixa està prou prop d'una lane perpendicular (≤ mitja caixa), la
// Y (o X) es corregeix gradualment per enfilar el passadís. Amb
// moviment diagonal no es fa snap — l'usuari pilota manualment.
const bool ONLY_H = (vx != 0.0F) && (vy == 0.0F);
const bool ONLY_V = (vy != 0.0F) && (vx == 0.0F);
// Pos d'inici de frame (abans de snap i integració): si en acabar el
// frame coincideix amb la posició final, el jugador no s'ha mogut
// realment (ni per input, ni per snap, ni ha superat la col·lisió) i cal
// forçar l'animació `stand` corresponent al facing — així s'atura el
// caminat quan xoca contra un mur o un sarcòfag.
const float FRAME_START_X = getPosX();
const float FRAME_START_Y = getPosY();
if (ONLY_H) {
snapToHorizontalLane(delta_time);
} else if (ONLY_V) {
@@ -109,6 +123,12 @@ void Player::update(float delta_time) {
clampToPlayfield();
// Animació basada en moviment real: si la col·lisió (o l'absència
// d'input) ha deixat el jugador al mateix lloc que al principi del
// frame, queda en `stand` del facing actual.
const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y);
setCurrentAnimation(animationName(MOVED));
// Detecció de canvi de tile. Es calcula *després* del clamp i de les
// col·lisions perquè la posició aquí ja és la definitiva del frame;
// així els sarcòfags no reben falsos positius mentre el Player rellisca
@@ -124,6 +144,18 @@ void Player::update(float delta_time) {
}
}
auto Player::animationName(bool moving) const -> const char* {
switch (facing_) {
case Facing::UP:
return moving ? "walk_up" : "stand_up";
case Facing::DOWN:
return moving ? "walk_down" : "stand_down";
case Facing::SIDE:
break;
}
return moving ? "walk" : "stand";
}
auto Player::getCurrentTile() const -> std::pair<int, int> {
// El grid del tilemap és de `Map::tile_size` píxels i està 16-alineat
// respecte a l'origen del tilemap (pot ser negatiu per overscan). El
@@ -151,7 +183,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),
+23 -3
View File
@@ -2,6 +2,7 @@
#include <SDL3/SDL.h>
#include <cstdint>
#include <string>
#include <utility>
@@ -60,10 +61,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).
@@ -76,6 +82,10 @@ class Player : public AnimatedSprite {
// wrappers deleguen a `snapToLane` només canviant l'eix actiu.
void snapToHorizontalLane(float delta_time);
void snapToVerticalLane(float delta_time);
// Nom d'animació per al `facing_` actual. Si `moving` és cert s'usa
// la variant `walk*`, altrament la variant `stand*`.
[[nodiscard]] auto animationName(bool moving) const -> const char*;
void snapToLane(
float delta_time,
float base,
@@ -86,6 +96,16 @@ class Player : public AnimatedSprite {
Controls controls_;
// Direcció "cap on mira" el jugador. Governa quin conjunt d'animacions
// s'aplica (lateral, amunt, avall) i si el sprite es flipeja o no. Els
// moviments purament horitzontals o diagonals prenen `SIDE` (prevalença
// lateral), els purament verticals fan `UP`/`DOWN`. En aturar-se es
// preserva l'última orientació perquè la versió stand coincideixi.
enum class Facing : std::uint8_t { SIDE,
UP,
DOWN };
Facing facing_{Facing::SIDE};
// Bus del nivell (opcional). Propietat externa (EntityManager); el
// Player només en guarda un punter per publicar events.
EventBus* bus_{nullptr};
+4
View File
@@ -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
+6
View File
@@ -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
+38 -18
View File
@@ -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();
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.15.0"; // Single source of truth — Makefile el grep
constexpr const char* VERSION = "0.18.0"; // Single source of truth — Makefile el grep
} // namespace Texts
namespace GameCanvas {
+85
View File
@@ -14,6 +14,12 @@
// T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame.
// T11 sarcofag — tile_size [3,1] llegit; perímetre 8; falsa perspectiva:
// el sprite.height no canvia la caixa lògica 48×16.
// T12 collision — `Entity::CollisionBox` encastada al sprite: la caixa
// 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
@@ -342,6 +348,83 @@ namespace {
REQUIRE(sarc->getPerimeterSize() == 8);
}
void testCollisionBox() {
std::cout << "[T12] collision box encastada al sprite\n";
// Torch sense bloc `collision:` → getCollisionRect == pixel_rect.
fkyaml::node torch_tmpl = EntityLoader::loadTemplate("torch");
Torch torch(torch_tmpl);
torch.setPixelRect({.x = 100.0F, .y = 200.0F, .w = 16.0F, .h = 16.0F});
const auto TRECT = torch.getCollisionRect();
REQUIRE(static_cast<int>(TRECT.w) == 16);
REQUIRE(static_cast<int>(TRECT.h) == 16);
REQUIRE(static_cast<int>(TRECT.x) == 100);
REQUIRE(static_cast<int>(TRECT.y) == 200);
// Mummy amb `collision: { size: [12, 12] }` i sprite 12×24 → caixa
// auto-centrada bottom dels peus: offset_x = (12-12)/2 = 0,
// offset_y = 24 - 12 = 12. Amb pixel_rect a (100, 200, 12, 24),
// la caixa ha de sortir a (100, 212, 12, 12) i feet_y = 224.
fkyaml::node mummy_tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(mummy_tmpl);
// Apliquem el CollisionBox com fa el loader (readCollisionBox)
// amb sprite 12×24, offset calculat per defecte.
mummy.setCollisionBox({.offset_x = 0, .offset_y = 12, .width = 12, .height = 12});
mummy.setPixelRect({.x = 100.0F, .y = 200.0F, .w = 12.0F, .h = 24.0F});
const auto MRECT = mummy.getCollisionRect();
REQUIRE(static_cast<int>(MRECT.x) == 100);
REQUIRE(static_cast<int>(MRECT.y) == 212);
REQUIRE(static_cast<int>(MRECT.w) == 12);
REQUIRE(static_cast<int>(MRECT.h) == 12);
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
@@ -379,6 +462,8 @@ auto main() -> int {
testDecorationFlags();
testLoadFromLevel();
testSarcophagusYaml();
testCollisionBox();
testMummyStep();
testTorchAnimation();
if (g_failures == 0) {