Merge feat/entity-editor — editor d'entitats complet

This commit is contained in:
JailDesigner committed 2026-05-02 13:36:34 +02:00
commit 1ae6912be5
25 files changed
+1473 -141

No files matched your search

+6
View File
@@ -116,6 +116,9 @@ set(APP_SOURCES
source/game/map/map.cpp source/game/map/map.cpp
source/game/map/tilemap.cpp source/game/map/tilemap.cpp
source/game/map/tilemap_writer.cpp source/game/map/tilemap_writer.cpp
# Writer surgical de claus dins de `geometry:` (door_col), paral·lel
# al TilemapWriter. Només Debug.
source/game/map/map_writer.cpp
source/game/map/paint_layer.cpp source/game/map/paint_layer.cpp
source/game/levels/level_manager.cpp source/game/levels/level_manager.cpp
# Mòdul editor (només activa contingut en Debug via `#ifdef _DEBUG`). # Mòdul editor (només activa contingut en Debug via `#ifdef _DEBUG`).
@@ -123,6 +126,9 @@ set(APP_SOURCES
# arrossega cap símbol del joc final. # arrossega cap símbol del joc final.
source/game/editor/editor.cpp source/game/editor/editor.cpp
source/game/editor/editor_config.cpp source/game/editor/editor_config.cpp
# Writer del bloc `entities:` del YAML del nivell, paral·lel al
# TilemapWriter pero per a la persistència de l'editor d'entitats.
source/game/entities/entity_writer.cpp
${ENTITY_SOURCES} ${ENTITY_SOURCES}
# Fitxer només del binari principal: `loadFromLevel` depèn de # Fitxer només del binari principal: `loadFromLevel` depèn de
# Resource::Cache (→ Surface → GIF → àudio). Separat per no arrossegar # Resource::Cache (→ Surface → GIF → àudio). Separat per no arrossegar
+1
View File
@@ -1,5 +1,6 @@
name: door name: door
type: interactable type: interactable
editable: true
sprite: sprite:
path: door.gif path: door.gif
size: [48, 32] size: [48, 32]
+1
View File
@@ -1,5 +1,6 @@
name: hole name: hole
type: static_hazard type: static_hazard
editable: true
sprite: sprite:
path: hole.gif path: hole.gif
size: [16, 16] size: [16, 16]
+1
View File
@@ -1,5 +1,6 @@
name: mummy name: mummy
type: dynamic_hazard type: dynamic_hazard
editable: true
sprite: sprite:
path: mummy.gif path: mummy.gif
size: [16, 32] size: [16, 32]
@@ -1,5 +1,6 @@
name: sarcophagus name: sarcophagus
type: interactable type: interactable
editable: true
sprite: sprite:
path: sarcophagus.gif path: sarcophagus.gif
size: [48, 32] size: [48, 32]
+1
View File
@@ -1,5 +1,6 @@
name: torch name: torch
type: decoration type: decoration
editable: true
sprite: sprite:
path: torch.gif path: torch.gif
size: [16, 16] size: [16, 16]
+10 -1
View File
@@ -44,9 +44,18 @@ keys:
- id: editor_toggle - id: editor_toggle
code: "9" code: "9"
desc: "Entra/surt del mode editor (només Debug)" desc: "Entra/surt del mode editor (només Debug)"
- id: editor_picker - id: editor_tile_picker
code: "T" code: "T"
desc: "Obri/tanca el tilepicker dins de l'editor" desc: "Obri/tanca el tilepicker dins de l'editor"
- id: editor_entity_picker
code: "E"
desc: "Obri/tanca l'entity picker dins de l'editor"
- id: editor_hand
code: "H"
desc: "Activa el mode mà de l'editor (selecciona/mou/borra entitats)"
- id: editor_delete
code: "Delete"
desc: "Esborra l'entitat seleccionada en mode mà"
- id: prev_room - id: prev_room
code: "[" code: "["
desc: "Salta a la sala anterior del levels.yaml (només Debug)" desc: "Salta a la sala anterior del levels.yaml (només Debug)"
+3
View File
@@ -27,6 +27,9 @@ input:
editor_on: "Editor ON" editor_on: "Editor ON"
editor_off: "Editor OFF" editor_off: "Editor OFF"
editor_save_error: "Editor: error guardant el mapa" editor_save_error: "Editor: error guardant el mapa"
editor_keep_door: "Editor: cal almenys una porta"
editor_keep_sarc: "Editor: cal almenys un sarcòfag"
editor_invalid_level: "Editor: nivell incomplet (porta i sarcs)"
hud: hud:
p1: "P1" p1: "P1"
+3
View File
@@ -27,6 +27,9 @@ input:
editor_on: "Editor ON" editor_on: "Editor ON"
editor_off: "Editor OFF" editor_off: "Editor OFF"
editor_save_error: "Editor: map save failed" editor_save_error: "Editor: map save failed"
editor_keep_door: "Editor: at least one door required"
editor_keep_sarc: "Editor: at least one sarcophagus required"
editor_invalid_level: "Editor: level incomplete (door and sarcs)"
hud: hud:
p1: "P1" p1: "P1"
+6 -13
View File
@@ -49,7 +49,7 @@ geometry:
# 16 px centrals (just aquesta columna) són el "forat" del mur trasser per # 16 px centrals (just aquesta columna) són el "forat" del mur trasser per
# on el jugador surt de la sala. Les 2 columnes flanquejants segueixen sent # on el jugador surt de la sala. Les 2 columnes flanquejants segueixen sent
# mur visible. El spawn dels jugadors també s'alinea amb aquesta columna. # mur visible. El spawn dels jugadors també s'alinea amb aquesta columna.
door_col: 10 door_col: 14
data: data:
- [11,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,13] - [11,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,13]
@@ -82,15 +82,12 @@ data:
# d'obertura (262/263). La momia al corredor central. L'antorxa al costat # d'obertura (262/263). La momia al corredor central. L'antorxa al costat
# superior esquerre per verificar render d'una entitat decorativa. # superior esquerre per verificar render d'una entitat decorativa.
entities: entities:
- type: torch
position: [4, 2]
- type: torch - type: torch
position: [8, 2] position: [8, 2]
- type: torch - type: torch
position: [12, 2] position: [12, 2]
- type: torch - type: torch
position: [16, 2] position: [16, 2]
- type: sarcophagus - type: sarcophagus
sarc_position: [0, 0] sarc_position: [0, 0]
- type: sarcophagus - type: sarcophagus
@@ -131,17 +128,8 @@ entities:
sarc_position: [2, 4] sarc_position: [2, 4]
- type: sarcophagus - type: sarcophagus
sarc_position: [3, 4] sarc_position: [3, 4]
- type: door
- type: mummy - type: mummy
position: [10, 7] position: [10, 7]
- type: mummy
position: [7, 9]
- type: hole
position: [11, 5]
- type: hole - type: hole
position: [4, 7] position: [4, 7]
- type: hole - type: hole
@@ -150,3 +138,8 @@ entities:
position: [15, 11] position: [15, 11]
- type: hole - type: hole
position: [15, 5] position: [15, 5]
- type: hole
position: [8, 5]
- type: mummy
position: [8, 9]
- type: door
+45 -6
View File
@@ -15,12 +15,18 @@ namespace Resource::Helper {
Pack g_pack; Pack g_pack;
bool g_pack_loaded = false; bool g_pack_loaded = false;
bool g_fallback_enabled = true; bool g_fallback_enabled = true;
// Inverteix la prioritat (disc abans que pack). Activat en Debug
// per defecte; veure setPreferDisk al header.
bool g_prefer_disk = false;
auto readFromDisk(const std::string& filepath) -> std::vector<std::uint8_t> { auto readFromDisk(const std::string& filepath, bool verbose = true) -> std::vector<std::uint8_t> {
const auto FULL = resolve(filepath); const auto FULL = resolve(filepath);
std::ifstream file(FULL, std::ios::binary | std::ios::ate); std::ifstream file(FULL, std::ios::binary | std::ios::ate);
if (!file) { if (!file) {
std::cerr << "Resource::Helper: cannot open " << FULL << '\n'; // Silenciem si veníem de prefer_disk: aleshores caurem
// al pack i no és cap error real que el fitxer no estigui
// al disc.
if (verbose) { std::cerr << "Resource::Helper: cannot open " << FULL << '\n'; }
return {}; return {};
} }
std::streamsize size = file.tellg(); std::streamsize size = file.tellg();
@@ -37,6 +43,11 @@ namespace Resource::Helper {
auto initializeResourceSystem(const std::string& pack_file, bool enable_fallback) -> bool { auto initializeResourceSystem(const std::string& pack_file, bool enable_fallback) -> bool {
g_fallback_enabled = enable_fallback; g_fallback_enabled = enable_fallback;
// En el flux Debug típic (fallback actiu, source tree disponible)
// preferim sempre disc: el desenvolupador edita YAML/GIF a `data/`
// i ho veu reflectit al següent reload sense re-packejar. En
// Release el fallback està desactivat i aquest flag és inert.
g_prefer_disk = enable_fallback;
g_pack_loaded = g_pack.loadPack(pack_file); g_pack_loaded = g_pack.loadPack(pack_file);
if (g_pack_loaded) { if (g_pack_loaded) {
@@ -76,17 +87,45 @@ namespace Resource::Helper {
} }
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t> { auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t> {
if (g_pack_loaded) { // Helper local: lookup al pack normalitzant la clau (els callers
// El pack està indexat relatiu a `data/` (la carpeta que // passen "data/foo.yaml" però l'índex del pack és relatiu a la
// s'empaqueta), però els callers passen "data/foo.yaml" amb // carpeta `data/`).
// prefix. Fem el strip ací perquè no hagin de tenir-ho en compte. const auto FROM_PACK = [&filepath]() -> std::vector<std::uint8_t> {
if (!g_pack_loaded) { return {}; }
constexpr std::string_view PREFIX = "data/"; constexpr std::string_view PREFIX = "data/";
std::string key = filepath; std::string key = filepath;
if (key.starts_with(PREFIX)) { key.erase(0, PREFIX.size()); } if (key.starts_with(PREFIX)) { key.erase(0, PREFIX.size()); }
if (g_pack.hasResource(key)) { return g_pack.getResource(key); } if (g_pack.hasResource(key)) { return g_pack.getResource(key); }
return {};
};
if (g_prefer_disk && g_fallback_enabled) {
// En Debug volem que el disc guanyi: així les edicions del
// editor in-game (i les manuals del repo) es vegen al pròxim
// reload sense rebuild del pack. Si el fitxer no és al disc,
// caiem al pack — útil per a recursos que només s'han
// empaquetat (cas estrany). El silenci a `readFromDisk` evita
// emplenar el log amb "cannot open" en aquest camí esperat.
auto bytes = readFromDisk(filepath, false);
if (!bytes.empty()) { return bytes; }
return FROM_PACK();
} }
// Política original: pack primer; disc com a fallback si el
// recurs no està empaquetat (típic d'arrencada en Debug abans
// del primer pack).
auto packed = FROM_PACK();
if (!packed.empty()) { return packed; }
if (g_fallback_enabled) { return readFromDisk(filepath); } if (g_fallback_enabled) { return readFromDisk(filepath); }
return {}; return {};
} }
void setPreferDisk(bool prefer) {
g_prefer_disk = prefer;
}
auto preferDisk() -> bool {
return g_prefer_disk;
}
} // namespace Resource::Helper } // namespace Resource::Helper
+11 -1
View File
@@ -32,7 +32,17 @@ namespace Resource::Helper {
// Carrega un recurs pel seu camí relatiu (p.ex. "fonts/hud.gif", // Carrega un recurs pel seu camí relatiu (p.ex. "fonts/hud.gif",
// "input/keys.yaml"). Prova primer el pack; si no hi és i el fallback // "input/keys.yaml"). Prova primer el pack; si no hi és i el fallback
// està actiu, cau a disc (data root + ruta). Retorna vector buit si // està actiu, cau a disc (data root + ruta). Retorna vector buit si
// no el troba. // no el troba. Si `preferDisk()` és true (Debug), inverteix la
// prioritat: prova disc abans del pack.
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t>; auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t>;
// Inverteix la prioritat de `loadFile` (disc abans que pack). En Debug
// s'activa per defecte al `initializeResourceSystem` perquè cada
// canvi al YAML/GIF al source tree es vegi sense re-packejar — és el
// que necessita l'editor in-game per veure les seues edicions
// immediatament al recarregar la sala. En Release no té efecte
// (el fallback està desactivat).
void setPreferDisk(bool prefer);
[[nodiscard]] auto preferDisk() -> bool;
} // namespace Resource::Helper } // namespace Resource::Helper
+774 -69
View File
@@ -5,17 +5,30 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <filesystem>
#include <numeric>
#include <sstream>
#include "core/input/key_config.hpp" #include "core/input/key_config.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/notification_presets.hpp" #include "core/rendering/notification_presets.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "core/rendering/text.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include "core/system/event_buffer.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/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/levels/level_manager.hpp"
#include "game/map/map.hpp"
#include "game/map/map_writer.hpp"
#include "game/map/tilemap.hpp" #include "game/map/tilemap.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
#include "utils/defines.hpp" #include "utils/defines.hpp"
@@ -106,21 +119,211 @@ namespace {
return {PX, PY}; 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};
}
} // namespace } // namespace
void Editor::enter() { void Editor::enter() {
if (active_) { return; } if (active_) { return; }
active_ = true; active_ = true;
// Estat de novella: comencem en mode HAND (selecció/manipulació
// d'entitats existents) — el cas d'ús més habitual quan obres
// l'editor és inspeccionar/moure/borrar el que ja hi ha. Els
// brushs i la selecció no es preserven entre sessions: cada
// entrada parteix net.
mode_ = Mode::HAND;
picker_ = Picker::NONE;
brush_tile_ = -1;
brush_entity_.clear();
hand_selected_idx_ = -1;
hand_hover_idx_ = -1;
ensureEditableEntities();
ensureHudFont();
Notification::notify(Locale::get().text("input.editor_on")); 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 = "data/entities/";
full += name;
full += '/';
full += REL;
const auto SIZE = sp["size"];
if (!SIZE.is_sequence() || SIZE.size() < 2) { continue; }
const int FRAME_W = SIZE[0].get_value<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; }
editable_entities_.push_back({.name = name, .sprite = surface, .frame_w = FRAME_W, .frame_h = FRAME_H});
}
}
namespace {
auto countByName(const EntityManager& entities, std::string_view name) -> int {
const auto LIST = entities.getEntities();
return static_cast<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;
}
}
out.push_back(std::move(s));
}
return out;
}
} // namespace
// cppcheck-suppress constParameterReference // cppcheck-suppress constParameterReference
// Tilemap& mutable per simetria amb `toggle()` i per futures // Tilemap& mutable per simetria amb `toggle()` i per futures
// invalidacions de caches d'edició, encara que `save()` sigui const. // invalidacions de caches d'edició, encara que `save()` sigui const.
void Editor::exit(Tilemap& tilemap) { void Editor::exit(Tilemap& tilemap, const EntityManager& entities) {
if (!active_) { return; } 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; active_ = false;
picker_open_ = false; picker_ = Picker::NONE;
// El writer salva sempre sobre la sala actualment carregada (segons // El writer salva sempre sobre la sala actualment carregada (segons
// el LevelManager): així el "9" de l'editor desa el que tens davant, // el LevelManager): així el "9" de l'editor desa el que tens davant,
// no una constant fixa. Resolem la ruta via Resource::Helper perquè // no una constant fixa. Resolem la ruta via Resource::Helper perquè
@@ -128,21 +331,72 @@ void Editor::exit(Tilemap& tilemap) {
// no el CWD; sense això, el writer obriria el path relatiu al CWD i // 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. // fallaria si el binari no es llança des del repo root.
const auto ABS_PATH = Resource::Helper::resolve(Levels::LevelManager::get().currentPath()); const auto ABS_PATH = Resource::Helper::resolve(Levels::LevelManager::get().currentPath());
const bool SAVED = tilemap.save(ABS_PATH); 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")); Notification::notify(Locale::get().text(SAVED ? "input.editor_off" : "input.editor_save_error"));
} }
void Editor::toggle(Tilemap& tilemap) { void Editor::toggle(Tilemap& tilemap, const EntityManager& entities) {
if (active_) { if (active_) {
exit(tilemap); exit(tilemap, entities);
} else { } else {
enter(); enter();
} }
} }
void Editor::update(int /*delta_ms*/, Tilemap& tilemap) { auto Editor::applyHotkey(int scancode) -> bool {
const SDL_Scancode TILE_PICKER_SC = KeyConfig::get().scancode("editor_tile_picker");
const SDL_Scancode ENTITY_PICKER_SC = KeyConfig::get().scancode("editor_entity_picker");
const SDL_Scancode HAND_SC = KeyConfig::get().scancode("editor_hand");
if (TILE_PICKER_SC != SDL_SCANCODE_UNKNOWN && scancode == TILE_PICKER_SC) {
picker_ = (picker_ == Picker::TILE) ? Picker::NONE : Picker::TILE;
if (picker_ == Picker::TILE) { tile_picker_scroll_ = 0; }
return true;
}
if (ENTITY_PICKER_SC != SDL_SCANCODE_UNKNOWN && scancode == ENTITY_PICKER_SC) {
picker_ = (picker_ == Picker::ENTITY) ? Picker::NONE : Picker::ENTITY;
if (picker_ == Picker::ENTITY) { entity_picker_scroll_ = 0; }
return true;
}
if (HAND_SC != SDL_SCANCODE_UNKNOWN && scancode == HAND_SC) {
mode_ = Mode::HAND;
picker_ = Picker::NONE;
hand_selected_idx_ = -1;
return true;
}
// ESC només tanca el picker; fora d'ell, deixem que GlobalInputs el
// tracte per a la seva acció de confirmar-eixida.
if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) {
picker_ = Picker::NONE;
return true;
}
return false;
}
void Editor::handleHotkeys() {
// Consumim les pròpies abans que cap altre handler (GlobalInputs) les
// veja. La transició concreta de cada tecla viu a `applyHotkey`.
for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) {
const bool IS_KEY = (it->type == SDL_EVENT_KEY_DOWN);
if (IS_KEY && applyHotkey(it->key.scancode)) {
it = EventBuffer::events.erase(it);
} else {
++it;
}
}
}
void Editor::update(int /*delta_ms*/, Tilemap& tilemap, EntityManager& entities) {
if (!active_) { return; } if (!active_) { return; }
handleHotkeys();
// Posició del ratolí en coordenades del canvas (independent del // Posició del ratolí en coordenades del canvas (independent del
// zoom de finestra i del mode d'escalat). // zoom de finestra i del mode d'escalat).
float mouse_x = 0.0F; float mouse_x = 0.0F;
@@ -152,43 +406,10 @@ void Editor::update(int /*delta_ms*/, Tilemap& tilemap) {
const int CX = static_cast<int>(CP.x); const int CX = static_cast<int>(CP.x);
const int CY = static_cast<int>(CP.y); const int CY = static_cast<int>(CP.y);
// Consumim tecles pròpies del picker abans que cap altre handler // Cursor → cel·la del tilemap. Comú a tots els modes amb mapa visible.
// (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_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 TS = tilemap.getTileSize();
const int MX = canvas_x - tilemap.getOriginX(); const int MX = CX - tilemap.getOriginX();
const int MY = canvas_y - tilemap.getOriginY(); const int MY = CY - tilemap.getOriginY();
cursor_col_ = -1; cursor_col_ = -1;
cursor_row_ = -1; cursor_row_ = -1;
if (TS > 0 && MX >= 0 && MY >= 0) { if (TS > 0 && MX >= 0 && MY >= 0) {
@@ -200,21 +421,482 @@ void Editor::updateMap(Tilemap& tilemap, int canvas_x, int canvas_y) {
} }
} }
for (const auto& ev : EventBuffer::events) { // Dispatch: el picker, si està obert, té prioritat sobre el mode.
if (ev.type != SDL_EVENT_MOUSE_BUTTON_DOWN) { continue; } if (picker_ == Picker::TILE) {
if (ev.button.button == SDL_BUTTON_LEFT) { updateTilePicker(tilemap, CX, CY);
if (brush_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) { } else if (picker_ == Picker::ENTITY) {
tilemap.setTileAt(cursor_col_, cursor_row_, brush_); updateEntityPicker(CX, CY);
} } else {
} else if (ev.button.button == SDL_BUTTON_RIGHT) { switch (mode_) {
brush_ = -1; 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::updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) { 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).
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing) -> std::string {
std::ostringstream y;
y << "type: " << brush << '\n';
if (brush == "sarcophagus") {
const auto [SC, SR] = cursorToSarcCell(cursor_col, cursor_row);
if (SC < 0 || sarcAlreadyAt(existing, SC, SR)) { return {}; }
y << "sarc_position: [" << SC << ", " << SR << "]\n";
return y.str();
}
if (brush == "door") {
if (doorExists(existing)) { return {}; }
return y.str();
}
const auto DEST = tileCellRect(cursor_col, cursor_row, Map::tile_size);
if (cellOccupied(existing, DEST)) { return {}; }
y << "position: [" << cursor_col << ", " << cursor_row << "]\n";
return y.str();
}
} // namespace
void Editor::updateEntityBrush(EntityManager& entities, int /*canvas_x*/, int /*canvas_y*/) {
for (const auto& ev : EventBuffer::events) {
if (ev.type != SDL_EVENT_MOUSE_BUTTON_DOWN) { continue; }
if (ev.button.button == SDL_BUTTON_LEFT) {
tryPlaceEntity(entities);
} else if (ev.button.button == SDL_BUTTON_RIGHT) {
// Click dret: descarta el brush però es queda en Mode::ENTITY.
// Per sortir cap a un altre mode, utilitza T (tile picker) o
// H (mà): manté la simetria amb el comportament del brush
// de tiles.
brush_entity_.clear();
}
}
}
namespace {
// Index de l'entitat sota el píxel donat. -1 si cap. Usem el rect
// de col·lisió (caixa base alineada al tilemap) i NO el sprite
// sencer: a entitats amb falsa perspectiva (mòmia 16×32, sarc
// 48×32) la part alta del sprite és "altura visual" que tapa
// cel·les transitables darrere. Si el hit-test usés el sprite
// sencer, no es podria seleccionar/col·locar res a aquelles
// cel·les. Per a entitats sense caixa explícita (torch, hole),
// `getCollisionRect` retorna el `pixel_rect` complet, així que el
// criteri segueix sent intuïtiu ("clic on es veu").
auto entityAtPixel(const std::vector<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; });
const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered);
if (STR.empty()) { return false; }
if (!entities.removeEntity(target)) { return false; }
try {
auto node = fkyaml::node::deserialize(STR.begin(), STR.end());
return entities.addSpawn(node) != nullptr;
} catch (const std::exception&) {
return false;
}
}
void Editor::deleteSelected(EntityManager& entities) {
auto list = entities.getEntities();
if (hand_selected_idx_ < 0 || hand_selected_idx_ >= static_cast<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_) + ")"
: "(--,--)";
const auto LBL = UiConfig::get().hudLabel();
const auto VAL = UiConfig::get().hudValue();
const auto FLASH = UiConfig::get().hudFlash();
const int KERN = UiConfig::get().hudFont().kerning;
// Mateixos Y que el HUD del joc per simetria visual: línia 1 = 4,
// línia 2 = 13. La font és proporcional, així que mesurem cada línia
// sencera abans de centrar-la.
constexpr int LINE1_Y = 4;
constexpr int LINE2_Y = 13;
constexpr int W = GameCanvas::WIDTH;
struct Segment {
std::string text;
std::uint8_t color;
};
auto draw_line = [&](const std::vector<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());
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())) {
brush_entity_ = editable_entities_[entity_picker_hover_].name;
mode_ = Mode::ENTITY;
picker_ = Picker::NONE;
}
}
}
}
void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const {
const auto LAY = layoutEntityPicker(static_cast<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::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
const auto LAY = layoutPicker(tilemap); const auto LAY = layoutPicker(tilemap);
picker_hover_ = -1; tile_picker_hover_ = -1;
// Espai útil per als tiles dins de la caixa (descomptat el border 1 px). // 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_X = LAY.x + PICKER_BORDER;
const int INNER_Y = LAY.y + PICKER_BORDER; const int INNER_Y = LAY.y + PICKER_BORDER;
@@ -227,37 +909,60 @@ void Editor::updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
const bool IN_SPACING_X = (RX % LAY.pitch) >= LAY.tile_size; const bool IN_SPACING_X = (RX % LAY.pitch) >= LAY.tile_size;
const bool IN_SPACING_Y = (RY % 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) { if (!IN_SPACING_X && !IN_SPACING_Y && VC < LAY.cols && VR < LAY.rows_visible) {
const int IDX = ((picker_scroll_ + VR) * LAY.cols) + VC; const int IDX = ((tile_picker_scroll_ + VR) * LAY.cols) + VC;
if (IDX < LAY.total_tiles) { picker_hover_ = IDX; } if (IDX < LAY.total_tiles) { tile_picker_hover_ = IDX; }
} }
} }
for (const auto& ev : EventBuffer::events) { for (const auto& ev : EventBuffer::events) {
if (ev.type == SDL_EVENT_MOUSE_WHEEL) { if (ev.type == SDL_EVENT_MOUSE_WHEEL) {
picker_scroll_ -= static_cast<int>(ev.wheel.y); tile_picker_scroll_ -= static_cast<int>(ev.wheel.y);
picker_scroll_ = std::clamp(picker_scroll_, 0, LAY.max_scroll); 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) { } else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (picker_hover_ >= 0) { if (tile_picker_hover_ >= 0) {
brush_ = picker_hover_; brush_tile_ = tile_picker_hover_;
picker_open_ = false; mode_ = Mode::TILE;
picker_ = Picker::NONE;
} }
} }
} }
} }
void Editor::render(const Tilemap& tilemap) const { void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const {
if (!active_) { return; } if (!active_) { return; }
auto& screen = Screen::get(); auto& screen = Screen::get();
constexpr int W = GameCanvas::WIDTH; constexpr int W = GameCanvas::WIDTH;
constexpr int H = GameCanvas::HEIGHT; constexpr int H = GameCanvas::HEIGHT;
if (picker_open_) { if (picker_ == Picker::TILE) {
renderPicker(tilemap); renderTilePicker(tilemap);
} else if (brush_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) { } 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). // Preview del brush sota el cursor (només quan el picker és tancat).
const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize()); const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize());
const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize()); const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize());
tilemap.renderTilePreview(PX, PY, brush_); 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(); const auto BORDER = EditorConfig::get().borderArgb();
@@ -267,7 +972,7 @@ void Editor::render(const Tilemap& tilemap) const {
screen.fillRect(W - 1, 0, 1, H, BORDER); screen.fillRect(W - 1, 0, 1, H, BORDER);
} }
void Editor::renderPicker(const Tilemap& tilemap) const { void Editor::renderTilePicker(const Tilemap& tilemap) const {
const auto LAY = layoutPicker(tilemap); const auto LAY = layoutPicker(tilemap);
if (LAY.cols <= 0 || LAY.rows_visible <= 0) { return; } if (LAY.cols <= 0 || LAY.rows_visible <= 0) { return; }
auto& screen = Screen::get(); auto& screen = Screen::get();
@@ -275,7 +980,7 @@ void Editor::renderPicker(const Tilemap& tilemap) const {
screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, cfg.pickerBgArgb()); screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, cfg.pickerBgArgb());
for (int vr = 0; vr < LAY.rows_visible; ++vr) { for (int vr = 0; vr < LAY.rows_visible; ++vr) {
const int LOGICAL_ROW = picker_scroll_ + vr; const int LOGICAL_ROW = tile_picker_scroll_ + vr;
for (int vc = 0; vc < LAY.cols; ++vc) { for (int vc = 0; vc < LAY.cols; ++vc) {
const int IDX = (LOGICAL_ROW * LAY.cols) + vc; const int IDX = (LOGICAL_ROW * LAY.cols) + vc;
if (IDX >= LAY.total_tiles) { break; } if (IDX >= LAY.total_tiles) { break; }
@@ -284,9 +989,9 @@ void Editor::renderPicker(const Tilemap& tilemap) const {
} }
} }
if (picker_hover_ >= 0) { if (tile_picker_hover_ >= 0) {
const int VR = (picker_hover_ / LAY.cols) - picker_scroll_; const int VR = (tile_picker_hover_ / LAY.cols) - tile_picker_scroll_;
const int VC = picker_hover_ % LAY.cols; const int VC = tile_picker_hover_ % LAY.cols;
if (VR >= 0 && VR < LAY.rows_visible) { if (VR >= 0 && VR < LAY.rows_visible) {
const auto [PX, PY] = pickerTileOrigin(LAY, VC, VR); const auto [PX, PY] = pickerTileOrigin(LAY, VC, VR);
const int TS = LAY.tile_size; const int TS = LAY.tile_size;
+158 -28
View File
@@ -2,6 +2,15 @@
#ifdef _DEBUG #ifdef _DEBUG
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
class Entity;
class EntityManager;
class Surface;
class Text;
class Tilemap; class Tilemap;
// Mode editor modal del GameScene. Només disponible en Debug — en Release // Mode editor modal del GameScene. Només disponible en Debug — en Release
@@ -11,55 +20,176 @@ class Tilemap;
// L'editor NO gestiona la tecla de toggle pròpia (`editor_toggle`): això // L'editor NO gestiona la tecla de toggle pròpia (`editor_toggle`): això
// ho fa GameScene per poder decidir la coexistència amb la pausa F11 // ho fa GameScene per poder decidir la coexistència amb la pausa F11
// (mútuament excloents). L'editor s'encarrega de: // (mútuament excloents). L'editor s'encarrega de:
// - Estat propi (actiu/no actiu, brush, picker obert). // - Estat propi (mode actiu, brush per mode, picker obert).
// - Input del ratolí i tecles internes (`T`, `ESC`). // - Input del ratolí i tecles internes (`T`, `E`, `H`, `Supr`, `ESC`).
// - Render del border blau + brush preview + picker. // - Render del border blau + brush preview + picker.
// - Persistència del tilemap al YAML del repo en sortir. // - Persistència del tilemap i de les entitats al YAML del repo en sortir.
// //
// GameScene, quan `active()` és true, salta el tick de jugadors/entitats // Modes del cursor (`Mode`):
// i no els pinta; el tilemap sí es pinta perquè és el que s'està editant. // - TILE : el clic esquerre pinta el `brush_tile_` al tilemap.
// - ENTITY : el clic esquerre col·loca una instància de `brush_entity_`.
// - HAND : selecciona/mou/borra entitats existents.
//
// Pickers (`Picker`):
// - TILE_PICKER (`T`) : selecciona índex de tile.
// - ENTITY_PICKER (`E`) : selecciona tipus d'entitat (nom registrat al
// factory).
// Mútuament excloents: obrir un tanca l'altre.
class Editor { class Editor {
public: public:
// Entra al mode editor: notifica i marca `active=true`. La congelació // Entra al mode editor: notifica i marca `active=true`. La congelació
// d'entitats / jugador la gestiona GameScene consultant `active()`. // d'entitats / jugador la gestiona GameScene consultant `active()`.
void enter(); void enter();
// Surt del mode editor: persisteix el tilemap al YAML (writer surgical) // Surt del mode editor: persisteix el tilemap i les entitats al YAML
// i notifica. Si la persistència falla, emet la notificació d'error. // (writers surgicals) i notifica. Si la persistència falla, emet la
void exit(Tilemap& tilemap); // notificació d'error.
void exit(Tilemap& tilemap, const EntityManager& entities);
// Helper: enter() si no actiu, exit() si actiu. Útil des de GameScene // Helper: enter() si no actiu, exit() si actiu. Útil des de GameScene
// quan detecta la tecla de toggle. // quan detecta la tecla de toggle.
void toggle(Tilemap& tilemap); void toggle(Tilemap& tilemap, const EntityManager& entities);
[[nodiscard]] auto active() const -> bool { return active_; } [[nodiscard]] auto active() const -> bool { return active_; }
// Processa input propi (brush, picker). No-op si no actiu. // Cert quan, en mode editor, GameScene ha de pintar les entitats
void update(int delta_ms, Tilemap& tilemap); // de la sala. Falses en mode TILE (volem veure el tilemap pelat
// per pintar-lo) i quan hi ha un picker obert (la caixa del picker
// ja tapa la pantalla i el soroll visual addicional sobra).
[[nodiscard]] auto shouldShowEntities() const -> bool {
return active_ && mode_ != Mode::TILE && picker_ == Picker::NONE;
}
// Pinta el border blau i, més endavant, brush preview + picker. // Processa input propi (brush, picker). No-op si no actiu.
// No-op si no actiu. // `entities` cal perquè els modes ENTITY i HAND modifiquen el manager
void render(const Tilemap& tilemap) const; // en directe (col·loca/mou/borra entitats); en TILE no s'usa.
void update(int delta_ms, Tilemap& tilemap, EntityManager& entities);
// Pinta el border blau i, segons mode i picker, brush preview, marc
// d'entitat seleccionada o el picker. No-op si no actiu.
// `entities` és necessari per al marc d'entitat hovered/seleccionada
// en Mode::HAND; en altres modes no s'usa.
void render(const Tilemap& tilemap, const EntityManager& entities) const;
private: private:
// Les branques d'update / render segons el mode (picker obert o // Mode del cursor: determina què fa el clic esquerre sobre el mapa.
// pintura al mapa) queden separades per claredat; no formen part enum class Mode : std::uint8_t { TILE,
// de l'API pública perquè només té sentit cridar-les des d'`update`. ENTITY,
void updateMap(Tilemap& tilemap, int canvas_x, int canvas_y); HAND };
void updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y);
void renderPicker(const Tilemap& tilemap) const; // Quin picker està obert (si n'hi ha). Mútuament excloents.
enum class Picker : std::uint8_t { NONE,
TILE,
ENTITY };
// Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`.
// Es crida al principi d'`update` perquè altres handlers no vegin
// tecles ja interpretades pel propi editor.
void handleHotkeys();
// Aplica una tecla i retorna true si l'editor l'ha consumit. Aïllat
// per mantenir baixa la complexitat cognitiva de `handleHotkeys` —
// l'iteració del buffer queda neta i la lògica de transició viu en
// un sol lloc.
auto applyHotkey(int scancode) -> bool;
// Branques d'update segons (mode, picker). Només té sentit cridar-les
// des d'`update`.
void updateTileBrush(Tilemap& tilemap, int canvas_x, int canvas_y);
void updateEntityBrush(EntityManager& entities, int canvas_x, int canvas_y);
void updateHand(const Tilemap& tilemap, EntityManager& entities, int canvas_x, int canvas_y);
void updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y);
void updateEntityPicker(int canvas_x, int canvas_y);
// Intent de col·locació segons brush_entity_ a la cel·la del cursor.
// Construeix un node spawn sintètic, fa el snap concret per tipus
// (sarc → rejilla 4×5; door → fila del mur i, de moment, columna
// canònica) i comprova solapaments. No-op si la cel·la està ocupada
// o el snap no és vàlid. Retorna true si s'ha col·locat.
auto tryPlaceEntity(EntityManager& entities) -> bool;
// Mou l'entitat seleccionada a la cel·la del cursor: borra l'antiga
// i crida `addSpawn` amb la nova posició. Reusa la mateixa lògica
// de snap/solapament que la col·locació nova, excloent l'entitat
// que es mou del check de solapament. Cas especial per a la porta:
// el sprite ocupa 3 cols (col-1, col, col+1) sobre el mur superior;
// per tant la columna central vàlida és [1, cols-2] — `tilemap` és
// necessari per saber `cols`.
auto tryMoveSelected(const Tilemap& tilemap, EntityManager& entities) const -> bool;
// Sub-handlers de `updateHand`. Aïllats per mantenir baixa la
// complexitat cognitiva del bucle d'esdeveniments.
void handleHandLeftClick(const Tilemap& tilemap, EntityManager& entities);
void handleHandRightClick();
// Borra l'entitat seleccionada. La validació de constraints
// (1 puerta, ≥1 sarc) es fa al pas 8.
void deleteSelected(EntityManager& entities);
// Render del marc d'una entitat: 1 px sobre el seu pixel_rect.
static void renderEntityFrame(const Entity& e, std::uint32_t argb);
// HUD d'edició: franja superior amb mode, brush, cursor i comptadors
// de constraints. Substitueix el HUD del joc mentre l'editor és actiu.
void renderEditorHud(const EntityManager& entities) const;
// Carrega la font 8bithud del cache. Lazy: al primer enter().
void ensureHudFont();
void renderTilePicker(const Tilemap& tilemap) const;
void renderEntityPicker(const Tilemap& tilemap) const;
bool active_{false}; bool active_{false};
Mode mode_{Mode::TILE};
Picker picker_{Picker::NONE};
// Estat de l'edició (brush + cursor + picker). Es poblen al pas // Cursor en cel·les del tilemap. -1/-1 = fora del mapa.
// següent; els declarem ara perquè `update()` pugui mutar-los i les int cursor_col_{-1};
// signatures queden estables entre hitos.
int brush_{-1}; // Índex de tile al tileset; -1 = sense brush.
int cursor_col_{-1}; // Cel·la del mapa sota el cursor (-1 = fora).
int cursor_row_{-1}; int cursor_row_{-1};
bool picker_open_{false};
int picker_scroll_{0}; // Fila superior visible del picker (linealitzat). // Brush actiu per Mode::TILE (índex al tileset; -1 = sense brush).
int picker_hover_{-1}; // Índex del tile sota el cursor dins del picker. int brush_tile_{-1};
// Estat del TilePicker.
int tile_picker_scroll_{0};
int tile_picker_hover_{-1};
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
std::string brush_entity_;
// Estat de l'EntityPicker.
int entity_picker_scroll_{0};
int entity_picker_hover_{-1}; // Índex a la llista de noms.
// Entrada editable del picker. Cada item recorda el nom (clau de
// factory + YAML), el surface del sprite (frame 0 com a icona) i la
// mida de cada frame al GIF — necessària per clipar el primer frame
// quan el GIF és un strip multi-frame.
struct EditableEntity {
std::string name;
std::shared_ptr<Surface> sprite;
int frame_w{0};
int frame_h{0};
};
// Construïda lazy al primer enter() i preservada entre sessions:
// el cost és I/O + parse de N plantilles YAML, però `EntityLoader`
// ja les memoritza, així que rellegir només resoldria per sortida i
// entrada al editor — no val la pena.
void ensureEditableEntities();
std::vector<EditableEntity> editable_entities_;
// Font compartida amb el Notifier/HUD: el surface el carrega Director
// a l'arrencada, ací només prenem la referència del cache. shared_ptr
// perquè `Text` accepta el surface per shared_ptr i així no copiem.
std::shared_ptr<Text> hud_font_;
// Selecció en Mode::HAND. Treballem amb índexs sobre el snapshot de
// `EntityManager::getEntities()` perquè és el que ens dóna l'API
// pública. Després de cada mutació (move/delete) ho recalculem; com
// que l'editor congela el joc, el snapshot només canvia per accions
// pròpies.
int hand_hover_idx_{-1};
int hand_selected_idx_{-1};
}; };
#endif // _DEBUG #endif // _DEBUG
@@ -152,7 +152,7 @@ class WhipAttack : public Attack {
// del nostre `getFeetY()` per delegar el bucket d'y-sort al jugador. // del nostre `getFeetY()` per delegar el bucket d'y-sort al jugador.
float owner_feet_y_{0.0F}; float owner_feet_y_{0.0F};
EntityManager* entity_manager_{nullptr}; // `entity_manager_` viu a Entity (protected): l'utilitzem d'allà.
const std::vector<Player*>* players_{nullptr}; const std::vector<Player*>* players_{nullptr};
DirSprite sprite_north_; DirSprite sprite_north_;
+15
View File
@@ -87,6 +87,21 @@ void EntityManager::setDoor(Door* door) {
} }
} }
auto EntityManager::removeEntity(Entity* observer) -> bool {
if (observer == nullptr) { return false; }
const bool WAS_DOOR = (door_ == observer);
const bool WAS_SARC = (observer->getName() == "sarcophagus");
auto it = std::ranges::find_if(entities_, [observer](const auto& up) { return up.get() == observer; });
if (it == entities_.end()) { return false; }
entities_.erase(it);
if (WAS_DOOR) { door_ = nullptr; }
if (WAS_SARC && total_sarcs_ > 0) { --total_sarcs_; }
// No tocar `opened_sarcs_` aquí: l'editor congela el joc i no pot
// disparar-se cap esdeveniment derivat. Quan el GameScene faça el
// reload posterior, els comptadors es re-deriven d'inici.
return true;
}
auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcophagus* { auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcophagus* {
if (sarc == nullptr) { return nullptr; } if (sarc == nullptr) { return nullptr; }
sarc->setEventBus(&bus_); sarc->setEventBus(&bus_);
+17
View File
@@ -7,6 +7,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "external/fkyaml_node.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
class Entity; class Entity;
@@ -43,6 +44,13 @@ class EntityManager {
// una. Si ja n'hi ha una assignada, la substitueix (útil a tests). // una. Si ja n'hi ha una assignada, la substitueix (útil a tests).
void setDoor(Door* door); void setDoor(Door* door);
// Treu i destrueix l'entitat observada. Si era la porta o un sarc,
// ajusta `door_` / `total_sarcs_` en conseqüència. Pensat per al
// editor: no toca `opened_sarcs_` ni publica events — el joc està
// congelat i, en sortir del editor, GameScene torna a fer reload(),
// així que l'estat es deriva net.
auto removeEntity(Entity* observer) -> bool;
// Marca una entitat com a sarcòfag — si no es passa per `add` amb // Marca una entitat com a sarcòfag — si no es passa per `add` amb
// type=sarcophagus no es compta al total. Útil en combinació amb // type=sarcophagus no es compta al total. Útil en combinació amb
// `add` quan es fa addSarcophagus(unique_ptr<Sarcophagus>). // `add` quan es fa addSarcophagus(unique_ptr<Sarcophagus>).
@@ -102,6 +110,15 @@ class EntityManager {
// `Map::tile_size`, `tilemap_origin_*` i `getSarcRect`). // `Map::tile_size`, `tilemap_origin_*` i `getSarcRect`).
void loadFromLevel(const std::string& level_yaml_path); void loadFromLevel(const std::string& level_yaml_path);
// Crea i afegeix una entitat a partir d'un node spawn (mateix format
// que els items del bloc `entities:` del YAML del nivell). Retorna el
// punter observacional a l'entitat creada, o `nullptr` si la plantilla
// no es pot carregar, el tipus no està registrat o el spawn no té els
// camps requerits (`position`, `sarc_position`, etc. segons el tipus).
// Pensat per al editor de Debug: construeix un node sintètic des
// d'una posició del cursor i el passa.
auto addSpawn(const fkyaml::node& spawn) -> Entity*;
// Reset complet del nivell: destrueix totes les entitats, reseteja // Reset complet del nivell: destrueix totes les entitats, reseteja
// els comptadors interns (sarcòfags oberts, total, exited, flag de // els comptadors interns (sarcòfags oberts, total, exited, flag de
// level_complete) i torna a cridar `loadFromLevel` per regenerar les // level_complete) i torna a cridar `loadFromLevel` per regenerar les
@@ -395,18 +395,15 @@ void EntityManager::reload(const std::string& level_yaml_path) {
loadFromLevel(level_yaml_path); loadFromLevel(level_yaml_path);
} }
void EntityManager::loadFromLevel(const std::string& level_yaml_path) { auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* {
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path); if (!spawn.contains("type")) { return nullptr; }
int loaded = 0;
for (const auto& spawn : SPAWNS) {
if (!spawn.contains("type")) { continue; }
const auto TYPE = spawn["type"].get_value<std::string>(); const auto TYPE = spawn["type"].get_value<std::string>();
SpriteInfo sprite; SpriteInfo sprite;
std::string anchor; std::string anchor;
auto entity = buildEntity(TYPE, sprite, anchor); auto entity = buildEntity(TYPE, sprite, anchor);
if (entity == nullptr) { continue; } if (entity == nullptr) { return nullptr; }
Entity* observer = entity.get();
bool ok = false; bool ok = false;
if (TYPE == "sarcophagus") { if (TYPE == "sarcophagus") {
@@ -416,9 +413,13 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
} else { } else {
ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn); ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn);
} }
const bool OK = ok; return ok ? observer : nullptr;
if (OK) { ++loaded; } }
}
void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path);
const int LOADED = static_cast<int>(std::ranges::count_if(SPAWNS,
[this](const fkyaml::node& spawn) { return addSpawn(spawn) != nullptr; }));
// Broadcast inicial: marca com a "pintats" tots els tiles ocupats per // Broadcast inicial: marca com a "pintats" tots els tiles ocupats per
// decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...). // decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...).
@@ -446,6 +447,6 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
<< " tile_painted broadcast inicial (decals de sòl)\n"; << " tile_painted broadcast inicial (decals de sòl)\n";
} }
std::cout << "EntityManager: " << loaded << " entitats carregades des de " std::cout << "EntityManager: " << LOADED << " entitats carregades des de "
<< level_yaml_path << '\n'; << level_yaml_path << '\n';
} }
+115
View File
@@ -0,0 +1,115 @@
#include "game/entities/entity_writer.hpp"
#ifdef _DEBUG
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
namespace EntityWriter {
namespace {
// Cerca la línia que comença per `<key>:` a columna 0. Mateix
// patró que TilemapWriter::findDataLine però parametritzat. Si
// un dia paga la pena, podem extreure'l a un helper compartit.
auto findKeyLine(const std::string& content, const std::string& key) -> std::size_t {
const std::string PREFIX = key + ":";
std::size_t pos = 0;
while (pos < content.size()) {
const bool AT_LINE_START = (pos == 0 || content[pos - 1] == '\n');
if (AT_LINE_START && content.compare(pos, PREFIX.size(), PREFIX) == 0) {
const std::size_t AFTER = pos + PREFIX.size();
if (AFTER >= content.size() || content[AFTER] == '\n' || content[AFTER] == '\r' || content[AFTER] == ' ' || content[AFTER] == '\t' || content[AFTER] == '#') {
return pos;
}
}
const auto NL = content.find('\n', pos);
if (NL == std::string::npos) { break; }
pos = NL + 1;
}
return std::string::npos;
}
// Avança mentre trobe línies indentades (espai/tab) o línies buides.
// A diferència del TilemapWriter, ací incloem línies buides dins
// del bloc: l'editor de YAML deixa sovint blanc entre grups
// d'entitats, i volem absorbir-lo per no deixar restes orfes
// entre el bloc nou i el següent top-level.
auto advancePastBlock(const std::string& content, std::size_t pos) -> std::size_t {
while (pos < content.size()) {
const char C = content[pos];
const bool IS_INDENT = (C == ' ' || C == '\t');
const bool IS_BLANK = (C == '\n' || C == '\r');
if (!IS_INDENT && !IS_BLANK) { break; }
const auto NL = content.find('\n', pos);
pos = (NL == std::string::npos) ? content.size() : NL + 1;
}
return pos;
}
auto buildBlock(const std::vector<Spawn>& spawns) -> std::string {
std::string out;
for (const auto& s : spawns) {
out += " - type: " + s.type + "\n";
if (s.sarc_col.has_value() && s.sarc_row.has_value()) {
out += " sarc_position: [" + std::to_string(*s.sarc_col) + ", " + std::to_string(*s.sarc_row) + "]\n";
} else if (s.col.has_value() && s.row.has_value()) {
out += " position: [" + std::to_string(*s.col) + ", " + std::to_string(*s.row) + "]\n";
}
}
return out;
}
} // namespace
auto saveEntitiesBlock(const std::string& path, const std::vector<Spawn>& spawns) -> bool {
std::ifstream in(path, std::ios::binary);
if (!in) { return false; }
std::ostringstream ss;
ss << in.rdbuf();
const std::string CONTENT = ss.str();
in.close();
const auto KEY_POS = findKeyLine(CONTENT, "entities");
if (KEY_POS == std::string::npos) { return false; }
const auto LINE_END = CONTENT.find('\n', KEY_POS);
const auto AFTER_KEY = (LINE_END == std::string::npos) ? CONTENT.size() : LINE_END + 1;
const auto BLOCK_END = advancePastBlock(CONTENT, AFTER_KEY);
// Mantenim una línia en blanc darrere del bloc si abans n'hi
// havia: ajuda a separar visualment el `entities:` de la pròxima
// secció. Si no n'hi havia, no afegim res.
const std::string TAIL = CONTENT.substr(BLOCK_END);
const std::string NEW_CONTENT =
CONTENT.substr(0, AFTER_KEY) + buildBlock(spawns) + TAIL;
const auto TMP_PATH = path + ".tmp";
{
std::ofstream out(TMP_PATH, std::ios::binary | std::ios::trunc);
if (!out) { return false; }
out.write(NEW_CONTENT.data(), static_cast<std::streamsize>(NEW_CONTENT.size()));
out.close();
if (!out) {
std::error_code ec;
std::filesystem::remove(TMP_PATH, ec);
return false;
}
}
std::error_code ec;
std::filesystem::rename(TMP_PATH, path, ec);
if (ec) {
std::filesystem::remove(TMP_PATH, ec);
return false;
}
return true;
}
} // namespace EntityWriter
#endif // _DEBUG
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#ifdef _DEBUG
#include <optional>
#include <string>
#include <vector>
// Reescriu NOMÉS el bloc `entities:` d'un YAML de nivell, preservant byte
// a byte la resta del fitxer: comentaris, ordre de claus, format del
// `geometry:` i `data:`, encoding i line endings entre línies no tocades.
// Pensat exclusivament per a l'editor (Debug).
//
// Format de sortida (estable, llegible a l'editor de text):
// entities:
// - type: torch
// position: [4, 2]
// - type: sarcophagus
// sarc_position: [0, 0]
// - type: door
//
// Semàntica del bloc origen:
// - Línia de capçalera: primera aparició de "entities:" a columna 0.
// Si manca, el writer FALLA i no toca el fitxer.
// - Contingut: línies indentades (espai o tab). Una línia buida o una
// línia a columna 0 marquen la fi del bloc.
//
// Si el fitxer original no té el bloc `entities:`, el writer no inventa
// ubicació: retorna false per que el caller decidisca (mostrar error,
// avortar, etc.) — no és funció del writer escollir l'estil del YAML.
//
// Escriptura atòmica: via `<path>.tmp` + `std::filesystem::rename`. Si
// l'escriptura falla a meitat, el YAML original queda intacte.
namespace EntityWriter {
// Cada spawn duplica l'estructura mínima del que el lector espera:
// type sempre, i un dels (position, sarc_position) o cap (cas door).
struct Spawn {
std::string type;
std::optional<int> col;
std::optional<int> row;
std::optional<int> sarc_col;
std::optional<int> sarc_row;
};
auto saveEntitiesBlock(const std::string& path, const std::vector<Spawn>& spawns) -> bool;
} // namespace EntityWriter
#endif // _DEBUG
+7
View File
@@ -179,6 +179,13 @@ class Player : public AnimatedSprite {
// contra mur) s'emparen automàticament. // contra mur) s'emparen automàticament.
void setOtherPlayers(const std::vector<Player*>* all) { all_players_ = all; } void setOtherPlayers(const std::vector<Player*>* all) { all_players_ = all; }
// Refresca la còpia local de `Map::Info`. Necessari quan el mapa
// (geometria, p.ex. `door_col`) canvia entre carregaments —
// típicament després del editor in-game. Sense aquest refresc, el
// `clampToPlayfield` segueix usant el corridor antic i el jugador
// no troba la nova "obertura" del mur trasser.
void setMapInfo(const Map::Info& info) { map_ = info; }
// Marcat pel resolver jugador↔jugador quan aquest frame el jugador // Marcat pel resolver jugador↔jugador quan aquest frame el jugador
// és arrossegat per una empenta (no té input propi; es desplaça // és arrossegat per una empenta (no té input propi; es desplaça
// perquè l'altre l'empeny). Determina que l'animació del frame siga // perquè l'altre l'empeny). Determina que l'animació del frame siga
+123
View File
@@ -0,0 +1,123 @@
#include "game/map/map_writer.hpp"
#ifdef _DEBUG
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
namespace MapWriter {
namespace {
// Cerca la línia que comença per `<key>:` a columna 0 (top-level)
// o, si `min_indent>0`, a una indentació mínima donada — això ens
// permet trobar tant `geometry:` com una clau interna del bloc.
auto findKeyLine(const std::string& content, const std::string& key, std::size_t start_pos, std::size_t min_indent) -> std::size_t {
const std::string PREFIX = key + ":";
std::size_t pos = start_pos;
while (pos < content.size()) {
// Compta la indentació de la línia actual.
std::size_t indent = 0;
while (pos + indent < content.size() && (content[pos + indent] == ' ' || content[pos + indent] == '\t')) {
++indent;
}
const bool INDENT_OK = (min_indent == 0) ? (indent == 0) : (indent >= min_indent);
if (INDENT_OK && pos + indent + PREFIX.size() <= content.size() && content.compare(pos + indent, PREFIX.size(), PREFIX) == 0) {
const std::size_t AFTER = pos + indent + PREFIX.size();
if (AFTER >= content.size() || content[AFTER] == '\n' || content[AFTER] == '\r' || content[AFTER] == ' ' || content[AFTER] == '\t' || content[AFTER] == '#') {
return pos + indent;
}
}
const auto NL = content.find('\n', pos);
if (NL == std::string::npos) { break; }
pos = NL + 1;
// Si la línia actual és de columna 0 i no és la que busquem,
// i estem dins d'un bloc indentat, sortim (no és part del bloc).
if (min_indent > 0 && indent == 0 && content.compare(pos - 1, 1, "\n") == 0) {
// No usem aquest break agressiu perquè línies en blanc
// (indent=0) no han de tallar la cerca dins del bloc.
// Tractades implícitament pel control superior.
}
}
return std::string::npos;
}
auto atomicWrite(const std::string& path, const std::string& content) -> bool {
const auto TMP_PATH = path + ".tmp";
{
std::ofstream out(TMP_PATH, std::ios::binary | std::ios::trunc);
if (!out) { return false; }
out.write(content.data(), static_cast<std::streamsize>(content.size()));
out.close();
if (!out) {
std::error_code ec;
std::filesystem::remove(TMP_PATH, ec);
return false;
}
}
std::error_code ec;
std::filesystem::rename(TMP_PATH, path, ec);
if (ec) {
std::filesystem::remove(TMP_PATH, ec);
return false;
}
return true;
}
} // namespace
auto saveDoorCol(const std::string& path, int door_col) -> bool {
std::ifstream in(path, std::ios::binary);
if (!in) { return false; }
std::ostringstream ss;
ss << in.rdbuf();
const std::string CONTENT = ss.str();
in.close();
// 1) Localitza el bloc `geometry:` a columna 0.
const auto GEO_POS = findKeyLine(CONTENT, "geometry", 0, 0);
if (GEO_POS == std::string::npos) { return false; }
const auto GEO_LINE_END = CONTENT.find('\n', GEO_POS);
if (GEO_LINE_END == std::string::npos) { return false; }
const std::size_t BLOCK_START = GEO_LINE_END + 1;
// 2) Dins del bloc, busca `door_col:` indentada (>=1 espai/tab).
const auto KEY_POS = findKeyLine(CONTENT, "door_col", BLOCK_START, 1);
const std::string NEW_LINE = " door_col: " + std::to_string(door_col);
if (KEY_POS != std::string::npos) {
// Substitueix la línia sencera (manté indentació original
// recalculant el start de la línia).
std::size_t line_start = KEY_POS;
while (line_start > 0 && CONTENT[line_start - 1] != '\n') { --line_start; }
const auto LINE_END = CONTENT.find('\n', KEY_POS);
const auto END = (LINE_END == std::string::npos) ? CONTENT.size() : LINE_END;
const std::string NEW_CONTENT =
CONTENT.substr(0, line_start) + NEW_LINE + CONTENT.substr(END);
return atomicWrite(path, NEW_CONTENT);
}
// Si manca, l'afegim al final del bloc geometry (just abans
// de la primera línia top-level que segueix). Trobem la fi
// del bloc avançant mentre les línies són indentades o buides.
std::size_t pos = BLOCK_START;
while (pos < CONTENT.size()) {
const char C = CONTENT[pos];
const bool INDENTED = (C == ' ' || C == '\t');
const bool BLANK = (C == '\n' || C == '\r');
if (!INDENTED && !BLANK) { break; }
const auto NL = CONTENT.find('\n', pos);
pos = (NL == std::string::npos) ? CONTENT.size() : NL + 1;
}
const std::string NEW_CONTENT =
CONTENT.substr(0, pos) + NEW_LINE + "\n" + CONTENT.substr(pos);
return atomicWrite(path, NEW_CONTENT);
}
} // namespace MapWriter
#endif // _DEBUG
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#ifdef _DEBUG
#include <string>
// Writer surgical de claus dins del bloc `geometry:` d'un YAML de nivell.
// Reescriu un escalar (p.ex. `door_col: N`) preservant byte a byte la
// resta del fitxer: comentaris, ordre de claus, format i indentació de
// la resta del `geometry:` i la resta de seccions.
//
// Pensat per al editor (Debug): quan el dissenyador mou la porta, només
// canvia `door_col`; tota la resta del YAML segueix igual.
//
// Si `geometry:` no existeix al fitxer, retorna false. Si la clau no
// existeix dins del bloc, l'afegeix al final del bloc, indentada com
// les altres entrades del mateix nivell.
//
// Escriptura atòmica via `<path>.tmp` + `std::filesystem::rename`.
namespace MapWriter {
auto saveDoorCol(const std::string& path, int door_col) -> bool;
} // namespace MapWriter
#endif // _DEBUG
+79 -11
View File
@@ -528,6 +528,12 @@ void GameScene::loadRoom(const std::string& yaml_path) {
tilemap_ = Tilemap(yaml_path); tilemap_ = Tilemap(yaml_path);
paint_layer_ = std::make_unique<PaintLayer>(tilemap_, &entity_manager_.getBus()); paint_layer_ = std::make_unique<PaintLayer>(tilemap_, &entity_manager_.getBus());
entity_manager_.reload(yaml_path); entity_manager_.reload(yaml_path);
// Refresquem la còpia local de Map::Info als players: la geometria
// pot haver canviat (p.ex. door_col del editor), i sense això
// `clampToPlayfield` continuaria usant el corridor antic i el
// jugador no podria sortir per la nova posició de la porta.
const auto INFO = Map::makeInfo();
for (auto& p : players_) { p->setMapInfo(INFO); }
// Tornem a fixar el comptador de "esperats" amb només els slots // Tornem a fixar el comptador de "esperats" amb només els slots
// actius — un slot DISABLED permanent (single-player) no compta. // actius — un slot DISABLED permanent (single-player) no compta.
const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true); const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true);
@@ -607,6 +613,30 @@ void GameScene::enterRoomIntro() {
} }
} }
#ifdef _DEBUG
void GameScene::respawnPlayersInstant() {
// Variant sense cinemàtica de l'spawn de l'INTRO: per a l'editor
// volem que la sala torni a un estat jugable d'immediat (sense
// música de sala, sense caiguda aèria). Reusem `computeSpawn` i el
// mateix patró de `triggerPlayerSpawn` però cridem `respawn(x, y)`
// (teleport instantani) en lloc de `spawnFromAir(x, y)`.
const auto SPAWN = computeSpawn();
const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]);
const float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size);
for (std::size_t i = 0; i < players_.size(); ++i) {
if (!players_starts_active_[i]) { continue; }
float x = SINGLE_X;
if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
players_[i]->setSpawnPoint(x, SPAWN.y);
players_[i]->respawn(x, SPAWN.y);
}
state_ = RoomFsm::State::PLAYING;
intro_completed_ = true;
transition_ = {};
std::cout << "[ROOM] post-editor: spawn instantani, estat -> PLAYING\n";
}
#endif
void GameScene::triggerPlayerSpawn(std::size_t slot_index) { void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
if (slot_index >= players_.size()) { return; } if (slot_index >= players_.size()) { return; }
if (!players_starts_active_[slot_index]) { return; } if (!players_starts_active_[slot_index]) { return; }
@@ -1141,9 +1171,29 @@ void GameScene::update(int delta_ms) {
#ifdef _DEBUG #ifdef _DEBUG
// Toggle de l'editor: només si no hi ha cap transició de pausa ni // Toggle de l'editor: només si no hi ha cap transició de pausa ni
// pausa consolidada. L'editor mateix no consumeix la tecla — la // pausa consolidada. L'editor mateix no consumeix la tecla — la
// llegim ací per decidir la coexistència. // llegim ací per decidir la coexistència. Tant a l'entrada com a la
// sortida fem `loadRoom` complet per partir sempre del YAML del
// disc: les entitats tornen a la posició inicial (momies, sarcs),
// el paint layer es buida i els jugadors es desactiven. Així
// l'editor sempre veu l'estat-base de la sala (no el d'una partida
// a meitat) i el joc, en sortir, arrenca fresc amb les edicions ja
// persistides al YAML.
if (pause_.state == PauseState::IDLE && detectEditorToggle()) { if (pause_.state == PauseState::IDLE && detectEditorToggle()) {
editor_.toggle(tilemap_); const bool WAS_ACTIVE = editor_.active();
editor_.toggle(tilemap_, entity_manager_);
const bool NOW_ACTIVE = editor_.active();
if (!WAS_ACTIVE && NOW_ACTIVE) {
// Acabem d'entrar: carreguem fresh des del disc. Els
// jugadors es queden DISABLED (l'editor no els pinta de
// totes maneres) i es reactivaran amb el spawn instantani
// a la sortida.
loadRoom(Levels::LevelManager::get().currentPath());
} else if (WAS_ACTIVE && !NOW_ACTIVE) {
// Acabem de sortir: el writer ja ha persistit el YAML.
// Carreguem fresh i fem spawn instantani sense cinemàtica.
loadRoom(Levels::LevelManager::get().currentPath());
respawnPlayersInstant();
}
} }
// Salts debug entre sales (`[` / `]`). Mútuament excloents amb la // Salts debug entre sales (`[` / `]`). Mútuament excloents amb la
@@ -1219,7 +1269,7 @@ void GameScene::update(int delta_ms) {
} }
#ifdef _DEBUG #ifdef _DEBUG
editor_.update(delta_ms, tilemap_); editor_.update(delta_ms, tilemap_, entity_manager_);
#endif #endif
// El HUD continua avançant les seues animacions encara que el món // El HUD continua avançant les seues animacions encara que el món
@@ -1242,18 +1292,36 @@ void GameScene::render() {
tilemap_.render(); tilemap_.render();
#ifdef _DEBUG #ifdef _DEBUG
// En mode editor amaguem entitats i jugador: el tilemap queda net // En mode editor amaguem jugadors, sombres, paint layer, fades, HUD
// per a poder clicar cel·la per cel·la sense interferència visual. // i textos de transició: el tilemap queda net per editar i només es
// El HUD futur (quan s'afegeixi) també farà cas d'aquest flag. // pinten les entitats (congelades, sense tick) per que el dissenyador
const bool SKIP_ACTORS = editor_.active(); // veja on estan.
const bool EDITOR_ACTIVE = editor_.active();
#else #else
constexpr bool SKIP_ACTORS = false; constexpr bool EDITOR_ACTIVE = false;
#endif #endif
#ifdef _DEBUG
const bool SHOW_ENTITIES_IN_EDITOR = editor_.shouldShowEntities();
#else
constexpr bool SHOW_ENTITIES_IN_EDITOR = false;
#endif
if (SHOW_ENTITIES_IN_EDITOR) {
// Render de les entitats amb el mateix y-sort que el joc normal
// (sense jugadors): així una mòmia situada davant d'un sarc en
// falsa perspectiva queda dibuixada per damunt — i no a l'inrevés
// segons l'ordre del YAML. Reusem `EntityManager::renderEntity`
// (estàtic) i sortegem per `getFeetY()` ascendent.
auto sorted = entity_manager_.getEntities();
std::ranges::stable_sort(sorted, [](const Entity* a, const Entity* b) {
return a->getFeetY() < b->getFeetY();
});
for (const auto* e : sorted) { EntityManager::renderEntity(*e); }
}
// Capa de pintat: va entre el tilemap base i els actors, perquè els // Capa de pintat: va entre el tilemap base i els actors, perquè els
// sarcòfags i el jugador la tapen. S'amaga al mode editor (decisió // sarcòfags i el jugador la tapen. S'amaga al mode editor (decisió
// explícita: l'editor edita el tilemap, no la capa de pintat). // explícita: l'editor edita el tilemap, no la capa de pintat).
if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); } if (!EDITOR_ACTIVE && paint_layer_) { paint_layer_->render(); }
if (!SKIP_ACTORS) { if (!EDITOR_ACTIVE) {
// Passada de sombres dels jugadors saltant. Va per davant del // Passada de sombres dels jugadors saltant. Va per davant del
// pintat (al damunt) però per darrere de TOTS els actors — // pintat (al damunt) però per darrere de TOTS els actors —
// entitats i jugadors, inclòs el saltador mateix— així la sombra // entitats i jugadors, inclòs el saltador mateix— així la sombra
@@ -1312,7 +1380,7 @@ void GameScene::render() {
} }
#ifdef _DEBUG #ifdef _DEBUG
editor_.render(tilemap_); editor_.render(tilemap_, entity_manager_);
#endif #endif
// Overlay de pausa: text amb stroke blanc (índex 255) centrat amb X // Overlay de pausa: text amb stroke blanc (índex 255) centrat amb X
+7
View File
@@ -288,6 +288,13 @@ class GameScene : public Scene {
// Idempotent quan ja s'estava a INTRO — útil per als salts debug. // Idempotent quan ja s'estava a INTRO — útil per als salts debug.
void enterRoomIntro(); void enterRoomIntro();
#ifdef _DEBUG
// Spawn instantani sense cinemàtica: usat al sortir del editor per
// fer un "fresh start" de la sala (jugadors al spawn, sense la
// cinemàtica `room_intro.ogg`). Posa l'estat directament a PLAYING.
void respawnPlayersInstant();
#endif
#ifdef _DEBUG #ifdef _DEBUG
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la // Editor modal del tilemap. Mútuament excloent amb la pausa: si la
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i // pausa no està IDLE la tecla de toggle de l'editor s'ignora, i