Compare commits

..
36 Commits
Author SHA1 Message Date
JailDesigner 3442b9b481 chore: bump v0.32.0 — Notifier configurable, F5/F6 gatejades i CRTPI passthrough 2026-05-02 15:16:40 +02:00
JailDesigner 8746ee9ec9 Merge fix/shader-keys-and-service-menu — passthrough real i F5/F6 gatejades 2026-05-02 15:13:41 +02:00
JailDesigner ef7fbf4027 fix(shader): force passthrough quan està apagat; F5/F6 ignorats sense shader actiu 2026-05-02 15:12:58 +02:00
JailDesigner 964c32dd0d Merge feat/notifier-polish — geometria i animació del Notifier al ui.yaml 2026-05-02 14:59:28 +02:00
JailDesigner 970fe2b4db feat(notifier): entrada/eixida per temps amb easing, configurable a ui.yaml 2026-05-02 14:58:25 +02:00
JailDesigner f816e75cc0 feat(input): KP_2 dispara notificació llarga per a provar multilinia 2026-05-02 14:51:29 +02:00
JailDesigner 2e8df45b38 feat(notifier): geometria al ui.yaml; +1px alçada, text 1px més avall, marge superior 3 2026-05-02 14:43:04 +02:00
JailDesigner 643e5442c3 Merge fix/lane-snap-and-door-corridor — snap a lane i corridor amb porta oberta 2026-05-02 14:31:57 +02:00
JailDesigner 82ccf65ce1 fix(player): no snap a lane si pica contra mur extern; corridor només si porta oberta 2026-05-02 14:30:09 +02:00
JailDesigner 1ae6912be5 Merge feat/entity-editor — editor d'entitats complet 2026-05-02 13:36:34 +02:00
JailDesigner c003e6665e feat(editor): mou la porta i persisteix geometry.door_col 2026-05-02 13:35:01 +02:00
JailDesigner a254fdd669 feat(editor): hand pick per base, z-sort, amaga entitats en TILE, HUD 2-files centrat 2026-05-02 12:42:47 +02:00
JailDesigner c0362abf67 fix(editor): inverteix posició des del collision_rect i restaura mòmies r1 2026-05-02 12:22:14 +02:00
JailDesigner d13313503c fix(editor): preferDisk en Debug + reload de sala al entrar i sortir 2026-05-02 12:20:16 +02:00
JailDesigner 2a78c78e77 feat(editor): franja HUD amb mode, brush/sel, cursor i recompte de constraints 2026-05-02 11:41:25 +02:00
JailDesigner 10e16d7905 feat(editor): valida 1 porta i ≥1 sarc en sortida i en borrar 2026-05-02 11:37:57 +02:00
JailDesigner ecb4625465 feat(editor): EntityWriter — persisteix el bloc entities: al sortir 2026-05-02 11:35:10 +02:00
JailDesigner b4e36944d7 feat(editor): mode mà — selecció, mou, borra i marc d'entitats 2026-05-02 11:28:41 +02:00
JailDesigner 9b09ccb765 feat(editor): col·locació d'entitats amb snap per tipus i bloqueig de solapaments 2026-05-02 11:21:05 +02:00
JailDesigner 94213f9fd6 feat(editor): entity picker amb noms del factory i flag editable per plantilla 2026-05-02 11:07:02 +02:00
JailDesigner f591aa82fd fix(whip_attack): treure entity_manager_ duplicat (ja viu a Entity) 2026-05-02 11:01:53 +02:00
JailDesigner 2ef5968e0d feat(editor): màquina d'estats Mode {TILE,ENTITY,HAND} + Picker 2026-05-02 10:58:24 +02:00
JailDesigner ca03a1307e feat(editor): bindings per E (entity picker), H (mà), Supr (borrar) 2026-05-02 10:53:50 +02:00
JailDesigner fe916d263f Merge feat/controls — sticks al menú, accept/cancel remappable, facing real 2026-05-01 10:25:06 +02:00
JailDesigner de7233a012 fix(player): facing del sprite des del desplaçament real, no de l'input 2026-05-01 10:24:13 +02:00
JailDesigner d26ecec04f feat(menu): acceptar amb ATTACK i cancel·lar amb JUMP del mando 2026-05-01 09:07:22 +02:00
JailDesigner fddea0c449 feat(menu): navegació del menú de servei amb sticks 2026-05-01 09:03:20 +02:00
JailDesigner 2c1c1a442b Merge feat/notifier-tweaks — vora externa al notificador 2026-05-01 08:57:02 +02:00
JailDesigner d1948afe4b feat(notifier): vora externa del color del text 2026-05-01 08:56:53 +02:00
JailDesigner ac06b05332 chore(audio): defaults a música 100% i so 20% 2026-04-30 20:45:03 +02:00
JailDesigner 7d11dde0b1 Merge feat/menu-pause — pausa del món i sons al menú de servei 2026-04-30 20:40:04 +02:00
JailDesigner 73add013dd art: stun de sam/morcus refinat i mòmia més baixa per saltar millor 2026-04-30 20:22:09 +02:00
JailDesigner 4e2ba827e3 Merge feat/mummy-ai — generalització del filtre de cruces de la mòmia 2026-04-30 20:06:54 +02:00
JailDesigner 9ac0c0cb74 refactor(mummy): generalitza el filtre de cruces a isBlockedAheadInDir 2026-04-30 20:05:36 +02:00
JailDesigner 45514ae2f4 feat(menu): sons de navegació i acceptació al menú de servei 2026-04-29 20:27:01 +02:00
JailDesigner 0847e9c544 feat(menu): congela el món mentre el menú de servei està obert 2026-04-29 20:19:20 +02:00
44 changed files with 1965 additions and 253 deletions

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]
+2 -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]
@@ -18,7 +19,7 @@ collision:
# jugador amb jump_z > height no col·lisiona amb la momia (la salta # jugador amb jump_z > height no col·lisiona amb la momia (la salta
# per damunt). Amb impulse=240 + gravity=600 el pic ronda els 48 px, # per damunt). Amb impulse=240 + gravity=600 el pic ronda els 48 px,
# així que 24 deixa marge per esquivar amb un salt no perfecte. # així que 24 deixa marge per esquivar amb un salt no perfecte.
height: 24 height: 12
flags: flags:
collides: true collides: true
interactable: false interactable: false
@@ -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"
+4 -15
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: 11
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,6 @@ 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
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 +136,6 @@ entities:
position: [15, 11] position: [15, 11]
- type: hole - type: hole
position: [15, 5] position: [15, 5]
- type: hole
position: [8, 5]
- type: door
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.
Binary file not shown.
+23
View File
@@ -65,6 +65,22 @@ hud:
flash_ms: 250 # durada del flash quan un jugador rep un hit flash_ms: 250 # durada del flash quan un jugador rep un hit
shake_ms: 300 # durada del shake que acompanya el flash shake_ms: 300 # durada del shake que acompanya el flash
# Geometria del popup superior (Notifier). En píxels del canvas, llevat de
# slide_ms (durada en ms de l'animació d'entrada i d'eixida; constant
# independentment de la mida de la caixa). El padding vertical és asimètric a
# propòsit: padding_top > padding_bottom puja una mica el text dins la caixa.
# Corbes disponibles: linear, outQuad, inQuad, inOutQuad, outCubic, inCubic.
notifier:
padding_h: 6
padding_top: 4
padding_bottom: 3
line_gap: 1
margin_top: 3
border_px: 1
slide_ms: 300
slide_in_curve: outCubic
slide_out_curve: inCubic
# Pausa graduada de la GameScene: el món no para d'un colp, s'atura amb # Pausa graduada de la GameScene: el món no para d'un colp, s'atura amb
# easing i torna a arrancar amb una altra corba. Els timings es poden # easing i torna a arrancar amb una altra corba. Els timings es poden
# afinar independentment, i les corbes permeten estils diferents entrant # afinar independentment, i les corbes permeten estils diferents entrant
@@ -75,3 +91,10 @@ pause:
exit_ms: 250 exit_ms: 250
enter_curve: outCubic enter_curve: outCubic
exit_curve: inCubic exit_curve: inCubic
# Sons del menú de servei. Els fitxers viuen a data/sounds/. SELECT
# acompanya navegació (amunt/avall) i back; ACCEPT acompanya modificació
# d'opció (esquerra/dreta/retorn) o entrada a un submenú.
sounds:
menu_select: "menu_select.wav"
menu_accept: "menu_accept.wav"
+16
View File
@@ -89,6 +89,9 @@ namespace GlobalInputs {
} }
void onNextShader() { void onNextShader() {
// F5/F6 només tenen sentit quan els shaders estan actius. Si no,
// ignorem la pulsació en silenci — l'usuari ha de prémer F4 abans.
if (!Options::video().shader.enabled) { return; }
const std::string NEW_NAME = (Options::video().shader.current_shader == "crtpi") ? "postfx" : "crtpi"; const std::string NEW_NAME = (Options::video().shader.current_shader == "crtpi") ? "postfx" : "crtpi";
Options::setActiveShaderName(NEW_NAME); Options::setActiveShaderName(NEW_NAME);
OptionsApply::reapplyShader(); OptionsApply::reapplyShader();
@@ -98,6 +101,7 @@ namespace GlobalInputs {
// Busca l'entrada actual al vector de presets i passa a la següent // Busca l'entrada actual al vector de presets i passa a la següent
// (wrap-around). Si no en troba cap s'evita el canvi silenciosament. // (wrap-around). Si no en troba cap s'evita el canvi silenciosament.
void onNextPreset() { void onNextPreset() {
if (!Options::video().shader.enabled) { return; }
const bool IS_CRTPI = Options::video().shader.current_shader == "crtpi"; const bool IS_CRTPI = Options::video().shader.current_shader == "crtpi";
std::string new_preset; std::string new_preset;
if (IS_CRTPI) { if (IS_CRTPI) {
@@ -154,6 +158,14 @@ namespace GlobalInputs {
Options::setVideoRenderInfoPosition(static_cast<Defaults::RenderInfoPosition>(NEXT)); Options::setVideoRenderInfoPosition(static_cast<Defaults::RenderInfoPosition>(NEXT));
} }
// Debug helper: dispara una notificació prou llarga perquè
// `wrapToWidth` la parteixi en (almenys) dues línies. Útil per a
// verificar visualment el padding asimètric, el line_gap i la caixa
// multilinia després de tocar la geometria a `data/ui.yaml`.
void onDebugLongNotification() {
Notification::notify("Notificació de prova llarga per a comprovar que el text es parteix correctament en dues línies dins la caixa.");
}
void onExit() { void onExit() {
if (exit_window_s > 0.0F) { if (exit_window_s > 0.0F) {
quit_requested = true; quit_requested = true;
@@ -193,6 +205,10 @@ namespace GlobalInputs {
onCycleRenderInfo(); onCycleRenderInfo();
} else if (SC == KeyConfig::get().scancode("exit")) { } else if (SC == KeyConfig::get().scancode("exit")) {
onExit(); onExit();
} else if (SC == SDL_SCANCODE_KP_2) {
// Tecla de debug fora del KeyConfig: dispara una notificació
// multilinia per a inspeccionar el layout de la caixa.
onDebugLongNotification();
} }
} }
} }
+50 -18
View File
@@ -6,6 +6,7 @@
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp" #include "core/rendering/text.hpp"
#include "core/ui/ui_config.hpp"
#include "utils/defines.hpp" #include "utils/defines.hpp"
namespace { namespace {
@@ -67,26 +68,39 @@ void Notifier::init(std::shared_ptr<Text> text, const Palette& ui_palette) {
} }
void Notifier::update(int delta_ms) { void Notifier::update(int delta_ms) {
const float DT = static_cast<float>(delta_ms) / 1000.0F; const auto& geo = UiConfig::get().notifier();
switch (status_) { switch (status_) {
case Status::RISING: case Status::RISING: {
y_ += SLIDE_SPEED * DT; slide_elapsed_ms_ += delta_ms;
if (y_ >= static_cast<float>(MARGIN_TOP)) { if (slide_elapsed_ms_ >= geo.slide_ms) {
y_ = static_cast<float>(MARGIN_TOP); y_ = slide_y_on_;
status_ = Status::ACTIVE; status_ = Status::ACTIVE;
} else {
const float T = static_cast<float>(slide_elapsed_ms_) / static_cast<float>(geo.slide_ms);
const float K = geo.slide_in_curve(T);
y_ = slide_y_off_ + ((slide_y_on_ - slide_y_off_) * K);
} }
break; break;
}
case Status::ACTIVE: case Status::ACTIVE:
remaining_ms_ -= delta_ms; remaining_ms_ -= delta_ms;
if (remaining_ms_ <= 0) { status_ = Status::VANISHING; } if (remaining_ms_ <= 0) {
break; slide_elapsed_ms_ = 0;
case Status::VANISHING: status_ = Status::VANISHING;
y_ -= SLIDE_SPEED * DT;
if (y_ <= static_cast<float>(-current_box_h_)) {
y_ = static_cast<float>(-current_box_h_ - MARGIN_TOP);
status_ = Status::HIDDEN;
} }
break; break;
case Status::VANISHING: {
slide_elapsed_ms_ += delta_ms;
if (slide_elapsed_ms_ >= geo.slide_ms) {
y_ = slide_y_off_;
status_ = Status::HIDDEN;
} else {
const float T = static_cast<float>(slide_elapsed_ms_) / static_cast<float>(geo.slide_ms);
const float K = geo.slide_out_curve(T);
y_ = slide_y_on_ + ((slide_y_off_ - slide_y_on_) * K);
}
break;
}
default: default:
break; break;
} }
@@ -95,6 +109,7 @@ void Notifier::update(int delta_ms) {
void Notifier::render() { void Notifier::render() {
if (status_ == Status::HIDDEN || text_ == nullptr || current_lines_.empty()) { return; } if (status_ == Status::HIDDEN || text_ == nullptr || current_lines_.empty()) { return; }
const auto& geo = UiConfig::get().notifier();
const int BOX_X = (GameCanvas::WIDTH - current_box_w_) / 2; const int BOX_X = (GameCanvas::WIDTH - current_box_w_) / 2;
const int BOX_Y = static_cast<int>(y_); const int BOX_Y = static_cast<int>(y_);
@@ -102,8 +117,17 @@ void Notifier::render() {
// (ARGB directe al canvas, sense tocar cap surface paletada). // (ARGB directe al canvas, sense tocar cap surface paletada).
Screen::get().fillRect(BOX_X, BOX_Y, current_box_w_, current_box_h_, ui_palette_[current_bg_color_]); Screen::get().fillRect(BOX_X, BOX_Y, current_box_w_, current_box_h_, ui_palette_[current_bg_color_]);
const int LINE_STEP = Rendering::Hud::FONT_SIZE_PX + LINE_GAP; // Vora externa: 4 línies d'1 px del color del text, paral·leles a cada
int line_y = BOX_Y + PADDING_V; // costat i una mica fora de la caixa. Cada segment es queda dins del
// rang del costat corresponent perquè les cantonades no es cobrisquen.
const std::uint32_t BORDER_ARGB = ui_palette_[current_text_color_];
Screen::get().fillRect(BOX_X, BOX_Y - geo.border_px, current_box_w_, geo.border_px, BORDER_ARGB);
Screen::get().fillRect(BOX_X, BOX_Y + current_box_h_, current_box_w_, geo.border_px, BORDER_ARGB);
Screen::get().fillRect(BOX_X - geo.border_px, BOX_Y, geo.border_px, current_box_h_, BORDER_ARGB);
Screen::get().fillRect(BOX_X + current_box_w_, BOX_Y, geo.border_px, current_box_h_, BORDER_ARGB);
const int LINE_STEP = Rendering::Hud::FONT_SIZE_PX + geo.line_gap;
int line_y = BOX_Y + geo.padding_top;
for (const auto& line : current_lines_) { for (const auto& line : current_lines_) {
text_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, text_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X, GameCanvas::CENTER_X,
@@ -120,18 +144,26 @@ void Notifier::notify(const std::string& text, Uint8 text_color, Uint8 bg_color,
// gestionar-ne una cua, canviar ací per una FIFO quan calga. // gestionar-ne una cua, canviar ací per una FIFO quan calga.
if (text_ == nullptr) { return; } if (text_ == nullptr) { return; }
const auto& geo = UiConfig::get().notifier();
const int MAX_TEXT_WIDTH = GameCanvas::WIDTH - (2 * SIDE_MARGIN) - (2 * geo.padding_h);
current_lines_ = wrapToWidth(text, MAX_TEXT_WIDTH, *text_, 1); current_lines_ = wrapToWidth(text, MAX_TEXT_WIDTH, *text_, 1);
// Amplada = max de les línies + padding. Alçada = N*glif + (N-1)*gap + padding. // Amplada = max de les línies + padding. Alçada = N*glif + (N-1)*gap + padding asimètric.
const int MAX_LINE_W = std::accumulate(current_lines_.begin(), current_lines_.end(), 0, [this](int acc, const std::string& s) { return std::max(acc, text_->length(s, 1)); }); const int MAX_LINE_W = std::accumulate(current_lines_.begin(), current_lines_.end(), 0, [this](int acc, const std::string& s) { return std::max(acc, text_->length(s, 1)); });
current_box_w_ = MAX_LINE_W + (2 * PADDING_H); current_box_w_ = MAX_LINE_W + (2 * geo.padding_h);
const int N = static_cast<int>(current_lines_.size()); const int N = static_cast<int>(current_lines_.size());
current_box_h_ = (N * Rendering::Hud::FONT_SIZE_PX) + ((N - 1) * LINE_GAP) + (2 * PADDING_V); current_box_h_ = (N * Rendering::Hud::FONT_SIZE_PX) + ((N - 1) * geo.line_gap) + geo.padding_top + geo.padding_bottom;
current_text_color_ = text_color; current_text_color_ = text_color;
current_bg_color_ = bg_color; current_bg_color_ = bg_color;
remaining_ms_ = time_ms; remaining_ms_ = time_ms;
y_ = static_cast<float>(-current_box_h_ - MARGIN_TOP);
// Posicions extremes en y (top-left de la caixa). Off = totalment fora
// del canvas per dalt; On = posició final amb la vora respectada.
slide_y_off_ = static_cast<float>(-current_box_h_ - geo.border_px - geo.margin_top);
slide_y_on_ = static_cast<float>(geo.margin_top + geo.border_px);
slide_elapsed_ms_ = 0;
y_ = slide_y_off_;
status_ = Status::RISING; status_ = Status::RISING;
} }
+12 -9
View File
@@ -43,15 +43,11 @@ class Notifier {
void notify(const std::string& text, Uint8 text_color, Uint8 bg_color, int time_ms); void notify(const std::string& text, Uint8 text_color, Uint8 bg_color, int time_ms);
static constexpr int PADDING_H = 6; // Marge lateral entre la caixa i les vores esquerra/dreta del canvas.
static constexpr int PADDING_V = 3; // Limita l'amplada disponible al text en multilinia (combinat amb el
static constexpr int LINE_GAP = 1; // Separació vertical entre línies multilinia. // padding horitzontal llegit d'UiConfig). No està exposat al YAML
static constexpr int MARGIN_TOP = 2; // Separació amb la vora superior del canvas. // perquè en pantalles 240 px no té sentit retallar més.
static constexpr float SLIDE_SPEED = 120.0F; static constexpr int SIDE_MARGIN = 10;
// Amplada màxima (sense comptar padding) que pot ocupar el text abans que
// `wrapToWidth` el parteixi en línies. Deixem marge respecte a la vora del
// canvas perquè la caixa no toqui els laterals.
static constexpr int MAX_TEXT_WIDTH = GameCanvas::WIDTH - 20 - (2 * PADDING_H);
private: private:
enum class Status : std::uint8_t { HIDDEN, enum class Status : std::uint8_t { HIDDEN,
@@ -72,4 +68,11 @@ class Notifier {
Uint8 current_text_color_{0}; Uint8 current_text_color_{0};
Uint8 current_bg_color_{0}; Uint8 current_bg_color_{0};
int remaining_ms_{0}; int remaining_ms_{0};
// Animació d'entrada/eixida per temps. `slide_elapsed_ms_` avança
// amb el delta dins de RISING/VANISHING; els extrems s'avaluen a
// `notify()` perquè depenen de la mida de la caixa.
int slide_elapsed_ms_{0};
float slide_y_off_{0.0F};
float slide_y_on_{0.0F};
}; };
+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
+8
View File
@@ -154,6 +154,14 @@ Director::Director() {
Resource::Cache::get().loadMusic("playing.ogg", "data/music/playing.ogg"); Resource::Cache::get().loadMusic("playing.ogg", "data/music/playing.ogg");
Resource::Cache::get().loadMusic("room_outro.ogg", "data/music/room_outro.ogg"); Resource::Cache::get().loadMusic("room_outro.ogg", "data/music/room_outro.ogg");
// Sons del menú de servei. Els noms venen de `data/ui.yaml` (amb
// fallback a `Defaults::UI::MENU_*_SOUND`); el ServiceMenu els
// dispara via Audio::Group::INTERFACE.
const auto& menu_select = UiConfig::get().menuSelectSound();
const auto& menu_accept = UiConfig::get().menuAcceptSound();
Resource::Cache::get().loadSound(menu_select, "data/sounds/" + menu_select);
Resource::Cache::get().loadSound(menu_accept, "data/sounds/" + menu_accept);
// Catàleg de presets d'efectes per als sons. Es carrega abans d'Audio // Catàleg de presets d'efectes per als sons. Es carrega abans d'Audio
// perquè els primers playSoundWith* (al començament del joc) ja // perquè els primers playSoundWith* (al començament del joc) ja
// troben els presets disponibles. // troben els presets disponibles.
+33 -1
View File
@@ -96,11 +96,24 @@ UiConfig::UiConfig()
pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS), pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS),
pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)), pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)),
pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)), pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)),
menu_select_sound_(Defaults::UI::MENU_SELECT_SOUND),
menu_accept_sound_(Defaults::UI::MENU_ACCEPT_SOUND),
hud_font_{.name = Defaults::UI::Font::HUD_NAME, .gif = Defaults::UI::Font::HUD_GIF, .fnt = Defaults::UI::Font::HUD_FNT, .kerning = Defaults::UI::Font::HUD_KERNING}, hud_font_{.name = Defaults::UI::Font::HUD_NAME, .gif = Defaults::UI::Font::HUD_GIF, .fnt = Defaults::UI::Font::HUD_FNT, .kerning = Defaults::UI::Font::HUD_KERNING},
placeholder_font_{.name = Defaults::UI::Font::PLACEHOLDER_NAME, .gif = Defaults::UI::Font::PLACEHOLDER_GIF, .fnt = Defaults::UI::Font::PLACEHOLDER_FNT, .kerning = Defaults::UI::Font::PLACEHOLDER_KERNING}, placeholder_font_{.name = Defaults::UI::Font::PLACEHOLDER_NAME, .gif = Defaults::UI::Font::PLACEHOLDER_GIF, .fnt = Defaults::UI::Font::PLACEHOLDER_FNT, .kerning = Defaults::UI::Font::PLACEHOLDER_KERNING},
room_text_font_{.name = Defaults::UI::Font::ROOM_TEXT_NAME, .gif = Defaults::UI::Font::ROOM_TEXT_GIF, .fnt = Defaults::UI::Font::ROOM_TEXT_FNT, .kerning = Defaults::UI::Font::ROOM_TEXT_KERNING}, room_text_font_{.name = Defaults::UI::Font::ROOM_TEXT_NAME, .gif = Defaults::UI::Font::ROOM_TEXT_GIF, .fnt = Defaults::UI::Font::ROOM_TEXT_FNT, .kerning = Defaults::UI::Font::ROOM_TEXT_KERNING},
menu_title_font_{.name = Defaults::UI::Font::MENU_TITLE_NAME, .gif = Defaults::UI::Font::MENU_TITLE_GIF, .fnt = Defaults::UI::Font::MENU_TITLE_FNT, .kerning = Defaults::UI::Font::MENU_TITLE_KERNING}, menu_title_font_{.name = Defaults::UI::Font::MENU_TITLE_NAME, .gif = Defaults::UI::Font::MENU_TITLE_GIF, .fnt = Defaults::UI::Font::MENU_TITLE_FNT, .kerning = Defaults::UI::Font::MENU_TITLE_KERNING},
menu_items_font_{.name = Defaults::UI::Font::MENU_ITEMS_NAME, .gif = Defaults::UI::Font::MENU_ITEMS_GIF, .fnt = Defaults::UI::Font::MENU_ITEMS_FNT, .kerning = Defaults::UI::Font::MENU_ITEMS_KERNING} { menu_items_font_{.name = Defaults::UI::Font::MENU_ITEMS_NAME, .gif = Defaults::UI::Font::MENU_ITEMS_GIF, .fnt = Defaults::UI::Font::MENU_ITEMS_FNT, .kerning = Defaults::UI::Font::MENU_ITEMS_KERNING},
notifier_{
.padding_h = Defaults::UI::Notifier::PADDING_H,
.padding_top = Defaults::UI::Notifier::PADDING_TOP,
.padding_bottom = Defaults::UI::Notifier::PADDING_BOTTOM,
.line_gap = Defaults::UI::Notifier::LINE_GAP,
.margin_top = Defaults::UI::Notifier::MARGIN_TOP,
.border_px = Defaults::UI::Notifier::BORDER_PX,
.slide_ms = Defaults::UI::Notifier::SLIDE_MS,
.slide_in_curve = curveByName(Defaults::UI::Notifier::SLIDE_IN_CURVE),
.slide_out_curve = curveByName(Defaults::UI::Notifier::SLIDE_OUT_CURVE),
} {
} }
auto UiConfig::get() -> UiConfig& { auto UiConfig::get() -> UiConfig& {
@@ -185,6 +198,25 @@ void UiConfig::load(const std::string& file_path) {
readCurve(p, "exit_curve", pause_exit_curve_); readCurve(p, "exit_curve", pause_exit_curve_);
} }
if (yaml.contains("sounds")) {
const auto& s = yaml["sounds"];
readField(s, "menu_select", menu_select_sound_);
readField(s, "menu_accept", menu_accept_sound_);
}
if (yaml.contains("notifier")) {
const auto& n = yaml["notifier"];
readField(n, "padding_h", notifier_.padding_h);
readField(n, "padding_top", notifier_.padding_top);
readField(n, "padding_bottom", notifier_.padding_bottom);
readField(n, "line_gap", notifier_.line_gap);
readField(n, "margin_top", notifier_.margin_top);
readField(n, "border_px", notifier_.border_px);
readField(n, "slide_ms", notifier_.slide_ms);
readCurve(n, "slide_in_curve", notifier_.slide_in_curve);
readCurve(n, "slide_out_curve", notifier_.slide_out_curve);
}
std::cout << "UiConfig: carregat des de " << file_path << '\n'; std::cout << "UiConfig: carregat des de " << file_path << '\n';
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cerr << "UiConfig: error parsejant " << file_path << ": " << e.what() << '\n'; std::cerr << "UiConfig: error parsejant " << file_path << ": " << e.what() << '\n';
+34 -5
View File
@@ -20,6 +20,27 @@ class UiConfig {
int kerning; int kerning;
}; };
// Signatura d'una funció d'easing: t ∈ [0, 1] → valor ∈ [0, 1].
// Permet triar corbes des de data/ui.yaml per nom (cadena) i guardar
// el punter a la corresponent funció de `utils/easing.hpp`.
using CurveFn = auto (*)(float) -> float;
// Geometria del Notifier (popup superior). Carregada des de
// data/ui.yaml secció `notifier:`; els valors per defecte vénen de
// Defaults::UI::Notifier. El moviment és per temps (slide_ms) i no per
// velocitat, perquè una caixa més gran no triga més a entrar/eixir.
struct Notifier {
int padding_h;
int padding_top;
int padding_bottom;
int line_gap;
int margin_top;
int border_px;
int slide_ms;
CurveFn slide_in_curve;
CurveFn slide_out_curve;
};
static auto get() -> UiConfig&; static auto get() -> UiConfig&;
UiConfig(const UiConfig&) = delete; UiConfig(const UiConfig&) = delete;
@@ -51,11 +72,6 @@ class UiConfig {
[[nodiscard]] auto roomText() const -> Uint8 { return room_text_; } [[nodiscard]] auto roomText() const -> Uint8 { return room_text_; }
[[nodiscard]] auto roomTextOutline() const -> Uint8 { return room_text_outline_; } [[nodiscard]] auto roomTextOutline() const -> Uint8 { return room_text_outline_; }
// Signatura d'una funció d'easing: t ∈ [0, 1] → valor ∈ [0, 1].
// Permet triar corbes des de data/ui.yaml per nom (cadena) i guardar
// el punter a la corresponent funció de `utils/easing.hpp`.
using CurveFn = auto (*)(float) -> float;
// Timings (ms). // Timings (ms).
[[nodiscard]] auto notificationMs() const -> int { return notification_ms_; } [[nodiscard]] auto notificationMs() const -> int { return notification_ms_; }
[[nodiscard]] auto exitMs() const -> int { return exit_ms_; } [[nodiscard]] auto exitMs() const -> int { return exit_ms_; }
@@ -76,6 +92,11 @@ class UiConfig {
// retorna Easing::linear i deixa un warning al log. // retorna Easing::linear i deixa un warning al log.
[[nodiscard]] static auto curveByName(const std::string& name) -> CurveFn; [[nodiscard]] static auto curveByName(const std::string& name) -> CurveFn;
// Sons del menú de servei. Claus de Resource::Cache::loadSound (els
// fitxers viuen a `data/sounds/`).
[[nodiscard]] auto menuSelectSound() const -> const std::string& { return menu_select_sound_; }
[[nodiscard]] auto menuAcceptSound() const -> const std::string& { return menu_accept_sound_; }
// Fonts de UI. // Fonts de UI.
[[nodiscard]] auto hudFont() const -> const Font& { return hud_font_; } [[nodiscard]] auto hudFont() const -> const Font& { return hud_font_; }
[[nodiscard]] auto placeholderFont() const -> const Font& { return placeholder_font_; } [[nodiscard]] auto placeholderFont() const -> const Font& { return placeholder_font_; }
@@ -83,6 +104,9 @@ class UiConfig {
[[nodiscard]] auto menuTitleFont() const -> const Font& { return menu_title_font_; } [[nodiscard]] auto menuTitleFont() const -> const Font& { return menu_title_font_; }
[[nodiscard]] auto menuItemsFont() const -> const Font& { return menu_items_font_; } [[nodiscard]] auto menuItemsFont() const -> const Font& { return menu_items_font_; }
// Geometria del Notifier.
[[nodiscard]] auto notifier() const -> const Notifier& { return notifier_; }
// Flags visuals. // Flags visuals.
[[nodiscard]] auto hudBgEnabled() const -> bool { return hud_bg_enabled_; } [[nodiscard]] auto hudBgEnabled() const -> bool { return hud_bg_enabled_; }
@@ -121,11 +145,16 @@ class UiConfig {
CurveFn pause_enter_curve_; CurveFn pause_enter_curve_;
CurveFn pause_exit_curve_; CurveFn pause_exit_curve_;
std::string menu_select_sound_;
std::string menu_accept_sound_;
Font hud_font_; Font hud_font_;
Font placeholder_font_; Font placeholder_font_;
Font room_text_font_; Font room_text_font_;
Font menu_title_font_; Font menu_title_font_;
Font menu_items_font_; Font menu_items_font_;
Notifier notifier_;
bool hud_bg_enabled_{false}; bool hud_bg_enabled_{false};
}; };
+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
+16 -13
View File
@@ -261,7 +261,7 @@ void Mummy::step(float delta_time) {
// TODO(refactor): mateixa cascada a `decideAtIntersection` amb la // TODO(refactor): mateixa cascada a `decideAtIntersection` amb la
// variant "afegir recta a Nivell 1". Factoritzable // variant "afegir recta a Nivell 1". Factoritzable
// quan tinga sentit (3+ callsites). // quan tinga sentit (3+ callsites).
auto pools = collectDirectionPools(speed_ * delta_time, /*avoid_decals=*/true); auto pools = collectDirectionPools(speed_ * delta_time, /*avoid_blocked=*/true);
const std::vector<Direction>* chosen = nullptr; const std::vector<Direction>* chosen = nullptr;
if (!pools.perp.empty()) { if (!pools.perp.empty()) {
chosen = &pools.perp; chosen = &pools.perp;
@@ -270,7 +270,7 @@ void Mummy::step(float delta_time) {
} }
DirectionPools pools_unfiltered; DirectionPools pools_unfiltered;
if (chosen == nullptr) { if (chosen == nullptr) {
pools_unfiltered = collectDirectionPools(speed_ * delta_time, /*avoid_decals=*/false); pools_unfiltered = collectDirectionPools(speed_ * delta_time, /*avoid_blocked=*/false);
if (!pools_unfiltered.perp.empty()) { if (!pools_unfiltered.perp.empty()) {
chosen = &pools_unfiltered.perp; chosen = &pools_unfiltered.perp;
} else if (!pools_unfiltered.uturn.empty()) { } else if (!pools_unfiltered.uturn.empty()) {
@@ -350,13 +350,13 @@ void Mummy::decideAtIntersection(float step_px) {
// permetia mai en creuaments, però és preferible recular que caure al // permetia mai en creuaments, però és preferible recular que caure al
// forat quan totes les altres surten a decal. // forat quan totes les altres surten a decal.
// TODO(refactor): cascada paral·lela a la branca de bloqueig de `step`. // TODO(refactor): cascada paral·lela a la branca de bloqueig de `step`.
auto pools = collectDirectionPools(step_px, /*avoid_decals=*/true); auto pools = collectDirectionPools(step_px, /*avoid_blocked=*/true);
// Nivell 1: perpendiculars (filtrades) + recta si lliure i sense decal. // Nivell 1: perpendiculars (filtrades) + recta si lliure i sense decal.
std::vector<Direction> level1 = std::move(pools.perp); std::vector<Direction> level1 = std::move(pools.perp);
const auto STRAIGHT_BOX = candidateBox(dir_x_ * step_px, dir_y_ * step_px); const auto STRAIGHT_BOX = candidateBox(dir_x_ * step_px, dir_y_ * step_px);
const bool STRAIGHT_OPEN = Map::insidePlayfield(STRAIGHT_BOX) && !Map::collides(STRAIGHT_BOX); const bool STRAIGHT_OPEN = Map::insidePlayfield(STRAIGHT_BOX) && !Map::collides(STRAIGHT_BOX);
if (STRAIGHT_OPEN && !isDecalAheadInDir(dir_x_, dir_y_)) { if (STRAIGHT_OPEN && !isBlockedAheadInDir(dir_x_, dir_y_)) {
level1.push_back(currentDirection()); level1.push_back(currentDirection());
} }
@@ -376,7 +376,7 @@ void Mummy::decideAtIntersection(float step_px) {
DirectionPools pools_unfiltered; DirectionPools pools_unfiltered;
std::vector<Direction> level3; std::vector<Direction> level3;
if (chosen == nullptr) { if (chosen == nullptr) {
pools_unfiltered = collectDirectionPools(step_px, /*avoid_decals=*/false); pools_unfiltered = collectDirectionPools(step_px, /*avoid_blocked=*/false);
level3 = std::move(pools_unfiltered.perp); level3 = std::move(pools_unfiltered.perp);
if (STRAIGHT_OPEN) { level3.push_back(currentDirection()); } if (STRAIGHT_OPEN) { level3.push_back(currentDirection()); }
if (!level3.empty()) { if (!level3.empty()) {
@@ -390,7 +390,7 @@ void Mummy::decideAtIntersection(float step_px) {
applyChoice(chooseDirection(*chosen)); applyChoice(chooseDirection(*chosen));
} }
auto Mummy::collectDirectionPools(float step, bool avoid_decals) const -> DirectionPools { auto Mummy::collectDirectionPools(float step, bool avoid_blocked) const -> DirectionPools {
struct Candidate { struct Candidate {
float dx; float dx;
float dy; float dy;
@@ -412,9 +412,9 @@ auto Mummy::collectDirectionPools(float step, bool avoid_decals) const -> Direct
if (!Map::insidePlayfield(TEST) || Map::collides(TEST)) { continue; } if (!Map::insidePlayfield(TEST) || Map::collides(TEST)) { continue; }
// Filtre de decals (Hole i futurs decals trigger): comprova el // Filtre de decals (Hole i futurs decals trigger): comprova el
// tile cardinal contigu; si conté un trigger, descartem la // tile cardinal contigu; si conté un trigger, descartem la
// direcció. El caller pot demanar `avoid_decals=false` com a // direcció. El caller pot demanar `avoid_blocked=false` com a
// fallback quan totes les filtrades estan bloquejades. // fallback quan totes les filtrades estan bloquejades.
if (avoid_decals && isDecalAheadInDir(c.dx, c.dy)) { continue; } if (avoid_blocked && isBlockedAheadInDir(c.dx, c.dy)) { continue; }
const bool IS_UTURN = (c.dx * dir_x_ < 0.0F) || (c.dy * dir_y_ < 0.0F); const bool IS_UTURN = (c.dx * dir_x_ < 0.0F) || (c.dy * dir_y_ < 0.0F);
if (IS_UTURN) { if (IS_UTURN) {
pools.uturn.push_back(c.dir); pools.uturn.push_back(c.dir);
@@ -425,10 +425,7 @@ auto Mummy::collectDirectionPools(float step, bool avoid_decals) const -> Direct
return pools; return pools;
} }
auto Mummy::isDecalAheadInDir(float dx_signed, float dy_signed) const -> bool { auto Mummy::isBlockedAheadInDir(float dx_signed, float dy_signed) const -> bool {
// Sense manager injectat (smoke tests sense `EntityManager::add`), el
// filtre és no-op: la mòmia es comporta exactament com abans del feature.
if (entity_manager_ == nullptr) { return false; }
if (dx_signed == 0.0F && dy_signed == 0.0F) { return false; } if (dx_signed == 0.0F && dy_signed == 0.0F) { return false; }
// El rect a consultar és el AABB del tile cardinal contigu (mida // El rect a consultar és el AABB del tile cardinal contigu (mida
// `Map::tile_size`), ancorat al tile actual de la mòmia. Calculem el // `Map::tile_size`), ancorat al tile actual de la mòmia. Calculem el
@@ -457,7 +454,13 @@ auto Mummy::isDecalAheadInDir(float dx_signed, float dy_signed) const -> bool {
.w = TS, .w = TS,
.h = TS, .h = TS,
}; };
return entity_manager_->findFloorTriggerAt(NEXT) != nullptr; // Ordre per cost creixent: comparació de coordenades, després iteració
// sobre sarcs i porta, i finalment cerca a l'index d'entitats. Sense
// manager (smoke tests) saltem només l'últim filtre.
if (!Map::insidePlayfield(NEXT)) { return true; }
if (Map::collides(NEXT)) { return true; }
if (entity_manager_ != nullptr && entity_manager_->findFloorTriggerAt(NEXT) != nullptr) { return true; }
return false;
} }
void Mummy::applyChoice(Direction dir) { void Mummy::applyChoice(Direction dir) {
+14 -8
View File
@@ -87,24 +87,30 @@ class Mummy : public DynamicHazard {
// `perpendiculars` (girs) i `uturn` (inversa). La direcció actual s'exclou // `perpendiculars` (girs) i `uturn` (inversa). La direcció actual s'exclou
// perquè ja sabem que està bloquejada. Cada candidata es valida amb // perquè ja sabem que està bloquejada. Cada candidata es valida amb
// `Map::insidePlayfield` + `Map::collides` sobre la caja resultant. Si // `Map::insidePlayfield` + `Map::collides` sobre la caja resultant. Si
// `avoid_decals` és true i hi ha `EntityManager` injectat, també es // `avoid_blocked` és true i hi ha `EntityManager` injectat, també es
// descarten les direccions que apunten a un tile amb un decal trigger // descarten les direccions que apunten a un tile amb un decal trigger
// (Hole o futurs pinchos plans/charcos). El caller és responsable de // (Hole o futurs pinchos plans/charcos). El caller és responsable de
// re-cridar amb `avoid_decals=false` com a fallback si totes les // re-cridar amb `avoid_blocked=false` com a fallback si totes les
// direccions filtrades estan bloquejades. // direccions filtrades estan bloquejades.
struct DirectionPools { struct DirectionPools {
std::vector<Direction> perp; std::vector<Direction> perp;
std::vector<Direction> uturn; std::vector<Direction> uturn;
}; };
[[nodiscard]] auto collectDirectionPools(float step, bool avoid_decals = true) const -> DirectionPools; [[nodiscard]] auto collectDirectionPools(float step, bool avoid_blocked = true) const -> DirectionPools;
// True si el tile cardinal contigu en la direcció (signe de dx, signe // True si el tile cardinal contigu en la direcció (signe de dx, signe
// de dy) conté un decal trigger (`is_floor_decal_ && triggers_on_overlap_`) // de dy) està bloquejat per qualsevol motiu: fora del playfield, ocupat
// per geometria estàtica (sarcs, door_corridor tancat) via `Map::collides`,
// o conté un decal trigger (`is_floor_decal_ && triggers_on_overlap_`)
// segons `EntityManager::findFloorTriggerAt`. Captura `Hole` i qualsevol // segons `EntityManager::findFloorTriggerAt`. Captura `Hole` i qualsevol
// futur decal trigger sense hardcodejar tipus. Si `entity_manager_` no // futur decal trigger sense hardcodejar tipus, i evita que la mòmia
// està injectat (smoke tests sense manager), retorna false silenciosament // triï a un creuament una direcció que el frame següent toparia (cas
// — la mòmia es comporta com abans del feature. // típic: a la intersecció anterior al límit nord del playfield, on el
[[nodiscard]] auto isDecalAheadInDir(float dx_signed, float dy_signed) const -> bool; // pas immediat encara hi cap però el tile sencer següent ja està fora).
// Si `entity_manager_` no està injectat (smoke tests sense manager),
// el filtre de decals és silenciós però els altres dos (playfield i
// collides) segueixen actius.
[[nodiscard]] auto isBlockedAheadInDir(float dx_signed, float dy_signed) const -> bool;
// Canvi de direcció + selecció de l'animació corresponent (un cardinal // Canvi de direcció + selecció de l'animació corresponent (un cardinal
// per branch). Tots els camins que trien nova direcció (ctor amb // per branch). Tots els camins que trien nova direcció (ctor amb
@@ -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
+100 -31
View File
@@ -330,15 +330,18 @@ void Player::updateFacingAndTurn(float delta_time, float vx, float vy) {
intermediate_cardinal_ = intermediateFor(INPUT_CARD); intermediate_cardinal_ = intermediateFor(INPUT_CARD);
turning_remaining_ms_ = static_cast<float>(turn_config.duration_ms); turning_remaining_ms_ = static_cast<float>(turn_config.duration_ms);
applyCardinal(intermediate_cardinal_); applyCardinal(intermediate_cardinal_);
} else if (vx != 0.0F) {
// Tria de facing: prevalença lateral, EAST/WEST segons el signe
// de vx; verticals purs van a NORTH/SOUTH. En aturar-se
// conservem `facing_` per a l'animació stand.
facing_ = (vx < 0.0F) ? Facing::WEST : Facing::EAST;
} else if (vy < 0.0F) { } else if (vy < 0.0F) {
// Tria de facing amb prevalença vertical: si hi ha component
// vertical, el sprite mira NORTH/SOUTH encara que també hi
// haja horitzontal. Així una diagonal (down+right, up+left,
// etc.) mostra el sprite mirant amunt o avall, no de costat.
// Només quan el moviment és purament horitzontal anem a
// EAST/WEST. En aturar-se conservem `facing_` per a stand.
facing_ = Facing::NORTH; facing_ = Facing::NORTH;
} else if (vy > 0.0F) { } else if (vy > 0.0F) {
facing_ = Facing::SOUTH; facing_ = Facing::SOUTH;
} else if (vx != 0.0F) {
facing_ = (vx < 0.0F) ? Facing::WEST : Facing::EAST;
} }
} }
@@ -1097,6 +1100,31 @@ void Player::applyMovement(float delta_time) {
clampToPlayfield(); clampToPlayfield();
// Refinem el `facing_` amb el desplaçament REAL del frame (post
// col·lisió/snap), no amb la velocitat d'entrada: si l'usuari prem
// diagonal en un pasillo, `tryMove` ja haurà anul·lat l'eix tapat
// per la paret, així que el sprite mira en la direcció realment
// efectuada (la d'input cru no ho sap). En AIRBORNE el facing es
// preserva des del despegue (vegeu el comentari de `integrateJump`),
// i durant un gir actiu el cardinal intermedi mana, així que els
// saltem. Si DX==DY==0 (parat o totalment bloquejat) preservem el
// facing previ per a l'animació stand. La prioritat vertical en
// diagonals lliures és intranscendent en pasillos (un dels dos eixos
// serà sempre 0); manté coherència amb la tria d'`updateFacingAndTurn`.
if (!AIRBORNE && turning_remaining_ms_ <= 0.0F) {
const float DX = getPosX() - FRAME_START_X;
const float DY = getPosY() - FRAME_START_Y;
if (DY < 0.0F) {
facing_ = Facing::NORTH;
} else if (DY > 0.0F) {
facing_ = Facing::SOUTH;
} else if (DX < 0.0F) {
facing_ = Facing::WEST;
} else if (DX > 0.0F) {
facing_ = Facing::EAST;
}
}
const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y); const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y);
// `being_pushed_` força `stand_*`: el jugador es desplaça sense input, // `being_pushed_` força `stand_*`: el jugador es desplaça sense input,
// animar-lo com a caminant faria creure que està caminant quan de // animar-lo com a caminant faria creure que està caminant quan de
@@ -1589,24 +1617,13 @@ auto Player::hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect {
}; };
} }
void Player::snapToLane( void Player::snapTowardsLane(
float delta_time, float delta_time,
float base, float target_lane,
float pitch,
int max_idx,
float (Player::*get_pos)() const, float (Player::*get_pos)() const,
void (Player::*set_pos)(float)) { void (Player::*set_pos)(float)) {
// Lanes en pos_axis: base + i·pitch per a i ∈ [0, max_idx]. Correspon a
// la caixa de col·lisió ocupant exactament un passadís perpendicular.
const auto THRESHOLD = static_cast<float>(collision_size) / 2.0F; const auto THRESHOLD = static_cast<float>(collision_size) / 2.0F;
const float DELTA = target_lane - (this->*get_pos)();
const auto IDX = std::clamp(
static_cast<int>(std::round(((this->*get_pos)() - base) / pitch)),
0,
max_idx);
const float LANE = base + (static_cast<float>(IDX) * pitch);
const float DELTA = LANE - (this->*get_pos)();
if (std::abs(DELTA) <= 0.0F || std::abs(DELTA) > THRESHOLD) { return; } if (std::abs(DELTA) <= 0.0F || std::abs(DELTA) > THRESHOLD) { return; }
const float FULL_STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time); const float FULL_STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time);
@@ -1691,20 +1708,72 @@ auto Player::tryPushSnapBlockers(
return true; return true;
} }
auto Player::wouldHitOuterWall(float dx, float dy) const -> bool {
SDL_FRect probe = getBaseBox();
probe.x += dx;
probe.y += dy;
if (Map::insidePlayfield(probe)) { return false; }
// Excepció: corridor de la porta OBERTA quan l'usuari prem cap
// amunt (dy < 0). No demanem que la probe ja estigui dins del
// corridor — encara no ho està, perquè el snap és justament qui
// ha d'aliniar-la. En lloc d'això comprovem si la base box està
// prou prop del centre del corridor com per a que el snap
// (mateix THRESHOLD = collision_size/2) puga completar-lo.
if (Map::door_open && dy < 0.0F && map_.door_corridor_w > 0) {
const float CORRIDOR_TARGET = static_cast<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - collision_size) / 2.0F);
const auto THRESHOLD = static_cast<float>(collision_size) / 2.0F;
if (std::abs(CORRIDOR_TARGET - probe.x) <= THRESHOLD) { return false; }
}
return true;
}
void Player::snapToHorizontalLane(float delta_time) { void Player::snapToHorizontalLane(float delta_time) {
snapToLane(delta_time, // Si el moviment principal del frame (eix X) està blocat per un mur
static_cast<float>(map_.playfield_y - collision_y_offset), // extern, no fem snap perpendicular: l'usuari està picant contra el
static_cast<float>(map_.pitch_y), // mur i no té sentit arrossegar-lo a un carril. Step probe d'1 px.
map_.grid_rows, if (intended_vx_ != 0.0F && wouldHitOuterWall(std::copysign(1.0F, intended_vx_), 0.0F)) {
&Player::getPosY, return;
&Player::setPosY); }
const auto BASE_Y = static_cast<float>(map_.playfield_y - collision_y_offset);
const auto PITCH_Y = static_cast<float>(map_.pitch_y);
const int IDX = std::clamp(
static_cast<int>(std::round((getPosY() - BASE_Y) / PITCH_Y)),
0,
map_.grid_rows);
const float LANE_REG = BASE_Y + (static_cast<float>(IDX) * PITCH_Y);
snapTowardsLane(delta_time, LANE_REG, &Player::getPosY, &Player::setPosY);
} }
void Player::snapToVerticalLane(float delta_time) { void Player::snapToVerticalLane(float delta_time) {
snapToLane(delta_time, // Mateixa idea que la versió horitzontal: si el moviment principal
static_cast<float>(map_.playfield_x - collision_x_offset), // (eix Y) està blocat per un mur extern, no aliniem en X. La
static_cast<float>(map_.pitch_x), // excepció del corridor de la porta hi aplica naturalment dins
map_.grid_cols, // `wouldHitOuterWall` (només quan és OBERT).
&Player::getPosX, if (intended_vy_ != 0.0F && wouldHitOuterWall(0.0F, std::copysign(1.0F, intended_vy_))) {
&Player::setPosX); return;
}
// Candidat 1: lane regular del laberint.
const auto BASE_X = static_cast<float>(map_.playfield_x - collision_x_offset);
const auto PITCH_X = static_cast<float>(map_.pitch_x);
const int IDX = std::clamp(
static_cast<int>(std::round((getPosX() - BASE_X) / PITCH_X)),
0,
map_.grid_cols);
const float LANE_REG = BASE_X + (static_cast<float>(IDX) * PITCH_X);
// Candidat 2: corridor de la porta. NOMÉS si la porta està oberta —
// si està tancada no té sentit arrossegar el jugador cap a un
// forat per on no pot passar.
float best_lane = LANE_REG;
if (Map::door_open && map_.door_corridor_w > 0) {
const float CORRIDOR_CENTER = static_cast<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - collision_size) / 2.0F);
const float LANE_DOOR = CORRIDOR_CENTER - static_cast<float>(collision_x_offset);
const float DELTA_REG = LANE_REG - getPosX();
const float DELTA_DOOR = LANE_DOOR - getPosX();
if (std::abs(DELTA_DOOR) < std::abs(DELTA_REG)) { best_lane = LANE_DOOR; }
}
snapTowardsLane(delta_time, best_lane, &Player::getPosX, &Player::setPosX);
} }
+21 -4
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
@@ -429,14 +436,24 @@ class Player : public AnimatedSprite {
// attack/recover), el sufix del cardinal (`_north`/`_south`/`_east`/ // attack/recover), el sufix del cardinal (`_north`/`_south`/`_east`/
// `_west`). // `_west`).
[[nodiscard]] auto animationName(bool moving) const -> std::string; [[nodiscard]] auto animationName(bool moving) const -> std::string;
void snapToLane( // Aplica el snap cap a una lane concreta amb la mecànica de fases
// 1/2 + push de blockers. Els wrappers (`snapToHorizontalLane` /
// `snapToVerticalLane`) decideixen la lane diana (grid regular, o
// corridor de la porta com a candidata especial).
void snapTowardsLane(
float delta_time, float delta_time,
float base, float target_lane,
float pitch,
int max_idx,
float (Player::*get_pos)() const, float (Player::*get_pos)() const,
void (Player::*set_pos)(float)); void (Player::*set_pos)(float));
// Cert si un step (dx, dy) trauria la base box fora del playfield
// (mur extern). Excepció: si el moviment és cap amunt (dy<0), la
// porta està OBERTA i la box probe queda dins del rang X del
// corridor, no és bloqueig — el jugador hi pot sortir. S'usa per
// suprimir el snap a lane quan el jugador no pot avançar perquè
// pica contra un mur exterior.
[[nodiscard]] auto wouldHitOuterWall(float dx, float dy) const -> bool;
// Quan el snap lateral trobaria un altre jugador al carril objectiu, // Quan el snap lateral trobaria un altre jugador al carril objectiu,
// l'empentem el mateix step en el mateix eix. Així `this` pot entrar // l'empentem el mateix step en el mateix eix. Així `this` pot entrar
// al passadís encara que l'altre estiga estacionat al mitj — mateix // al passadís encara que l'altre estiga estacionat al mitj — mateix
+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
+6
View File
@@ -808,6 +808,12 @@ namespace Options {
} }
auto buildActiveShader() -> Rendering::ShaderType { auto buildActiveShader() -> Rendering::ShaderType {
// Si el shader està deshabilitat, forcem POSTFX. El backend tracta
// POSTFX amb params a zero com a passthrough; CRTPI, en canvi, manté
// el seu pipeline encara amb params buits (no és passthrough), així
// que mai s'ha d'arribar al pipeline de CRTPI quan l'usuari ha apagat
// els shaders, ni des d'F4 ni des del menú de servei.
if (!g_video.shader.enabled) { return Rendering::ShaderType::POSTFX; }
if (g_video.shader.current_shader == "crtpi") { if (g_video.shader.current_shader == "crtpi") {
return Rendering::ShaderType::CRTPI; return Rendering::ShaderType::CRTPI;
} }
+91 -12
View File
@@ -38,6 +38,7 @@
#include "game/map/map.hpp" #include "game/map/map.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "game/ui/hud.hpp" #include "game/ui/hud.hpp"
#include "game/ui/service_menu.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
#include "utils/defines.hpp" #include "utils/defines.hpp"
#include "utils/easing.hpp" #include "utils/easing.hpp"
@@ -527,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);
@@ -606,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; }
@@ -1120,7 +1151,17 @@ namespace {
void GameScene::update(int delta_ms) { void GameScene::update(int delta_ms) {
handlePauseInput(); handlePauseInput();
// Congelació instantània mentre el menú de servei està obert. A
// diferència de la pausa de gameplay (que és un dial amb easing i
// fade de música), aquesta és binària: el món no avança gens i la
// música no es toca. També posem en pausa el dial de la pausa
// regular si estava a mig camí, perquè no continui mentre l'usuari
// navega el menú.
const bool MENU_FROZEN = ServiceMenu::get().isOpen();
if (!MENU_FROZEN) {
updatePauseTransition(delta_ms); updatePauseTransition(delta_ms);
}
// Transició de sala: viu en temps real (no escalada per la pausa) // Transició de sala: viu en temps real (no escalada per la pausa)
// perquè és UI. Avança avans del bloc de món perquè quan PLAYING // perquè és UI. Avança avans del bloc de món perquè quan PLAYING
@@ -1130,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
@@ -1172,7 +1233,7 @@ void GameScene::update(int delta_ms) {
constexpr bool EDITOR_FROZEN = false; constexpr bool EDITOR_FROZEN = false;
#endif #endif
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale); const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) { if (!EDITOR_FROZEN && !MENU_FROZEN && SCALED_DELTA_MS > 0) {
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F; const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
// Pipeline de moviment en tres passes per poder intercalar la // Pipeline de moviment en tres passes per poder intercalar la
@@ -1208,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
@@ -1231,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
@@ -1301,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
+106 -16
View File
@@ -445,47 +445,127 @@ void ServiceMenu::activateCurrent(int direction) {
namespace { namespace {
// Resol el botó SERVICE configurat per al mando que ha emès aquest // Resol el botó configurat per a una acció concreta (SERVICE / ATTACK
// event. -1 si no troba cap match (mando desconegut o desconnectat // / JUMP) al mando que ha emès aquest event. -1 si no troba cap match
// mentrestant). // (mando desconegut o desconnectat mentrestant).
auto serviceButtonForEvent(SDL_JoystickID jid) -> int { auto actionButtonForEvent(SDL_JoystickID jid, GamepadBindings::Action action) -> int {
for (const auto& info : GamepadManager::get().getAll()) { for (const auto& info : GamepadManager::get().getAll()) {
if (info.pad == nullptr) { continue; } if (info.pad == nullptr) { continue; }
if (SDL_GetJoystickID(SDL_GetGamepadJoystick(info.pad)) != jid) { continue; } if (SDL_GetJoystickID(SDL_GetGamepadJoystick(info.pad)) != jid) { continue; }
return GamepadBindings::get().getButton(info.name, info.path, GamepadBindings::Action::SERVICE); return GamepadBindings::get().getButton(info.name, info.path, action);
} }
return -1; return -1;
} }
} // namespace } // namespace
void ServiceMenu::playSelectSound() {
Audio::get()->playSound(UiConfig::get().menuSelectSound(), Audio::Group::INTERFACE);
}
void ServiceMenu::playAcceptSound() {
Audio::get()->playSound(UiConfig::get().menuAcceptSound(), Audio::Group::INTERFACE);
}
void ServiceMenu::handleGamepadAxisMotion(const SDL_GamepadAxisEvent& ev) {
// Llindars amb histèresi: cal travessar STICK_THRESHOLD per "entrar" a
// un extrem, però només es torna a la zona neutra quan baixa de
// STICK_RELEASE. Així evitem que petits tremolors al voltant del
// llindar facin spam d'events. Valors triats per donar la mateixa
// sensació que el stick del jugador (deadzone=12000) però una mica
// més estrictes per a la navegació discreta del menú.
constexpr Sint16 STICK_THRESHOLD = 16000;
constexpr Sint16 STICK_RELEASE = 8000;
std::int8_t* zone = nullptr;
bool horizontal = false;
switch (ev.axis) {
case SDL_GAMEPAD_AXIS_LEFTX:
zone = &lx_zone_;
horizontal = true;
break;
case SDL_GAMEPAD_AXIS_LEFTY:
zone = &ly_zone_;
horizontal = false;
break;
case SDL_GAMEPAD_AXIS_RIGHTX:
zone = &rx_zone_;
horizontal = true;
break;
case SDL_GAMEPAD_AXIS_RIGHTY:
zone = &ry_zone_;
horizontal = false;
break;
default:
return; // Ignorem L2/R2 i la resta.
}
// Càlcul de la zona nova: si el valor està més enllà del llindar fort,
// entra a l'extrem; si està dins de la zona morta, torna a 0; altrament
// manté la zona prèvia (banda d'histèresi).
std::int8_t new_zone = *zone;
if (ev.value > STICK_THRESHOLD) {
new_zone = +1;
} else if (ev.value < -STICK_THRESHOLD) {
new_zone = -1;
} else if (std::abs(ev.value) < STICK_RELEASE) {
new_zone = 0;
}
if (new_zone == *zone) { return; }
*zone = new_zone;
if (new_zone == 0) { return; } // Soltar el stick no dispara res.
if (horizontal) {
activateCurrent(new_zone);
playAcceptSound();
} else {
moveCursor(new_zone);
playSelectSound();
}
}
void ServiceMenu::handleGamepadButtonDown(const SDL_GamepadButtonEvent& ev) { void ServiceMenu::handleGamepadButtonDown(const SDL_GamepadButtonEvent& ev) {
// El botó SERVICE (per defecte BACK; remapejable per dispositiu via // Tots tres botons rellevants (SERVICE/ATTACK/JUMP) són remapejables per
// `GamepadBindings`) tanca el menú sencer. Cada mando pot tenir el // dispositiu via `GamepadBindings`. Resolem-los una sola vegada a partir
// seu propi mapeig — resolem-lo a partir del joystick instance ID. // del joystick instance ID i comparem; els defaults (WEST=ATTACK,
const int SERVICE_BTN = serviceButtonForEvent(ev.which); // SOUTH=JUMP, BACK=SERVICE) preserven el comportament històric.
if (SERVICE_BTN >= 0 && static_cast<int>(ev.button) == SERVICE_BTN) { const int SERVICE_BTN = actionButtonForEvent(ev.which, GamepadBindings::Action::SERVICE);
const int ACCEPT_BTN = actionButtonForEvent(ev.which, GamepadBindings::Action::ATTACK);
const int CANCEL_BTN = actionButtonForEvent(ev.which, GamepadBindings::Action::JUMP);
const int BTN = static_cast<int>(ev.button);
if (SERVICE_BTN >= 0 && BTN == SERVICE_BTN) {
close(); close();
playSelectSound();
return; return;
} }
if (ACCEPT_BTN >= 0 && BTN == ACCEPT_BTN) {
activateCurrent(+1);
playAcceptSound();
return;
}
if (CANCEL_BTN >= 0 && BTN == CANCEL_BTN) {
popPage();
playSelectSound();
return;
}
switch (ev.button) { switch (ev.button) {
case SDL_GAMEPAD_BUTTON_DPAD_UP: case SDL_GAMEPAD_BUTTON_DPAD_UP:
moveCursor(-1); moveCursor(-1);
playSelectSound();
break; break;
case SDL_GAMEPAD_BUTTON_DPAD_DOWN: case SDL_GAMEPAD_BUTTON_DPAD_DOWN:
moveCursor(+1); moveCursor(+1);
playSelectSound();
break; break;
case SDL_GAMEPAD_BUTTON_DPAD_LEFT: case SDL_GAMEPAD_BUTTON_DPAD_LEFT:
activateCurrent(-1); activateCurrent(-1);
playAcceptSound();
break; break;
case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: case SDL_GAMEPAD_BUTTON_DPAD_RIGHT:
case SDL_GAMEPAD_BUTTON_EAST:
case SDL_GAMEPAD_BUTTON_NORTH:
activateCurrent(+1); activateCurrent(+1);
break; playAcceptSound();
case SDL_GAMEPAD_BUTTON_WEST:
case SDL_GAMEPAD_BUTTON_SOUTH:
popPage();
break; break;
default: default:
break; break;
@@ -556,6 +636,11 @@ auto ServiceMenu::handleEvent(const SDL_Event& ev) -> bool {
return true; return true;
} }
if (ev.type == SDL_EVENT_GAMEPAD_AXIS_MOTION) {
handleGamepadAxisMotion(ev.gaxis);
return true;
}
if (ev.type != SDL_EVENT_KEY_DOWN) { return false; } if (ev.type != SDL_EVENT_KEY_DOWN) { return false; }
const auto SC = ev.key.scancode; const auto SC = ev.key.scancode;
@@ -567,21 +652,26 @@ auto ServiceMenu::handleEvent(const SDL_Event& ev) -> bool {
switch (SC) { switch (SC) {
case SDL_SCANCODE_UP: case SDL_SCANCODE_UP:
moveCursor(-1); moveCursor(-1);
playSelectSound();
break; break;
case SDL_SCANCODE_DOWN: case SDL_SCANCODE_DOWN:
moveCursor(+1); moveCursor(+1);
playSelectSound();
break; break;
case SDL_SCANCODE_LEFT: case SDL_SCANCODE_LEFT:
activateCurrent(-1); activateCurrent(-1);
playAcceptSound();
break; break;
case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_RIGHT:
case SDL_SCANCODE_RETURN: case SDL_SCANCODE_RETURN:
case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_KP_ENTER:
activateCurrent(+1); activateCurrent(+1);
playAcceptSound();
break; break;
case SDL_SCANCODE_BACKSPACE: case SDL_SCANCODE_BACKSPACE:
case SDL_SCANCODE_ESCAPE: case SDL_SCANCODE_ESCAPE:
popPage(); popPage();
playSelectSound();
break; break;
default: default:
break; break;
+20
View File
@@ -207,6 +207,18 @@ class ServiceMenu {
// mètode propi per a mantenir la complexitat cognitiva d'`handleEvent` // mètode propi per a mantenir la complexitat cognitiva d'`handleEvent`
// sota el llindar de clang-tidy. // sota el llindar de clang-tidy.
void handleGamepadButtonDown(const SDL_GamepadButtonEvent& ev); void handleGamepadButtonDown(const SDL_GamepadButtonEvent& ev);
// Navegació via sticks analògics. Treballa per edge-detection: cada
// eix recorda en quina "zona" està (negativa / neutra / positiva) i
// sols dispara una acció quan creua de neutra a un extrem, igual que
// un botó del DPAD. Aplicada tant al stick esquerre com al dret.
void handleGamepadAxisMotion(const SDL_GamepadAxisEvent& ev);
// Sons d'UI: SELECT acompanya navegació (amunt/avall) i back; ACCEPT
// acompanya modificació d'opció (esquerra/dreta/retorn). Els noms de
// recurs venen d'`UiConfig` i el preload el fa el Director.
static void playSelectSound();
static void playAcceptSound();
[[nodiscard]] auto top() -> Page&; [[nodiscard]] auto top() -> Page&;
[[nodiscard]] auto topConst() const -> const Page&; [[nodiscard]] auto topConst() const -> const Page&;
[[nodiscard]] static auto isItemVisible(const Item& item) -> bool; [[nodiscard]] static auto isItemVisible(const Item& item) -> bool;
@@ -264,6 +276,14 @@ class ServiceMenu {
float transition_progress_{0.0F}; // 0 → 1 durant la transició de pàgina. float transition_progress_{0.0F}; // 0 → 1 durant la transició de pàgina.
bool capturing_{false}; // True mentre esperem una tecla per a un KEY_BIND. bool capturing_{false}; // True mentre esperem una tecla per a un KEY_BIND.
// Estat per eix del stick per a edge-detection: -1, 0, +1 segons la
// zona on viu el valor actual. Un canvi de zona dispara una acció
// (només quan entra a un extrem); reset a 0 quan torna al centre.
std::int8_t lx_zone_{0};
std::int8_t ly_zone_{0};
std::int8_t rx_zone_{0};
std::int8_t ry_zone_{0};
// Sub-mòdul de captura de botons del mando. Quan està actiu absorbeix // Sub-mòdul de captura de botons del mando. Quan està actiu absorbeix
// tots els events de mando (BUTTON_DOWN/UP, AXIS_MOTION) i, en acabar, // tots els events de mando (BUTTON_DOWN/UP, AXIS_MOTION) i, en acabar,
// persisteix la nova configuració via `GamepadBindings`. Replica el // persisteix la nova configuració via `GamepadBindings`. Replica el
+28 -2
View File
@@ -62,9 +62,9 @@ namespace Defaults {
constexpr bool ENABLED = true; constexpr bool ENABLED = true;
constexpr float VOLUME = 1.0F; constexpr float VOLUME = 1.0F;
constexpr bool MUSIC_ENABLED = true; constexpr bool MUSIC_ENABLED = true;
constexpr float MUSIC_VOLUME = 0.8F; constexpr float MUSIC_VOLUME = 1.0F;
constexpr bool SOUND_ENABLED = true; constexpr bool SOUND_ENABLED = true;
constexpr float SOUND_VOLUME = 1.0F; constexpr float SOUND_VOLUME = 0.2F;
constexpr float VOLUME_STEP = 0.05F; // Pas estàndard per a la UI (5%). constexpr float VOLUME_STEP = 0.05F; // Pas estàndard per a la UI (5%).
constexpr int FREQUENCY = 48000; // Freqüència de mostreig del dispositiu (Hz). constexpr int FREQUENCY = 48000; // Freqüència de mostreig del dispositiu (Hz).
constexpr int CROSSFADE_MS = 1500; // Durada per defecte del crossfade entre pistes (ms). constexpr int CROSSFADE_MS = 1500; // Durada per defecte del crossfade entre pistes (ms).
@@ -168,6 +168,26 @@ namespace Defaults {
constexpr Uint8 NOTIFICATION_BG = 64; constexpr Uint8 NOTIFICATION_BG = 64;
constexpr int NOTIFICATION_TIME_MS = 4000; constexpr int NOTIFICATION_TIME_MS = 4000;
// Geometria del Notifier (en píxels del canvas). Asimètric a
// propòsit: PADDING_TOP > PADDING_BOTTOM puja una mica el text
// dins la caixa i deixa la base més arrapada al vora inferior.
// MARGIN_TOP és la separació entre la vora superior del canvas i
// la línia exterior de la caixa. SLIDE_MS és la durada de l'entrada
// i de l'eixida (independent de la mida de la caixa, perquè el
// moviment va per temps i no per velocitat). Les corbes són noms
// del registre d'`Easing` (veure UiConfig::curveByName).
namespace Notifier {
constexpr int PADDING_H = 6;
constexpr int PADDING_TOP = 4;
constexpr int PADDING_BOTTOM = 3;
constexpr int LINE_GAP = 1;
constexpr int MARGIN_TOP = 3;
constexpr int BORDER_PX = 1;
constexpr int SLIDE_MS = 400;
constexpr const char* SLIDE_IN_CURVE = "outCubic";
constexpr const char* SLIDE_OUT_CURVE = "inCubic";
} // namespace Notifier
// Preset per a la confirmació d'eixida (ESC doble). La durada ha // Preset per a la confirmació d'eixida (ESC doble). La durada ha
// de coincidir amb la del Notifier perquè el missatge i la finestra // de coincidir amb la del Notifier perquè el missatge i la finestra
// de confirmació expiren alhora (veure GlobalInputs::onExit). // de confirmació expiren alhora (veure GlobalInputs::onExit).
@@ -216,6 +236,12 @@ namespace Defaults {
constexpr int PAUSE_EXIT_MS = 250; constexpr int PAUSE_EXIT_MS = 250;
constexpr const char* PAUSE_ENTER_CURVE = "outCubic"; constexpr const char* PAUSE_ENTER_CURVE = "outCubic";
constexpr const char* PAUSE_EXIT_CURVE = "inCubic"; constexpr const char* PAUSE_EXIT_CURVE = "inCubic";
// Sons del menú de servei. SELECT acompanya navegació (amunt/avall)
// i back; ACCEPT acompanya modificació d'opció (esq/dret/retorn).
// Els fitxers viuen a `data/sounds/`; només es referencia el nom.
constexpr const char* MENU_SELECT_SOUND = "menu_select.wav";
constexpr const char* MENU_ACCEPT_SOUND = "menu_accept.wav";
} // namespace UI } // namespace UI
#ifdef _DEBUG #ifdef _DEBUG
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts { namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner"; constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.31.0"; // Single source of truth — Makefile el grep constexpr const char* VERSION = "0.32.0"; // Single source of truth — Makefile el grep
} // namespace Texts } // namespace Texts
namespace GameCanvas { namespace GameCanvas {