feat(entities): bloc collision i anchor:feet per entitats amb falsa perspectiva
This commit is contained in:
1 parent
1af5ef861f
commit
e6b612d164
7 files changed
+162
-5
No files matched your search
@@ -4,6 +4,13 @@ sprite:
|
|||||||
path: mummy.gif
|
path: mummy.gif
|
||||||
size: [12, 24]
|
size: [12, 24]
|
||||||
tile_size: [1, 1]
|
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:
|
flags:
|
||||||
collides: true
|
collides: true
|
||||||
interactable: false
|
interactable: false
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 828 B After Width: | Height: | Size: 1.8 KiB |
@@ -4,6 +4,22 @@ Registre del disseny, els treballs fets i els pendents per al sistema d'entitats
|
|||||||
|
|
||||||
Estats possibles: `DONE`, `WIP`, `DEFERRED`, `WONTFIX`.
|
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
|
## 1. Context i objectiu inicial
|
||||||
|
|||||||
@@ -26,3 +26,18 @@ void Entity::onPlayerContact(Player* /*player*/) {
|
|||||||
void Entity::notifyTileEntered(int /*col*/, int /*row*/) {
|
void Entity::notifyTileEntered(int /*col*/, int /*row*/) {
|
||||||
// No-op per defecte; només `Sarcophagus` l'usa.
|
// 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;
|
||||||
|
}
|
||||||
@@ -37,6 +37,18 @@ class Entity {
|
|||||||
int row{0};
|
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() = default;
|
||||||
Entity(std::string name, TilePos tile, SDL_FRect pixel_rect);
|
Entity(std::string name, TilePos tile, SDL_FRect pixel_rect);
|
||||||
virtual ~Entity() = default;
|
virtual ~Entity() = default;
|
||||||
@@ -100,11 +112,28 @@ class Entity {
|
|||||||
// Entitats sense animació retornen `nullopt` → frame 0 per defecte.
|
// Entitats sense animació retornen `nullopt` → frame 0 per defecte.
|
||||||
[[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; }
|
[[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:
|
protected:
|
||||||
std::string name_;
|
std::string name_;
|
||||||
TilePos tile_{};
|
TilePos tile_{};
|
||||||
SDL_FRect pixel_rect_{};
|
SDL_FRect pixel_rect_{};
|
||||||
std::optional<AnimationComponent> anim_;
|
std::optional<AnimationComponent> anim_;
|
||||||
|
std::optional<CollisionBox> collision_box_;
|
||||||
std::shared_ptr<Surface> surface_{nullptr};
|
std::shared_ptr<Surface> surface_{nullptr};
|
||||||
bool collides_{false};
|
bool collides_{false};
|
||||||
bool interactable_{false};
|
bool interactable_{false};
|
||||||
|
|||||||
@@ -88,6 +88,41 @@ namespace {
|
|||||||
return info;
|
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
|
// Coordenades píxel (canvas) d'una cel·la (col, row) del tilemap. El
|
||||||
// grid del tilemap està 16-alineat respecte a `tilemap_origin_*`, que
|
// grid del tilemap està 16-alineat respecte a `tilemap_origin_*`, que
|
||||||
// pot ser negatiu per overscan CPC.
|
// 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
|
// 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à
|
// nullptr si la plantilla no es pot carregar o si el tipus no està
|
||||||
// registrat al factory — el loop principal ja imprimeix el missatge.
|
// 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;
|
fkyaml::node tmpl;
|
||||||
try {
|
try {
|
||||||
tmpl = EntityLoader::loadTemplate(type);
|
tmpl = EntityLoader::loadTemplate(type);
|
||||||
@@ -133,6 +171,10 @@ namespace {
|
|||||||
Resource::Cache::get().loadSurface(sprite_out.key, sprite_out.full_path);
|
Resource::Cache::get().loadSurface(sprite_out.key, sprite_out.full_path);
|
||||||
entity->setSurface(Resource::Cache::get().getSurface(sprite_out.key));
|
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;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,14 +222,26 @@ namespace {
|
|||||||
return true;
|
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")) {
|
if (!spawn.contains("position")) {
|
||||||
std::cerr << "EntityManager: " << type << " sense position\n";
|
std::cerr << "EntityManager: " << type << " sense position\n";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const auto& p = spawn["position"];
|
const auto& p = spawn["position"];
|
||||||
const auto PX = tileToPixel(p[0].get_value<int>(), p[1].get_value<int>());
|
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") {
|
if (type == "door") {
|
||||||
auto* door_ptr = dynamic_cast<Door*>(entity.get());
|
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>();
|
const auto TYPE = spawn["type"].get_value<std::string>();
|
||||||
|
|
||||||
SpriteInfo sprite;
|
SpriteInfo sprite;
|
||||||
auto entity = buildEntity(TYPE, sprite);
|
std::string anchor;
|
||||||
|
auto entity = buildEntity(TYPE, sprite, anchor);
|
||||||
if (entity == nullptr) { continue; }
|
if (entity == nullptr) { continue; }
|
||||||
|
|
||||||
const bool OK = (TYPE == "sarcophagus")
|
const bool OK = (TYPE == "sarcophagus")
|
||||||
? placeSarcophagus(*this, std::move(entity), sprite, spawn)
|
? 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; }
|
if (OK) { ++loaded; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
// T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame.
|
// 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:
|
// T11 sarcofag — tile_size [3,1] llegit; perímetre 8; falsa perspectiva:
|
||||||
// el sprite.height no canvia la caixa lògica 48×16.
|
// 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):
|
// Execució (des de la ruta del repo):
|
||||||
// make build && cd build && ctest --output-on-failure
|
// make build && cd build && ctest --output-on-failure
|
||||||
@@ -342,6 +346,36 @@ namespace {
|
|||||||
REQUIRE(sarc->getPerimeterSize() == 8);
|
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() {
|
void testTorchAnimation() {
|
||||||
std::cout << "[T10] torch animation wired from yaml\n";
|
std::cout << "[T10] torch animation wired from yaml\n";
|
||||||
// Regressió: el torch.yaml declarava `frames: [0]` i el render no
|
// Regressió: el torch.yaml declarava `frames: [0]` i el render no
|
||||||
@@ -379,6 +413,7 @@ auto main() -> int {
|
|||||||
testDecorationFlags();
|
testDecorationFlags();
|
||||||
testLoadFromLevel();
|
testLoadFromLevel();
|
||||||
testSarcophagusYaml();
|
testSarcophagusYaml();
|
||||||
|
testCollisionBox();
|
||||||
testTorchAnimation();
|
testTorchAnimation();
|
||||||
|
|
||||||
if (g_failures == 0) {
|
if (g_failures == 0) {
|
||||||
|
|||||||
Reference in new issue
Block a user