Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3442b9b481 | ||
|
|
8746ee9ec9 | ||
|
|
ef7fbf4027 | ||
|
|
964c32dd0d | ||
|
|
970fe2b4db | ||
|
|
f816e75cc0 | ||
|
|
2e8df45b38 | ||
|
|
643e5442c3 | ||
|
|
82ccf65ce1 | ||
|
|
1ae6912be5 | ||
|
|
c003e6665e | ||
|
|
a254fdd669 | ||
|
|
c0362abf67 | ||
|
|
d13313503c | ||
|
|
2a78c78e77 | ||
|
|
10e16d7905 | ||
|
|
ecb4625465 | ||
|
|
b4e36944d7 | ||
|
|
9b09ccb765 | ||
|
|
94213f9fd6 | ||
|
|
f591aa82fd | ||
|
|
2ef5968e0d | ||
|
|
ca03a1307e | ||
|
|
fe916d263f | ||
|
|
de7233a012 | ||
|
|
d26ecec04f | ||
|
|
fddea0c449 | ||
|
|
2c1c1a442b | ||
|
|
d1948afe4b | ||
|
|
ac06b05332 | ||
|
|
7d11dde0b1 | ||
|
|
73add013dd | ||
|
|
4e2ba827e3 | ||
|
|
9ac0c0cb74 | ||
|
|
45514ae2f4 | ||
|
|
0847e9c544 |
No files matched your search
@@ -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,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,5 +1,6 @@
|
|||||||
name: hole
|
name: hole
|
||||||
type: static_hazard
|
type: static_hazard
|
||||||
|
editable: true
|
||||||
sprite:
|
sprite:
|
||||||
path: hole.gif
|
path: hole.gif
|
||||||
size: [16, 16]
|
size: [16, 16]
|
||||||
|
|||||||
@@ -1,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,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
@@ -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)"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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.
@@ -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"
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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};
|
||||||
};
|
};
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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_);
|
||||||
|
|||||||
@@ -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,30 +395,31 @@ void EntityManager::reload(const std::string& level_yaml_path) {
|
|||||||
loadFromLevel(level_yaml_path);
|
loadFromLevel(level_yaml_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* {
|
||||||
|
if (!spawn.contains("type")) { return nullptr; }
|
||||||
|
const auto TYPE = spawn["type"].get_value<std::string>();
|
||||||
|
|
||||||
|
SpriteInfo sprite;
|
||||||
|
std::string anchor;
|
||||||
|
auto entity = buildEntity(TYPE, sprite, anchor);
|
||||||
|
if (entity == nullptr) { return nullptr; }
|
||||||
|
Entity* observer = entity.get();
|
||||||
|
|
||||||
|
bool ok = false;
|
||||||
|
if (TYPE == "sarcophagus") {
|
||||||
|
ok = placeSarcophagus(*this, std::move(entity), sprite, spawn);
|
||||||
|
} else if (TYPE == "door") {
|
||||||
|
ok = placeDoor(*this, std::move(entity), sprite);
|
||||||
|
} else {
|
||||||
|
ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn);
|
||||||
|
}
|
||||||
|
return ok ? observer : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
|
void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
|
||||||
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path);
|
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path);
|
||||||
int loaded = 0;
|
const int LOADED = static_cast<int>(std::ranges::count_if(SPAWNS,
|
||||||
|
[this](const fkyaml::node& spawn) { return addSpawn(spawn) != nullptr; }));
|
||||||
for (const auto& spawn : SPAWNS) {
|
|
||||||
if (!spawn.contains("type")) { continue; }
|
|
||||||
const auto TYPE = spawn["type"].get_value<std::string>();
|
|
||||||
|
|
||||||
SpriteInfo sprite;
|
|
||||||
std::string anchor;
|
|
||||||
auto entity = buildEntity(TYPE, sprite, anchor);
|
|
||||||
if (entity == nullptr) { continue; }
|
|
||||||
|
|
||||||
bool ok = false;
|
|
||||||
if (TYPE == "sarcophagus") {
|
|
||||||
ok = placeSarcophagus(*this, std::move(entity), sprite, spawn);
|
|
||||||
} else if (TYPE == "door") {
|
|
||||||
ok = placeDoor(*this, std::move(entity), sprite);
|
|
||||||
} else {
|
|
||||||
ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn);
|
|
||||||
}
|
|
||||||
const bool OK = ok;
|
|
||||||
if (OK) { ++loaded; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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';
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
updatePauseTransition(delta_ms);
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user