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

377 lines
15 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 "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;
// Estat de novella: tornem a Mode::TILE sense brush ni picker. Els
// brushs (tile/entity) i la selecció de la mà no es preserven entre
// sessions del editor: cada entrada parteix net.
mode_ = Mode::TILE;
picker_ = Picker::NONE;
brush_tile_ = -1;
brush_entity_.clear();
hand_selected_idx_ = -1;
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_ = 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 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();
}
}
auto Editor::applyHotkey(int scancode) -> bool {
const SDL_Scancode TILE_PICKER_SC = KeyConfig::get().scancode("editor_tile_picker");
const SDL_Scancode ENTITY_PICKER_SC = KeyConfig::get().scancode("editor_entity_picker");
const SDL_Scancode HAND_SC = KeyConfig::get().scancode("editor_hand");
if (TILE_PICKER_SC != SDL_SCANCODE_UNKNOWN && scancode == TILE_PICKER_SC) {
picker_ = (picker_ == Picker::TILE) ? Picker::NONE : Picker::TILE;
if (picker_ == Picker::TILE) { tile_picker_scroll_ = 0; }
return true;
}
if (ENTITY_PICKER_SC != SDL_SCANCODE_UNKNOWN && scancode == ENTITY_PICKER_SC) {
picker_ = (picker_ == Picker::ENTITY) ? Picker::NONE : Picker::ENTITY;
if (picker_ == Picker::ENTITY) { entity_picker_scroll_ = 0; }
return true;
}
if (HAND_SC != SDL_SCANCODE_UNKNOWN && scancode == HAND_SC) {
mode_ = Mode::HAND;
picker_ = Picker::NONE;
hand_selected_idx_ = -1;
return true;
}
// ESC només tanca el picker; fora d'ell, deixem que GlobalInputs el
// tracte per a la seva acció de confirmar-eixida.
if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) {
picker_ = Picker::NONE;
return true;
}
return false;
}
void Editor::handleHotkeys() {
// Consumim les pròpies abans que cap altre handler (GlobalInputs) les
// veja. La transició concreta de cada tecla viu a `applyHotkey`.
for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) {
const bool IS_KEY = (it->type == SDL_EVENT_KEY_DOWN);
if (IS_KEY && applyHotkey(it->key.scancode)) {
it = EventBuffer::events.erase(it);
} else {
++it;
}
}
}
void Editor::update(int /*delta_ms*/, Tilemap& tilemap) {
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 {
switch (mode_) {
case Mode::TILE:
updateTileBrush(tilemap, CX, CY);
break;
case Mode::ENTITY:
updateEntityBrush(CX, CY);
break;
case Mode::HAND:
updateHand(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;
}
}
}
// NOLINTBEGIN(readability-convert-member-functions-to-static)
// Stubs: l'estat i la lògica concreta es construeixen als passos següents
// (entity placement, hand mode). Mantenim la signatura per estabilitat
// d'API i perquè update() pugui despatxar sense branques especials.
void Editor::updateEntityBrush(int /*canvas_x*/, int /*canvas_y*/) {
// Pas 5: col·locació amb snap per tipus + bloqueig de solapaments.
}
void Editor::updateHand(int /*canvas_x*/, int /*canvas_y*/) {
// Pas 6: hover, select, move, delete, marc d'entitat seleccionada.
}
void Editor::updateEntityPicker(int /*canvas_x*/, int /*canvas_y*/) {
// Pas 4: rejilla d'entitats registrades + click → brush_entity_.
}
void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const {
// Pas 4: render del picker d'entitats.
}
// NOLINTEND(readability-convert-member-functions-to-static)
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 {
if (!active_) { return; }
auto& screen = Screen::get();
constexpr int W = GameCanvas::WIDTH;
constexpr int H = GameCanvas::HEIGHT;
if (picker_ == Picker::TILE) {
renderTilePicker(tilemap);
} else if (picker_ == Picker::ENTITY) {
renderEntityPicker(tilemap);
} else if (mode_ == Mode::TILE && brush_tile_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
// Preview del brush sota el cursor (només quan el picker és tancat).
const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize());
const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize());
tilemap.renderTilePreview(PX, PY, brush_tile_);
}
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