From ca03a1307ec9671d330198ba6cf8d3b8bce24e5a Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 10:53:50 +0200 Subject: [PATCH 01/13] =?UTF-8?q?feat(editor):=20bindings=20per=20E=20(ent?= =?UTF-8?q?ity=20picker),=20H=20(m=C3=A0),=20Supr=20(borrar)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/input/keys.yaml | 11 ++++++++++- source/game/editor/editor.cpp | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/data/input/keys.yaml b/data/input/keys.yaml index a6f71cd..5901e07 100644 --- a/data/input/keys.yaml +++ b/data/input/keys.yaml @@ -44,9 +44,18 @@ keys: - id: editor_toggle code: "9" desc: "Entra/surt del mode editor (només Debug)" - - id: editor_picker + - id: editor_tile_picker code: "T" desc: "Obri/tanca el tilepicker dins de l'editor" + - id: editor_entity_picker + code: "E" + desc: "Obri/tanca l'entity picker dins de l'editor" + - id: editor_hand + code: "H" + desc: "Activa el mode mà de l'editor (selecciona/mou/borra entitats)" + - id: editor_delete + code: "Delete" + desc: "Esborra l'entitat seleccionada en mode mà" - id: prev_room code: "[" desc: "Salta a la sala anterior del levels.yaml (només Debug)" diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 6c81f85..06bb425 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -156,7 +156,7 @@ void Editor::update(int /*delta_ms*/, Tilemap& tilemap) { // (GlobalInputs) les vegi. ESC només es consumeix si el picker està // obert — fora d'ell segueix arribant a GlobalInputs per la seva // acció de confirmar-eixida. - const SDL_Scancode PICKER_SC = KeyConfig::get().scancode("editor_picker"); + const SDL_Scancode PICKER_SC = KeyConfig::get().scancode("editor_tile_picker"); for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) { bool consumed = false; if (it->type == SDL_EVENT_KEY_DOWN) { From 2ef5968e0d5c07a0dc1a151510a4bee09b9aebb5 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 10:58:24 +0200 Subject: [PATCH 02/13] =?UTF-8?q?feat(editor):=20m=C3=A0quina=20d'estats?= =?UTF-8?q?=20Mode=20{TILE,ENTITY,HAND}=20+=20Picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/editor/editor.cpp | 191 +++++++++++++++++++++++----------- source/game/editor/editor.hpp | 89 ++++++++++++---- 2 files changed, 196 insertions(+), 84 deletions(-) diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 06bb425..5f6e0ee 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -111,6 +111,14 @@ namespace { void Editor::enter() { if (active_) { return; } active_ = true; + // Estat de novella: tornem a Mode::TILE sense brush ni picker. Els + // brushs (tile/entity) i la selecció de la mà no es preserven entre + // sessions del editor: cada entrada parteix net. + mode_ = Mode::TILE; + picker_ = Picker::NONE; + brush_tile_ = -1; + brush_entity_.clear(); + hand_selected_idx_ = -1; Notification::notify(Locale::get().text("input.editor_on")); } @@ -120,7 +128,7 @@ void Editor::enter() { void Editor::exit(Tilemap& tilemap) { if (!active_) { return; } active_ = false; - picker_open_ = false; + picker_ = Picker::NONE; // El writer salva sempre sobre la sala actualment carregada (segons // el LevelManager): així el "9" de l'editor desa el que tens davant, // no una constant fixa. Resolem la ruta via Resource::Helper perquè @@ -140,9 +148,54 @@ void Editor::toggle(Tilemap& tilemap) { } } +auto Editor::applyHotkey(int scancode) -> bool { + const SDL_Scancode TILE_PICKER_SC = KeyConfig::get().scancode("editor_tile_picker"); + const SDL_Scancode ENTITY_PICKER_SC = KeyConfig::get().scancode("editor_entity_picker"); + const SDL_Scancode HAND_SC = KeyConfig::get().scancode("editor_hand"); + + if (TILE_PICKER_SC != SDL_SCANCODE_UNKNOWN && scancode == TILE_PICKER_SC) { + picker_ = (picker_ == Picker::TILE) ? Picker::NONE : Picker::TILE; + if (picker_ == Picker::TILE) { tile_picker_scroll_ = 0; } + return true; + } + if (ENTITY_PICKER_SC != SDL_SCANCODE_UNKNOWN && scancode == ENTITY_PICKER_SC) { + picker_ = (picker_ == Picker::ENTITY) ? Picker::NONE : Picker::ENTITY; + if (picker_ == Picker::ENTITY) { entity_picker_scroll_ = 0; } + return true; + } + if (HAND_SC != SDL_SCANCODE_UNKNOWN && scancode == HAND_SC) { + mode_ = Mode::HAND; + picker_ = Picker::NONE; + hand_selected_idx_ = -1; + return true; + } + // ESC només tanca el picker; fora d'ell, deixem que GlobalInputs el + // tracte per a la seva acció de confirmar-eixida. + if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) { + picker_ = Picker::NONE; + return true; + } + return false; +} + +void Editor::handleHotkeys() { + // Consumim les pròpies abans que cap altre handler (GlobalInputs) les + // veja. La transició concreta de cada tecla viu a `applyHotkey`. + for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) { + const bool IS_KEY = (it->type == SDL_EVENT_KEY_DOWN); + if (IS_KEY && applyHotkey(it->key.scancode)) { + it = EventBuffer::events.erase(it); + } else { + ++it; + } + } +} + void Editor::update(int /*delta_ms*/, Tilemap& tilemap) { if (!active_) { return; } + handleHotkeys(); + // Posició del ratolí en coordenades del canvas (independent del // zoom de finestra i del mode d'escalat). float mouse_x = 0.0F; @@ -152,43 +205,10 @@ void Editor::update(int /*delta_ms*/, Tilemap& tilemap) { const int CX = static_cast(CP.x); const int CY = static_cast(CP.y); - // Consumim tecles pròpies del picker abans que cap altre handler - // (GlobalInputs) les vegi. ESC només es consumeix si el picker està - // obert — fora d'ell segueix arribant a GlobalInputs per la seva - // acció de confirmar-eixida. - const SDL_Scancode PICKER_SC = KeyConfig::get().scancode("editor_tile_picker"); - for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) { - bool consumed = false; - if (it->type == SDL_EVENT_KEY_DOWN) { - if (PICKER_SC != SDL_SCANCODE_UNKNOWN && it->key.scancode == PICKER_SC) { - picker_open_ = !picker_open_; - if (picker_open_) { picker_scroll_ = 0; } - consumed = true; - } else if (picker_open_ && it->key.scancode == SDL_SCANCODE_ESCAPE) { - picker_open_ = false; - consumed = true; - } - } - if (consumed) { - it = EventBuffer::events.erase(it); - } else { - ++it; - } - } - - if (picker_open_) { - updatePicker(tilemap, CX, CY); - } else { - updateMap(tilemap, CX, CY); - } -} - -void Editor::updateMap(Tilemap& tilemap, int canvas_x, int canvas_y) { - // Cursor → cel·la del tilemap. Fora del mapa deixem -1/-1 perquè - // `render()` sàpiga no dibuixar preview i `setTileAt` ignori el clic. + // Cursor → cel·la del tilemap. Comú a tots els modes amb mapa visible. const int TS = tilemap.getTileSize(); - const int MX = canvas_x - tilemap.getOriginX(); - const int MY = canvas_y - tilemap.getOriginY(); + const int MX = CX - tilemap.getOriginX(); + const int MY = CY - tilemap.getOriginY(); cursor_col_ = -1; cursor_row_ = -1; if (TS > 0 && MX >= 0 && MY >= 0) { @@ -200,21 +220,63 @@ void Editor::updateMap(Tilemap& tilemap, int canvas_x, int canvas_y) { } } - for (const auto& ev : EventBuffer::events) { - if (ev.type != SDL_EVENT_MOUSE_BUTTON_DOWN) { continue; } - if (ev.button.button == SDL_BUTTON_LEFT) { - if (brush_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) { - tilemap.setTileAt(cursor_col_, cursor_row_, brush_); - } - } else if (ev.button.button == SDL_BUTTON_RIGHT) { - brush_ = -1; + // Dispatch: el picker, si està obert, té prioritat sobre el mode. + if (picker_ == Picker::TILE) { + updateTilePicker(tilemap, CX, CY); + } else if (picker_ == Picker::ENTITY) { + updateEntityPicker(CX, CY); + } else { + switch (mode_) { + case Mode::TILE: + updateTileBrush(tilemap, CX, CY); + break; + case Mode::ENTITY: + updateEntityBrush(CX, CY); + break; + case Mode::HAND: + updateHand(CX, CY); + break; } } } -void Editor::updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) { +void Editor::updateTileBrush(Tilemap& tilemap, int /*canvas_x*/, int /*canvas_y*/) { + for (const auto& ev : EventBuffer::events) { + if (ev.type != SDL_EVENT_MOUSE_BUTTON_DOWN) { continue; } + if (ev.button.button == SDL_BUTTON_LEFT) { + if (brush_tile_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) { + tilemap.setTileAt(cursor_col_, cursor_row_, brush_tile_); + } + } else if (ev.button.button == SDL_BUTTON_RIGHT) { + brush_tile_ = -1; + } + } +} + +// NOLINTBEGIN(readability-convert-member-functions-to-static) +// Stubs: l'estat i la lògica concreta es construeixen als passos següents +// (entity placement, hand mode). Mantenim la signatura per estabilitat +// d'API i perquè update() pugui despatxar sense branques especials. +void Editor::updateEntityBrush(int /*canvas_x*/, int /*canvas_y*/) { + // Pas 5: col·locació amb snap per tipus + bloqueig de solapaments. +} + +void Editor::updateHand(int /*canvas_x*/, int /*canvas_y*/) { + // Pas 6: hover, select, move, delete, marc d'entitat seleccionada. +} + +void Editor::updateEntityPicker(int /*canvas_x*/, int /*canvas_y*/) { + // Pas 4: rejilla d'entitats registrades + click → brush_entity_. +} + +void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const { + // Pas 4: render del picker d'entitats. +} +// NOLINTEND(readability-convert-member-functions-to-static) + +void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) { const auto LAY = layoutPicker(tilemap); - picker_hover_ = -1; + tile_picker_hover_ = -1; // Espai útil per als tiles dins de la caixa (descomptat el border 1 px). const int INNER_X = LAY.x + PICKER_BORDER; const int INNER_Y = LAY.y + PICKER_BORDER; @@ -227,19 +289,20 @@ void Editor::updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) { const bool IN_SPACING_X = (RX % LAY.pitch) >= LAY.tile_size; const bool IN_SPACING_Y = (RY % LAY.pitch) >= LAY.tile_size; if (!IN_SPACING_X && !IN_SPACING_Y && VC < LAY.cols && VR < LAY.rows_visible) { - const int IDX = ((picker_scroll_ + VR) * LAY.cols) + VC; - if (IDX < LAY.total_tiles) { picker_hover_ = IDX; } + const int IDX = ((tile_picker_scroll_ + VR) * LAY.cols) + VC; + if (IDX < LAY.total_tiles) { tile_picker_hover_ = IDX; } } } for (const auto& ev : EventBuffer::events) { if (ev.type == SDL_EVENT_MOUSE_WHEEL) { - picker_scroll_ -= static_cast(ev.wheel.y); - picker_scroll_ = std::clamp(picker_scroll_, 0, LAY.max_scroll); + tile_picker_scroll_ -= static_cast(ev.wheel.y); + tile_picker_scroll_ = std::clamp(tile_picker_scroll_, 0, LAY.max_scroll); } else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) { - if (picker_hover_ >= 0) { - brush_ = picker_hover_; - picker_open_ = false; + if (tile_picker_hover_ >= 0) { + brush_tile_ = tile_picker_hover_; + mode_ = Mode::TILE; + picker_ = Picker::NONE; } } } @@ -251,13 +314,15 @@ void Editor::render(const Tilemap& tilemap) const { constexpr int W = GameCanvas::WIDTH; constexpr int H = GameCanvas::HEIGHT; - if (picker_open_) { - renderPicker(tilemap); - } else if (brush_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) { + if (picker_ == Picker::TILE) { + renderTilePicker(tilemap); + } else if (picker_ == Picker::ENTITY) { + renderEntityPicker(tilemap); + } 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()); const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize()); - tilemap.renderTilePreview(PX, PY, brush_); + tilemap.renderTilePreview(PX, PY, brush_tile_); } const auto BORDER = EditorConfig::get().borderArgb(); @@ -267,7 +332,7 @@ void Editor::render(const Tilemap& tilemap) const { screen.fillRect(W - 1, 0, 1, H, BORDER); } -void Editor::renderPicker(const Tilemap& tilemap) const { +void Editor::renderTilePicker(const Tilemap& tilemap) const { const auto LAY = layoutPicker(tilemap); if (LAY.cols <= 0 || LAY.rows_visible <= 0) { return; } auto& screen = Screen::get(); @@ -275,7 +340,7 @@ void Editor::renderPicker(const Tilemap& tilemap) const { screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, cfg.pickerBgArgb()); for (int vr = 0; vr < LAY.rows_visible; ++vr) { - const int LOGICAL_ROW = picker_scroll_ + vr; + const int LOGICAL_ROW = tile_picker_scroll_ + vr; for (int vc = 0; vc < LAY.cols; ++vc) { const int IDX = (LOGICAL_ROW * LAY.cols) + vc; if (IDX >= LAY.total_tiles) { break; } @@ -284,9 +349,9 @@ void Editor::renderPicker(const Tilemap& tilemap) const { } } - if (picker_hover_ >= 0) { - const int VR = (picker_hover_ / LAY.cols) - picker_scroll_; - const int VC = picker_hover_ % LAY.cols; + if (tile_picker_hover_ >= 0) { + const int VR = (tile_picker_hover_ / LAY.cols) - tile_picker_scroll_; + const int VC = tile_picker_hover_ % LAY.cols; if (VR >= 0 && VR < LAY.rows_visible) { const auto [PX, PY] = pickerTileOrigin(LAY, VC, VR); const int TS = LAY.tile_size; diff --git a/source/game/editor/editor.hpp b/source/game/editor/editor.hpp index 9b033c1..4cce204 100644 --- a/source/game/editor/editor.hpp +++ b/source/game/editor/editor.hpp @@ -2,6 +2,9 @@ #ifdef _DEBUG +#include +#include + class Tilemap; // Mode editor modal del GameScene. Només disponible en Debug — en Release @@ -11,13 +14,21 @@ class Tilemap; // L'editor NO gestiona la tecla de toggle pròpia (`editor_toggle`): això // ho fa GameScene per poder decidir la coexistència amb la pausa F11 // (mútuament excloents). L'editor s'encarrega de: -// - Estat propi (actiu/no actiu, brush, picker obert). -// - Input del ratolí i tecles internes (`T`, `ESC`). +// - Estat propi (mode actiu, brush per mode, picker obert). +// - Input del ratolí i tecles internes (`T`, `E`, `H`, `Supr`, `ESC`). // - Render del border blau + brush preview + picker. -// - Persistència del tilemap al YAML del repo en sortir. +// - Persistència del tilemap i de les entitats al YAML del repo en sortir. // -// GameScene, quan `active()` és true, salta el tick de jugadors/entitats -// i no els pinta; el tilemap sí es pinta perquè és el que s'està editant. +// Modes del cursor (`Mode`): +// - TILE : el clic esquerre pinta el `brush_tile_` al tilemap. +// - ENTITY : el clic esquerre col·loca una instància de `brush_entity_`. +// - HAND : selecciona/mou/borra entitats existents. +// +// Pickers (`Picker`): +// - TILE_PICKER (`T`) : selecciona índex de tile. +// - ENTITY_PICKER (`E`) : selecciona tipus d'entitat (nom registrat al +// factory). +// Mútuament excloents: obrir un tanca l'altre. class Editor { public: // Entra al mode editor: notifica i marca `active=true`. La congelació @@ -37,29 +48,65 @@ class Editor { // Processa input propi (brush, picker). No-op si no actiu. void update(int delta_ms, Tilemap& tilemap); - // Pinta el border blau i, més endavant, brush preview + picker. - // No-op si no actiu. + // Pinta el border blau i, segons mode i picker, brush preview, marc + // d'entitat seleccionada o el picker. No-op si no actiu. void render(const Tilemap& tilemap) const; private: - // Les branques d'update / render segons el mode (picker obert o - // pintura al mapa) queden separades per claredat; no formen part - // de l'API pública perquè només té sentit cridar-les des d'`update`. - void updateMap(Tilemap& tilemap, int canvas_x, int canvas_y); - void updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y); - void renderPicker(const Tilemap& tilemap) const; + // Mode del cursor: determina què fa el clic esquerre sobre el mapa. + enum class Mode : std::uint8_t { TILE, + ENTITY, + HAND }; + + // Quin picker està obert (si n'hi ha). Mútuament excloents. + enum class Picker : std::uint8_t { NONE, + TILE, + ENTITY }; + + // Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`. + // Es crida al principi d'`update` perquè altres handlers no vegin + // tecles ja interpretades pel propi editor. + void handleHotkeys(); + + // Aplica una tecla i retorna true si l'editor l'ha consumit. Aïllat + // per mantenir baixa la complexitat cognitiva de `handleHotkeys` — + // l'iteració del buffer queda neta i la lògica de transició viu en + // un sol lloc. + auto applyHotkey(int scancode) -> bool; + + // Branques d'update segons (mode, picker). Només té sentit cridar-les + // des d'`update`. + void updateTileBrush(Tilemap& tilemap, int canvas_x, int canvas_y); + void updateEntityBrush(int canvas_x, int canvas_y); + void updateHand(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 renderTilePicker(const Tilemap& tilemap) const; + void renderEntityPicker(const Tilemap& tilemap) const; bool active_{false}; + Mode mode_{Mode::TILE}; + Picker picker_{Picker::NONE}; - // Estat de l'edició (brush + cursor + picker). Es poblen al pas - // següent; els declarem ara perquè `update()` pugui mutar-los i les - // signatures queden estables entre hitos. - int brush_{-1}; // Índex de tile al tileset; -1 = sense brush. - int cursor_col_{-1}; // Cel·la del mapa sota el cursor (-1 = fora). + // Cursor en cel·les del tilemap. -1/-1 = fora del mapa. + int cursor_col_{-1}; int cursor_row_{-1}; - bool picker_open_{false}; - int picker_scroll_{0}; // Fila superior visible del picker (linealitzat). - int picker_hover_{-1}; // Índex del tile sota el cursor dins del picker. + + // Brush actiu per Mode::TILE (índex al tileset; -1 = sense brush). + int brush_tile_{-1}; + // Estat del TilePicker. + 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. + + // Selecció en Mode::HAND (índex dins de l'EntityManager; -1 = cap). + int hand_selected_idx_{-1}; }; #endif // _DEBUG From f591aa82fd0cc91de491a9c03b31f8a875e239c9 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 11:01:53 +0200 Subject: [PATCH 03/13] fix(whip_attack): treure entity_manager_ duplicat (ja viu a Entity) --- source/game/entities/concrete/whip_attack.hpp | 2 +- source/game/scenes/game_scene.cpp | 32 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/source/game/entities/concrete/whip_attack.hpp b/source/game/entities/concrete/whip_attack.hpp index 6a34ed3..60331e8 100644 --- a/source/game/entities/concrete/whip_attack.hpp +++ b/source/game/entities/concrete/whip_attack.hpp @@ -152,7 +152,7 @@ class WhipAttack : public Attack { // del nostre `getFeetY()` per delegar el bucket d'y-sort al jugador. float owner_feet_y_{0.0F}; - EntityManager* entity_manager_{nullptr}; + // `entity_manager_` viu a Entity (protected): l'utilitzem d'allà. const std::vector* players_{nullptr}; DirSprite sprite_north_; diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 0394e39..549091b 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -1141,9 +1141,18 @@ void GameScene::update(int delta_ms) { #ifdef _DEBUG // Toggle de l'editor: només si no hi ha cap transició de pausa ni // pausa consolidada. L'editor mateix no consumeix la tecla — la - // llegim ací per decidir la coexistència. + // llegim ací per decidir la coexistència. Quan se surt del editor, + // recarreguem les entitats des del YAML perquè el manager torne a + // estar sincronitzat amb el que el writer acaba de desar (cada + // sessió d'edició parteix del fitxer, no de l'estat runtime previ). if (pause_.state == PauseState::IDLE && detectEditorToggle()) { + const bool WAS_ACTIVE = editor_.active(); editor_.toggle(tilemap_); + if (WAS_ACTIVE) { + entity_manager_.reload(Levels::LevelManager::get().currentPath()); + const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true); + entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT); + } } // Salts debug entre sales (`[` / `]`). Mútuament excloents amb la @@ -1242,18 +1251,25 @@ void GameScene::render() { tilemap_.render(); #ifdef _DEBUG - // En mode editor amaguem entitats i jugador: el tilemap queda net - // per a poder clicar cel·la per cel·la sense interferència visual. - // El HUD futur (quan s'afegeixi) també farà cas d'aquest flag. - const bool SKIP_ACTORS = editor_.active(); + // En mode editor amaguem jugadors, sombres, paint layer, fades, HUD + // i textos de transició: el tilemap queda net per editar i només es + // pinten les entitats (congelades, sense tick) per que el dissenyador + // veja on estan. + const bool EDITOR_ACTIVE = editor_.active(); #else - constexpr bool SKIP_ACTORS = false; + constexpr bool EDITOR_ACTIVE = false; #endif + if (EDITOR_ACTIVE) { + // Render barat de les entitats sense z-sort entre elles ni amb + // jugadors: ja no hi ha actors mòbils. L'ordre és el d'inserció + // del manager — suficient per editar. + entity_manager_.renderAll(); + } // Capa de pintat: va entre el tilemap base i els actors, perquè els // sarcòfags i el jugador la tapen. S'amaga al mode editor (decisió // explícita: l'editor edita el tilemap, no la capa de pintat). - if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); } - if (!SKIP_ACTORS) { + if (!EDITOR_ACTIVE && paint_layer_) { paint_layer_->render(); } + if (!EDITOR_ACTIVE) { // Passada de sombres dels jugadors saltant. Va per davant del // pintat (al damunt) però per darrere de TOTS els actors — // entitats i jugadors, inclòs el saltador mateix— així la sombra From 94213f9fd6aeedce0992cdac59913c6c40cc9afd Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 11:07:02 +0200 Subject: [PATCH 04/13] feat(editor): entity picker amb noms del factory i flag editable per plantilla --- data/entities/door/door.yaml | 1 + data/entities/hole/hole.yaml | 1 + data/entities/mummy/mummy.yaml | 1 + data/entities/sarcophagus/sarcophagus.yaml | 1 + data/entities/torch/torch.yaml | 1 + source/game/editor/editor.cpp | 182 ++++++++++++++++++++- source/game/editor/editor.hpp | 22 +++ 7 files changed, 204 insertions(+), 5 deletions(-) diff --git a/data/entities/door/door.yaml b/data/entities/door/door.yaml index 0b041e0..b69bf66 100644 --- a/data/entities/door/door.yaml +++ b/data/entities/door/door.yaml @@ -1,5 +1,6 @@ name: door type: interactable +editable: true sprite: path: door.gif size: [48, 32] diff --git a/data/entities/hole/hole.yaml b/data/entities/hole/hole.yaml index 59c6153..900f59e 100644 --- a/data/entities/hole/hole.yaml +++ b/data/entities/hole/hole.yaml @@ -1,5 +1,6 @@ name: hole type: static_hazard +editable: true sprite: path: hole.gif size: [16, 16] diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index 3090dc0..ef41d5b 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -1,5 +1,6 @@ name: mummy type: dynamic_hazard +editable: true sprite: path: mummy.gif size: [16, 32] diff --git a/data/entities/sarcophagus/sarcophagus.yaml b/data/entities/sarcophagus/sarcophagus.yaml index ab2e2ff..701e0f3 100644 --- a/data/entities/sarcophagus/sarcophagus.yaml +++ b/data/entities/sarcophagus/sarcophagus.yaml @@ -1,5 +1,6 @@ name: sarcophagus type: interactable +editable: true sprite: path: sarcophagus.gif size: [48, 32] diff --git a/data/entities/torch/torch.yaml b/data/entities/torch/torch.yaml index b659ead..215b45f 100644 --- a/data/entities/torch/torch.yaml +++ b/data/entities/torch/torch.yaml @@ -1,5 +1,6 @@ name: torch type: decoration +editable: true sprite: path: torch.gif size: [16, 16] diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 5f6e0ee..8f38335 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -14,7 +14,10 @@ #include "core/resources/resource_cache.hpp" #include "core/resources/resource_helper.hpp" #include "core/system/event_buffer.hpp" +#include "external/fkyaml_node.hpp" #include "game/editor/editor_config.hpp" +#include "game/entities/entity_factory.hpp" +#include "game/entities/entity_loader.hpp" #include "game/levels/level_manager.hpp" #include "game/map/tilemap.hpp" #include "utils/defaults.hpp" @@ -106,6 +109,63 @@ namespace { return {PX, PY}; } + // Mida fixa de cada cel·la del picker d'entitats. Suficient per als + // sprites més grans del catàleg actual (door 48×32, sarc 48×32) sense + // escalar — el sprite es centra dins de la cel·la. + constexpr int ENTITY_CELL = 48; + + // Geometria de l'EntityPicker. Anàloga a `PickerLayout` però amb + // cel·les fixes ENTITY_CELL × ENTITY_CELL: les entitats tenen + // dimensions diverses, així la rejilla queda visualment regular i + // el sprite es centra dins de la cel·la al render. + struct EntityPickerLayout { + int x{0}; + int y{0}; + int w{0}; + int h{0}; + int cols{0}; + int rows_visible{0}; + int spacing{0}; + int pitch{0}; // ENTITY_CELL + spacing — pas entre cel·les veïnes. + int total_items{0}; + int max_scroll{0}; + }; + + auto layoutEntityPicker(int total_items) -> EntityPickerLayout { + EntityPickerLayout lay{}; + lay.spacing = std::max(0, EditorConfig::get().pickerSpacing()); + lay.pitch = ENTITY_CELL + lay.spacing; + const int M = EditorConfig::get().pickerMargin(); + const int INNER_W = GameCanvas::WIDTH - (2 * M); + const int INNER_H = GameCanvas::HEIGHT - (2 * M); + lay.total_items = std::max(0, total_items); + if (lay.pitch <= 0 || lay.total_items == 0) { return lay; } + + const int AVAIL_W = INNER_W - (2 * PICKER_BORDER); + const int AVAIL_H = INNER_H - (2 * PICKER_BORDER); + const int MAX_COLS = std::max(1, (AVAIL_W + lay.spacing) / lay.pitch); + const int MAX_ROWS = std::max(1, (AVAIL_H + lay.spacing) / lay.pitch); + + lay.cols = std::min(MAX_COLS, lay.total_items); + const int TOTAL_ROWS = (lay.cols > 0) ? ((lay.total_items + lay.cols - 1) / lay.cols) : 0; + lay.rows_visible = std::min(MAX_ROWS, TOTAL_ROWS); + lay.max_scroll = std::max(0, TOTAL_ROWS - lay.rows_visible); + + const int CELLS_W = (lay.cols * ENTITY_CELL) + ((lay.cols - 1) * lay.spacing); + const int CELLS_H = (lay.rows_visible > 0) ? ((lay.rows_visible * ENTITY_CELL) + ((lay.rows_visible - 1) * lay.spacing)) : 0; + lay.w = CELLS_W + (2 * PICKER_BORDER); + lay.h = CELLS_H + (2 * PICKER_BORDER); + lay.x = M + ((INNER_W - lay.w) / 2); + lay.y = M + ((INNER_H - lay.h) / 2); + return lay; + } + + auto entityCellOrigin(const EntityPickerLayout& lay, int vc, int vr) -> std::pair { + const int PX = lay.x + PICKER_BORDER + (vc * lay.pitch); + const int PY = lay.y + PICKER_BORDER + (vr * lay.pitch); + return {PX, PY}; + } + } // namespace void Editor::enter() { @@ -119,9 +179,49 @@ void Editor::enter() { brush_tile_ = -1; brush_entity_.clear(); hand_selected_idx_ = -1; + ensureEditableEntities(); Notification::notify(Locale::get().text("input.editor_on")); } +void Editor::ensureEditableEntities() { + if (!editable_entities_.empty()) { return; } + // Recorrem tots els noms registrats al factory i ens quedem amb els + // que la seva plantilla YAML marca com a `editable: true`. Carreguem + // el surface al cache aquí mateix perquè render/picker el tinguen + // disponible sense fer I/O al pintat. + auto& cache = Resource::Cache::get(); + auto names = EntityFactory::instance().registeredNames(); + std::ranges::sort(names); + editable_entities_.reserve(names.size()); + for (const auto& name : names) { + fkyaml::node tmpl; + try { + tmpl = EntityLoader::loadTemplate(name); + } catch (const std::exception&) { + continue; + } + if (!tmpl.contains("editable") || !tmpl["editable"].get_value()) { continue; } + if (!tmpl.contains("sprite")) { continue; } + const auto& sp = tmpl["sprite"]; + if (!sp.contains("path") || !sp.contains("size")) { continue; } + + const auto SPRITE_PATH = "data/entities/" + name + "/" + sp["path"].get_value(); + const auto SIZE = sp["size"]; + if (!SIZE.is_sequence() || SIZE.size() < 2) { continue; } + const int FRAME_W = SIZE[0].get_value(); + const int FRAME_H = SIZE[1].get_value(); + + auto surface = cache.getSurface(SPRITE_PATH); + if (surface == nullptr) { + cache.loadSurface(SPRITE_PATH, SPRITE_PATH); + surface = cache.getSurface(SPRITE_PATH); + } + if (surface == nullptr) { continue; } + + editable_entities_.push_back({.name = name, .sprite = surface, .frame_w = FRAME_W, .frame_h = FRAME_H}); + } +} + // cppcheck-suppress constParameterReference // Tilemap& mutable per simetria amb `toggle()` i per futures // invalidacions de caches d'edició, encara que `save()` sigui const. @@ -254,7 +354,7 @@ void Editor::updateTileBrush(Tilemap& tilemap, int /*canvas_x*/, int /*canvas_y* } // NOLINTBEGIN(readability-convert-member-functions-to-static) -// Stubs: l'estat i la lògica concreta es construeixen als passos següents +// Stubs: la lògica concreta es construeix als passos següents // (entity placement, hand mode). Mantenim la signatura per estabilitat // d'API i perquè update() pugui despatxar sense branques especials. void Editor::updateEntityBrush(int /*canvas_x*/, int /*canvas_y*/) { @@ -264,15 +364,87 @@ void Editor::updateEntityBrush(int /*canvas_x*/, int /*canvas_y*/) { void Editor::updateHand(int /*canvas_x*/, int /*canvas_y*/) { // Pas 6: hover, select, move, delete, marc d'entitat seleccionada. } +// NOLINTEND(readability-convert-member-functions-to-static) -void Editor::updateEntityPicker(int /*canvas_x*/, int /*canvas_y*/) { - // Pas 4: rejilla d'entitats registrades + click → brush_entity_. +void Editor::updateEntityPicker(int canvas_x, int canvas_y) { + const auto LAY = layoutEntityPicker(static_cast(editable_entities_.size())); + entity_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_picker_scroll_ + VR) * LAY.cols) + VC; + if (IDX < LAY.total_items) { entity_picker_hover_ = IDX; } + } + } + + for (const auto& ev : EventBuffer::events) { + if (ev.type == SDL_EVENT_MOUSE_WHEEL) { + entity_picker_scroll_ -= static_cast(ev.wheel.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(editable_entities_.size())) { + brush_entity_ = editable_entities_[entity_picker_hover_].name; + mode_ = Mode::ENTITY; + picker_ = Picker::NONE; + } + } + } } void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const { - // Pas 4: render del picker d'entitats. + const auto LAY = layoutEntityPicker(static_cast(editable_entities_.size())); + 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()); + + for (int vr = 0; vr < LAY.rows_visible; ++vr) { + const int LOGICAL_ROW = entity_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& ent = editable_entities_[IDX]; + const auto [PX, PY] = entityCellOrigin(LAY, vc, vr); + // Frame 0 del strip: clip [0,0] - [frame_w, frame_h] dins de + // la cel·la. Centrem el sprite si és més petit que la cel·la. + const int OX = PX + ((ENTITY_CELL - ent.frame_w) / 2); + const int OY = PY + ((ENTITY_CELL - ent.frame_h) / 2); + const SDL_FRect SRC{ + .x = 0.0F, + .y = 0.0F, + .w = static_cast(ent.frame_w), + .h = static_cast(ent.frame_h), + }; + ent.sprite->render(OX, OY, &SRC, SDL_FLIP_NONE); + } + } + + if (entity_picker_hover_ >= 0) { + const int VR = (entity_picker_hover_ / LAY.cols) - entity_picker_scroll_; + const int VC = entity_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); } -// NOLINTEND(readability-convert-member-functions-to-static) void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) { const auto LAY = layoutPicker(tilemap); diff --git a/source/game/editor/editor.hpp b/source/game/editor/editor.hpp index 4cce204..e99d645 100644 --- a/source/game/editor/editor.hpp +++ b/source/game/editor/editor.hpp @@ -3,8 +3,11 @@ #ifdef _DEBUG #include +#include #include +#include +class Surface; class Tilemap; // Mode editor modal del GameScene. Només disponible en Debug — en Release @@ -105,6 +108,25 @@ class Editor { 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. + struct EditableEntity { + std::string name; + std::shared_ptr sprite; + int frame_w{0}; + int frame_h{0}; + }; + + // Construïda lazy al primer enter() i preservada entre sessions: + // el cost és I/O + parse de N plantilles YAML, però `EntityLoader` + // ja les memoritza, així que rellegir només resoldria per sortida i + // entrada al editor — no val la pena. + void ensureEditableEntities(); + + std::vector editable_entities_; + // Selecció en Mode::HAND (índex dins de l'EntityManager; -1 = cap). int hand_selected_idx_{-1}; }; From 9b09ccb76582febecdb77ac4a81bacedca5fdad4 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 11:21:05 +0200 Subject: [PATCH 05/13] =?UTF-8?q?feat(editor):=20col=C2=B7locaci=C3=B3=20d?= =?UTF-8?q?'entitats=20amb=20snap=20per=20tipus=20i=20bloqueig=20de=20sola?= =?UTF-8?q?paments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/editor/editor.cpp | 128 ++++++++++++++++-- source/game/editor/editor.hpp | 16 ++- source/game/entities/entity_manager.hpp | 10 ++ .../entities/entity_manager_loadlevel.cpp | 47 +++---- source/game/scenes/game_scene.cpp | 2 +- 5 files changed, 165 insertions(+), 38 deletions(-) diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 8f38335..ba68cb6 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -5,6 +5,7 @@ #include #include +#include #include "core/input/key_config.hpp" #include "core/locale/locale.hpp" @@ -16,9 +17,12 @@ #include "core/system/event_buffer.hpp" #include "external/fkyaml_node.hpp" #include "game/editor/editor_config.hpp" +#include "game/entities/entity.hpp" #include "game/entities/entity_factory.hpp" #include "game/entities/entity_loader.hpp" +#include "game/entities/entity_manager.hpp" #include "game/levels/level_manager.hpp" +#include "game/map/map.hpp" #include "game/map/tilemap.hpp" #include "utils/defaults.hpp" #include "utils/defines.hpp" @@ -291,7 +295,7 @@ void Editor::handleHotkeys() { } } -void Editor::update(int /*delta_ms*/, Tilemap& tilemap) { +void Editor::update(int /*delta_ms*/, Tilemap& tilemap, EntityManager& entities) { if (!active_) { return; } handleHotkeys(); @@ -331,10 +335,10 @@ void Editor::update(int /*delta_ms*/, Tilemap& tilemap) { updateTileBrush(tilemap, CX, CY); break; case Mode::ENTITY: - updateEntityBrush(CX, CY); + updateEntityBrush(entities, CX, CY); break; case Mode::HAND: - updateHand(CX, CY); + updateHand(entities, CX, CY); break; } } @@ -353,18 +357,120 @@ void Editor::updateTileBrush(Tilemap& tilemap, int /*canvas_x*/, int /*canvas_y* } } -// NOLINTBEGIN(readability-convert-member-functions-to-static) -// Stubs: la lògica concreta es construeix als passos següents -// (entity placement, hand mode). Mantenim la signatura per estabilitat -// d'API i perquè update() pugui despatxar sense branques especials. -void Editor::updateEntityBrush(int /*canvas_x*/, int /*canvas_y*/) { - // Pas 5: col·locació amb snap per tipus + bloqueig de solapaments. +namespace { + + // Cel·la del tilemap → rect píxel d'una cel·la unitat. + auto tileCellRect(int col, int row, int tile_size) -> SDL_FRect { + return { + .x = static_cast((col * tile_size) + Map::tilemap_origin_x), + .y = static_cast((row * tile_size) + Map::tilemap_origin_y), + .w = static_cast(tile_size), + .h = static_cast(tile_size), + }; + } + + auto rectsOverlap(const SDL_FRect& a, const SDL_FRect& b) -> bool { + return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; + } + + // Per a un sarc viu, recupera la seva (sarc_col, sarc_row) invertint + // `getCollisionRect` (que coincideix exactament amb `Map::getSarcRect` + // — vegeu placeSarcophagus). No necessitem getters específics al + // Sarcophagus: la geometria és reversible. + auto sarcGridFromRect(const SDL_FRect& rect) -> std::pair { + if (Map::pitch_x <= 0 || Map::pitch_y <= 0) { return {-1, -1}; } + const int SC = (static_cast(rect.x) - Map::origin_x) / Map::pitch_x; + const int SR = (static_cast(rect.y) - Map::origin_y) / Map::pitch_y; + return {SC, SR}; + } + + // Cel·la del cursor → cel·la sarc (4×5). Retorna {-1,-1} si cau fora + // de la rejilla. Cap dependència de l'editor: pur càlcul de Map. + auto cursorToSarcCell(int cursor_col, int cursor_row) -> std::pair { + if (Map::pitch_x <= 0 || Map::pitch_y <= 0) { return {-1, -1}; } + const int CURSOR_PX = (cursor_col * Map::tile_size) + Map::tilemap_origin_x; + const int CURSOR_PY = (cursor_row * Map::tile_size) + Map::tilemap_origin_y; + const int SC = (CURSOR_PX - Map::origin_x) / Map::pitch_x; + const int SR = (CURSOR_PY - Map::origin_y) / Map::pitch_y; + if (SC < 0 || SC >= Map::grid_cols || SR < 0 || SR >= Map::grid_rows) { + return {-1, -1}; + } + return {SC, SR}; + } + + auto sarcAlreadyAt(const std::vector& existing, int sc, int sr) -> bool { + return std::ranges::any_of(existing, [sc, sr](const Entity* e) { + if (e->getName() != "sarcophagus") { return false; } + const auto [ESC, ESR] = sarcGridFromRect(e->getCollisionRect()); + return ESC == sc && ESR == sr; + }); + } + + auto doorExists(const std::vector& existing) -> bool { + return std::ranges::any_of(existing, [](const Entity* e) { return e->getName() == "door"; }); + } + + auto cellOccupied(const std::vector& existing, const SDL_FRect& dest) -> bool { + return std::ranges::any_of(existing, + [&dest](const Entity* e) { return rectsOverlap(dest, e->getCollisionRect()); }); + } + + // 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& existing) -> std::string { + std::ostringstream y; + y << "type: " << brush << '\n'; + if (brush == "sarcophagus") { + const auto [SC, SR] = cursorToSarcCell(cursor_col, cursor_row); + if (SC < 0 || sarcAlreadyAt(existing, SC, SR)) { return {}; } + y << "sarc_position: [" << SC << ", " << SR << "]\n"; + return y.str(); + } + if (brush == "door") { + if (doorExists(existing)) { return {}; } + return y.str(); + } + const auto DEST = tileCellRect(cursor_col, cursor_row, Map::tile_size); + if (cellOccupied(existing, DEST)) { return {}; } + y << "position: [" << cursor_col << ", " << cursor_row << "]\n"; + return y.str(); + } + +} // namespace + +void Editor::updateEntityBrush(EntityManager& entities, int /*canvas_x*/, int /*canvas_y*/) { + for (const auto& ev : EventBuffer::events) { + if (ev.type != SDL_EVENT_MOUSE_BUTTON_DOWN) { continue; } + if (ev.button.button == SDL_BUTTON_LEFT) { + tryPlaceEntity(entities); + } else if (ev.button.button == SDL_BUTTON_RIGHT) { + // Click dret: descarta el brush però es queda en Mode::ENTITY. + // Per sortir cap a un altre mode, utilitza T (tile picker) o + // H (mà): manté la simetria amb el comportament del brush + // de tiles. + brush_entity_.clear(); + } + } } -void Editor::updateHand(int /*canvas_x*/, int /*canvas_y*/) { +// cppcheck-suppress constParameterReference +// Manté firma simètrica amb les altres branques d'update; la lògica +// real arriba al pas 6. +void Editor::updateHand(EntityManager& /*entities*/, int /*canvas_x*/, int /*canvas_y*/) { // Pas 6: hover, select, move, delete, marc d'entitat seleccionada. } -// NOLINTEND(readability-convert-member-functions-to-static) + +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()); + if (STR.empty()) { return false; } + try { + auto node = fkyaml::node::deserialize(STR.begin(), STR.end()); + return entities.addSpawn(node) != nullptr; + } catch (const std::exception&) { + return false; + } +} void Editor::updateEntityPicker(int canvas_x, int canvas_y) { const auto LAY = layoutEntityPicker(static_cast(editable_entities_.size())); diff --git a/source/game/editor/editor.hpp b/source/game/editor/editor.hpp index e99d645..6a48baf 100644 --- a/source/game/editor/editor.hpp +++ b/source/game/editor/editor.hpp @@ -7,6 +7,7 @@ #include #include +class EntityManager; class Surface; class Tilemap; @@ -49,7 +50,9 @@ class Editor { [[nodiscard]] auto active() const -> bool { return active_; } // Processa input propi (brush, picker). No-op si no actiu. - void update(int delta_ms, Tilemap& tilemap); + // `entities` cal perquè els modes ENTITY i HAND modifiquen el manager + // en directe (col·loca/mou/borra entitats); en TILE no s'usa. + void update(int delta_ms, Tilemap& tilemap, EntityManager& entities); // Pinta el border blau i, segons mode i picker, brush preview, marc // d'entitat seleccionada o el picker. No-op si no actiu. @@ -80,11 +83,18 @@ class Editor { // Branques d'update segons (mode, picker). Només té sentit cridar-les // des d'`update`. void updateTileBrush(Tilemap& tilemap, int canvas_x, int canvas_y); - void updateEntityBrush(int canvas_x, int canvas_y); - void updateHand(int canvas_x, int canvas_y); + void updateEntityBrush(EntityManager& entities, int canvas_x, int canvas_y); + void updateHand(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); + // 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 + // (sarc → rejilla 4×5; door → fila del mur i, de moment, columna + // canònica) i comprova solapaments. No-op si la cel·la està ocupada + // o el snap no és vàlid. Retorna true si s'ha col·locat. + auto tryPlaceEntity(EntityManager& entities) -> bool; + void renderTilePicker(const Tilemap& tilemap) const; void renderEntityPicker(const Tilemap& tilemap) const; diff --git a/source/game/entities/entity_manager.hpp b/source/game/entities/entity_manager.hpp index a2d2515..363ed1b 100644 --- a/source/game/entities/entity_manager.hpp +++ b/source/game/entities/entity_manager.hpp @@ -7,6 +7,7 @@ #include #include +#include "external/fkyaml_node.hpp" #include "game/entities/event_bus.hpp" class Entity; @@ -102,6 +103,15 @@ class EntityManager { // `Map::tile_size`, `tilemap_origin_*` i `getSarcRect`). void loadFromLevel(const std::string& level_yaml_path); + // Crea i afegeix una entitat a partir d'un node spawn (mateix format + // que els items del bloc `entities:` del YAML del nivell). Retorna el + // punter observacional a l'entitat creada, o `nullptr` si la plantilla + // no es pot carregar, el tipus no està registrat o el spawn no té els + // camps requerits (`position`, `sarc_position`, etc. segons el tipus). + // Pensat per al editor de Debug: construeix un node sintètic des + // d'una posició del cursor i el passa. + auto addSpawn(const fkyaml::node& spawn) -> Entity*; + // Reset complet del nivell: destrueix totes les entitats, reseteja // els comptadors interns (sarcòfags oberts, total, exited, flag de // level_complete) i torna a cridar `loadFromLevel` per regenerar les diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 0eb4181..c562772 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -395,30 +395,31 @@ void EntityManager::reload(const std::string& level_yaml_path) { loadFromLevel(level_yaml_path); } +auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* { + if (!spawn.contains("type")) { return nullptr; } + const auto TYPE = spawn["type"].get_value(); + + SpriteInfo sprite; + std::string anchor; + auto entity = buildEntity(TYPE, sprite, anchor); + if (entity == nullptr) { return nullptr; } + Entity* observer = entity.get(); + + bool ok = false; + if (TYPE == "sarcophagus") { + ok = placeSarcophagus(*this, std::move(entity), sprite, spawn); + } else if (TYPE == "door") { + ok = placeDoor(*this, std::move(entity), sprite); + } else { + ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn); + } + return ok ? observer : nullptr; +} + void EntityManager::loadFromLevel(const std::string& level_yaml_path) { const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path); - int loaded = 0; - - for (const auto& spawn : SPAWNS) { - if (!spawn.contains("type")) { continue; } - const auto TYPE = spawn["type"].get_value(); - - SpriteInfo sprite; - std::string anchor; - auto entity = buildEntity(TYPE, sprite, anchor); - if (entity == nullptr) { continue; } - - bool ok = false; - if (TYPE == "sarcophagus") { - ok = placeSarcophagus(*this, std::move(entity), sprite, spawn); - } else if (TYPE == "door") { - ok = placeDoor(*this, std::move(entity), sprite); - } else { - ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn); - } - const bool OK = ok; - if (OK) { ++loaded; } - } + const int LOADED = static_cast(std::ranges::count_if(SPAWNS, + [this](const fkyaml::node& spawn) { return addSpawn(spawn) != nullptr; })); // Broadcast inicial: marca com a "pintats" tots els tiles ocupats per // decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...). @@ -446,6 +447,6 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) { << " tile_painted broadcast inicial (decals de sòl)\n"; } - std::cout << "EntityManager: " << loaded << " entitats carregades des de " + std::cout << "EntityManager: " << LOADED << " entitats carregades des de " << level_yaml_path << '\n'; } diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 549091b..f5489da 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -1228,7 +1228,7 @@ void GameScene::update(int delta_ms) { } #ifdef _DEBUG - editor_.update(delta_ms, tilemap_); + editor_.update(delta_ms, tilemap_, entity_manager_); #endif // El HUD continua avançant les seues animacions encara que el món From b4e36944d70bb694d1204a72895b8e9fd960b013 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 11:28:41 +0200 Subject: [PATCH 06/13] =?UTF-8?q?feat(editor):=20mode=20m=C3=A0=20?= =?UTF-8?q?=E2=80=94=20selecci=C3=B3,=20mou,=20borra=20i=20marc=20d'entita?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/game/editor/editor.cpp | 137 ++++++++++++++++++++++-- source/game/editor/editor.hpp | 30 +++++- source/game/entities/entity_manager.cpp | 15 +++ source/game/entities/entity_manager.hpp | 7 ++ source/game/scenes/game_scene.cpp | 2 +- 5 files changed, 182 insertions(+), 9 deletions(-) diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index ba68cb6..4a2625f 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -453,11 +453,123 @@ void Editor::updateEntityBrush(EntityManager& entities, int /*canvas_x*/, int /* } } -// cppcheck-suppress constParameterReference -// Manté firma simètrica amb les altres branques d'update; la lògica -// real arriba al pas 6. -void Editor::updateHand(EntityManager& /*entities*/, int /*canvas_x*/, int /*canvas_y*/) { - // Pas 6: hover, select, move, delete, marc d'entitat seleccionada. +namespace { + + // Index de l'entitat sota el píxel donat. -1 si cap. Usem el rect de + // pintat (`getPixelRect`) i no el de col·lisió perquè el dissenyador + // espera "fer clic on es veu", encara que el cos físic sigui més + // estret (típic en sarc/porta amb falsa perspectiva). + auto entityAtPixel(const std::vector& list, int px, int py) -> int { + const auto FX = static_cast(px); + const auto FY = static_cast(py); + for (int i = static_cast(list.size()) - 1; i >= 0; --i) { + const auto R = list[i]->getPixelRect(); + if (FX >= R.x && FX < R.x + R.w && FY >= R.y && FY < R.y + R.h) { return i; } + } + return -1; + } + + auto isDeleteKey(const SDL_Event& ev) -> bool { + if (ev.type != SDL_EVENT_KEY_DOWN) { return false; } + const auto SC = KeyConfig::get().scancode("editor_delete"); + return SC != SDL_SCANCODE_UNKNOWN && ev.key.scancode == SC; + } + +} // namespace + +void Editor::updateHand(EntityManager& entities, int canvas_x, int canvas_y) { + const auto LIST = entities.getEntities(); + hand_hover_idx_ = entityAtPixel(LIST, canvas_x, canvas_y); + if (hand_selected_idx_ >= static_cast(LIST.size())) { hand_selected_idx_ = -1; } + + for (const auto& ev : EventBuffer::events) { + const bool IS_MOUSE_DOWN = (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN); + if (IS_MOUSE_DOWN && ev.button.button == SDL_BUTTON_LEFT) { + handleHandLeftClick(entities); + } else if (IS_MOUSE_DOWN && ev.button.button == SDL_BUTTON_RIGHT) { + handleHandRightClick(); + } else if (isDeleteKey(ev) && hand_selected_idx_ >= 0) { + deleteSelected(entities); + } + } +} + +void Editor::handleHandLeftClick(EntityManager& entities) { + if (hand_selected_idx_ < 0) { + // Sense selecció: si pulsem sobre una entitat, seleccionem. + if (hand_hover_idx_ >= 0) { hand_selected_idx_ = hand_hover_idx_; } + return; + } + if (hand_hover_idx_ == hand_selected_idx_) { + // Mateixa entitat: deselecciona. + hand_selected_idx_ = -1; + return; + } + if (hand_hover_idx_ >= 0) { + // Una altra entitat: canvia la selecció. + hand_selected_idx_ = hand_hover_idx_; + return; + } + // Cel·la sense entitat: intenta moure-hi la seleccionada. + if (tryMoveSelected(entities)) { hand_selected_idx_ = -1; } +} + +void Editor::handleHandRightClick() { + if (hand_selected_idx_ >= 0) { + hand_selected_idx_ = -1; + } else { + mode_ = Mode::TILE; + } +} + +auto Editor::tryMoveSelected(EntityManager& entities) const -> bool { + if (hand_selected_idx_ < 0 || cursor_col_ < 0 || cursor_row_ < 0) { return false; } + auto list = entities.getEntities(); + if (hand_selected_idx_ >= static_cast(list.size())) { return false; } + + auto* target = list[hand_selected_idx_]; + const std::string TYPE = target->getName(); + + // Construïm el nou spawn excloent l'entitat que mou del check de + // solapaments: si no, sempre xocaria amb ella mateixa. Per a la + // porta, el "moviment" actualment és no-op (no portem encara la + // columna a YAML), així que el descartem amb claredat. + if (TYPE == "door") { return false; } + + std::vector filtered; + 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); + if (STR.empty()) { return false; } + if (!entities.removeEntity(target)) { return false; } + try { + auto node = fkyaml::node::deserialize(STR.begin(), STR.end()); + return entities.addSpawn(node) != nullptr; + } catch (const std::exception&) { + return false; + } +} + +void Editor::deleteSelected(EntityManager& entities) { + auto list = entities.getEntities(); + if (hand_selected_idx_ < 0 || hand_selected_idx_ >= static_cast(list.size())) { return; } + auto* target = list[hand_selected_idx_]; + if (entities.removeEntity(target)) { hand_selected_idx_ = -1; } +} + +void Editor::renderEntityFrame(const Entity& e, std::uint32_t argb) { + const auto R = e.getPixelRect(); + auto& screen = Screen::get(); + const auto X = static_cast(R.x); + const auto Y = static_cast(R.y); + const auto W = static_cast(R.w); + const auto H = static_cast(R.h); + if (W <= 0 || H <= 0) { return; } + screen.fillRect(X, Y, W, 1, argb); + screen.fillRect(X, Y + H - 1, W, 1, argb); + screen.fillRect(X, Y, 1, H, argb); + screen.fillRect(X + W - 1, Y, 1, H, argb); } auto Editor::tryPlaceEntity(EntityManager& entities) -> bool { @@ -586,7 +698,7 @@ void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y } } -void Editor::render(const Tilemap& tilemap) const { +void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const { if (!active_) { return; } auto& screen = Screen::get(); constexpr int W = GameCanvas::WIDTH; @@ -601,6 +713,19 @@ void Editor::render(const Tilemap& tilemap) const { const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize()); const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize()); tilemap.renderTilePreview(PX, PY, brush_tile_); + } else if (mode_ == Mode::HAND && picker_ == Picker::NONE) { + // Marcs d'entitat: hover (tenue) i selecció (brillant). El + // pickerHoverArgb el reusem com a "selecció brillant"; el + // border (tipus blau) com a "hover tenue". + const auto LIST = entities.getEntities(); + const auto HOVER_ARGB = EditorConfig::get().borderArgb(); + const auto SELECT_ARGB = EditorConfig::get().pickerHoverArgb(); + if (hand_hover_idx_ >= 0 && hand_hover_idx_ < static_cast(LIST.size()) && hand_hover_idx_ != hand_selected_idx_) { + renderEntityFrame(*LIST[hand_hover_idx_], HOVER_ARGB); + } + if (hand_selected_idx_ >= 0 && hand_selected_idx_ < static_cast(LIST.size())) { + renderEntityFrame(*LIST[hand_selected_idx_], SELECT_ARGB); + } } const auto BORDER = EditorConfig::get().borderArgb(); diff --git a/source/game/editor/editor.hpp b/source/game/editor/editor.hpp index 6a48baf..39927d4 100644 --- a/source/game/editor/editor.hpp +++ b/source/game/editor/editor.hpp @@ -7,6 +7,7 @@ #include #include +class Entity; class EntityManager; class Surface; class Tilemap; @@ -56,7 +57,9 @@ class Editor { // Pinta el border blau i, segons mode i picker, brush preview, marc // d'entitat seleccionada o el picker. No-op si no actiu. - void render(const Tilemap& tilemap) const; + // `entities` és necessari per al marc d'entitat hovered/seleccionada + // en Mode::HAND; en altres modes no s'usa. + void render(const Tilemap& tilemap, const EntityManager& entities) const; private: // Mode del cursor: determina què fa el clic esquerre sobre el mapa. @@ -95,6 +98,24 @@ class Editor { // o el snap no és vàlid. Retorna true si s'ha col·locat. auto tryPlaceEntity(EntityManager& entities) -> bool; + // Mou l'entitat seleccionada a la cel·la del cursor: borra l'antiga + // i crida `addSpawn` amb la nova posició. Reusa la mateixa lògica + // de snap/solapament que la col·locació nova, excloent l'entitat + // que es mou del check de solapament. + auto tryMoveSelected(EntityManager& entities) const -> bool; + + // Sub-handlers de `updateHand`. Aïllats per mantenir baixa la + // complexitat cognitiva del bucle d'esdeveniments. + void handleHandLeftClick(EntityManager& entities); + void handleHandRightClick(); + + // Borra l'entitat seleccionada. La validació de constraints + // (1 puerta, ≥1 sarc) es fa al pas 8. + void deleteSelected(EntityManager& entities); + + // Render del marc d'una entitat: 1 px sobre el seu pixel_rect. + static void renderEntityFrame(const Entity& e, std::uint32_t argb); + void renderTilePicker(const Tilemap& tilemap) const; void renderEntityPicker(const Tilemap& tilemap) const; @@ -137,7 +158,12 @@ class Editor { std::vector editable_entities_; - // Selecció en Mode::HAND (índex dins de l'EntityManager; -1 = cap). + // Selecció en Mode::HAND. Treballem amb índexs sobre el snapshot de + // `EntityManager::getEntities()` perquè és el que ens dóna l'API + // pública. Després de cada mutació (move/delete) ho recalculem; com + // que l'editor congela el joc, el snapshot només canvia per accions + // pròpies. + int hand_hover_idx_{-1}; int hand_selected_idx_{-1}; }; diff --git a/source/game/entities/entity_manager.cpp b/source/game/entities/entity_manager.cpp index 0a679c6..45b67bb 100644 --- a/source/game/entities/entity_manager.cpp +++ b/source/game/entities/entity_manager.cpp @@ -87,6 +87,21 @@ void EntityManager::setDoor(Door* door) { } } +auto EntityManager::removeEntity(Entity* observer) -> bool { + if (observer == nullptr) { return false; } + const bool WAS_DOOR = (door_ == observer); + const bool WAS_SARC = (observer->getName() == "sarcophagus"); + auto it = std::ranges::find_if(entities_, [observer](const auto& up) { return up.get() == observer; }); + if (it == entities_.end()) { return false; } + entities_.erase(it); + if (WAS_DOOR) { door_ = nullptr; } + if (WAS_SARC && total_sarcs_ > 0) { --total_sarcs_; } + // No tocar `opened_sarcs_` aquí: l'editor congela el joc i no pot + // disparar-se cap esdeveniment derivat. Quan el GameScene faça el + // reload posterior, els comptadors es re-deriven d'inici. + return true; +} + auto EntityManager::addSarcophagus(std::unique_ptr sarc) -> Sarcophagus* { if (sarc == nullptr) { return nullptr; } sarc->setEventBus(&bus_); diff --git a/source/game/entities/entity_manager.hpp b/source/game/entities/entity_manager.hpp index 363ed1b..77b3c25 100644 --- a/source/game/entities/entity_manager.hpp +++ b/source/game/entities/entity_manager.hpp @@ -44,6 +44,13 @@ class EntityManager { // una. Si ja n'hi ha una assignada, la substitueix (útil a tests). void setDoor(Door* door); + // Treu i destrueix l'entitat observada. Si era la porta o un sarc, + // ajusta `door_` / `total_sarcs_` en conseqüència. Pensat per al + // editor: no toca `opened_sarcs_` ni publica events — el joc està + // congelat i, en sortir del editor, GameScene torna a fer reload(), + // així que l'estat es deriva net. + auto removeEntity(Entity* observer) -> bool; + // Marca una entitat com a sarcòfag — si no es passa per `add` amb // type=sarcophagus no es compta al total. Útil en combinació amb // `add` quan es fa addSarcophagus(unique_ptr). diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index f5489da..ca0fd7a 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -1328,7 +1328,7 @@ void GameScene::render() { } #ifdef _DEBUG - editor_.render(tilemap_); + editor_.render(tilemap_, entity_manager_); #endif // Overlay de pausa: text amb stroke blanc (índex 255) centrat amb X From ecb462546516a3780f7b62ba9d0ab7a180be3450 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 11:35:10 +0200 Subject: [PATCH 07/13] =?UTF-8?q?feat(editor):=20EntityWriter=20=E2=80=94?= =?UTF-8?q?=20persisteix=20el=20bloc=20entities:=20al=20sortir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 3 + source/game/editor/editor.cpp | 43 ++++++++- source/game/editor/editor.hpp | 9 +- source/game/entities/entity_writer.cpp | 115 +++++++++++++++++++++++++ source/game/entities/entity_writer.hpp | 50 +++++++++++ source/game/scenes/game_scene.cpp | 2 +- 6 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 source/game/entities/entity_writer.cpp create mode 100644 source/game/entities/entity_writer.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 84f3ebb..ca7cb1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -123,6 +123,9 @@ set(APP_SOURCES # arrossega cap símbol del joc final. source/game/editor/editor.cpp source/game/editor/editor_config.cpp + # Writer del bloc `entities:` del YAML del nivell, paral·lel al + # TilemapWriter pero per a la persistència de l'editor d'entitats. + source/game/entities/entity_writer.cpp ${ENTITY_SOURCES} # Fitxer només del binari principal: `loadFromLevel` depèn de # Resource::Cache (→ Surface → GIF → àudio). Separat per no arrossegar diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 4a2625f..224c2f4 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -21,6 +21,7 @@ #include "game/entities/entity_factory.hpp" #include "game/entities/entity_loader.hpp" #include "game/entities/entity_manager.hpp" +#include "game/entities/entity_writer.hpp" #include "game/levels/level_manager.hpp" #include "game/map/map.hpp" #include "game/map/tilemap.hpp" @@ -226,10 +227,42 @@ void Editor::ensureEditableEntities() { } } +namespace { + + // Reconstrueix els spawns del nivell des de l'estat viu del manager. + // Per cada entitat: extreu type i la posició adequada (`position` + // generic, `sarc_position` per a sarcs, cap per a `door`). + auto gatherSpawns(const EntityManager& entities) -> std::vector { + std::vector out; + const auto LIST = entities.getEntities(); + out.reserve(LIST.size()); + for (const auto* e : LIST) { + EntityWriter::Spawn s; + s.type = e->getName(); + if (s.type == "sarcophagus") { + if (Map::pitch_x > 0 && Map::pitch_y > 0) { + const auto LOGICAL = e->getCollisionRect(); + s.sarc_col = (static_cast(LOGICAL.x) - Map::origin_x) / Map::pitch_x; + s.sarc_row = (static_cast(LOGICAL.y) - Map::origin_y) / Map::pitch_y; + } + } else if (s.type != "door") { + if (Map::tile_size > 0) { + const auto R = e->getPixelRect(); + s.col = (static_cast(R.x) - Map::tilemap_origin_x) / Map::tile_size; + s.row = (static_cast(R.y) - Map::tilemap_origin_y) / Map::tile_size; + } + } + out.push_back(std::move(s)); + } + return out; + } + +} // namespace + // cppcheck-suppress constParameterReference // Tilemap& mutable per simetria amb `toggle()` i per futures // invalidacions de caches d'edició, encara que `save()` sigui const. -void Editor::exit(Tilemap& tilemap) { +void Editor::exit(Tilemap& tilemap, const EntityManager& entities) { if (!active_) { return; } active_ = false; picker_ = Picker::NONE; @@ -240,13 +273,15 @@ void Editor::exit(Tilemap& tilemap) { // no el CWD; sense això, el writer obriria el path relatiu al CWD i // fallaria si el binari no es llança des del repo root. const auto ABS_PATH = Resource::Helper::resolve(Levels::LevelManager::get().currentPath()); - const bool SAVED = tilemap.save(ABS_PATH); + const bool TILES_OK = tilemap.save(ABS_PATH); + const bool ENTITIES_OK = EntityWriter::saveEntitiesBlock(ABS_PATH, gatherSpawns(entities)); + const bool SAVED = TILES_OK && ENTITIES_OK; Notification::notify(Locale::get().text(SAVED ? "input.editor_off" : "input.editor_save_error")); } -void Editor::toggle(Tilemap& tilemap) { +void Editor::toggle(Tilemap& tilemap, const EntityManager& entities) { if (active_) { - exit(tilemap); + exit(tilemap, entities); } else { enter(); } diff --git a/source/game/editor/editor.hpp b/source/game/editor/editor.hpp index 39927d4..90e7637 100644 --- a/source/game/editor/editor.hpp +++ b/source/game/editor/editor.hpp @@ -40,13 +40,14 @@ class Editor { // d'entitats / jugador la gestiona GameScene consultant `active()`. void enter(); - // Surt del mode editor: persisteix el tilemap al YAML (writer surgical) - // i notifica. Si la persistència falla, emet la notificació d'error. - void exit(Tilemap& tilemap); + // Surt del mode editor: persisteix el tilemap i les entitats al YAML + // (writers surgicals) i notifica. Si la persistència falla, emet la + // notificació d'error. + void exit(Tilemap& tilemap, const EntityManager& entities); // Helper: enter() si no actiu, exit() si actiu. Útil des de GameScene // quan detecta la tecla de toggle. - void toggle(Tilemap& tilemap); + void toggle(Tilemap& tilemap, const EntityManager& entities); [[nodiscard]] auto active() const -> bool { return active_; } diff --git a/source/game/entities/entity_writer.cpp b/source/game/entities/entity_writer.cpp new file mode 100644 index 0000000..7195ab5 --- /dev/null +++ b/source/game/entities/entity_writer.cpp @@ -0,0 +1,115 @@ +#include "game/entities/entity_writer.hpp" + +#ifdef _DEBUG + +#include +#include +#include +#include +#include +#include + +namespace EntityWriter { + + namespace { + + // Cerca la línia que comença per `:` a columna 0. Mateix + // patró que TilemapWriter::findDataLine però parametritzat. Si + // un dia paga la pena, podem extreure'l a un helper compartit. + auto findKeyLine(const std::string& content, const std::string& key) -> std::size_t { + const std::string PREFIX = key + ":"; + std::size_t pos = 0; + while (pos < content.size()) { + const bool AT_LINE_START = (pos == 0 || content[pos - 1] == '\n'); + if (AT_LINE_START && content.compare(pos, PREFIX.size(), PREFIX) == 0) { + const std::size_t AFTER = pos + PREFIX.size(); + if (AFTER >= content.size() || content[AFTER] == '\n' || content[AFTER] == '\r' || content[AFTER] == ' ' || content[AFTER] == '\t' || content[AFTER] == '#') { + return pos; + } + } + const auto NL = content.find('\n', pos); + if (NL == std::string::npos) { break; } + pos = NL + 1; + } + return std::string::npos; + } + + // Avança mentre trobe línies indentades (espai/tab) o línies buides. + // A diferència del TilemapWriter, ací incloem línies buides dins + // del bloc: l'editor de YAML deixa sovint blanc entre grups + // d'entitats, i volem absorbir-lo per no deixar restes orfes + // entre el bloc nou i el següent top-level. + auto advancePastBlock(const std::string& content, std::size_t pos) -> std::size_t { + while (pos < content.size()) { + const char C = content[pos]; + const bool IS_INDENT = (C == ' ' || C == '\t'); + const bool IS_BLANK = (C == '\n' || C == '\r'); + if (!IS_INDENT && !IS_BLANK) { break; } + const auto NL = content.find('\n', pos); + pos = (NL == std::string::npos) ? content.size() : NL + 1; + } + return pos; + } + + auto buildBlock(const std::vector& spawns) -> std::string { + std::string out; + for (const auto& s : spawns) { + out += " - type: " + s.type + "\n"; + if (s.sarc_col.has_value() && s.sarc_row.has_value()) { + out += " sarc_position: [" + std::to_string(*s.sarc_col) + ", " + std::to_string(*s.sarc_row) + "]\n"; + } else if (s.col.has_value() && s.row.has_value()) { + out += " position: [" + std::to_string(*s.col) + ", " + std::to_string(*s.row) + "]\n"; + } + } + return out; + } + + } // namespace + + auto saveEntitiesBlock(const std::string& path, const std::vector& spawns) -> bool { + std::ifstream in(path, std::ios::binary); + if (!in) { return false; } + std::ostringstream ss; + ss << in.rdbuf(); + const std::string CONTENT = ss.str(); + in.close(); + + const auto KEY_POS = findKeyLine(CONTENT, "entities"); + if (KEY_POS == std::string::npos) { return false; } + + const auto LINE_END = CONTENT.find('\n', KEY_POS); + const auto AFTER_KEY = (LINE_END == std::string::npos) ? CONTENT.size() : LINE_END + 1; + const auto BLOCK_END = advancePastBlock(CONTENT, AFTER_KEY); + + // Mantenim una línia en blanc darrere del bloc si abans n'hi + // havia: ajuda a separar visualment el `entities:` de la pròxima + // secció. Si no n'hi havia, no afegim res. + const std::string TAIL = CONTENT.substr(BLOCK_END); + const std::string NEW_CONTENT = + CONTENT.substr(0, AFTER_KEY) + buildBlock(spawns) + TAIL; + + const auto TMP_PATH = path + ".tmp"; + { + std::ofstream out(TMP_PATH, std::ios::binary | std::ios::trunc); + if (!out) { return false; } + out.write(NEW_CONTENT.data(), static_cast(NEW_CONTENT.size())); + out.close(); + if (!out) { + std::error_code ec; + std::filesystem::remove(TMP_PATH, ec); + return false; + } + } + + std::error_code ec; + std::filesystem::rename(TMP_PATH, path, ec); + if (ec) { + std::filesystem::remove(TMP_PATH, ec); + return false; + } + return true; + } + +} // namespace EntityWriter + +#endif // _DEBUG diff --git a/source/game/entities/entity_writer.hpp b/source/game/entities/entity_writer.hpp new file mode 100644 index 0000000..bf003f6 --- /dev/null +++ b/source/game/entities/entity_writer.hpp @@ -0,0 +1,50 @@ +#pragma once + +#ifdef _DEBUG + +#include +#include +#include + +// Reescriu NOMÉS el bloc `entities:` d'un YAML de nivell, preservant byte +// a byte la resta del fitxer: comentaris, ordre de claus, format del +// `geometry:` i `data:`, encoding i line endings entre línies no tocades. +// Pensat exclusivament per a l'editor (Debug). +// +// Format de sortida (estable, llegible a l'editor de text): +// entities: +// - type: torch +// position: [4, 2] +// - type: sarcophagus +// sarc_position: [0, 0] +// - type: door +// +// Semàntica del bloc origen: +// - Línia de capçalera: primera aparició de "entities:" a columna 0. +// Si manca, el writer FALLA i no toca el fitxer. +// - Contingut: línies indentades (espai o tab). Una línia buida o una +// línia a columna 0 marquen la fi del bloc. +// +// Si el fitxer original no té el bloc `entities:`, el writer no inventa +// ubicació: retorna false per que el caller decidisca (mostrar error, +// avortar, etc.) — no és funció del writer escollir l'estil del YAML. +// +// Escriptura atòmica: via `.tmp` + `std::filesystem::rename`. Si +// l'escriptura falla a meitat, el YAML original queda intacte. +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). + struct Spawn { + std::string type; + std::optional col; + std::optional row; + std::optional sarc_col; + std::optional sarc_row; + }; + + auto saveEntitiesBlock(const std::string& path, const std::vector& spawns) -> bool; + +} // namespace EntityWriter + +#endif // _DEBUG diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index ca0fd7a..6d56735 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -1147,7 +1147,7 @@ void GameScene::update(int delta_ms) { // sessió d'edició parteix del fitxer, no de l'estat runtime previ). if (pause_.state == PauseState::IDLE && detectEditorToggle()) { const bool WAS_ACTIVE = editor_.active(); - editor_.toggle(tilemap_); + editor_.toggle(tilemap_, entity_manager_); if (WAS_ACTIVE) { entity_manager_.reload(Levels::LevelManager::get().currentPath()); const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true); From 10e16d790518fac93004cc5ce59aede58a2a8d5f Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 11:37:57 +0200 Subject: [PATCH 08/13] =?UTF-8?q?feat(editor):=20valida=201=20porta=20i=20?= =?UTF-8?q?=E2=89=A51=20sarc=20en=20sortida=20i=20en=20borrar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/locale/ca.yaml | 3 +++ data/locale/en.yaml | 3 +++ source/game/editor/editor.cpp | 30 ++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index d4d6b4b..a448e58 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -27,6 +27,9 @@ input: editor_on: "Editor ON" editor_off: "Editor OFF" editor_save_error: "Editor: error guardant el mapa" + editor_keep_door: "Editor: cal almenys una porta" + editor_keep_sarc: "Editor: cal almenys un sarcòfag" + editor_invalid_level: "Editor: nivell incomplet (porta i sarcs)" hud: p1: "P1" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index f9e123e..5971339 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -27,6 +27,9 @@ input: editor_on: "Editor ON" editor_off: "Editor OFF" editor_save_error: "Editor: map save failed" + editor_keep_door: "Editor: at least one door required" + editor_keep_sarc: "Editor: at least one sarcophagus required" + editor_invalid_level: "Editor: level incomplete (door and sarcs)" hud: p1: "P1" diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 224c2f4..183b67d 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -229,6 +229,16 @@ void Editor::ensureEditableEntities() { namespace { + auto countByName(const EntityManager& entities, std::string_view name) -> int { + const auto LIST = entities.getEntities(); + return static_cast(std::ranges::count_if(LIST, + [name](const Entity* e) { return e->getName() == name; })); + } + + auto levelIsValid(const EntityManager& entities) -> bool { + return countByName(entities, "door") == 1 && countByName(entities, "sarcophagus") >= 1; + } + // Reconstrueix els spawns del nivell des de l'estat viu del manager. // Per cada entitat: extreu type i la posició adequada (`position` // generic, `sarc_position` per a sarcs, cap per a `door`). @@ -264,6 +274,14 @@ namespace { // invalidacions de caches d'edició, encara que `save()` sigui const. void Editor::exit(Tilemap& tilemap, const EntityManager& entities) { if (!active_) { return; } + // Constraint del nivell: cal exactament una porta i com a mínim un + // sarcòfag perquè la sala sigui jugable. Si no es compleix, ens + // quedem dins del editor i avisem; l'usuari ha d'arreglar-ho abans + // de poder eixir (i, per tant, persistir). + if (!levelIsValid(entities)) { + Notification::notify(Locale::get().text("input.editor_invalid_level")); + return; + } active_ = false; picker_ = Picker::NONE; // El writer salva sempre sobre la sala actualment carregada (segons @@ -590,6 +608,18 @@ void Editor::deleteSelected(EntityManager& entities) { auto list = entities.getEntities(); if (hand_selected_idx_ < 0 || hand_selected_idx_ >= static_cast(list.size())) { return; } auto* target = list[hand_selected_idx_]; + const auto NAME = target->getName(); + // Bloqueig en-place: només denegem el borrat si convertiria el + // nivell en invàlid. Per a sarcs deixem baixar fins a 1; per a + // porta no deixem mai eliminar la única. + if (NAME == "door") { + Notification::notify(Locale::get().text("input.editor_keep_door")); + return; + } + if (NAME == "sarcophagus" && countByName(entities, "sarcophagus") <= 1) { + Notification::notify(Locale::get().text("input.editor_keep_sarc")); + return; + } if (entities.removeEntity(target)) { hand_selected_idx_ = -1; } } From 2a78c78e7783c40be0adb12f29ffe2af7211d31d Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 11:41:25 +0200 Subject: [PATCH 09/13] feat(editor): franja HUD amb mode, brush/sel, cursor i recompte de constraints --- source/game/editor/editor.cpp | 81 +++++++++++++++++++++++++++++++++++ source/game/editor/editor.hpp | 13 ++++++ 2 files changed, 94 insertions(+) diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 183b67d..5ced037 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -9,12 +9,15 @@ #include "core/input/key_config.hpp" #include "core/locale/locale.hpp" +#include "core/rendering/hud_constants.hpp" #include "core/rendering/notification_presets.hpp" #include "core/rendering/screen.hpp" #include "core/rendering/surface.hpp" +#include "core/rendering/text.hpp" #include "core/resources/resource_cache.hpp" #include "core/resources/resource_helper.hpp" #include "core/system/event_buffer.hpp" +#include "core/ui/ui_config.hpp" #include "external/fkyaml_node.hpp" #include "game/editor/editor_config.hpp" #include "game/entities/entity.hpp" @@ -185,9 +188,19 @@ void Editor::enter() { brush_entity_.clear(); hand_selected_idx_ = -1; ensureEditableEntities(); + ensureHudFont(); Notification::notify(Locale::get().text("input.editor_on")); } +void Editor::ensureHudFont() { + if (hud_font_ != nullptr) { return; } + const auto KEY = UiConfig::get().hudFont().name + ".gif"; + auto surface = Resource::Cache::get().getSurface(KEY); + if (surface == nullptr) { return; } + hud_font_ = std::make_shared(surface, UiConfig::get().hudFont().fnt); + hud_font_->setDefaultKerning(UiConfig::get().hudFont().kerning); +} + void Editor::ensureEditableEntities() { if (!editable_entities_.empty()) { return; } // Recorrem tots els noms registrats al factory i ens quedem amb els @@ -623,6 +636,67 @@ void Editor::deleteSelected(EntityManager& entities) { if (entities.removeEntity(target)) { hand_selected_idx_ = -1; } } +void Editor::renderEditorHud(const EntityManager& entities) const { + if (hud_font_ == nullptr || Map::hud_h <= 0) { return; } + auto& screen = Screen::get(); + const auto& pal = Resource::Cache::get().getPalette("ui"); + screen.fillRect(0, 0, GameCanvas::WIDTH, Map::hud_h, pal[UiConfig::get().hudBg()]); + + std::string mode_str; + switch (mode_) { + case Mode::TILE: + mode_str = "TILE"; + break; + case Mode::ENTITY: + mode_str = "ENTITY"; + break; + case Mode::HAND: + mode_str = "HAND"; + break; + } + + std::string brush_label; + std::string brush_value; + if (mode_ == Mode::TILE) { + brush_label = "BRUSH:"; + brush_value = (brush_tile_ >= 0) ? std::to_string(brush_tile_) : "-"; + } else if (mode_ == Mode::ENTITY) { + brush_label = "BRUSH:"; + brush_value = brush_entity_.empty() ? "-" : brush_entity_; + } else { + const auto LIST = entities.getEntities(); + brush_label = "SEL:"; + brush_value = (hand_selected_idx_ >= 0 && hand_selected_idx_ < static_cast(LIST.size())) + ? LIST[hand_selected_idx_]->getName() + : "-"; + } + + const std::string COORD = (cursor_col_ >= 0) + ? "(" + std::to_string(cursor_col_) + "," + std::to_string(cursor_row_) + ")" + : "(--,--)"; + + const auto LBL = UiConfig::get().hudLabel(); + const auto VAL = UiConfig::get().hudValue(); + const int KERN = UiConfig::get().hudFont().kerning; + const int Y = (Map::hud_h - Rendering::Hud::FONT_SIZE_PX) / 2; + int x = 2; + + auto draw = [&](const std::string& s, std::uint8_t color) { + hud_font_->writeColored(x, Y, s, color, KERN); + x += hud_font_->length(s, KERN); + }; + + draw("MODE:", LBL); + draw(" " + mode_str, VAL); + draw(" " + brush_label, LBL); + draw(" " + brush_value, VAL); + draw(" " + COORD, VAL); + draw(" DOOR:", LBL); + draw(" " + std::to_string(countByName(entities, "door")), VAL); + draw(" SARCS:", LBL); + draw(" " + std::to_string(countByName(entities, "sarcophagus")), VAL); +} + void Editor::renderEntityFrame(const Entity& e, std::uint32_t argb) { const auto R = e.getPixelRect(); auto& screen = Screen::get(); @@ -793,6 +867,13 @@ void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const } } + // HUD d'edició: només quan no hi ha picker obert (no té sentit + // tapar el picker amb la franja). Es pinta abans del border per + // que la línia de vora quedi visible per damunt. + if (picker_ == Picker::NONE) { + renderEditorHud(entities); + } + const auto BORDER = EditorConfig::get().borderArgb(); screen.fillRect(0, 0, W, 1, BORDER); screen.fillRect(0, H - 1, W, 1, BORDER); diff --git a/source/game/editor/editor.hpp b/source/game/editor/editor.hpp index 90e7637..500db4f 100644 --- a/source/game/editor/editor.hpp +++ b/source/game/editor/editor.hpp @@ -10,6 +10,7 @@ class Entity; class EntityManager; class Surface; +class Text; class Tilemap; // Mode editor modal del GameScene. Només disponible en Debug — en Release @@ -117,6 +118,13 @@ class Editor { // Render del marc d'una entitat: 1 px sobre el seu pixel_rect. static void renderEntityFrame(const Entity& e, std::uint32_t argb); + // HUD d'edició: franja superior amb mode, brush, cursor i comptadors + // de constraints. Substitueix el HUD del joc mentre l'editor és actiu. + void renderEditorHud(const EntityManager& entities) const; + + // Carrega la font 8bithud del cache. Lazy: al primer enter(). + void ensureHudFont(); + void renderTilePicker(const Tilemap& tilemap) const; void renderEntityPicker(const Tilemap& tilemap) const; @@ -159,6 +167,11 @@ class Editor { std::vector editable_entities_; + // 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. + std::shared_ptr hud_font_; + // Selecció en Mode::HAND. Treballem amb índexs sobre el snapshot de // `EntityManager::getEntities()` perquè és el que ens dóna l'API // pública. Després de cada mutació (move/delete) ho recalculem; com From d13313503cc58cd37a1855bf6d5c9f7f303e9576 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 12:20:16 +0200 Subject: [PATCH 10/13] fix(editor): preferDisk en Debug + reload de sala al entrar i sortir --- source/core/resources/resource_helper.cpp | 51 ++++++++++++++++++++--- source/core/resources/resource_helper.hpp | 12 +++++- source/game/scenes/game_scene.cpp | 51 +++++++++++++++++++---- source/game/scenes/game_scene.hpp | 7 ++++ 4 files changed, 106 insertions(+), 15 deletions(-) diff --git a/source/core/resources/resource_helper.cpp b/source/core/resources/resource_helper.cpp index 79c6d5c..86cf03b 100644 --- a/source/core/resources/resource_helper.cpp +++ b/source/core/resources/resource_helper.cpp @@ -15,12 +15,18 @@ namespace Resource::Helper { Pack g_pack; bool g_pack_loaded = false; bool g_fallback_enabled = true; + // Inverteix la prioritat (disc abans que pack). Activat en Debug + // per defecte; veure setPreferDisk al header. + bool g_prefer_disk = false; - auto readFromDisk(const std::string& filepath) -> std::vector { + auto readFromDisk(const std::string& filepath, bool verbose = true) -> std::vector { const auto FULL = resolve(filepath); std::ifstream file(FULL, std::ios::binary | std::ios::ate); if (!file) { - std::cerr << "Resource::Helper: cannot open " << FULL << '\n'; + // Silenciem si veníem de prefer_disk: aleshores caurem + // al pack i no és cap error real que el fitxer no estigui + // al disc. + if (verbose) { std::cerr << "Resource::Helper: cannot open " << FULL << '\n'; } return {}; } std::streamsize size = file.tellg(); @@ -37,6 +43,11 @@ namespace Resource::Helper { auto initializeResourceSystem(const std::string& pack_file, bool enable_fallback) -> bool { g_fallback_enabled = enable_fallback; + // En el flux Debug típic (fallback actiu, source tree disponible) + // preferim sempre disc: el desenvolupador edita YAML/GIF a `data/` + // i ho veu reflectit al següent reload sense re-packejar. En + // Release el fallback està desactivat i aquest flag és inert. + g_prefer_disk = enable_fallback; g_pack_loaded = g_pack.loadPack(pack_file); if (g_pack_loaded) { @@ -76,17 +87,45 @@ namespace Resource::Helper { } auto loadFile(const std::string& filepath) -> std::vector { - if (g_pack_loaded) { - // El pack està indexat relatiu a `data/` (la carpeta que - // s'empaqueta), però els callers passen "data/foo.yaml" amb - // prefix. Fem el strip ací perquè no hagin de tenir-ho en compte. + // Helper local: lookup al pack normalitzant la clau (els callers + // passen "data/foo.yaml" però l'índex del pack és relatiu a la + // carpeta `data/`). + const auto FROM_PACK = [&filepath]() -> std::vector { + if (!g_pack_loaded) { return {}; } constexpr std::string_view PREFIX = "data/"; std::string key = filepath; if (key.starts_with(PREFIX)) { key.erase(0, PREFIX.size()); } if (g_pack.hasResource(key)) { return g_pack.getResource(key); } + return {}; + }; + + if (g_prefer_disk && g_fallback_enabled) { + // En Debug volem que el disc guanyi: així les edicions del + // editor in-game (i les manuals del repo) es vegen al pròxim + // reload sense rebuild del pack. Si el fitxer no és al disc, + // caiem al pack — útil per a recursos que només s'han + // empaquetat (cas estrany). El silenci a `readFromDisk` evita + // emplenar el log amb "cannot open" en aquest camí esperat. + auto bytes = readFromDisk(filepath, false); + if (!bytes.empty()) { return bytes; } + return FROM_PACK(); } + + // Política original: pack primer; disc com a fallback si el + // recurs no està empaquetat (típic d'arrencada en Debug abans + // del primer pack). + auto packed = FROM_PACK(); + if (!packed.empty()) { return packed; } if (g_fallback_enabled) { return readFromDisk(filepath); } return {}; } + void setPreferDisk(bool prefer) { + g_prefer_disk = prefer; + } + + auto preferDisk() -> bool { + return g_prefer_disk; + } + } // namespace Resource::Helper diff --git a/source/core/resources/resource_helper.hpp b/source/core/resources/resource_helper.hpp index 3f2b3d8..bfb14d2 100644 --- a/source/core/resources/resource_helper.hpp +++ b/source/core/resources/resource_helper.hpp @@ -32,7 +32,17 @@ namespace Resource::Helper { // Carrega un recurs pel seu camí relatiu (p.ex. "fonts/hud.gif", // "input/keys.yaml"). Prova primer el pack; si no hi és i el fallback // està actiu, cau a disc (data root + ruta). Retorna vector buit si - // no el troba. + // no el troba. Si `preferDisk()` és true (Debug), inverteix la + // prioritat: prova disc abans del pack. auto loadFile(const std::string& filepath) -> std::vector; + // Inverteix la prioritat de `loadFile` (disc abans que pack). En Debug + // s'activa per defecte al `initializeResourceSystem` perquè cada + // canvi al YAML/GIF al source tree es vegi sense re-packejar — és el + // que necessita l'editor in-game per veure les seues edicions + // immediatament al recarregar la sala. En Release no té efecte + // (el fallback està desactivat). + void setPreferDisk(bool prefer); + [[nodiscard]] auto preferDisk() -> bool; + } // namespace Resource::Helper diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 6d56735..61c9a8e 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -607,6 +607,30 @@ void GameScene::enterRoomIntro() { } } +#ifdef _DEBUG +void GameScene::respawnPlayersInstant() { + // Variant sense cinemàtica de l'spawn de l'INTRO: per a l'editor + // volem que la sala torni a un estat jugable d'immediat (sense + // música de sala, sense caiguda aèria). Reusem `computeSpawn` i el + // mateix patró de `triggerPlayerSpawn` però cridem `respawn(x, y)` + // (teleport instantani) en lloc de `spawnFromAir(x, y)`. + const auto SPAWN = computeSpawn(); + const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]); + const float SINGLE_X = SPAWN.p1_x + static_cast(Map::tile_size); + for (std::size_t i = 0; i < players_.size(); ++i) { + if (!players_starts_active_[i]) { continue; } + float x = SINGLE_X; + if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; } + players_[i]->setSpawnPoint(x, SPAWN.y); + players_[i]->respawn(x, SPAWN.y); + } + state_ = RoomFsm::State::PLAYING; + intro_completed_ = true; + transition_ = {}; + std::cout << "[ROOM] post-editor: spawn instantani, estat -> PLAYING\n"; +} +#endif + void GameScene::triggerPlayerSpawn(std::size_t slot_index) { if (slot_index >= players_.size()) { return; } if (!players_starts_active_[slot_index]) { return; } @@ -1141,17 +1165,28 @@ void GameScene::update(int delta_ms) { #ifdef _DEBUG // Toggle de l'editor: només si no hi ha cap transició de pausa ni // pausa consolidada. L'editor mateix no consumeix la tecla — la - // llegim ací per decidir la coexistència. Quan se surt del editor, - // recarreguem les entitats des del YAML perquè el manager torne a - // estar sincronitzat amb el que el writer acaba de desar (cada - // sessió d'edició parteix del fitxer, no de l'estat runtime previ). + // llegim ací per decidir la coexistència. Tant a l'entrada com a la + // sortida fem `loadRoom` complet per partir sempre del YAML del + // disc: les entitats tornen a la posició inicial (momies, sarcs), + // el paint layer es buida i els jugadors es desactiven. Així + // l'editor sempre veu l'estat-base de la sala (no el d'una partida + // a meitat) i el joc, en sortir, arrenca fresc amb les edicions ja + // persistides al YAML. if (pause_.state == PauseState::IDLE && detectEditorToggle()) { const bool WAS_ACTIVE = editor_.active(); editor_.toggle(tilemap_, entity_manager_); - if (WAS_ACTIVE) { - entity_manager_.reload(Levels::LevelManager::get().currentPath()); - const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true); - entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT); + const bool NOW_ACTIVE = editor_.active(); + if (!WAS_ACTIVE && NOW_ACTIVE) { + // Acabem d'entrar: carreguem fresh des del disc. Els + // jugadors es queden DISABLED (l'editor no els pinta de + // totes maneres) i es reactivaran amb el spawn instantani + // a la sortida. + loadRoom(Levels::LevelManager::get().currentPath()); + } else if (WAS_ACTIVE && !NOW_ACTIVE) { + // Acabem de sortir: el writer ja ha persistit el YAML. + // Carreguem fresh i fem spawn instantani sense cinemàtica. + loadRoom(Levels::LevelManager::get().currentPath()); + respawnPlayersInstant(); } } diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index 7619223..2adaf83 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -288,6 +288,13 @@ class GameScene : public Scene { // Idempotent quan ja s'estava a INTRO — útil per als salts debug. void enterRoomIntro(); +#ifdef _DEBUG + // Spawn instantani sense cinemàtica: usat al sortir del editor per + // fer un "fresh start" de la sala (jugadors al spawn, sense la + // cinemàtica `room_intro.ogg`). Posa l'estat directament a PLAYING. + void respawnPlayersInstant(); +#endif + #ifdef _DEBUG // Editor modal del tilemap. Mútuament excloent amb la pausa: si la // pausa no està IDLE la tecla de toggle de l'editor s'ignora, i From c0362abf67e4b69e56f7fa1d141f31f796339c38 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 12:22:14 +0200 Subject: [PATCH 11/13] =?UTF-8?q?fix(editor):=20inverteix=20posici=C3=B3?= =?UTF-8?q?=20des=20del=20collision=5Frect=20i=20restaura=20m=C3=B2mies=20?= =?UTF-8?q?r1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/maps/p1/r1.yaml | 9 ++------- source/game/editor/editor.cpp | 35 +++++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/data/maps/p1/r1.yaml b/data/maps/p1/r1.yaml index eac3d3a..77d35c9 100644 --- a/data/maps/p1/r1.yaml +++ b/data/maps/p1/r1.yaml @@ -90,7 +90,6 @@ entities: position: [12, 2] - type: torch position: [16, 2] - - type: sarcophagus sarc_position: [0, 0] - type: sarcophagus @@ -131,17 +130,11 @@ entities: sarc_position: [2, 4] - type: sarcophagus sarc_position: [3, 4] - - type: door - - - type: mummy position: [10, 7] - type: mummy position: [7, 9] - - - type: hole - position: [11, 5] - type: hole position: [4, 7] - type: hole @@ -150,3 +143,5 @@ entities: position: [15, 11] - type: hole position: [15, 5] + - type: hole + position: [6, 5] diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 5ced037..6acd873 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include "core/input/key_config.hpp" @@ -223,16 +224,31 @@ void Editor::ensureEditableEntities() { const auto& sp = tmpl["sprite"]; if (!sp.contains("path") || !sp.contains("size")) { continue; } - const auto SPRITE_PATH = "data/entities/" + name + "/" + sp["path"].get_value(); + // Mateixa convenció de claus que `entity_manager_loadlevel.cpp`: + // la key del cache és el basename del fitxer (p.ex. "door.gif"), + // i el full_path només s'usa per a `loadSurface`. `getSurface` + // llança si no troba — provem primer i, en cas de miss, carreguem. + const auto REL = sp["path"].get_value(); + const std::string KEY = std::filesystem::path(REL).filename().string(); + std::string full = "data/entities/"; + full += name; + full += '/'; + full += REL; const auto SIZE = sp["size"]; if (!SIZE.is_sequence() || SIZE.size() < 2) { continue; } const int FRAME_W = SIZE[0].get_value(); const int FRAME_H = SIZE[1].get_value(); - auto surface = cache.getSurface(SPRITE_PATH); - if (surface == nullptr) { - cache.loadSurface(SPRITE_PATH, SPRITE_PATH); - surface = cache.getSurface(SPRITE_PATH); + std::shared_ptr surface; + try { + surface = cache.getSurface(KEY); + } catch (const std::exception&) { + try { + cache.loadSurface(KEY, full); + surface = cache.getSurface(KEY); + } catch (const std::exception&) { + continue; + } } if (surface == nullptr) { continue; } @@ -269,8 +285,15 @@ namespace { s.sarc_row = (static_cast(LOGICAL.y) - Map::origin_y) / Map::pitch_y; } } else if (s.type != "door") { + // Invertir des del rect de col·lisió, no del pixel_rect: + // amb anchor=feet (típic d'actors com la mòmia) el + // pixel_rect està desplaçat verticalment respecte a la + // cel·la-pivot, perquè el sprite sobresurt cap amunt amb + // falsa perspectiva. La caixa de col·lisió, en canvi, ja + // està alineada amb la cel·la del tilemap, i això és el + // que el `position: [col, row]` del YAML representa. if (Map::tile_size > 0) { - const auto R = e->getPixelRect(); + const auto R = e->getCollisionRect(); s.col = (static_cast(R.x) - Map::tilemap_origin_x) / Map::tile_size; s.row = (static_cast(R.y) - Map::tilemap_origin_y) / Map::tile_size; } From a254fdd669be2ffb7595c34f21649d1b03c08f2d Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 12:42:47 +0200 Subject: [PATCH 12/13] feat(editor): hand pick per base, z-sort, amaga entitats en TILE, HUD 2-files centrat --- data/maps/p1/r1.yaml | 6 +- source/game/editor/editor.cpp | 94 ++++++++++++++++++++++--------- source/game/editor/editor.hpp | 8 +++ source/game/scenes/game_scene.cpp | 21 +++++-- 4 files changed, 94 insertions(+), 35 deletions(-) diff --git a/data/maps/p1/r1.yaml b/data/maps/p1/r1.yaml index 77d35c9..96e3874 100644 --- a/data/maps/p1/r1.yaml +++ b/data/maps/p1/r1.yaml @@ -133,8 +133,6 @@ entities: - type: door - type: mummy position: [10, 7] - - type: mummy - position: [7, 9] - type: hole position: [4, 7] - type: hole @@ -144,4 +142,6 @@ entities: - type: hole position: [15, 5] - type: hole - position: [6, 5] + position: [8, 5] + - type: mummy + position: [8, 9] diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 6acd873..7d27bd4 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -6,11 +6,11 @@ #include #include +#include #include #include "core/input/key_config.hpp" #include "core/locale/locale.hpp" -#include "core/rendering/hud_constants.hpp" #include "core/rendering/notification_presets.hpp" #include "core/rendering/screen.hpp" #include "core/rendering/surface.hpp" @@ -180,14 +180,17 @@ namespace { void Editor::enter() { if (active_) { return; } active_ = true; - // Estat de novella: tornem a Mode::TILE sense brush ni picker. Els - // brushs (tile/entity) i la selecció de la mà no es preserven entre - // sessions del editor: cada entrada parteix net. - mode_ = Mode::TILE; + // Estat de novella: comencem en mode HAND (selecció/manipulació + // d'entitats existents) — el cas d'ús més habitual quan obres + // l'editor és inspeccionar/moure/borrar el que ja hi ha. Els + // brushs i la selecció no es preserven entre sessions: cada + // entrada parteix net. + mode_ = Mode::HAND; picker_ = Picker::NONE; brush_tile_ = -1; brush_entity_.clear(); hand_selected_idx_ = -1; + hand_hover_idx_ = -1; ensureEditableEntities(); ensureHudFont(); Notification::notify(Locale::get().text("input.editor_on")); @@ -544,15 +547,20 @@ void Editor::updateEntityBrush(EntityManager& entities, int /*canvas_x*/, int /* namespace { - // Index de l'entitat sota el píxel donat. -1 si cap. Usem el rect de - // pintat (`getPixelRect`) i no el de col·lisió perquè el dissenyador - // espera "fer clic on es veu", encara que el cos físic sigui més - // estret (típic en sarc/porta amb falsa perspectiva). + // Index de l'entitat sota el píxel donat. -1 si cap. Usem el rect + // de col·lisió (caixa base alineada al tilemap) i NO el sprite + // sencer: a entitats amb falsa perspectiva (mòmia 16×32, sarc + // 48×32) la part alta del sprite és "altura visual" que tapa + // cel·les transitables darrere. Si el hit-test usés el sprite + // sencer, no es podria seleccionar/col·locar res a aquelles + // cel·les. Per a entitats sense caixa explícita (torch, hole), + // `getCollisionRect` retorna el `pixel_rect` complet, així que el + // criteri segueix sent intuïtiu ("clic on es veu"). auto entityAtPixel(const std::vector& list, int px, int py) -> int { const auto FX = static_cast(px); const auto FY = static_cast(py); for (int i = static_cast(list.size()) - 1; i >= 0; --i) { - const auto R = list[i]->getPixelRect(); + const auto R = list[i]->getCollisionRect(); if (FX >= R.x && FX < R.x + R.w && FY >= R.y && FY < R.y + R.h) { return i; } } return -1; @@ -662,8 +670,13 @@ void Editor::deleteSelected(EntityManager& entities) { void Editor::renderEditorHud(const EntityManager& entities) const { if (hud_font_ == nullptr || Map::hud_h <= 0) { return; } auto& screen = Screen::get(); - const auto& pal = Resource::Cache::get().getPalette("ui"); - screen.fillRect(0, 0, GameCanvas::WIDTH, Map::hud_h, pal[UiConfig::get().hudBg()]); + + // Fons opcional: respectem el flag global del HUD del joc per a + // coherència visual quan el dissenyador alterna entre joc i editor. + if (UiConfig::get().hudBgEnabled()) { + const auto& pal = Resource::Cache::get().getPalette("ui"); + screen.fillRect(0, 0, GameCanvas::WIDTH, Map::hud_h, pal[UiConfig::get().hudBg()]); + } std::string mode_str; switch (mode_) { @@ -700,28 +713,55 @@ void Editor::renderEditorHud(const EntityManager& entities) const { const auto LBL = UiConfig::get().hudLabel(); const auto VAL = UiConfig::get().hudValue(); + const auto FLASH = UiConfig::get().hudFlash(); const int KERN = UiConfig::get().hudFont().kerning; - const int Y = (Map::hud_h - Rendering::Hud::FONT_SIZE_PX) / 2; - int x = 2; + // Mateixos Y que el HUD del joc per simetria visual: línia 1 = 4, + // línia 2 = 13. La font és proporcional, així que mesurem cada línia + // sencera abans de centrar-la. + constexpr int LINE1_Y = 4; + constexpr int LINE2_Y = 13; + constexpr int W = GameCanvas::WIDTH; - auto draw = [&](const std::string& s, std::uint8_t color) { - hud_font_->writeColored(x, Y, s, color, KERN); - x += hud_font_->length(s, KERN); + struct Segment { + std::string text; + std::uint8_t color; + }; + auto draw_line = [&](const std::vector& segs, int y) { + const int TOTAL = std::accumulate(segs.begin(), segs.end(), 0, [&](int acc, const Segment& s) { return acc + hud_font_->length(s.text, KERN); }); + int x = (W - TOTAL) / 2; + for (const auto& s : segs) { + hud_font_->writeColored(x, y, s.text, s.color, KERN); + x += hud_font_->length(s.text, KERN); + } }; - draw("MODE:", LBL); - draw(" " + mode_str, VAL); - draw(" " + brush_label, LBL); - draw(" " + brush_value, VAL); - draw(" " + COORD, VAL); - draw(" DOOR:", LBL); - draw(" " + std::to_string(countByName(entities, "door")), VAL); - draw(" SARCS:", LBL); - draw(" " + std::to_string(countByName(entities, "sarcophagus")), VAL); + // Línia 1: estat actiu (mode + brush/selecció + cursor). + draw_line({{.text = "MODE:", .color = LBL}, + {.text = " " + mode_str, .color = VAL}, + {.text = " " + brush_label, .color = LBL}, + {.text = " " + brush_value, .color = VAL}, + {.text = " " + COORD, .color = VAL}}, + LINE1_Y); + + // Línia 2: comptadors de constraints. Color "flash" del HUD del + // joc quan no es compleixen — mateix vocabulari visual que les + // alertes del marcador. + const int DOOR_C = countByName(entities, "door"); + const int SARC_C = countByName(entities, "sarcophagus"); + const auto DOOR_COLOR = (DOOR_C == 1) ? VAL : FLASH; + const auto SARC_COLOR = (SARC_C >= 1) ? VAL : FLASH; + draw_line({{.text = "DOOR:", .color = LBL}, + {.text = " " + std::to_string(DOOR_C), .color = DOOR_COLOR}, + {.text = " SARCS:", .color = LBL}, + {.text = " " + std::to_string(SARC_C), .color = SARC_COLOR}}, + LINE2_Y); } void Editor::renderEntityFrame(const Entity& e, std::uint32_t argb) { - const auto R = e.getPixelRect(); + // El marc l'alimenta el rect de col·lisió perquè coincideixi amb + // el que `entityAtPixel` considera "clicable". Així el dissenyador + // veu exactament la zona sensible al hover/selecció. + const auto R = e.getCollisionRect(); auto& screen = Screen::get(); const auto X = static_cast(R.x); const auto Y = static_cast(R.y); diff --git a/source/game/editor/editor.hpp b/source/game/editor/editor.hpp index 500db4f..e3ff0f4 100644 --- a/source/game/editor/editor.hpp +++ b/source/game/editor/editor.hpp @@ -52,6 +52,14 @@ class Editor { [[nodiscard]] auto active() const -> bool { return active_; } + // Cert quan, en mode editor, GameScene ha de pintar les entitats + // de la sala. Falses en mode TILE (volem veure el tilemap pelat + // per pintar-lo) i quan hi ha un picker obert (la caixa del picker + // ja tapa la pantalla i el soroll visual addicional sobra). + [[nodiscard]] auto shouldShowEntities() const -> bool { + return active_ && mode_ != Mode::TILE && picker_ == Picker::NONE; + } + // Processa input propi (brush, picker). No-op si no actiu. // `entities` cal perquè els modes ENTITY i HAND modifiquen el manager // en directe (col·loca/mou/borra entitats); en TILE no s'usa. diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 61c9a8e..47550ad 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -1294,11 +1294,22 @@ void GameScene::render() { #else constexpr bool EDITOR_ACTIVE = false; #endif - if (EDITOR_ACTIVE) { - // Render barat de les entitats sense z-sort entre elles ni amb - // jugadors: ja no hi ha actors mòbils. L'ordre és el d'inserció - // del manager — suficient per editar. - entity_manager_.renderAll(); +#ifdef _DEBUG + const bool SHOW_ENTITIES_IN_EDITOR = editor_.shouldShowEntities(); +#else + constexpr bool SHOW_ENTITIES_IN_EDITOR = false; +#endif + if (SHOW_ENTITIES_IN_EDITOR) { + // Render de les entitats amb el mateix y-sort que el joc normal + // (sense jugadors): així una mòmia situada davant d'un sarc en + // falsa perspectiva queda dibuixada per damunt — i no a l'inrevés + // segons l'ordre del YAML. Reusem `EntityManager::renderEntity` + // (estàtic) i sortegem per `getFeetY()` ascendent. + auto sorted = entity_manager_.getEntities(); + std::ranges::stable_sort(sorted, [](const Entity* a, const Entity* b) { + return a->getFeetY() < b->getFeetY(); + }); + for (const auto* e : sorted) { EntityManager::renderEntity(*e); } } // Capa de pintat: va entre el tilemap base i els actors, perquè els // sarcòfags i el jugador la tapen. S'amaga al mode editor (decisió From c003e6665e2287dba8e8238909020ddeff11596e Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 2 May 2026 13:35:01 +0200 Subject: [PATCH 13/13] feat(editor): mou la porta i persisteix geometry.door_col --- CMakeLists.txt | 3 + data/maps/p1/r1.yaml | 6 +- source/game/editor/editor.cpp | 52 ++++++++++--- source/game/editor/editor.hpp | 11 ++- source/game/entities/player.hpp | 7 ++ source/game/map/map_writer.cpp | 123 ++++++++++++++++++++++++++++++ source/game/map/map_writer.hpp | 26 +++++++ source/game/scenes/game_scene.cpp | 6 ++ 8 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 source/game/map/map_writer.cpp create mode 100644 source/game/map/map_writer.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ca7cb1c..ca81c35 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -116,6 +116,9 @@ set(APP_SOURCES source/game/map/map.cpp source/game/map/tilemap.cpp source/game/map/tilemap_writer.cpp + # Writer surgical de claus dins de `geometry:` (door_col), paral·lel + # al TilemapWriter. Només Debug. + source/game/map/map_writer.cpp source/game/map/paint_layer.cpp source/game/levels/level_manager.cpp # Mòdul editor (només activa contingut en Debug via `#ifdef _DEBUG`). diff --git a/data/maps/p1/r1.yaml b/data/maps/p1/r1.yaml index 96e3874..10e028f 100644 --- a/data/maps/p1/r1.yaml +++ b/data/maps/p1/r1.yaml @@ -49,7 +49,7 @@ geometry: # 16 px centrals (just aquesta columna) són el "forat" del mur trasser per # on el jugador surt de la sala. Les 2 columnes flanquejants segueixen sent # mur visible. El spawn dels jugadors també s'alinea amb aquesta columna. - door_col: 10 + door_col: 14 data: - [11,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,13] @@ -82,8 +82,6 @@ data: # d'obertura (262/263). La momia al corredor central. L'antorxa al costat # superior esquerre per verificar render d'una entitat decorativa. entities: - - type: torch - position: [4, 2] - type: torch position: [8, 2] - type: torch @@ -130,7 +128,6 @@ entities: sarc_position: [2, 4] - type: sarcophagus sarc_position: [3, 4] - - type: door - type: mummy position: [10, 7] - type: hole @@ -145,3 +142,4 @@ entities: position: [8, 5] - type: mummy position: [8, 9] + - type: door diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index 7d27bd4..2e05696 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -28,6 +28,7 @@ #include "game/entities/entity_writer.hpp" #include "game/levels/level_manager.hpp" #include "game/map/map.hpp" +#include "game/map/map_writer.hpp" #include "game/map/tilemap.hpp" #include "utils/defaults.hpp" #include "utils/defines.hpp" @@ -332,7 +333,11 @@ void Editor::exit(Tilemap& tilemap, const EntityManager& entities) { const auto ABS_PATH = Resource::Helper::resolve(Levels::LevelManager::get().currentPath()); const bool TILES_OK = tilemap.save(ABS_PATH); const bool ENTITIES_OK = EntityWriter::saveEntitiesBlock(ABS_PATH, gatherSpawns(entities)); - const bool SAVED = TILES_OK && ENTITIES_OK; + // Persistim també `geometry.door_col`: l'editor permet canviar la + // columna de la porta, així que la geometry del YAML ha de + // reflectir-ho a la pròxima càrrega. + const bool GEO_OK = MapWriter::saveDoorCol(ABS_PATH, Map::door_col); + const bool SAVED = TILES_OK && ENTITIES_OK && GEO_OK; Notification::notify(Locale::get().text(SAVED ? "input.editor_off" : "input.editor_save_error")); } @@ -430,7 +435,7 @@ void Editor::update(int /*delta_ms*/, Tilemap& tilemap, EntityManager& entities) updateEntityBrush(entities, CX, CY); break; case Mode::HAND: - updateHand(entities, CX, CY); + updateHand(tilemap, entities, CX, CY); break; } } @@ -574,7 +579,7 @@ namespace { } // namespace -void Editor::updateHand(EntityManager& entities, int canvas_x, int canvas_y) { +void Editor::updateHand(const Tilemap& tilemap, EntityManager& entities, int canvas_x, int canvas_y) { const auto LIST = entities.getEntities(); hand_hover_idx_ = entityAtPixel(LIST, canvas_x, canvas_y); if (hand_selected_idx_ >= static_cast(LIST.size())) { hand_selected_idx_ = -1; } @@ -582,7 +587,7 @@ void Editor::updateHand(EntityManager& entities, int canvas_x, int canvas_y) { for (const auto& ev : EventBuffer::events) { const bool IS_MOUSE_DOWN = (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN); if (IS_MOUSE_DOWN && ev.button.button == SDL_BUTTON_LEFT) { - handleHandLeftClick(entities); + handleHandLeftClick(tilemap, entities); } else if (IS_MOUSE_DOWN && ev.button.button == SDL_BUTTON_RIGHT) { handleHandRightClick(); } else if (isDeleteKey(ev) && hand_selected_idx_ >= 0) { @@ -591,7 +596,7 @@ void Editor::updateHand(EntityManager& entities, int canvas_x, int canvas_y) { } } -void Editor::handleHandLeftClick(EntityManager& entities) { +void Editor::handleHandLeftClick(const Tilemap& tilemap, EntityManager& entities) { if (hand_selected_idx_ < 0) { // Sense selecció: si pulsem sobre una entitat, seleccionem. if (hand_hover_idx_ >= 0) { hand_selected_idx_ = hand_hover_idx_; } @@ -608,7 +613,7 @@ void Editor::handleHandLeftClick(EntityManager& entities) { return; } // Cel·la sense entitat: intenta moure-hi la seleccionada. - if (tryMoveSelected(entities)) { hand_selected_idx_ = -1; } + if (tryMoveSelected(tilemap, entities)) { hand_selected_idx_ = -1; } } void Editor::handleHandRightClick() { @@ -619,7 +624,7 @@ void Editor::handleHandRightClick() { } } -auto Editor::tryMoveSelected(EntityManager& entities) const -> bool { +auto Editor::tryMoveSelected(const Tilemap& tilemap, EntityManager& entities) const -> bool { if (hand_selected_idx_ < 0 || cursor_col_ < 0 || cursor_row_ < 0) { return false; } auto list = entities.getEntities(); if (hand_selected_idx_ >= static_cast(list.size())) { return false; } @@ -627,12 +632,35 @@ auto Editor::tryMoveSelected(EntityManager& entities) const -> bool { auto* target = list[hand_selected_idx_]; const std::string TYPE = target->getName(); - // Construïm el nou spawn excloent l'entitat que mou del check de - // solapaments: si no, sempre xocaria amb ella mateixa. Per a la - // porta, el "moviment" actualment és no-op (no portem encara la - // columna a YAML), així que el descartem amb claredat. - if (TYPE == "door") { return false; } + if (TYPE == "door") { + // La porta viu sempre al mur trasser; només varia la columna. + // El sprite ocupa 3 cel·les (col-1, col, col+1), així que la + // columna central vàlida és [1, cols-2]. La fila no influeix + // (l'usuari pot clicar a qualsevol fila): només importa la + // columna del cursor. + const int COLS = tilemap.getCols(); + if (cursor_col_ < 1 || cursor_col_ > COLS - 2) { return false; } + if (cursor_col_ == Map::door_col) { return false; } + // Recolocació: actualitzem `Map::door_col` (i el seu derivat + // `door_corridor_x`), borrem l'entitat porta actual i en + // creem una de nova: `placeDoor` la posicionarà segons el + // nou `Map::door_col`. La persistència al YAML + // (`geometry.door_col`) es fa a `Editor::exit` a banda dels + // blocs `data:` i `entities:`. + Map::door_col = cursor_col_; + Map::door_corridor_x = (Map::door_col * Map::tile_size) + Map::tilemap_origin_x; + if (!entities.removeEntity(target)) { return false; } + try { + const std::string STR = "type: door\n"; + auto node = fkyaml::node::deserialize(STR.begin(), STR.end()); + return entities.addSpawn(node) != nullptr; + } catch (const std::exception&) { + return false; + } + } + // Resta d'entitats: snap a cel·la del cursor amb check de + // solapaments excloent la pròpia. std::vector filtered; filtered.reserve(list.size() - 1); std::ranges::copy_if(list, std::back_inserter(filtered), [target](const Entity* e) { return e != target; }); diff --git a/source/game/editor/editor.hpp b/source/game/editor/editor.hpp index e3ff0f4..6e7d814 100644 --- a/source/game/editor/editor.hpp +++ b/source/game/editor/editor.hpp @@ -97,7 +97,7 @@ class Editor { // des d'`update`. void updateTileBrush(Tilemap& tilemap, int canvas_x, int canvas_y); void updateEntityBrush(EntityManager& entities, int canvas_x, int canvas_y); - void updateHand(EntityManager& entities, int canvas_x, int canvas_y); + 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); @@ -111,12 +111,15 @@ class Editor { // Mou l'entitat seleccionada a la cel·la del cursor: borra l'antiga // i crida `addSpawn` amb la nova posició. Reusa la mateixa lògica // de snap/solapament que la col·locació nova, excloent l'entitat - // que es mou del check de solapament. - auto tryMoveSelected(EntityManager& entities) const -> bool; + // que es mou del check de solapament. Cas especial per a la porta: + // el sprite ocupa 3 cols (col-1, col, col+1) sobre el mur superior; + // per tant la columna central vàlida és [1, cols-2] — `tilemap` és + // necessari per saber `cols`. + auto tryMoveSelected(const Tilemap& tilemap, EntityManager& entities) const -> bool; // Sub-handlers de `updateHand`. Aïllats per mantenir baixa la // complexitat cognitiva del bucle d'esdeveniments. - void handleHandLeftClick(EntityManager& entities); + void handleHandLeftClick(const Tilemap& tilemap, EntityManager& entities); void handleHandRightClick(); // Borra l'entitat seleccionada. La validació de constraints diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index c1a8617..150b94d 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -179,6 +179,13 @@ class Player : public AnimatedSprite { // contra mur) s'emparen automàticament. void setOtherPlayers(const std::vector* all) { all_players_ = all; } + // Refresca la còpia local de `Map::Info`. Necessari quan el mapa + // (geometria, p.ex. `door_col`) canvia entre carregaments — + // típicament després del editor in-game. Sense aquest refresc, el + // `clampToPlayfield` segueix usant el corridor antic i el jugador + // no troba la nova "obertura" del mur trasser. + void setMapInfo(const Map::Info& info) { map_ = info; } + // Marcat pel resolver jugador↔jugador quan aquest frame el jugador // és arrossegat per una empenta (no té input propi; es desplaça // perquè l'altre l'empeny). Determina que l'animació del frame siga diff --git a/source/game/map/map_writer.cpp b/source/game/map/map_writer.cpp new file mode 100644 index 0000000..9e6e26e --- /dev/null +++ b/source/game/map/map_writer.cpp @@ -0,0 +1,123 @@ +#include "game/map/map_writer.hpp" + +#ifdef _DEBUG + +#include +#include +#include +#include +#include +#include + +namespace MapWriter { + + namespace { + + // Cerca la línia que comença per `:` a columna 0 (top-level) + // o, si `min_indent>0`, a una indentació mínima donada — això ens + // permet trobar tant `geometry:` com una clau interna del bloc. + auto findKeyLine(const std::string& content, const std::string& key, std::size_t start_pos, std::size_t min_indent) -> std::size_t { + const std::string PREFIX = key + ":"; + std::size_t pos = start_pos; + while (pos < content.size()) { + // Compta la indentació de la línia actual. + std::size_t indent = 0; + while (pos + indent < content.size() && (content[pos + indent] == ' ' || content[pos + indent] == '\t')) { + ++indent; + } + const bool INDENT_OK = (min_indent == 0) ? (indent == 0) : (indent >= min_indent); + if (INDENT_OK && pos + indent + PREFIX.size() <= content.size() && content.compare(pos + indent, PREFIX.size(), PREFIX) == 0) { + const std::size_t AFTER = pos + indent + PREFIX.size(); + if (AFTER >= content.size() || content[AFTER] == '\n' || content[AFTER] == '\r' || content[AFTER] == ' ' || content[AFTER] == '\t' || content[AFTER] == '#') { + return pos + indent; + } + } + const auto NL = content.find('\n', pos); + if (NL == std::string::npos) { break; } + pos = NL + 1; + // Si la línia actual és de columna 0 i no és la que busquem, + // i estem dins d'un bloc indentat, sortim (no és part del bloc). + if (min_indent > 0 && indent == 0 && content.compare(pos - 1, 1, "\n") == 0) { + // No usem aquest break agressiu perquè línies en blanc + // (indent=0) no han de tallar la cerca dins del bloc. + // Tractades implícitament pel control superior. + } + } + return std::string::npos; + } + + auto atomicWrite(const std::string& path, const std::string& content) -> bool { + const auto TMP_PATH = path + ".tmp"; + { + std::ofstream out(TMP_PATH, std::ios::binary | std::ios::trunc); + if (!out) { return false; } + out.write(content.data(), static_cast(content.size())); + out.close(); + if (!out) { + std::error_code ec; + std::filesystem::remove(TMP_PATH, ec); + return false; + } + } + std::error_code ec; + std::filesystem::rename(TMP_PATH, path, ec); + if (ec) { + std::filesystem::remove(TMP_PATH, ec); + return false; + } + return true; + } + + } // namespace + + auto saveDoorCol(const std::string& path, int door_col) -> bool { + std::ifstream in(path, std::ios::binary); + if (!in) { return false; } + std::ostringstream ss; + ss << in.rdbuf(); + const std::string CONTENT = ss.str(); + in.close(); + + // 1) Localitza el bloc `geometry:` a columna 0. + const auto GEO_POS = findKeyLine(CONTENT, "geometry", 0, 0); + if (GEO_POS == std::string::npos) { return false; } + const auto GEO_LINE_END = CONTENT.find('\n', GEO_POS); + if (GEO_LINE_END == std::string::npos) { return false; } + const std::size_t BLOCK_START = GEO_LINE_END + 1; + + // 2) Dins del bloc, busca `door_col:` indentada (>=1 espai/tab). + const auto KEY_POS = findKeyLine(CONTENT, "door_col", BLOCK_START, 1); + const std::string NEW_LINE = " door_col: " + std::to_string(door_col); + + if (KEY_POS != std::string::npos) { + // Substitueix la línia sencera (manté indentació original + // recalculant el start de la línia). + std::size_t line_start = KEY_POS; + while (line_start > 0 && CONTENT[line_start - 1] != '\n') { --line_start; } + const auto LINE_END = CONTENT.find('\n', KEY_POS); + const auto END = (LINE_END == std::string::npos) ? CONTENT.size() : LINE_END; + const std::string NEW_CONTENT = + CONTENT.substr(0, line_start) + NEW_LINE + CONTENT.substr(END); + return atomicWrite(path, NEW_CONTENT); + } + + // Si manca, l'afegim al final del bloc geometry (just abans + // de la primera línia top-level que segueix). Trobem la fi + // del bloc avançant mentre les línies són indentades o buides. + std::size_t pos = BLOCK_START; + while (pos < CONTENT.size()) { + const char C = CONTENT[pos]; + const bool INDENTED = (C == ' ' || C == '\t'); + const bool BLANK = (C == '\n' || C == '\r'); + if (!INDENTED && !BLANK) { break; } + const auto NL = CONTENT.find('\n', pos); + pos = (NL == std::string::npos) ? CONTENT.size() : NL + 1; + } + const std::string NEW_CONTENT = + CONTENT.substr(0, pos) + NEW_LINE + "\n" + CONTENT.substr(pos); + return atomicWrite(path, NEW_CONTENT); + } + +} // namespace MapWriter + +#endif // _DEBUG diff --git a/source/game/map/map_writer.hpp b/source/game/map/map_writer.hpp new file mode 100644 index 0000000..c248b17 --- /dev/null +++ b/source/game/map/map_writer.hpp @@ -0,0 +1,26 @@ +#pragma once + +#ifdef _DEBUG + +#include + +// Writer surgical de claus dins del bloc `geometry:` d'un YAML de nivell. +// Reescriu un escalar (p.ex. `door_col: N`) preservant byte a byte la +// resta del fitxer: comentaris, ordre de claus, format i indentació de +// la resta del `geometry:` i la resta de seccions. +// +// Pensat per al editor (Debug): quan el dissenyador mou la porta, només +// canvia `door_col`; tota la resta del YAML segueix igual. +// +// Si `geometry:` no existeix al fitxer, retorna false. Si la clau no +// existeix dins del bloc, l'afegeix al final del bloc, indentada com +// les altres entrades del mateix nivell. +// +// Escriptura atòmica via `.tmp` + `std::filesystem::rename`. +namespace MapWriter { + + auto saveDoorCol(const std::string& path, int door_col) -> bool; + +} // namespace MapWriter + +#endif // _DEBUG diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 47550ad..07b4610 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -528,6 +528,12 @@ void GameScene::loadRoom(const std::string& yaml_path) { tilemap_ = Tilemap(yaml_path); paint_layer_ = std::make_unique(tilemap_, &entity_manager_.getBus()); entity_manager_.reload(yaml_path); + // Refresquem la còpia local de Map::Info als players: la geometria + // pot haver canviat (p.ex. door_col del editor), i sense això + // `clampToPlayfield` continuaria usant el corridor antic i el + // jugador no podria sortir per la nova posició de la porta. + const auto INFO = Map::makeInfo(); + for (auto& p : players_) { p->setMapInfo(INFO); } // Tornem a fixar el comptador de "esperats" amb només els slots // actius — un slot DISABLED permanent (single-player) no compta. const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true);