feat(entities): afegeix stone i column com a muros decoratius

This commit is contained in:
JailDesigner committed 2026-05-12 13:03:35 +02:00
1 parent 1c49248b72
commit 6749780356
18 files changed
+448 -36

No files matched your search

+171 -7
View File
@@ -176,6 +176,23 @@ namespace {
return {PX, PY};
}
// Compta sub-sprites del atlas i decideix si el template demana
// sub-picker de frames. Extret de `ensureEditableEntities` per acotar
// la complexitat cognitiva del callsite. Per a strips d'1 frame
// (la majoria del catàleg) retorna (1, false).
struct AtlasVariants {
int frame_count;
bool has_variants;
};
auto computeAtlasVariants(const Surface& surface, int frame_w, int frame_h, const fkyaml::node& tmpl) -> AtlasVariants {
const int COLS = (frame_w > 0) ? (surface.getWidth() / frame_w) : 1;
const int ROWS = (frame_h > 0) ? (surface.getHeight() / frame_h) : 1;
const int FRAME_COUNT = std::max(1, COLS * ROWS);
const bool FLAG = tmpl.contains("editable_variants") && tmpl["editable_variants"].get_value<bool>();
return {.frame_count = FRAME_COUNT, .has_variants = FLAG && FRAME_COUNT > 1};
}
} // namespace
void Editor::enter() {
@@ -190,6 +207,10 @@ void Editor::enter() {
picker_ = Picker::NONE;
brush_tile_ = -1;
brush_entity_.clear();
brush_entity_frame_ = -1;
entity_frame_picker_source_ = nullptr;
entity_frame_picker_hover_ = -1;
entity_frame_picker_scroll_ = 0;
hand_selected_idx_ = -1;
hand_hover_idx_ = -1;
ensureEditableEntities();
@@ -256,7 +277,15 @@ void Editor::ensureEditableEntities() {
}
if (surface == nullptr) { continue; }
editable_entities_.push_back({.name = name, .sprite = surface, .frame_w = FRAME_W, .frame_h = FRAME_H});
const auto [FRAME_COUNT, HAS_VARIANTS] = computeAtlasVariants(*surface, FRAME_W, FRAME_H, tmpl);
editable_entities_.push_back({
.name = name,
.sprite = surface,
.frame_w = FRAME_W,
.frame_h = FRAME_H,
.frame_count = FRAME_COUNT,
.has_variants = HAS_VARIANTS,
});
}
}
@@ -301,6 +330,13 @@ namespace {
s.col = (static_cast<int>(R.x) - Map::tilemap_origin_x) / Map::tile_size;
s.row = (static_cast<int>(R.y) - Map::tilemap_origin_y) / Map::tile_size;
}
// Variant del atlas: si l'entitat té un AnimationComponent
// d'un sol frame, és una entitat amb variant escollida
// (debris, columna). Persistim l'índex perquè la pròxima
// càrrega restauri la mateixa cara del sub-sprite.
if (const auto& anim = e->getAnimation(); anim.has_value() && anim->frameCount() == 1) {
s.frame = anim->currentFrame();
}
}
out.push_back(std::move(s));
}
@@ -374,6 +410,7 @@ auto Editor::applyHotkey(int scancode) -> bool {
// tracte per a la seva acció de confirmar-eixida.
if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) {
picker_ = Picker::NONE;
entity_frame_picker_source_ = nullptr;
return true;
}
return false;
@@ -426,6 +463,8 @@ void Editor::update(int /*delta_ms*/, Tilemap& tilemap, EntityManager& entities)
updateTilePicker(tilemap, CX, CY);
} else if (picker_ == Picker::ENTITY) {
updateEntityPicker(CX, CY);
} else if (picker_ == Picker::ENTITY_FRAME) {
updateEntityFramePicker(CX, CY);
} else {
switch (mode_) {
case Mode::TILE:
@@ -514,7 +553,9 @@ namespace {
// Composa el spawn YAML per al brush actual. Retorna empty si el snap
// no és vàlid (fora de rejilla, solapament, o duplicat de porta).
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing) -> std::string {
// `frame` (-1 = no emetre) afegeix el camp `frame: N` per a entitats
// amb variants estàtiques (debris, columna).
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing, int frame) -> std::string {
std::ostringstream y;
y << "type: " << brush << '\n';
if (brush == "sarcophagus") {
@@ -530,6 +571,7 @@ namespace {
const auto DEST = tileCellRect(cursor_col, cursor_row, Map::tile_size);
if (cellOccupied(existing, DEST)) { return {}; }
y << "position: [" << cursor_col << ", " << cursor_row << "]\n";
if (frame >= 0) { y << "frame: " << frame << '\n'; }
return y.str();
}
@@ -546,6 +588,7 @@ void Editor::updateEntityBrush(EntityManager& entities, int /*canvas_x*/, int /*
// H (mà): manté la simetria amb el comportament del brush
// de tiles.
brush_entity_.clear();
brush_entity_frame_ = -1;
}
}
}
@@ -665,7 +708,14 @@ auto Editor::tryMoveSelected(const Tilemap& tilemap, EntityManager& entities) co
filtered.reserve(list.size() - 1);
std::ranges::copy_if(list, std::back_inserter(filtered), [target](const Entity* e) { return e != target; });
const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered);
// Si l'entitat moguda té una variant fixada (AnimationComponent d'un
// sol frame, convenció per a debris/columnes), la preservem al spawn
// reconstruït — així moure no perd la cara escollida.
int frame = -1;
if (const auto& anim = target->getAnimation(); anim.has_value() && anim->frameCount() == 1) {
frame = anim->currentFrame();
}
const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered, frame);
if (STR.empty()) { return false; }
if (!entities.removeEntity(target)) { return false; }
try {
@@ -806,7 +856,7 @@ void Editor::renderEntityFrame(const Entity& e, std::uint32_t argb) {
auto Editor::tryPlaceEntity(EntityManager& entities) -> bool {
if (brush_entity_.empty() || cursor_col_ < 0 || cursor_row_ < 0) { return false; }
const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities());
const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities(), brush_entity_frame_);
if (STR.empty()) { return false; }
try {
auto node = fkyaml::node::deserialize(STR.begin(), STR.end());
@@ -840,9 +890,21 @@ void Editor::updateEntityPicker(int canvas_x, int canvas_y) {
entity_picker_scroll_ = std::clamp(entity_picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (entity_picker_hover_ >= 0 && entity_picker_hover_ < static_cast<int>(editable_entities_.size())) {
brush_entity_ = editable_entities_[entity_picker_hover_].name;
mode_ = Mode::ENTITY;
picker_ = Picker::NONE;
const auto& sel = editable_entities_[entity_picker_hover_];
if (sel.has_variants) {
// Entitat amb variants: encara no fixem brush. Obrim
// el sub-picker de frames; el brush es fixarà només
// quan l'usuari trii un frame concret allà.
entity_frame_picker_source_ = &sel;
entity_frame_picker_scroll_ = 0;
entity_frame_picker_hover_ = -1;
picker_ = Picker::ENTITY_FRAME;
} else {
brush_entity_ = sel.name;
brush_entity_frame_ = -1;
mode_ = Mode::ENTITY;
picker_ = Picker::NONE;
}
}
}
}
@@ -896,6 +958,106 @@ void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const {
screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER);
}
void Editor::updateEntityFramePicker(int canvas_x, int canvas_y) {
// Si la font ha desaparegut (no hauria de passar mentre el picker
// estigui obert, però defensem el cas), tanquem el sub-picker en lloc
// de fer crash.
if (entity_frame_picker_source_ == nullptr) {
picker_ = Picker::ENTITY;
return;
}
const auto LAY = layoutEntityPicker(entity_frame_picker_source_->frame_count);
entity_frame_picker_hover_ = -1;
const int INNER_X = LAY.x + PICKER_BORDER;
const int INNER_Y = LAY.y + PICKER_BORDER;
const int RX = canvas_x - INNER_X;
const int RY = canvas_y - INNER_Y;
if (RX >= 0 && RY >= 0 && LAY.pitch > 0 && LAY.cols > 0) {
const int VC = RX / LAY.pitch;
const int VR = RY / LAY.pitch;
const bool IN_SPACING_X = (RX % LAY.pitch) >= ENTITY_CELL;
const bool IN_SPACING_Y = (RY % LAY.pitch) >= ENTITY_CELL;
if (!IN_SPACING_X && !IN_SPACING_Y && VC < LAY.cols && VR < LAY.rows_visible) {
const int IDX = ((entity_frame_picker_scroll_ + VR) * LAY.cols) + VC;
if (IDX < LAY.total_items) { entity_frame_picker_hover_ = IDX; }
}
}
for (const auto& ev : EventBuffer::events) {
if (ev.type == SDL_EVENT_MOUSE_WHEEL) {
entity_frame_picker_scroll_ -= static_cast<int>(ev.wheel.y);
entity_frame_picker_scroll_ = std::clamp(entity_frame_picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (entity_frame_picker_hover_ >= 0) {
brush_entity_ = entity_frame_picker_source_->name;
brush_entity_frame_ = entity_frame_picker_hover_;
mode_ = Mode::ENTITY;
picker_ = Picker::NONE;
}
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_RIGHT) {
// Botó dret: tanca el sub-picker i torna al picker d'entitats
// sense fixar brush. Coherent amb la convenció de "right =
// cancel·la l'acció actual" dels altres modes.
picker_ = Picker::ENTITY;
entity_frame_picker_source_ = nullptr;
}
}
}
void Editor::renderEntityFramePicker() const {
if (entity_frame_picker_source_ == nullptr) { return; }
const auto& src = *entity_frame_picker_source_;
const auto LAY = layoutEntityPicker(src.frame_count);
if (LAY.cols <= 0 || LAY.rows_visible <= 0) { return; }
auto& screen = Screen::get();
const auto& cfg = EditorConfig::get();
screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, cfg.pickerBgArgb());
// Cols del atlas (sub-sprites per fila): es dedueix de la mida del
// surface i del frame, igual que fa `EntityManager::renderEntity`.
const int ATLAS_COLS = (src.frame_w > 0) ? (src.sprite->getWidth() / src.frame_w) : 1;
const int ATLAS_COLS_SAFE = (ATLAS_COLS > 0) ? ATLAS_COLS : 1;
for (int vr = 0; vr < LAY.rows_visible; ++vr) {
const int LOGICAL_ROW = entity_frame_picker_scroll_ + vr;
for (int vc = 0; vc < LAY.cols; ++vc) {
const int IDX = (LOGICAL_ROW * LAY.cols) + vc;
if (IDX >= LAY.total_items) { break; }
const auto [PX, PY] = entityCellOrigin(LAY, vc, vr);
const int OX = PX + ((ENTITY_CELL - src.frame_w) / 2);
const int OY = PY + ((ENTITY_CELL - src.frame_h) / 2);
const int A_COL = IDX % ATLAS_COLS_SAFE;
const int A_ROW = IDX / ATLAS_COLS_SAFE;
const SDL_FRect SRC{
.x = static_cast<float>(A_COL * src.frame_w),
.y = static_cast<float>(A_ROW * src.frame_h),
.w = static_cast<float>(src.frame_w),
.h = static_cast<float>(src.frame_h),
};
src.sprite->render(OX, OY, &SRC, SDL_FLIP_NONE);
}
}
if (entity_frame_picker_hover_ >= 0) {
const int VR = (entity_frame_picker_hover_ / LAY.cols) - entity_frame_picker_scroll_;
const int VC = entity_frame_picker_hover_ % LAY.cols;
if (VR >= 0 && VR < LAY.rows_visible) {
const auto [PX, PY] = entityCellOrigin(LAY, VC, VR);
const auto C = cfg.pickerHoverArgb();
screen.fillRect(PX, PY, ENTITY_CELL, 1, C);
screen.fillRect(PX, PY + ENTITY_CELL - 1, ENTITY_CELL, 1, C);
screen.fillRect(PX, PY, 1, ENTITY_CELL, C);
screen.fillRect(PX + ENTITY_CELL - 1, PY, 1, ENTITY_CELL, C);
}
}
const auto BORDER = cfg.borderArgb();
screen.fillRect(LAY.x, LAY.y, LAY.w, 1, BORDER);
screen.fillRect(LAY.x, LAY.y + LAY.h - 1, LAY.w, 1, BORDER);
screen.fillRect(LAY.x, LAY.y, 1, LAY.h, BORDER);
screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER);
}
void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
const auto LAY = layoutPicker(tilemap);
tile_picker_hover_ = -1;
@@ -940,6 +1102,8 @@ void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const
renderTilePicker(tilemap);
} else if (picker_ == Picker::ENTITY) {
renderEntityPicker(tilemap);
} else if (picker_ == Picker::ENTITY_FRAME) {
renderEntityFramePicker();
} else if (mode_ == Mode::TILE && brush_tile_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
// Preview del brush sota el cursor (només quan el picker és tancat).
const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize());
+31 -7
View File
@@ -78,9 +78,13 @@ class Editor {
HAND };
// Quin picker està obert (si n'hi ha). Mútuament excloents.
// - ENTITY_FRAME: sub-picker que apareix després de clicar una entitat
// amb variants al picker principal. Mostra tots els sub-sprites del
// atlas (índex frame) per a triar quina cara representa la instància.
enum class Picker : std::uint8_t { NONE,
TILE,
ENTITY };
ENTITY,
ENTITY_FRAME };
// Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`.
// Es crida al principi d'`update` perquè altres handlers no vegin
@@ -100,6 +104,7 @@ class Editor {
void updateHand(const Tilemap& tilemap, EntityManager& entities, int canvas_x, int canvas_y);
void updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y);
void updateEntityPicker(int canvas_x, int canvas_y);
void updateEntityFramePicker(int canvas_x, int canvas_y);
// Intent de col·locació segons brush_entity_ a la cel·la del cursor.
// Construeix un node spawn sintètic, fa el snap concret per tipus
@@ -138,6 +143,7 @@ class Editor {
void renderTilePicker(const Tilemap& tilemap) const;
void renderEntityPicker(const Tilemap& tilemap) const;
void renderEntityFramePicker() const;
bool active_{false};
Mode mode_{Mode::TILE};
@@ -153,21 +159,22 @@ class Editor {
int tile_picker_scroll_{0};
int tile_picker_hover_{-1};
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
std::string brush_entity_;
// Estat de l'EntityPicker.
int entity_picker_scroll_{0};
int entity_picker_hover_{-1}; // Índex a la llista de noms.
// Entrada editable del picker. Cada item recorda el nom (clau de
// factory + YAML), el surface del sprite (frame 0 com a icona) i la
// mida de cada frame al GIF — necessària per clipar el primer frame
// quan el GIF és un strip multi-frame.
// - `frame_count` és el nombre total de sub-sprites del atlas
// (cols × rows segons les dimensions del surface i del frame).
// - `has_variants` és `true` quan el template declara `editable_variants:
// true` i hi ha més d'un frame: aleshores clicar al picker obre el
// sub-picker de frames en lloc de fixar el brush directament.
struct EditableEntity {
std::string name;
std::shared_ptr<Surface> sprite;
int frame_w{0};
int frame_h{0};
int frame_count{1};
bool has_variants{false};
};
// Construïda lazy al primer enter() i preservada entre sessions:
@@ -178,6 +185,23 @@ class Editor {
std::vector<EditableEntity> editable_entities_;
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
std::string brush_entity_;
// Frame escollit per al brush actiu (-1 = no aplicable: entitat sense
// variants o brush encara per fixar). El emet el sub-picker en clicar
// un frame; `tryPlaceEntity` el passa al spawn YAML com a `frame: N`.
int brush_entity_frame_{-1};
// Estat de l'EntityPicker.
int entity_picker_scroll_{0};
int entity_picker_hover_{-1}; // Índex a la llista de noms.
// Estat del sub-picker de frames. `entity_frame_picker_source_` apunta
// a la entitat activa (vista al EntityPicker) per a la qual el
// sub-picker mostra les variants. Quan el sub-picker es tanca sense
// selecció, ho deixem a nullptr i `brush_entity_frame_ = -1`.
int entity_frame_picker_scroll_{0};
int entity_frame_picker_hover_{-1};
const EditableEntity* entity_frame_picker_source_{nullptr};
// Font compartida amb el Notifier/HUD: el surface el carrega Director
// a l'arrencada, ací només prenem la referència del cache. shared_ptr
// perquè `Text` accepta el surface per shared_ptr i així no copiem.
@@ -0,0 +1,27 @@
#include <string>
#include "external/fkyaml_node.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/solid_decoration.hpp"
// Murs decoratius estàtics que comparteixen el mateix codi C++ però es
// registren amb claus de factory diferents per a que el YAML els pugui
// referenciar pel seu tipus i el editor els pugui distingir al picker.
// La selecció del sub-sprite (frame del atlas) viatja al spawn YAML
// (`frame: N`); l'aplica `placeGeneric` via `Entity::setAnimation`. Aquí no
// cal cap lògica específica per a la variant.
namespace {
class StaticSolid : public SolidDecoration {
public:
explicit StaticSolid(const fkyaml::node& yaml) {
if (yaml.contains("name")) {
name_ = yaml["name"].get_value<std::string>();
}
}
};
} // namespace
REGISTER_ENTITY_AS("stone", StaticSolid, Stone)
REGISTER_ENTITY_AS("column", StaticSolid, Column)
+18
View File
@@ -125,6 +125,15 @@ class Entity {
[[nodiscard]] auto collides() const -> bool { return collides_; }
[[nodiscard]] auto interactable() const -> bool { return interactable_; }
// Marca d'entitat que ha de bloquejar el moviment del jugador (com un
// mur). `collides_=true` només garanteix que l'entitat reb el callback
// `onPlayerContact` quan el jugador la toca, no que el seu rect sigui
// sòlid: dynamic hazards (mòmies, roques) tenen `collides_=true` però
// no han de bloquejar el pas — el contacte resol el dany i la roca es
// mou independentment. Per a "murs passius" (sarcs, columnes, debris)
// posem aquest flag a true i `Map::collides` els tracta com a obstacle.
[[nodiscard]] auto blocksMovement() const -> bool { return blocks_movement_; }
// Trigger d'àrea: l'entitat NO bloqueja el moviment (`collides_=false`)
// però sí vol rebre `onPlayerContact` quan el jugador la solapa. Pensat
// per a hazards "no físics" (forats al sòl, picos retràctils, plaques
@@ -187,6 +196,14 @@ class Entity {
// Entitats sense animació retornen `nullopt` → frame 0 per defecte.
[[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; }
// Sobreescriu l'`AnimationComponent` actual. L'usa el loader del nivell
// per aplicar el camp opcional `frame: N` del spawn a entitats amb
// variants estàtiques (debris, columnes, etc.): el spawn fixa una
// animació de 1 frame al índex escollit i el render del manager clipa
// el sub-sprite corresponent. També és útil per a tests que volen
// controlar el frame mostrat sense passar pel YAML.
void setAnimation(AnimationComponent anim) { anim_ = std::move(anim); }
// Alçada del cos de l'entitat en píxels. La consulten els filtres
// de col·lisió per altura: si un jugador en l'aire passa per damunt
// d'aquest valor (jump_z_ > height), no col·lisiona amb l'entitat —
@@ -240,6 +257,7 @@ class Entity {
bool interactable_{false};
bool triggers_on_overlap_{false};
bool is_floor_decal_{false};
bool blocks_movement_{false};
int height_{0};
bool dead_{false};
SDL_FlipMode flip_{SDL_FLIP_NONE};
+23
View File
@@ -7,6 +7,7 @@
#include "game/entities/concrete/door.hpp"
#include "game/entities/concrete/sarcophagus.hpp"
#include "game/entities/entity.hpp"
#include "game/map/map.hpp"
// NOTA: `loadFromLevel` viu a `entity_manager_loadlevel.cpp` per evitar
// arrossegar `Resource::Cache`, `Surface` i les seues dependències (GIF,
@@ -51,12 +52,23 @@ EntityManager::EntityManager() {
});
}
});
// Connectar amb el check global de col·lisió del Map: les entitats
// sòlides genèriques (debris, columnes, futurs murs) viuen al manager,
// no a la graella fixa de sarcs, així que el `Map::collides` del Player
// les ha de consultar via callback. Es desregistra al dtor.
Map::setEntityCollider([this](const SDL_FRect& box) {
return entityCollidesAt(box);
});
}
EntityManager::~EntityManager() {
bus_.unsubscribe(sarc_sub_id_);
bus_.unsubscribe(tile_sub_id_);
bus_.unsubscribe(exit_sub_id_);
// Desconnectar del Map: si quedem en namespace global apuntant a un
// `this` que mor, qualsevol `Map::collides` posterior cauria en UB.
Map::setEntityCollider(nullptr);
}
void EntityManager::setExpectedPlayerCount(int n) {
@@ -153,6 +165,17 @@ auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Ent
return nullptr;
}
auto EntityManager::entityCollidesAt(const SDL_FRect& box) const -> bool {
// Iteració lineal sobre entitats amb `blocksMovement()`. La convenció
// d'overlap és la mateixa que `Map::collides` (estricta: el toc al
// píxel no compta) per coherència en els passadissos del laberint.
return std::ranges::any_of(entities_, [&box](const auto& e) {
if (!e->blocksMovement()) { return false; }
const auto R = e->getCollisionRect();
return (R.x + R.w) > box.x && (box.x + box.w) > R.x && (R.y + R.h) > box.y && (box.y + box.h) > R.y;
});
}
auto EntityManager::getEntities() const -> std::vector<Entity*> {
std::vector<Entity*> out;
out.reserve(entities_.size());
+5
View File
@@ -181,6 +181,11 @@ class EntityManager {
}
private:
// Suport per al callback registrat a `Map::setEntityCollider`. Itera
// les entitats amb `blocksMovement()` i fa overlap estricte (mateixa
// convenció que `Map::collides`). El callback el registra el ctor.
[[nodiscard]] auto entityCollidesAt(const SDL_FRect& box) const -> bool;
EventBus bus_;
// Un sol contenidor lògic per a totes les entitats instanciades. La
// discriminació de tipus la fan els virtuals (`onPlayerContact`,
@@ -395,6 +395,17 @@ namespace {
}
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
// Variant del atlas: el spawn pot fixar un sub-sprite estàtic via
// `frame: N`. El render del manager ja sap clipar (col, row) a
// partir d'un índex lineal i la mida del frame; ací només cal
// muntar un `AnimationComponent` d'un sol frame i imposar-lo a la
// entitat. Cap entitat existent al joc emet aquest camp; només
// els muros decoratius amb variants (stone, column, ...) l'usen.
if (spawn.contains("frame")) {
const int FRAME = spawn["frame"].get_value<int>();
entity->setAnimation(AnimationComponent({FRAME}, 1.0F, false));
}
manager.add(std::move(entity));
return true;
}
@@ -459,31 +470,45 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
if (addSpawn(spawn) != nullptr) { ++loaded; }
}
// Broadcast inicial: marca com a "pintats" tots els tiles ocupats per
// decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...).
// Imprescindible perquè el jugador no pot trepitjar aquests tiles
// (cauria al forat) i els sarcòfags vecins no podrien completar el
// perímetre — la sala seria incompletable. Usa el canal semàntic
// `tile_painted` (lògica de joc), no `on_tile_entered` (presència
// física del jugador). Ho fem aquí, després del bucle de spawns,
// perquè tots els sarcòfags ja estan registrats al fan-out (la
// subscripció del manager és al ctor, anterior a qualsevol load).
// Broadcast inicial: marca com a "pintats" tots els tiles que el
// jugador no pot trepitjar mai, de manera que el perímetre dels
// sarcòfags vecins ja els consideri visitats i la sala sigui
// completable. Inclou dues famílies:
// - Decals de sòl (Hole, i futurs pinchos plans, plaques, charcos):
// trigger-on-overlap que mataria/atraparia al jugador en trepitjar.
// - Entitats sòlides genèriques (debris, columnes...): obstacles
// físics que `Map::collides` bloqueja, així `isPaintable` retorna
// false per als seus tiles i ningú els pot pintar.
// Usa el canal semàntic `tile_painted` (lògica de joc), no
// `on_tile_entered` (presència física). Es fa aquí, després del bucle
// de spawns, perquè tots els sarcòfags ja estan registrats al fan-out
// (la subscripció del manager és al ctor, anterior a qualsevol load).
int decal_broadcast = 0;
// Els decals de sòl (Hole, futurs pinchos plans...) són StaticHazards;
// iterem tot `entities_` i el filtre per `isFloorDecal()` els tria.
for (const auto& entity : entities_) {
if (!entity->isFloorDecal()) { continue; }
const bool IS_DECAL = entity->isFloorDecal();
const bool IS_SOLID = entity->blocksMovement();
if (!IS_DECAL && !IS_SOLID) { continue; }
const auto BOX = entity->getCollisionRect();
const auto [COL, ROW] = Map::pixelToTile(BOX.x, BOX.y);
bus_.publish(Event{
.name = "tile_painted",
.payload = TilePos{.col = COL, .row = ROW},
.player_id = -1,
});
++decal_broadcast;
// Per a entitats que ocupen més d'un tile (futurs murs 32×16,
// 16×32, etc.), iterem tota la zona coberta pel rect. Per a decals
// i blocs d'un sol tile cau a un sol broadcast (mateix comportament
// que abans).
const auto [COL0, ROW0] = Map::pixelToTile(BOX.x, BOX.y);
const int COLS = (Map::tile_size > 0) ? std::max(1, static_cast<int>(BOX.w) / Map::tile_size) : 1;
const int ROWS = (Map::tile_size > 0) ? std::max(1, static_cast<int>(BOX.h) / Map::tile_size) : 1;
for (int dr = 0; dr < ROWS; ++dr) {
for (int dc = 0; dc < COLS; ++dc) {
bus_.publish(Event{
.name = "tile_painted",
.payload = TilePos{.col = COL0 + dc, .row = ROW0 + dr},
.player_id = -1,
});
++decal_broadcast;
}
}
}
if (decal_broadcast > 0) {
Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast);
Log::info("EntityManager", "{} tile_painted broadcast inicial (decals + murs)", decal_broadcast);
}
Log::info("EntityManager", "{} entitats + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path);
+3
View File
@@ -60,6 +60,9 @@ namespace EntityWriter {
} else if (s.col.has_value() && s.row.has_value()) {
out += " position: [" + std::to_string(*s.col) + ", " + std::to_string(*s.row) + "]\n";
}
if (s.frame.has_value()) {
out += " frame: " + std::to_string(*s.frame) + "\n";
}
}
return out;
}
+4
View File
@@ -35,12 +35,16 @@ namespace EntityWriter {
// Cada spawn duplica l'estructura mínima del que el lector espera:
// type sempre, i un dels (position, sarc_position) o cap (cas door).
// `frame` és opcional i només l'emeten les entitats amb variants
// estàtiques (debris, columnes); fixa quin sub-sprite del atlas
// representa aquesta instància concreta.
struct Spawn {
std::string type;
std::optional<int> col;
std::optional<int> row;
std::optional<int> sarc_col;
std::optional<int> sarc_row;
std::optional<int> frame;
};
auto saveEntitiesBlock(const std::string& path, const std::vector<Spawn>& spawns) -> bool;
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "game/entities/entity.hpp"
// Mur passiu: bloqueja moviment i salt però no té ni estat ni acció. Paral·lel
// a `Decoration` (sense col·lisió) i `Interactable` (col·lisiona i té
// estat/interact). Pensat per a debris/obstacles purament visuals que sí
// han de detenir el jugador: pedres, columnes, restes.
//
// Diferència amb `Interactable`: no override `interact()` ni
// `notifyTilePainted`. La caixa de col·lisió i alçada efectiva surten del
// YAML (`collision.size`, `collision.height`); aquest ctor només fixa els
// flags i un default raonable per a "mur infranquejable".
class SolidDecoration : public Entity {
public:
using Entity::Entity;
SolidDecoration() {
collides_ = true;
interactable_ = false;
height_ = 999;
// Sí bloqueja el moviment del jugador: `Map::collides` el consulta
// via callback per parar el moviment com si fos una paret sòlida.
blocks_movement_ = true;
}
};
+17
View File
@@ -21,6 +21,18 @@ namespace {
namespace Map {
namespace {
// Predicat extra que el `collides` consulta com a sobre-capa,
// injectat des de `EntityManager` per a entitats sòlides genèriques
// (debris, columnes). El TU local manté l'estat sense exposar-lo
// al header.
std::function<bool(const SDL_FRect&)> g_entity_collider;
} // namespace
void setEntityCollider(std::function<bool(const SDL_FRect&)> fn) {
g_entity_collider = std::move(fn);
}
void loadFrom(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
@@ -123,6 +135,11 @@ namespace Map {
};
if (overlapStrict(base_box, CORRIDOR)) { return true; }
}
// Capa final: entitats sòlides genèriques registrades pel manager.
// S'avalua al final perquè els obstacles fixos (sarcs, porta) són
// l'origen del laberint i el camí més curt; les entitats addicionals
// són normalment poques.
if (g_entity_collider && g_entity_collider(base_box)) { return true; }
return false;
}
+10 -1
View File
@@ -2,6 +2,7 @@
#include <SDL3/SDL.h>
#include <functional>
#include <string>
#include <utility>
@@ -91,9 +92,17 @@ namespace Map {
// Mapa de colisions (separat del mapa de dibuix): retorna true si el
// rectangle `base_box` (la base del jugador, per exemple) s'encavalca
// amb cap sarcòfag.
// amb cap sarcòfag, el passadís de la porta tancada, o cap entitat
// sòlida genèrica registrada via `setEntityCollider`.
[[nodiscard]] auto collides(const SDL_FRect& base_box) -> bool;
// Registre d'un predicat extern que `collides` consulta com a sobre-
// capa: l'usa `EntityManager` per a entitats amb `blocksMovement()`
// (debris, columnes...) que no estan a la graella fixa de sarcòfags.
// Passa `nullptr` per netejar. Reset implícit a `loadFrom` (cada
// càrrega de nivell sol portar un manager nou).
void setEntityCollider(std::function<bool(const SDL_FRect&)> fn);
// 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
+15 -1
View File
@@ -1449,7 +1449,21 @@ void GameScene::update(int delta_ms) {
// perquè són UI. Avancen abans del bloc de món perquè quan PLAYING
// arrenque ja sigui efectiu en el mateix frame.
updateRoomTransition(delta_ms);
updateGameOverFlow(delta_ms);
// Game-over flow pausat mentre el menú de servei o l'editor estan
// oberts. L'editor fa `loadRoom` complet i deixa els jugadors DISABLED
// (`isOutOfPlay()==true`) esperant respawn instantani a la sortida;
// si el flux corre durant aquest interval, `activePlayerCount()==0`
// mentre `state_==PLAYING` dispara un CONTINUE_PROMPT espuri que
// acaba portant al LOGO. La pausa aquí és simètrica amb com el menú
// i l'editor congelen la resta del món.
#ifdef _DEBUG
const bool GAME_OVER_PAUSED = MENU_FROZEN || editor_.active();
#else
const bool GAME_OVER_PAUSED = MENU_FROZEN;
#endif
if (!GAME_OVER_PAUSED) {
updateGameOverFlow(delta_ms);
}
#ifdef _DEBUG
// Toggle de l'editor. L'editor mateix no consumeix la tecla — la