feat(entities): afegeix stone i column com a muros decoratius
This commit is contained in:
1 parent
1c49248b72
commit
6749780356
18 files changed
+448
-36
No files matched your search
@@ -52,6 +52,7 @@ set(ENTITY_SOURCES_BASE
|
||||
source/game/entities/concrete/floor_trap.cpp
|
||||
source/game/entities/concrete/hole.cpp
|
||||
source/game/entities/concrete/dust.cpp
|
||||
source/game/entities/concrete/solid_props.cpp
|
||||
)
|
||||
|
||||
# Entitats que criden directament símbols de Player (com WhipAttack via
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: column
|
||||
type: decoration
|
||||
editable: true
|
||||
# Sub-picker de frames a l'editor: 6 variants (atlas 2×3 de sub-sprites 16×32).
|
||||
editable_variants: true
|
||||
sprite:
|
||||
path: ../debris/column.gif
|
||||
size: [16, 32]
|
||||
# Ancoratge "feet": el tile [col, row] del spawn és la base de la columna.
|
||||
# El sprite de 16×32 sobresurt 16 px cap amunt sense afectar col·lisió ni
|
||||
# z-order (la caixa lògica és el tile inferior).
|
||||
anchor: feet
|
||||
tile_size: [1, 1]
|
||||
collision:
|
||||
size: [16, 16]
|
||||
# Mur infranquejable al salt: el jugador no pot passar pel damunt.
|
||||
height: 999
|
||||
flags:
|
||||
collides: true
|
||||
interactable: false
|
||||
animations:
|
||||
default: { frames: [0], fps: 1, loop: false }
|
||||
@@ -0,0 +1,22 @@
|
||||
name: stone
|
||||
type: decoration
|
||||
editable: true
|
||||
# Sub-picker de frames a l'editor: ofereix una graella amb les 24 variants
|
||||
# del atlas (6×4 sub-sprites 16×16) per escollir quina representa cada
|
||||
# instància col·locada.
|
||||
editable_variants: true
|
||||
sprite:
|
||||
path: ../debris/stone.gif
|
||||
size: [16, 16]
|
||||
tile_size: [1, 1]
|
||||
collision:
|
||||
size: [16, 16]
|
||||
# Mur infranquejable al salt: el jugador no la pot saltar per damunt.
|
||||
height: 999
|
||||
flags:
|
||||
collides: true
|
||||
interactable: false
|
||||
# Animació "default" amb un sol frame: el spawn YAML pot sobreescriure-la
|
||||
# via `frame: N` per fixar la variant escollida al editor.
|
||||
animations:
|
||||
default: { frames: [0], fps: 1, loop: false }
|
||||
@@ -131,5 +131,11 @@ entities:
|
||||
- type: torch
|
||||
position: [4, 2]
|
||||
- type: door
|
||||
- type: column
|
||||
position: [6, 5]
|
||||
frame: 2
|
||||
- type: mummy
|
||||
position: [10, 11]
|
||||
- type: stone
|
||||
position: [10, 5]
|
||||
frame: 16
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -87,6 +87,7 @@ namespace {
|
||||
const std::vector<std::string> TYPES = {
|
||||
"torch", "sarcophagus", "door", "mummy",
|
||||
"rolling_rock", "wall_trap", "floor_trap", "hole",
|
||||
"stone", "column",
|
||||
};
|
||||
for (const auto& type : TYPES) {
|
||||
fkyaml::node tmpl = EntityLoader::loadTemplate(type);
|
||||
@@ -271,6 +272,7 @@ namespace {
|
||||
const std::vector<std::string> TYPES = {
|
||||
"torch", "sarcophagus", "door", "mummy",
|
||||
"rolling_rock", "wall_trap", "floor_trap", "hole",
|
||||
"stone", "column",
|
||||
};
|
||||
for (const auto& type : TYPES) {
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user