312 lines
13 KiB
C++
312 lines
13 KiB
C++
#include "game/editor/editor.hpp"
|
||
|
||
#ifdef _DEBUG
|
||
|
||
#include <SDL3/SDL.h>
|
||
|
||
#include <algorithm>
|
||
|
||
#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/resources/resource_cache.hpp"
|
||
#include "core/resources/resource_helper.hpp"
|
||
#include "core/system/event_buffer.hpp"
|
||
#include "game/editor/editor_config.hpp"
|
||
#include "game/levels/level_manager.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};
|
||
}
|
||
|
||
} // namespace
|
||
|
||
void Editor::enter() {
|
||
if (active_) { return; }
|
||
active_ = true;
|
||
Notification::notify(Locale::get().text("input.editor_on"));
|
||
}
|
||
|
||
// 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) {
|
||
if (!active_) { return; }
|
||
active_ = false;
|
||
picker_open_ = false;
|
||
// 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 SAVED = tilemap.save(ABS_PATH);
|
||
Notification::notify(Locale::get().text(SAVED ? "input.editor_off" : "input.editor_save_error"));
|
||
}
|
||
|
||
void Editor::toggle(Tilemap& tilemap) {
|
||
if (active_) {
|
||
exit(tilemap);
|
||
} else {
|
||
enter();
|
||
}
|
||
}
|
||
|
||
void Editor::update(int /*delta_ms*/, Tilemap& tilemap) {
|
||
if (!active_) { return; }
|
||
|
||
// 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);
|
||
|
||
// Consumim tecles pròpies del picker abans que cap altre handler
|
||
// (GlobalInputs) les vegi. ESC només es consumeix si el picker està
|
||
// obert — fora d'ell segueix arribant a GlobalInputs per la seva
|
||
// acció de confirmar-eixida.
|
||
const SDL_Scancode PICKER_SC = KeyConfig::get().scancode("editor_tile_picker");
|
||
for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) {
|
||
bool consumed = false;
|
||
if (it->type == SDL_EVENT_KEY_DOWN) {
|
||
if (PICKER_SC != SDL_SCANCODE_UNKNOWN && it->key.scancode == PICKER_SC) {
|
||
picker_open_ = !picker_open_;
|
||
if (picker_open_) { picker_scroll_ = 0; }
|
||
consumed = true;
|
||
} else if (picker_open_ && it->key.scancode == SDL_SCANCODE_ESCAPE) {
|
||
picker_open_ = false;
|
||
consumed = true;
|
||
}
|
||
}
|
||
if (consumed) {
|
||
it = EventBuffer::events.erase(it);
|
||
} else {
|
||
++it;
|
||
}
|
||
}
|
||
|
||
if (picker_open_) {
|
||
updatePicker(tilemap, CX, CY);
|
||
} else {
|
||
updateMap(tilemap, CX, CY);
|
||
}
|
||
}
|
||
|
||
void Editor::updateMap(Tilemap& tilemap, int canvas_x, int canvas_y) {
|
||
// Cursor → cel·la del tilemap. Fora del mapa deixem -1/-1 perquè
|
||
// `render()` sàpiga no dibuixar preview i `setTileAt` ignori el clic.
|
||
const int TS = tilemap.getTileSize();
|
||
const int MX = canvas_x - tilemap.getOriginX();
|
||
const int MY = canvas_y - 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;
|
||
}
|
||
}
|
||
|
||
for (const auto& ev : EventBuffer::events) {
|
||
if (ev.type != SDL_EVENT_MOUSE_BUTTON_DOWN) { continue; }
|
||
if (ev.button.button == SDL_BUTTON_LEFT) {
|
||
if (brush_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
|
||
tilemap.setTileAt(cursor_col_, cursor_row_, brush_);
|
||
}
|
||
} else if (ev.button.button == SDL_BUTTON_RIGHT) {
|
||
brush_ = -1;
|
||
}
|
||
}
|
||
}
|
||
|
||
void Editor::updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
|
||
const auto LAY = layoutPicker(tilemap);
|
||
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 = ((picker_scroll_ + VR) * LAY.cols) + VC;
|
||
if (IDX < LAY.total_tiles) { picker_hover_ = IDX; }
|
||
}
|
||
}
|
||
|
||
for (const auto& ev : EventBuffer::events) {
|
||
if (ev.type == SDL_EVENT_MOUSE_WHEEL) {
|
||
picker_scroll_ -= static_cast<int>(ev.wheel.y);
|
||
picker_scroll_ = std::clamp(picker_scroll_, 0, LAY.max_scroll);
|
||
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
|
||
if (picker_hover_ >= 0) {
|
||
brush_ = picker_hover_;
|
||
picker_open_ = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void Editor::render(const Tilemap& tilemap) const {
|
||
if (!active_) { return; }
|
||
auto& screen = Screen::get();
|
||
constexpr int W = GameCanvas::WIDTH;
|
||
constexpr int H = GameCanvas::HEIGHT;
|
||
|
||
if (picker_open_) {
|
||
renderPicker(tilemap);
|
||
} else if (brush_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
|
||
// 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_);
|
||
}
|
||
|
||
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::renderPicker(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 = 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 (picker_hover_ >= 0) {
|
||
const int VR = (picker_hover_ / LAY.cols) - picker_scroll_;
|
||
const int VC = 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
|