Files
aee-arcade/source/game/editor/editor.cpp
T

1183 lines
51 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "game/editor/editor.hpp"
#ifdef _DEBUG
#include <SDL3/SDL.h>
#include <algorithm>
#include <filesystem>
#include <numeric>
#include <sstream>
#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<int, int> {
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<int, int> {
const int PX = lay.x + PICKER_BORDER + (vc * lay.pitch);
const int PY = lay.y + PICKER_BORDER + (vr * lay.pitch);
return {PX, PY};
}
// Compta sub-sprites del atlas i decideix si el template demana
// sub-picker de frames. Extret de `ensureEditableEntities` per acotar
// la complexitat cognitiva del callsite. Per a strips d'1 frame
// (la majoria del catàleg) retorna (1, false).
struct AtlasVariants {
int frame_count;
bool has_variants;
};
auto computeAtlasVariants(const Surface& surface, int frame_w, int frame_h, const fkyaml::node& tmpl) -> AtlasVariants {
const int COLS = (frame_w > 0) ? (surface.getWidth() / frame_w) : 1;
const int ROWS = (frame_h > 0) ? (surface.getHeight() / frame_h) : 1;
const int FRAME_COUNT = std::max(1, COLS * ROWS);
const bool FLAG = tmpl.contains("editable_variants") && tmpl["editable_variants"].get_value<bool>();
return {.frame_count = FRAME_COUNT, .has_variants = FLAG && FRAME_COUNT > 1};
}
} // namespace
void Editor::enter() {
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();
brush_entity_frame_ = -1;
entity_frame_picker_source_ = nullptr;
entity_frame_picker_hover_ = -1;
entity_frame_picker_scroll_ = 0;
hand_selected_idx_ = -1;
hand_hover_idx_ = -1;
ensureEditableEntities();
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<Text>(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<bool>()) { 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<std::string>();
const std::string KEY = std::filesystem::path(REL).filename().string();
std::string full = Defaults::Paths::ENTITIES_DIR;
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<int>();
const int FRAME_H = SIZE[1].get_value<int>();
std::shared_ptr<Surface> 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; }
const auto [FRAME_COUNT, HAS_VARIANTS] = computeAtlasVariants(*surface, FRAME_W, FRAME_H, tmpl);
editable_entities_.push_back({
.name = name,
.sprite = surface,
.frame_w = FRAME_W,
.frame_h = FRAME_H,
.frame_count = FRAME_COUNT,
.has_variants = HAS_VARIANTS,
});
}
}
namespace {
auto countByName(const EntityManager& entities, std::string_view name) -> int {
const auto LIST = entities.getEntities();
return static_cast<int>(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<EntityWriter::Spawn> {
std::vector<EntityWriter::Spawn> 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<int>(LOGICAL.x) - Map::origin_x) / Map::pitch_x;
s.sarc_row = (static_cast<int>(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<int>(R.x) - Map::tilemap_origin_x) / Map::tile_size;
s.row = (static_cast<int>(R.y) - Map::tilemap_origin_y) / Map::tile_size;
}
// Variant del atlas: si l'entitat té un AnimationComponent
// d'un sol frame, és una entitat amb variant escollida
// (debris, columna). Persistim l'índex perquè la pròxima
// càrrega restauri la mateixa cara del sub-sprite.
if (const auto& anim = e->getAnimation(); anim.has_value() && anim->frameCount() == 1) {
s.frame = anim->currentFrame();
}
}
out.push_back(std::move(s));
}
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;
entity_frame_picker_source_ = nullptr;
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<int>(CP.x);
const int CY = static_cast<int>(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 if (picker_ == Picker::ENTITY_FRAME) {
updateEntityFramePicker(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<float>((col * tile_size) + Map::tilemap_origin_x),
.y = static_cast<float>((row * tile_size) + Map::tilemap_origin_y),
.w = static_cast<float>(tile_size),
.h = static_cast<float>(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<int, int> {
if (Map::pitch_x <= 0 || Map::pitch_y <= 0) { return {-1, -1}; }
const int SC = (static_cast<int>(rect.x) - Map::origin_x) / Map::pitch_x;
const int SR = (static_cast<int>(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<int, int> {
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<Entity*>& 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<Entity*>& existing) -> bool {
return std::ranges::any_of(existing, [](const Entity* e) { return e->getName() == "door"; });
}
auto cellOccupied(const std::vector<Entity*>& 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).
// `frame` (-1 = no emetre) afegeix el camp `frame: N` per a entitats
// amb variants estàtiques (debris, columna).
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing, int frame) -> std::string {
std::ostringstream y;
y << "type: " << brush << '\n';
if (brush == "sarcophagus") {
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";
if (frame >= 0) { y << "frame: " << frame << '\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();
brush_entity_frame_ = -1;
}
}
}
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<Entity*>& list, int px, int py) -> int {
const auto FX = static_cast<float>(px);
const auto FY = static_cast<float>(py);
for (int i = static_cast<int>(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<int>(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<int>(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<Entity*> filtered;
filtered.reserve(list.size() - 1);
std::ranges::copy_if(list, std::back_inserter(filtered), [target](const Entity* e) { return e != target; });
// Si l'entitat moguda té una variant fixada (AnimationComponent d'un
// sol frame, convenció per a debris/columnes), la preservem al spawn
// reconstruït — així moure no perd la cara escollida.
int frame = -1;
if (const auto& anim = target->getAnimation(); anim.has_value() && anim->frameCount() == 1) {
frame = anim->currentFrame();
}
const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered, frame);
if (STR.empty()) { return false; }
if (!entities.removeEntity(target)) { return false; }
try {
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<int>(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<int>(LIST.size()))
? LIST[hand_selected_idx_]->getName()
: "-";
}
const std::string COORD = (cursor_col_ >= 0)
? "(" + std::to_string(cursor_col_) + "," + std::to_string(cursor_row_) + ")"
: "(--,--)";
// El HUD ara té tres famílies (P1/CENTER/P2); l'editor n'agafa la
// del CENTER (ambre/or) per ser neutra i contrastar amb el mapa.
const auto LBL = UiConfig::get().hudCenterLabel();
const auto VAL = UiConfig::get().hudCenterValue();
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<Segment>& 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<int>(R.x);
const auto Y = static_cast<int>(R.y);
const auto W = static_cast<int>(R.w);
const auto H = static_cast<int>(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(), brush_entity_frame_);
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<int>(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<int>(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<int>(editable_entities_.size())) {
const auto& sel = editable_entities_[entity_picker_hover_];
if (sel.has_variants) {
// Entitat amb variants: encara no fixem brush. Obrim
// el sub-picker de frames; el brush es fixarà només
// quan l'usuari trii un frame concret allà.
entity_frame_picker_source_ = &sel;
entity_frame_picker_scroll_ = 0;
entity_frame_picker_hover_ = -1;
picker_ = Picker::ENTITY_FRAME;
} else {
brush_entity_ = sel.name;
brush_entity_frame_ = -1;
mode_ = Mode::ENTITY;
picker_ = Picker::NONE;
}
}
}
}
}
void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const {
const auto LAY = layoutEntityPicker(static_cast<int>(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<float>(ent.frame_w),
.h = static_cast<float>(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::updateEntityFramePicker(int canvas_x, int canvas_y) {
// Si la font ha desaparegut (no hauria de passar mentre el picker
// estigui obert, però defensem el cas), tanquem el sub-picker en lloc
// de fer crash.
if (entity_frame_picker_source_ == nullptr) {
picker_ = Picker::ENTITY;
return;
}
const auto LAY = layoutEntityPicker(entity_frame_picker_source_->frame_count);
entity_frame_picker_hover_ = -1;
const int INNER_X = LAY.x + PICKER_BORDER;
const int INNER_Y = LAY.y + PICKER_BORDER;
const int RX = canvas_x - INNER_X;
const int RY = canvas_y - INNER_Y;
if (RX >= 0 && RY >= 0 && LAY.pitch > 0 && LAY.cols > 0) {
const int VC = RX / LAY.pitch;
const int VR = RY / LAY.pitch;
const bool IN_SPACING_X = (RX % LAY.pitch) >= ENTITY_CELL;
const bool IN_SPACING_Y = (RY % LAY.pitch) >= ENTITY_CELL;
if (!IN_SPACING_X && !IN_SPACING_Y && VC < LAY.cols && VR < LAY.rows_visible) {
const int IDX = ((entity_frame_picker_scroll_ + VR) * LAY.cols) + VC;
if (IDX < LAY.total_items) { entity_frame_picker_hover_ = IDX; }
}
}
for (const auto& ev : EventBuffer::events) {
if (ev.type == SDL_EVENT_MOUSE_WHEEL) {
entity_frame_picker_scroll_ -= static_cast<int>(ev.wheel.y);
entity_frame_picker_scroll_ = std::clamp(entity_frame_picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (entity_frame_picker_hover_ >= 0) {
brush_entity_ = entity_frame_picker_source_->name;
brush_entity_frame_ = entity_frame_picker_hover_;
mode_ = Mode::ENTITY;
picker_ = Picker::NONE;
}
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_RIGHT) {
// Botó dret: tanca el sub-picker i torna al picker d'entitats
// sense fixar brush. Coherent amb la convenció de "right =
// cancel·la l'acció actual" dels altres modes.
picker_ = Picker::ENTITY;
entity_frame_picker_source_ = nullptr;
}
}
}
void Editor::renderEntityFramePicker() const {
if (entity_frame_picker_source_ == nullptr) { return; }
const auto& src = *entity_frame_picker_source_;
const auto LAY = layoutEntityPicker(src.frame_count);
if (LAY.cols <= 0 || LAY.rows_visible <= 0) { return; }
auto& screen = Screen::get();
const auto& cfg = EditorConfig::get();
screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, cfg.pickerBgArgb());
// Cols del atlas (sub-sprites per fila): es dedueix de la mida del
// surface i del frame, igual que fa `EntityManager::renderEntity`.
const int ATLAS_COLS = (src.frame_w > 0) ? (src.sprite->getWidth() / src.frame_w) : 1;
const int ATLAS_COLS_SAFE = (ATLAS_COLS > 0) ? ATLAS_COLS : 1;
for (int vr = 0; vr < LAY.rows_visible; ++vr) {
const int LOGICAL_ROW = entity_frame_picker_scroll_ + vr;
for (int vc = 0; vc < LAY.cols; ++vc) {
const int IDX = (LOGICAL_ROW * LAY.cols) + vc;
if (IDX >= LAY.total_items) { break; }
const auto [PX, PY] = entityCellOrigin(LAY, vc, vr);
const int OX = PX + ((ENTITY_CELL - src.frame_w) / 2);
const int OY = PY + ((ENTITY_CELL - src.frame_h) / 2);
const int A_COL = IDX % ATLAS_COLS_SAFE;
const int A_ROW = IDX / ATLAS_COLS_SAFE;
const SDL_FRect SRC{
.x = static_cast<float>(A_COL * src.frame_w),
.y = static_cast<float>(A_ROW * src.frame_h),
.w = static_cast<float>(src.frame_w),
.h = static_cast<float>(src.frame_h),
};
src.sprite->render(OX, OY, &SRC, SDL_FLIP_NONE);
}
}
if (entity_frame_picker_hover_ >= 0) {
const int VR = (entity_frame_picker_hover_ / LAY.cols) - entity_frame_picker_scroll_;
const int VC = entity_frame_picker_hover_ % LAY.cols;
if (VR >= 0 && VR < LAY.rows_visible) {
const auto [PX, PY] = entityCellOrigin(LAY, VC, VR);
const auto C = cfg.pickerHoverArgb();
screen.fillRect(PX, PY, ENTITY_CELL, 1, C);
screen.fillRect(PX, PY + ENTITY_CELL - 1, ENTITY_CELL, 1, C);
screen.fillRect(PX, PY, 1, ENTITY_CELL, C);
screen.fillRect(PX + ENTITY_CELL - 1, PY, 1, ENTITY_CELL, C);
}
}
const auto BORDER = cfg.borderArgb();
screen.fillRect(LAY.x, LAY.y, LAY.w, 1, BORDER);
screen.fillRect(LAY.x, LAY.y + LAY.h - 1, LAY.w, 1, BORDER);
screen.fillRect(LAY.x, LAY.y, 1, LAY.h, BORDER);
screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER);
}
void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
const auto LAY = layoutPicker(tilemap);
tile_picker_hover_ = -1;
// 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<int>(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 (picker_ == Picker::ENTITY_FRAME) {
renderEntityFramePicker();
} else if (mode_ == Mode::TILE && brush_tile_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
// Preview del brush sota el cursor (només quan el picker és tancat).
const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize());
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<int>(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<int>(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