#include "game/editor/editor.hpp" #ifdef _DEBUG #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" namespace { // Gruix del border 1 px del picker: reservat dins del rectangle perquè // no envaeixi els tiles ni se surti fora de la caixa. La cella efectiva // de cada tile té `pitch = tile_size + spacing` i la graella s'inicia // un píxel cap dins de cada vora. constexpr int PICKER_BORDER = 1; // Geometria del TilePicker en espai canvas. `cols × rows_visible` tiles // encapsulats dins d'un rectangle amb `picker.margin` per vora i un // border 1 px que el delimita. Si el tileset cap en amplada (la suma // de tiles + spacing + border ≤ INNER_W), en respectem la graella // natural — els tiles surten ordenats igual que al GIF; si no, // linealitzem els índexs en una graella més ampla que sí cap. Quan el // contingut cap sense scroll, la caixa s'arrossega a alçada mínima i // es centra verticalment dins de l'àrea interna. struct PickerLayout { int x{0}; int y{0}; int w{0}; int h{0}; int cols{0}; int rows_visible{0}; int tile_size{0}; int spacing{0}; int pitch{0}; // tile_size + spacing — pas entre orígens de tiles veïns. int total_tiles{0}; int max_scroll{0}; }; auto layoutPicker(const Tilemap& tm) -> PickerLayout { PickerLayout lay{}; lay.tile_size = tm.getTileSize(); lay.spacing = std::max(0, EditorConfig::get().pickerSpacing()); lay.pitch = lay.tile_size + lay.spacing; const int M = EditorConfig::get().pickerMargin(); const int INNER_W = GameCanvas::WIDTH - (2 * M); const int INNER_H = GameCanvas::HEIGHT - (2 * M); if (lay.tile_size <= 0 || lay.pitch <= 0) { return lay; } // Espai útil per a tiles, descomptat el border 1 px de cada vora. // Inversió de N·ts + (N-1)·spacing ≤ AVAIL → N ≤ (AVAIL+spacing)/pitch. const int AVAIL_W = INNER_W - (2 * PICKER_BORDER); const int AVAIL_H = INNER_H - (2 * PICKER_BORDER); const int MAX_COLS = std::max(0, (AVAIL_W + lay.spacing) / lay.pitch); const int MAX_ROWS = std::max(0, (AVAIL_H + lay.spacing) / lay.pitch); int tcols = 0; const auto TILESET = Resource::Cache::get().getSurface(tm.getTilesetPath()); if (TILESET != nullptr) { tcols = TILESET->getWidth() / lay.tile_size; const int TROWS = TILESET->getHeight() / lay.tile_size; lay.total_tiles = tcols * TROWS; } // Com que els índexs del tileset són row-major (idx = row·TCOLS + col), // fer servir cols = TCOLS reprodueix exactament la disposició del GIF. const bool FITS_W = (tcols > 0 && tcols <= MAX_COLS); lay.cols = FITS_W ? tcols : MAX_COLS; const int TOTAL_ROWS = (lay.cols > 0) ? ((lay.total_tiles + lay.cols - 1) / lay.cols) : 0; // Si tot el contingut cap sense scroll, encongim la caixa a les // files reals; així podem centrar-la verticalment. lay.rows_visible = std::min(MAX_ROWS, TOTAL_ROWS); lay.max_scroll = std::max(0, TOTAL_ROWS - lay.rows_visible); // Mida real de la caixa: tiles + spacing intern + 1 px de border per cara. const int TILES_W = (lay.cols > 0) ? ((lay.cols * lay.tile_size) + ((lay.cols - 1) * lay.spacing)) : 0; const int TILES_H = (lay.rows_visible > 0) ? ((lay.rows_visible * lay.tile_size) + ((lay.rows_visible - 1) * lay.spacing)) : 0; lay.w = TILES_W + (2 * PICKER_BORDER); lay.h = TILES_H + (2 * PICKER_BORDER); // Centrat horitzontal i vertical dins de l'àrea interna; quan el // picker arriba a l'alçada màxima, el centrat vertical degenera a // origen=M (no hi ha espai extra que repartir). lay.x = M + ((INNER_W - lay.w) / 2); lay.y = M + ((INNER_H - lay.h) / 2); return lay; } // Origen (cantonada superior-esquerra) del tile a la cel·la (vc, vr) // dins del picker. Centralitzat perquè render i hit-test no divergeixin. auto pickerTileOrigin(const PickerLayout& 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}; } // 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, 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 // el LevelManager): així el "9" de l'editor desa el que tens davant, // no una constant fixa. Resolem la ruta via Resource::Helper perquè // en Debug el root de dades és AEE_SOURCE_DIR (ruta absoluta al repo), // 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 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, const EntityManager& entities) { if (active_) { exit(tilemap, entities); } else { enter(); } } 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; float mouse_y = 0.0F; SDL_GetMouseState(&mouse_x, &mouse_y); const auto CP = Screen::get().windowToCanvas(mouse_x, mouse_y); const int CX = static_cast(CP.x); const int CY = static_cast(CP.y); // Cursor → cel·la del tilemap. Comú a tots els modes amb mapa visible. const int TS = tilemap.getTileSize(); 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) { const int C = MX / TS; const int R = MY / TS; if (C < tilemap.getCols() && R < tilemap.getRows()) { cursor_col_ = C; cursor_row_ = R; } } // 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::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); 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; const int RX = canvas_x - INNER_X; const int RY = canvas_y - INNER_Y; if (RX >= 0 && RY >= 0 && LAY.tile_size > 0 && LAY.pitch > 0 && LAY.cols > 0) { const int VC = RX / LAY.pitch; const int VR = RY / LAY.pitch; // Píxels de spacing entre cel·les: el clic cau a la "vora morta". 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 = ((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) { 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 (tile_picker_hover_ >= 0) { brush_tile_ = tile_picker_hover_; mode_ = Mode::TILE; picker_ = Picker::NONE; } } } } 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_ == 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_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(); screen.fillRect(0, 0, W, 1, BORDER); screen.fillRect(0, H - 1, W, 1, BORDER); screen.fillRect(0, 0, 1, H, BORDER); screen.fillRect(W - 1, 0, 1, H, BORDER); } 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(); 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 = 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; } const auto [PX, PY] = pickerTileOrigin(LAY, vc, vr); tilemap.renderTilePreview(PX, PY, IDX); } } 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; const auto C = cfg.pickerHoverArgb(); screen.fillRect(PX, PY, TS, 1, C); screen.fillRect(PX, PY + TS - 1, TS, 1, C); screen.fillRect(PX, PY, 1, TS, C); screen.fillRect(PX + TS - 1, PY, 1, TS, C); } } // Border 1 px del picker, dibuixat al damunt del fons i dels tiles // perquè delimite clarament la caixa també quan hi ha tiles que // arriben a la vora. 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); } #endif // _DEBUG