diff --git a/CMakeLists.txt b/CMakeLists.txt index 84f3ebb..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`). @@ -123,6 +126,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/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/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/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/data/maps/p1/r1.yaml b/data/maps/p1/r1.yaml index eac3d3a..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,15 +82,12 @@ 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 position: [12, 2] - type: torch position: [16, 2] - - type: sarcophagus sarc_position: [0, 0] - type: sarcophagus @@ -131,17 +128,8 @@ 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 +138,8 @@ entities: position: [15, 11] - type: hole position: [15, 5] + - type: hole + position: [8, 5] + - type: mummy + position: [8, 9] + - type: door 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/editor/editor.cpp b/source/game/editor/editor.cpp index 6c81f85..2e05696 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -5,17 +5,30 @@ #include #include +#include +#include +#include #include "core/input/key_config.hpp" #include "core/locale/locale.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" +#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/map_writer.hpp" #include "game/map/tilemap.hpp" #include "utils/defaults.hpp" #include "utils/defines.hpp" @@ -106,21 +119,211 @@ 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() { if (active_) { return; } active_ = true; + // 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")); } +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 + // 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; } + + // 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(); + + 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; } + + editable_entities_.push_back({.name = name, .sprite = surface, .frame_w = FRAME_W, .frame_h = FRAME_H}); + } +} + +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`). + 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") { + // 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->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; + } + } + 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; } + // 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_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è @@ -128,21 +331,72 @@ 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)); + // 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")); } -void Editor::toggle(Tilemap& tilemap) { +void Editor::toggle(Tilemap& tilemap, const EntityManager& entities) { if (active_) { - exit(tilemap); + exit(tilemap, entities); } else { enter(); } } -void Editor::update(int /*delta_ms*/, 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, EntityManager& entities) { 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 +406,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_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 +421,482 @@ 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(entities, CX, CY); + break; + case Mode::HAND: + updateHand(tilemap, entities, 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; + } + } +} + +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(); + } + } +} + +namespace { + + // 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]->getCollisionRect(); + 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(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; } + + 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(tilemap, 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(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_; } + 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(tilemap, entities)) { hand_selected_idx_ = -1; } +} + +void Editor::handleHandRightClick() { + if (hand_selected_idx_ >= 0) { + hand_selected_idx_ = -1; + } else { + mode_ = Mode::TILE; + } +} + +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; } + + auto* target = list[hand_selected_idx_]; + const std::string TYPE = target->getName(); + + 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; }); + + 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_]; + 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; } +} + +void Editor::renderEditorHud(const EntityManager& entities) const { + if (hud_font_ == nullptr || Map::hud_h <= 0) { return; } + auto& screen = Screen::get(); + + // 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_) { + 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 auto FLASH = UiConfig::get().hudFlash(); + const int KERN = UiConfig::get().hudFont().kerning; + // 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; + + 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); + } + }; + + // 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) { + // 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); + 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 { + 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())); + 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 { + 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); +} + +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,37 +909,60 @@ 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; } } } } -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; 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_); + } 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); + } + } + + // 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(); @@ -267,7 +972,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 +980,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 +989,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..6e7d814 100644 --- a/source/game/editor/editor.hpp +++ b/source/game/editor/editor.hpp @@ -2,6 +2,15 @@ #ifdef _DEBUG +#include +#include +#include +#include + +class Entity; +class EntityManager; +class Surface; +class Text; class Tilemap; // Mode editor modal del GameScene. Només disponible en Debug — en Release @@ -11,55 +20,176 @@ 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ó // 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_; } - // Processa input propi (brush, picker). No-op si no actiu. - void update(int delta_ms, Tilemap& tilemap); + // 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; + } - // Pinta el border blau i, més endavant, brush preview + picker. - // No-op si no actiu. - void render(const Tilemap& tilemap) const; + // 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. + 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. + // `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: - // 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(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); + + // 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; + + // 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. 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(const Tilemap& tilemap, 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); + + // 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; 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. + + // 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_; + + // 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 + // 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}; }; #endif // _DEBUG 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/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 a2d2515..77b3c25 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; @@ -43,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). @@ -102,6 +110,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/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/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 0394e39..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); @@ -607,6 +613,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,9 +1171,29 @@ 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. 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()) { - editor_.toggle(tilemap_); + const bool WAS_ACTIVE = editor_.active(); + editor_.toggle(tilemap_, entity_manager_); + 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(); + } } // Salts debug entre sales (`[` / `]`). Mútuament excloents amb la @@ -1219,7 +1269,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 @@ -1242,18 +1292,36 @@ 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 +#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ó // 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 @@ -1312,7 +1380,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 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