1205 lines
53 KiB
C++
1205 lines
53 KiB
C++
#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ó; cppcheck no pot anticipar que les futures mutacions estan previstes en el contracte.
|
||
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::renderEntityBrushPreview(const Tilemap& tilemap) const {
|
||
auto it = std::ranges::find_if(editable_entities_, [this](const auto& e) { return e.name == brush_entity_; });
|
||
if (it == editable_entities_.end() || it->sprite == nullptr) { return; }
|
||
const int FRAME = (brush_entity_frame_ >= 0) ? brush_entity_frame_ : 0;
|
||
const int ATLAS_COLS = (it->frame_w > 0) ? (it->sprite->getWidth() / it->frame_w) : 1;
|
||
const int ATLAS_COLS_SAFE = (ATLAS_COLS > 0) ? ATLAS_COLS : 1;
|
||
const int A_COL = FRAME % ATLAS_COLS_SAFE;
|
||
const int A_ROW = FRAME / ATLAS_COLS_SAFE;
|
||
const SDL_FRect SRC{
|
||
.x = static_cast<float>(A_COL * it->frame_w),
|
||
.y = static_cast<float>(A_ROW * it->frame_h),
|
||
.w = static_cast<float>(it->frame_w),
|
||
.h = static_cast<float>(it->frame_h),
|
||
};
|
||
const int TS = tilemap.getTileSize();
|
||
const int TILE_PX = tilemap.getOriginX() + (cursor_col_ * TS);
|
||
const int TILE_PY = tilemap.getOriginY() + (cursor_row_ * TS);
|
||
const int DST_X = TILE_PX + ((TS - it->frame_w) / 2);
|
||
const int DST_Y = TILE_PY + TS - it->frame_h;
|
||
it->sprite->render(DST_X, DST_Y, &SRC, SDL_FLIP_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::ENTITY && !brush_entity_.empty() && cursor_col_ >= 0 && cursor_row_ >= 0) {
|
||
renderEntityBrushPreview(tilemap);
|
||
} 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
|