feat(entities): bloc collision i anchor:feet per entitats amb falsa perspectiva

This commit is contained in:
JailDesigner committed 2026-04-21 19:39:02 +02:00
1 parent 1af5ef861f
commit e6b612d164
7 files changed
+162 -5

No files matched your search

+7
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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 828 B

After

Width:  |  Height:  |  Size: 1.8 KiB

+16
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
+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};
@@ -88,6 +88,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 +150,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 +171,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;
}
@@ -180,14 +222,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 +263,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; }
}
+35
View File
@@ -14,6 +14,10 @@
// 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).
//
// Execució (des de la ruta del repo):
// make build && cd build && ctest --output-on-failure
@@ -342,6 +346,36 @@ 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 testTorchAnimation() {
std::cout << "[T10] torch animation wired from yaml\n";
// Regressió: el torch.yaml declarava `frames: [0]` i el render no
@@ -379,6 +413,7 @@ auto main() -> int {
testDecorationFlags();
testLoadFromLevel();
testSarcophagusYaml();
testCollisionBox();
testTorchAnimation();
if (g_failures == 0) {