From 5ed963b53d1da219471e60bf9aaadb701e1ef1df Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 12 May 2026 20:56:27 +0200 Subject: [PATCH 1/9] feat(rendering): lighting module CPU + gradient config YAML --- CMakeLists.txt | 1 + data/config/lighting.yaml | 22 ++++ source/core/rendering/lighting.cpp | 187 +++++++++++++++++++++++++++++ source/core/rendering/lighting.hpp | 64 ++++++++++ source/core/system/director.cpp | 5 + source/utils/defaults.hpp | 16 +++ 6 files changed, 295 insertions(+) create mode 100644 data/config/lighting.yaml create mode 100644 source/core/rendering/lighting.cpp create mode 100644 source/core/rendering/lighting.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 233d03d..0364b04 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,6 +81,7 @@ set(APP_SOURCES source/core/input/mouse.cpp source/core/rendering/screen.cpp source/core/rendering/canvas.cpp + source/core/rendering/lighting.cpp source/core/rendering/notifier.cpp source/core/rendering/render_info.cpp source/core/rendering/gif.cpp diff --git a/data/config/lighting.yaml b/data/config/lighting.yaml new file mode 100644 index 0000000..68c8a2e --- /dev/null +++ b/data/config/lighting.yaml @@ -0,0 +1,22 @@ +# Configuració global del sistema d'il·luminació. +# +# Cada llum emissora (player, torxa, mòmia, ...) té un únic paràmetre +# propi: el radi en píxels. La forma de la llum dins d'aquest radi +# (els tres anells del gradient) viu en aquest fitxer perquè és una +# decisió de "feel" comuna a totes les entitats — així pots tocar la +# corba aquí sense haver de modificar 10 YAMLs. +# +# Forma del gradient (centre → vora): +# 0..inner_ratio del radi → inner_intensity (zona totalment il·luminada) +# inner_ratio..mid_ratio → mid_intensity (anell mig) +# mid_ratio..1.0 → outer_intensity (anell exterior) +# fora del radi → light_level de la sala +# +# Les intensitats són factors 0..1 que es multipliquen sobre el píxel +# final del canvas. inner=1.0 vol dir "píxel sense alterar". +gradient: + inner_ratio: 0.8 + mid_ratio: 0.9 + inner_intensity: 1.0 + mid_intensity: 0.9 + outer_intensity: 0.8 diff --git a/source/core/rendering/lighting.cpp b/source/core/rendering/lighting.cpp new file mode 100644 index 0000000..ed9076a --- /dev/null +++ b/source/core/rendering/lighting.cpp @@ -0,0 +1,187 @@ +#include "core/rendering/lighting.hpp" + +#include +#include +#include +#include + +#include "core/resources/resource_helper.hpp" +#include "external/fkyaml_node.hpp" +#include "utils/defaults.hpp" +#include "utils/log.hpp" + +namespace Lighting { + + namespace { + + // Buffer de luminància compartit entre frames. Es redimensiona + // només si canvia la mida del canvas (mai en un run normal: el + // canvas del joc és 320×240 fix). El reusem perquè una `apply` + // allotjant cada frame seria un cost evitable. + auto luminanceBuffer() -> std::vector& { + static std::vector buf_; + return buf_; + } + + // Configuració global, inicialitzada amb els defaults compilats. + // loadConfig() la reemplaça si troba el fitxer. + auto activeConfig() -> GradientConfig& { + static GradientConfig cfg_{ + .inner_ratio = Defaults::Lighting::INNER_RATIO, + .mid_ratio = Defaults::Lighting::MID_RATIO, + .inner_intensity = Defaults::Lighting::INNER_INTENSITY, + .mid_intensity = Defaults::Lighting::MID_INTENSITY, + .outer_intensity = Defaults::Lighting::OUTER_INTENSITY, + }; + return cfg_; + } + + // Quantitza un factor 0..1 a 0..255. `clamp` per defensar-nos de + // YAMLs que entreguen valors fora de rang sense forçar la mà. + auto toByte(float v) -> std::uint8_t { + const float CLAMPED = std::clamp(v, 0.0F, 1.0F); + return static_cast(std::lround(CLAMPED * 255.0F)); + } + + void readField(const fkyaml::node& node, const char* key, float& dst) { + if (node.contains(key)) { dst = node[key].get_value(); } + } + + // Pre-calcula els 3 quadrats de radi i les intensitats que farem + // servir dins del bucle per píxel. Es resol una sola vegada per + // emissor i s'evita arrastrar 6 paràmetres a `rasterizeEmitter`. + struct EmitterRings { + int radius_sq{0}; + int mid_sq{0}; + int inner_sq{0}; + std::uint8_t inner{0}; + std::uint8_t mid{0}; + std::uint8_t outer{0}; + }; + + auto computeRings(int radius, const GradientConfig& cfg) -> EmitterRings { + const auto R_F = static_cast(radius); + const float R_INNER = R_F * cfg.inner_ratio; + const float R_MID = R_F * cfg.mid_ratio; + return EmitterRings{ + .radius_sq = radius * radius, + .mid_sq = static_cast(R_MID * R_MID), + .inner_sq = static_cast(R_INNER * R_INNER), + .inner = toByte(cfg.inner_intensity), + .mid = toByte(cfg.mid_intensity), + .outer = toByte(cfg.outer_intensity), + }; + } + + // Itera la bounding box de l'emissor clipada al canvas i actualitza + // la fila de luminància corresponent amb `max(actual, ring)`. Es + // crida un cop per emissor, així extreure-la deixa `apply` simple. + void rasterizeEmitter(const Emitter& e, const EmitterRings& rings, std::uint8_t* lum, int width, int height) { + const int R = e.radius; + const int CX = static_cast(e.cx); + const int CY = static_cast(e.cy); + const int X0 = std::max(0, CX - R); + const int Y0 = std::max(0, CY - R); + const int X1 = std::min(width - 1, CX + R); + const int Y1 = std::min(height - 1, CY + R); + for (int y = Y0; y <= Y1; ++y) { + const int DY = y - CY; + const int DY_SQ = DY * DY; + if (DY_SQ > rings.radius_sq) { continue; } + std::uint8_t* row = lum + (static_cast(y) * width); + for (int x = X0; x <= X1; ++x) { + const int DX = x - CX; + const int DIST_SQ = (DX * DX) + DY_SQ; + if (DIST_SQ > rings.radius_sq) { continue; } + std::uint8_t intensity = rings.outer; + if (DIST_SQ <= rings.inner_sq) { + intensity = rings.inner; + } else if (DIST_SQ <= rings.mid_sq) { + intensity = rings.mid; + } + row[x] = std::max(intensity, row[x]); + } + } + } + + // Pasada final: pixel = pixel * lum / 255 per canal. El canal alpha + // es preserva (la resta del pipeline assumeix opacitat total al + // canvas; veure Rendering::OPAQUE_ALPHA). + void modulateCanvas(std::uint32_t* canvas, const std::uint8_t* lum, int width, int height) { + for (int y = 0; y < height; ++y) { + std::uint32_t* px = canvas + (static_cast(y) * width); + const std::uint8_t* lrow = lum + (static_cast(y) * width); + for (int x = 0; x < width; ++x) { + const std::uint8_t L = lrow[x]; + if (L == 255) { continue; } // píxel intacte. + const std::uint32_t SRC = px[x]; + const std::uint8_t SR = (SRC >> 16) & 0xFF; + const std::uint8_t SG = (SRC >> 8) & 0xFF; + const std::uint8_t SB = SRC & 0xFF; + const auto R = static_cast((SR * L) / 255); + const auto G = static_cast((SG * L) / 255); + const auto B = static_cast((SB * L) / 255); + px[x] = (SRC & 0xFF000000U) | + (static_cast(R) << 16) | + (static_cast(G) << 8) | + static_cast(B); + } + } + } + + } // namespace + + auto config() -> const GradientConfig& { + return activeConfig(); + } + + void loadConfig(const std::string& yaml_path) { + auto bytes = Resource::Helper::loadFile(yaml_path); + if (bytes.empty()) { + Log::warn("Lighting", "no s'ha pogut obrir {} — s'usaran els defaults compilats", yaml_path); + return; + } + try { + const auto* begin = reinterpret_cast(bytes.data()); + const auto* end = begin + bytes.size(); + auto yaml = fkyaml::node::deserialize(begin, end); + if (yaml.contains("gradient")) { + const auto& g = yaml["gradient"]; + auto& cfg = activeConfig(); + readField(g, "inner_ratio", cfg.inner_ratio); + readField(g, "mid_ratio", cfg.mid_ratio); + readField(g, "inner_intensity", cfg.inner_intensity); + readField(g, "mid_intensity", cfg.mid_intensity); + readField(g, "outer_intensity", cfg.outer_intensity); + } + } catch (const std::exception& ex) { + Log::warn("Lighting", "error parsejant {}: {} — s'usaran els defaults compilats", yaml_path, ex.what()); + } + } + + void apply(std::uint32_t* canvas, int width, int height, float room_light, std::span emitters) { + if (canvas == nullptr || width <= 0 || height <= 0) { return; } + + // Atajo: si la sala està totalment il·luminada i no hi ha + // emissors, la pasada seria un `pixel * 1.0` global — no cal + // tocar res. És el camí curt habitual mentre no posem + // `light_level` a les sales. + const std::uint8_t ROOM = toByte(room_light); + if (ROOM == 255 && emitters.empty()) { return; } + + auto& lum = luminanceBuffer(); + const std::size_t TOTAL = static_cast(width) * static_cast(height); + if (lum.size() != TOTAL) { lum.resize(TOTAL); } + std::ranges::fill(lum, ROOM); + + const auto& cfg = activeConfig(); + for (const auto& e : emitters) { + if (e.radius <= 0) { continue; } + const auto RINGS = computeRings(e.radius, cfg); + rasterizeEmitter(e, RINGS, lum.data(), width, height); + } + + modulateCanvas(canvas, lum.data(), width, height); + } + +} // namespace Lighting diff --git a/source/core/rendering/lighting.hpp b/source/core/rendering/lighting.hpp new file mode 100644 index 0000000..54781d5 --- /dev/null +++ b/source/core/rendering/lighting.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include + +// Sistema d'il·luminació CPU. Treballa sobre el mateix canvas ARGB del +// joc (320×240) que tot el pipeline de render: després que GameScene +// haja pintat tilemap, paint layer, sombres i actors, una pasada final +// multiplica cada píxel del canvas per un factor de luminància 0..1 +// derivat de la combinació de "llum de la sala" + cercles emissors al +// voltant de certes entitats. +// +// Forma de cada emissor (radial, 3 anells configurables al fitxer +// data/config/lighting.yaml; veure `GradientConfig`): +// 0..inner_ratio del radi → inner_intensity +// inner_ratio..mid_ratio → mid_intensity +// mid_ratio..1.0 → outer_intensity +// fora del radi → light_level de la sala +// +// Combinació entre emissors: per a cada píxel es pren el màxim del +// llum de sala i de la intensitat aportada per cada emissor que el +// solapa — no se "sumen" intensitats. Coherent amb la idea de "centre +// 1.0": el píxel que cau dins de qualsevol nucli no s'enfosqueix. +namespace Lighting { + + // Posició al canvas (en píxels, mateixa unitat que la resta del + // motor) i radi efectiu de l'emissor. El centre és el del sprite + // visible: és l'entitat qui sap on cau (veure `Entity::getLightCenter`). + struct Emitter { + float cx{0.0F}; + float cy{0.0F}; + int radius{0}; + }; + + // Forma del gradient. Compartida per tots els emissors per evitar + // duplicar la corba a cada YAML d'entitat. Càrrega via loadConfig. + struct GradientConfig { + float inner_ratio{0.8F}; + float mid_ratio{0.9F}; + float inner_intensity{1.0F}; + float mid_intensity{0.9F}; + float outer_intensity{0.8F}; + }; + + // Llegeix `yaml_path` (data/config/lighting.yaml) i pobla la + // configuració global. Qualsevol clau absent cau als Defaults::Lighting. + // No es crida més d'una vegada en un run normal; tornar-la a cridar + // simplement reemplaça la configuració activa. + void loadConfig(const std::string& yaml_path); + + // Configuració activa (per a debug/inspectors). Inicialitzada amb els + // Defaults::Lighting fins que loadConfig() es crida. + [[nodiscard]] auto config() -> const GradientConfig&; + + // Aplica la pasada d'il·luminació al canvas in-place. `room_light` + // és l'atenuació base de la sala (0..1, 1 = sense atenuació). El + // span d'emissors pot ser buit — en aquest cas tota la sala queda + // multiplicada per `room_light`. Aquesta funció no fa cap allocació + // si es crida amb la mateixa mida de canvas múltiples cops: manté + // un buffer intern reutilitzable. + void apply(std::uint32_t* canvas, int width, int height, float room_light, std::span emitters); + +} // namespace Lighting diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index bb42584..86655cd 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -19,6 +19,7 @@ #include "core/input/mouse.hpp" #include "core/locale/locale.hpp" #include "core/rendering/effects/shadow.hpp" +#include "core/rendering/lighting.hpp" #include "core/rendering/notifier.hpp" #include "core/rendering/render_info.hpp" #include "core/rendering/screen.hpp" @@ -131,6 +132,10 @@ Director::Director() { // Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a // data/config/ui.yaml cau al valor compilat a Defaults::UI. UiConfig::get().load(Defaults::Paths::UI_YAML); + // Forma del gradient d'il·luminació (3 anells radials). El radi + // per emissor i el `light_level` per sala viuen als YAMLs propis; + // ací només la corba compartida per a tots els emissors. + Lighting::loadConfig(Defaults::Paths::LIGHTING_YAML); // Configuració específica de les escenes TITLE i LOGO. Es carreguen // additivament sobre el mateix UiConfig: cada fitxer només conté la // seua subclau (`title:` o `logo:`), i les claus absents a la base diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index a81fee9..9eb0a70 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -106,6 +106,21 @@ namespace Defaults { constexpr float PUSH_FACTOR = 0.30F; } // namespace Player + // Fallbacks del sistema d'il·luminació. El gradient en viu viu a + // data/config/lighting.yaml; aquests valors només s'apliquen si el + // fitxer falta o li manca alguna clau. La forma final (3 anells) es + // descriu al YAML; veure `Lighting::GradientConfig` per a la semàntica. + namespace Lighting { + constexpr float INNER_RATIO = 0.8F; + constexpr float MID_RATIO = 0.9F; + constexpr float INNER_INTENSITY = 1.0F; + constexpr float MID_INTENSITY = 0.9F; + constexpr float OUTER_INTENSITY = 0.8F; + // Nivell d'il·luminació de sala per defecte. Una sala sense la + // clau `light_level` queda totalment il·luminada (com hui). + constexpr float ROOM_LIGHT_DEFAULT = 1.0F; + } // namespace Lighting + // Fallbacks del tema d'UI: índexs de paleta, timings (ms) i fonts. // El tema en viu és a data/config/ui.yaml, carregat per UiConfig a // l'arrencada; aquests valors només s'apliquen si el fitxer falta o @@ -498,6 +513,7 @@ namespace Defaults { constexpr const char* SOUNDS_YAML = "data/config/sounds.yaml"; constexpr const char* INPUT_YAML = "data/config/input.yaml"; constexpr const char* EDITOR_YAML = "data/config/editor.yaml"; + constexpr const char* LIGHTING_YAML = "data/config/lighting.yaml"; constexpr const char* UI_PALETTE = "data/palettes/uzebox.pal"; // Configuració d'escenes (yaml). El de la GameScene viu a From 68be8e55ec1df801430125f2384b220ea755bb69 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 12 May 2026 20:58:30 +0200 Subject: [PATCH 2/9] feat(map): Map::light_level per sala des del YAML --- source/game/map/map.cpp | 5 +++++ source/game/map/map.hpp | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/source/game/map/map.cpp b/source/game/map/map.cpp index 798441b..36b6397 100644 --- a/source/game/map/map.cpp +++ b/source/game/map/map.cpp @@ -5,6 +5,7 @@ #include "core/resources/resource_helper.hpp" #include "external/fkyaml_node.hpp" +#include "utils/defaults.hpp" #include "utils/log.hpp" namespace { @@ -102,6 +103,10 @@ namespace Map { // canviarà el flag a true quan transiti a OPEN. door_open = false; + // Llum base de la sala. La clau és opcional a l'arrel del YAML; + // per defecte 1.0 (sala totalment il·luminada, comportament hui). + light_level = yaml.contains("light_level") ? yaml["light_level"].get_value() : Defaults::Lighting::ROOM_LIGHT_DEFAULT; + Log::info("Map", "geometria {}x{} sarcs {}x{} pitch {}x{} door_col {} des de {}", grid_cols, grid_rows, sarc_w, sarc_h, pitch_x, pitch_y, door_col, yaml_path); } diff --git a/source/game/map/map.hpp b/source/game/map/map.hpp index f04254e..7804086 100644 --- a/source/game/map/map.hpp +++ b/source/game/map/map.hpp @@ -50,6 +50,13 @@ namespace Map { inline int back_wall_h = 0; inline int side_wall_w = 0; + // Nivell d'il·luminació base de la sala. 1.0 = totalment il·luminada + // (com hui, sense efecte visual); 0.0 = completament negra fora del + // radi d'un emissor. La clau `light_level:` al YAML de la sala el + // sobreescriu — si manca, queda al default 1.0 i la pasada + // d'il·luminació del frame es salta com a no-op. + inline float light_level = 1.0F; + // Derivats de la geometria base: el pas entre sarcòfags ha de cabre // exactament el jugador, i el mateix passadís s'usa a l'exterior. inline int corridor_w = 0; From ebb161f1f5e0759cd757d70998bc5621e91fb0ec Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 12 May 2026 21:06:01 +0200 Subject: [PATCH 3/9] feat(entities): light_radius + getLightCenter a Entity i Player --- source/game/entities/entity.cpp | 10 ++++++++++ source/game/entities/entity.hpp | 14 ++++++++++++++ .../game/entities/entity_manager_loadlevel.cpp | 12 ++++++++++++ source/game/entities/player.cpp | 18 ++++++++++++++++++ source/game/entities/player.hpp | 13 +++++++++++++ 5 files changed, 67 insertions(+) diff --git a/source/game/entities/entity.cpp b/source/game/entities/entity.cpp index f235f5a..1e4902e 100644 --- a/source/game/entities/entity.cpp +++ b/source/game/entities/entity.cpp @@ -63,6 +63,16 @@ auto Entity::getCollisionRect() const -> SDL_FRect { }; } +auto Entity::getLightCenter() const -> SDL_FPoint { + // Centre del rect de render. Per a entitats que es desplacen + // visualment sobre la seva posició lògica (p.ex. el Player saltant), + // la subclasse override aquest mètode per restar el desplaçament. + return { + .x = pixel_rect_.x + (pixel_rect_.w * 0.5F), + .y = pixel_rect_.y + (pixel_rect_.h * 0.5F), + }; +} + auto Entity::getFeetY() const -> float { // Decal de sòl: sentinel mínim per quedar al principi del Y-sort // ascendent de `GameScene` (stable_sort) i així pintar-se ABANS de diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index 05c655c..0aa76f5 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -253,6 +253,19 @@ class Entity { [[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; } void setFlip(SDL_FlipMode flip) { flip_ = flip; } + // Radi de la llum emesa per aquesta entitat (en píxels del canvas). + // 0 = no emet (cas habitual). El bloc opcional `light: { radius: N }` + // del YAML de la plantilla el pobla via `EntityManager::buildEntity`. + [[nodiscard]] auto getLightRadius() const -> int { return light_radius_; } + void setLightRadius(int r) { light_radius_ = r; } + + // Centre de la llum en píxels del canvas. Per defecte és el centre del + // `pixel_rect_` (que és la caixa de render: sprite top-left + mida). + // Virtual perquè entitats amb desplaçament visual sobre la posició + // lògica (p.ex. el Player en l'aire, que rebaixa visualment `pos_.y` + // amb `jump_z_`) puguin retornar el centre que realment es veu. + [[nodiscard]] virtual auto getLightCenter() const -> SDL_FPoint; + protected: std::string name_; TilePos tile_{}; @@ -266,6 +279,7 @@ class Entity { bool is_floor_decal_{false}; bool blocks_movement_{false}; int height_{0}; + int light_radius_{0}; bool dead_{false}; SDL_FlipMode flip_{SDL_FLIP_NONE}; EventBus* bus_{nullptr}; diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 443e6a3..6a1e9ab 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -155,6 +155,17 @@ namespace { return c["height"].get_value(); } + // Bloc opcional `light: { radius: N }` de la plantilla. Si manca, el + // radi és 0 i l'entitat no entra a la pasada d'il·luminació. La forma + // del gradient (3 anells) viu a data/config/lighting.yaml — no cal + // declarar-la per entitat. + auto readLightRadius(const fkyaml::node& tmpl) -> int { + if (!tmpl.contains("light")) { return 0; } + const auto& l = tmpl["light"]; + if (!l.contains("radius")) { return 0; } + return l["radius"].get_value(); + } + // Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }` // de la plantilla d'entitat. // - Sense `base_size` ni `offset`: la caja queda alineada a la vora @@ -277,6 +288,7 @@ namespace { entity->setCollisionBox(*BOX); } entity->setHeight(readHeight(tmpl)); + entity->setLightRadius(readLightRadius(tmpl)); anchor_out = readAnchor(tmpl); return entity; } diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 78f093b..3fab317 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -141,6 +141,14 @@ void Player::loadFrom(const std::string& yaml_path) { // `duration_ms` és 0 (o el bloc manca) la mecànica queda // desactivada i els hazards toquen al primer frame, com abans. readImmunityBlock(yaml); + + // Bloc opcional `light: { radius: N }` — radi en píxels del cercle + // de llum emès pel jugador. 0 = no emet. La forma del gradient és + // global (data/config/lighting.yaml), no per jugador. + light_radius = 0; + if (yaml.contains("light") && yaml["light"].contains("radius")) { + light_radius = yaml["light"]["radius"].get_value(); + } } void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) { @@ -1462,6 +1470,16 @@ void Player::render() { if (CLIP_HOLE) { Rendering::popClip(); } } +auto Player::getLightCenter() const -> SDL_FPoint { + // Sprite top-left = (pos_.x, pos_.y). El render desplaça verticalment + // `jump_z_` píxels durant el salt; aplicar el mateix offset aquí fa + // que la llum "salti" amb el jugador en lloc de quedar-se al sòl. + return { + .x = getPosX() + (static_cast(sprite_w) * 0.5F), + .y = getPosY() - jump_z_ + (static_cast(sprite_h) * 0.5F), + }; +} + void Player::renderShadow() const { // Sense salt actiu, no hi ha sombra: això simplifica els callsites, // que poden cridar `renderShadow` per a tots els jugadors sense diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index f85d4d3..b311c60 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -124,6 +124,12 @@ class Player : public AnimatedSprite { // creua mentre `jump_z_` és superior a aquest valor, no col·lisionem. inline static int body_height = 0; + // Radi de la llum emesa pel jugador (en píxels del canvas), llegit + // del bloc opcional `light: { radius: N }` del YAML. 0 = no emet. + // Es declara estàtic com la resta de configs del jugador (immunity, + // jump, ...): tots els jugadors del mateix YAML comparteixen el valor. + inline static int light_radius = 0; + // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. static void loadFrom(const std::string& yaml_path); @@ -132,6 +138,13 @@ class Player : public AnimatedSprite { // marcador i `Player::resetForJoin` per a re-entrades mid-partida. [[nodiscard]] static auto getInitialLives() -> int { return death_config.initial_lives; } + // Emissió de llum (mateix paper que `Entity::getLightRadius` per a + // les entitats). El centre puja amb el salt: durant `jump_z_>0` el + // sprite es dibuixa amb `pos_.y - jump_z_`, i la llum ha de seguir-lo + // per a que el cercle "salti" amb el jugador, no es quedi al sòl. + [[nodiscard]] static auto getLightRadius() -> int { return light_radius; } + [[nodiscard]] auto getLightCenter() const -> SDL_FPoint; + Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1); // Update en una sola crida: manté l'API anterior per als tests i From d1d5679d1474da57ad06ad2fba0938862188fcf1 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 12 May 2026 21:12:06 +0200 Subject: [PATCH 4/9] feat(game_scene): wire lighting pass entre actors i HUD --- source/game/scenes/game_scene.cpp | 37 +++++++++++++++++++++++++++++++ source/game/scenes/game_scene.hpp | 7 ++++++ 2 files changed, 44 insertions(+) diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 22deca0..2d807bb 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -16,6 +16,7 @@ #include "core/input/global_inputs.hpp" #include "core/input/key_config.hpp" #include "core/locale/locale.hpp" +#include "core/rendering/lighting.hpp" #include "core/rendering/notifier.hpp" #include "core/rendering/render_info.hpp" #include "core/rendering/screen.hpp" @@ -1005,6 +1006,36 @@ void GameScene::updateRoomTransition(int delta_ms) { } } +void GameScene::applyLightingPass() { + // Atajo: si la sala està totalment il·luminada i ningú emet, + // `Lighting::apply` ja s'autosalta amb les mateixes condicions. + // Recollim igualment perquè el cas comú durant el desenvolupament + // serà tocar `Map::light_level` per sales concretes. + std::vector emitters; + emitters.reserve(players_.size() + entity_manager_.entityCount()); + const int PLAYER_R = Player::getLightRadius(); + // Quan el YAML del jugador no declara `light:`, `PLAYER_R` és 0 i + // `Lighting::apply` salta el cercle al filtre intern. No fem un + // gating extern aquí (els jugadors són només 1-2 actors i el cost + // d'afegir-los al vector és menyspreable). + for (const auto& p : players_) { + if (p->isOutOfPlay()) { continue; } + const auto C = p->getLightCenter(); + emitters.push_back({.cx = C.x, .cy = C.y, .radius = PLAYER_R}); + } + for (const auto* e : entity_manager_.getEntities()) { + const int R = e->getLightRadius(); + if (R <= 0) { continue; } + const auto C = e->getLightCenter(); + emitters.push_back({.cx = C.x, .cy = C.y, .radius = R}); + } + Lighting::apply(Screen::get().getCanvasArgb(), + Screen::get().getCanvasWidth(), + Screen::get().getCanvasHeight(), + Map::light_level, + emitters); +} + void GameScene::renderRoomFade() { // Delegació als FadeToBlack: les durades, la direcció i el progrés // viuen dins de cada instància. Comportament: @@ -1708,6 +1739,12 @@ void GameScene::render() { } } + // Pasada d'il·luminació: oscureix el canvas segons `Map::light_level` + // i hi obre cercles brillants al voltant dels emissors (jugadors, + // antorxes, mòmies, ...). Va abans del fade i del HUD perquè el + // fade pinta negre encima i la UI ha de quedar llegible. + applyLightingPass(); + // Fade del món: pintat ENTRE actors i HUD perquè el marcador, les // notificacions i el service menu queden per damunt sense afectar-se. // Inactiu si l'estat és PLAYING. diff --git a/source/game/scenes/game_scene.hpp b/source/game/scenes/game_scene.hpp index c137899..602ad73 100644 --- a/source/game/scenes/game_scene.hpp +++ b/source/game/scenes/game_scene.hpp @@ -271,6 +271,13 @@ class GameScene : public Scene { void renderRoomFade(); void renderRoomText(); + // Pasada d'il·luminació CPU sobre el canvas, entre els actors i el + // HUD: recull els emissors actius (jugadors + entitats amb + // `light_radius>0`) i aplica el gradient amb `Map::light_level` + // com a llum de sala. El HUD/notificacions queden fora de la + // multiplicació perquè es pinten després. + void applyLightingPass(); + // Configuració, estat i transició de la sub-FSM de game over // (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la // RoomFsm via `GameOverFsm::loadConfig`. From 31957461893276b96764d2fb1a35f9a2b322a21e Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 12 May 2026 21:14:16 +0200 Subject: [PATCH 5/9] =?UTF-8?q?feat(data):=20activa=20la=20llum=20del=20ju?= =?UTF-8?q?gador,=20antorxes=20i=20m=C3=B2mies=20+=20sala=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/entities/mummy/mummy.yaml | 6 ++++++ data/entities/player/morcus.yaml | 7 +++++++ data/entities/player/sam.yaml | 7 +++++++ data/entities/torch/torch.yaml | 6 ++++++ data/maps/p1/r1.yaml | 6 ++++++ 5 files changed, 32 insertions(+) diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index 39071e0..779b86d 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -68,3 +68,9 @@ hurt: # So opcional: basename del fitxer dins `data/audio/sounds/`. Es dispara # un sol cop per fuetada quan la mòmia entra a HURT. Si manca, no sona. sound: mummy_hurt.wav + +# Halo tènue al voltant de la mòmia. Radi més curt que el del jugador +# perquè el rastre lumínic delati la seva posició sense "il·luminar +# la sala" — vol ser una pista visual a la foscor. +light: + radius: 24 diff --git a/data/entities/player/morcus.yaml b/data/entities/player/morcus.yaml index 3801448..93db1db 100644 --- a/data/entities/player/morcus.yaml +++ b/data/entities/player/morcus.yaml @@ -140,3 +140,10 @@ immunity: duration_ms: 3000 blink_period_ms: 100 palette: morcus_wash + +# Llum emesa pel jugador. `radius` és el radi del cercle de llum +# en píxels del canvas; el gradient (3 anells: 1.0 / 0.9 / 0.8) viu +# a data/config/lighting.yaml i és compartit per tots els emissors. +# Omet el bloc per desactivar la llum d'aquest personatge. +light: + radius: 64 diff --git a/data/entities/player/sam.yaml b/data/entities/player/sam.yaml index 6894059..b25f81b 100644 --- a/data/entities/player/sam.yaml +++ b/data/entities/player/sam.yaml @@ -140,3 +140,10 @@ immunity: duration_ms: 3000 blink_period_ms: 100 palette: sam_wash + +# Llum emesa pel jugador. `radius` és el radi del cercle de llum +# en píxels del canvas; el gradient (3 anells: 1.0 / 0.9 / 0.8) viu +# a data/config/lighting.yaml i és compartit per tots els emissors. +# Omet el bloc per desactivar la llum d'aquest personatge. +light: + radius: 64 diff --git a/data/entities/torch/torch.yaml b/data/entities/torch/torch.yaml index 215b45f..ed78bc2 100644 --- a/data/entities/torch/torch.yaml +++ b/data/entities/torch/torch.yaml @@ -16,3 +16,9 @@ flags: # El render clipa cada frame per `anim.currentFrame()` × 16 píxels en Y. animations: idle: { frames: [0, 1, 2, 3], fps: 8, loop: true } + +# Cercle de llum estàtic centrat a l'antorxa. El radi és en píxels del +# canvas; el gradient global (data/config/lighting.yaml) hi posa la +# forma (3 anells). Omet el bloc per fer una antorxa "apagada". +light: + radius: 40 diff --git a/data/maps/p1/r1.yaml b/data/maps/p1/r1.yaml index 752f7c2..a636d6e 100644 --- a/data/maps/p1/r1.yaml +++ b/data/maps/p1/r1.yaml @@ -15,6 +15,12 @@ tileset: data/tilesets/pyramid_1.gif tile_size: 16 origin: [-8, 0] +# Il·luminació base de la sala (0..1). 1.0 = sala totalment il·luminada +# (com hui, sense efecte visual); 0.0 = completament negra fora del radi +# d'un emissor. El gradient dels cercles d'emissor viu a +# data/config/lighting.yaml. Omet aquesta clau per deixar la sala neta. +light_level: 0.25 + # Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl # pintable. Un índex per jugador perquè a multijugador local el recorregut # es distingeix a simple vista. Provisional fins que l'art del pintat From 35aaa6c519bace4f221ca7c13895e6023705269f Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 12 May 2026 22:07:25 +0200 Subject: [PATCH 6/9] feat(lighting): flicker tipus foc per antorxes --- data/config/lighting.yaml | 14 +++++ data/entities/torch/torch.yaml | 11 ++-- source/core/rendering/lighting.cpp | 51 ++++++++++++++++++- source/core/rendering/lighting.hpp | 17 +++++++ source/game/entities/entity.hpp | 8 +++ .../entities/entity_manager_loadlevel.cpp | 28 ++++++---- source/game/scenes/game_scene.cpp | 4 +- source/utils/defaults.hpp | 7 +++ 8 files changed, 124 insertions(+), 16 deletions(-) diff --git a/data/config/lighting.yaml b/data/config/lighting.yaml index 68c8a2e..e4000b7 100644 --- a/data/config/lighting.yaml +++ b/data/config/lighting.yaml @@ -20,3 +20,17 @@ gradient: inner_intensity: 1.0 mid_intensity: 0.9 outer_intensity: 0.8 + +# Modulació del radi quan una entitat declara `flicker: true` al seu +# bloc `light:`. El radi efectiu per frame és: +# r_eff = r_base + A1·sin(2π·f1·t + φ1) + A2·sin(2π·f2·t + φ2) +# on (φ1, φ2) es deriven de la posició de l'emissor per a que cada +# antorxa balancegi en una fase diferent (sense estat per emissor). +# Combinant dues sinusoides amb freqüències incommensurables s'obté +# un patró "fluid" que el ull llegeix com a foc; pujar `noise_*` +# aporta més inquietud, baixar-ho fa la flama més serena. +flicker: + wave_amplitude_px: 2 + wave_frequency_hz: 3.0 + noise_amplitude_px: 1 + noise_frequency_hz: 7.0 diff --git a/data/entities/torch/torch.yaml b/data/entities/torch/torch.yaml index ed78bc2..488cdb9 100644 --- a/data/entities/torch/torch.yaml +++ b/data/entities/torch/torch.yaml @@ -17,8 +17,13 @@ flags: animations: idle: { frames: [0, 1, 2, 3], fps: 8, loop: true } -# Cercle de llum estàtic centrat a l'antorxa. El radi és en píxels del -# canvas; el gradient global (data/config/lighting.yaml) hi posa la -# forma (3 anells). Omet el bloc per fer una antorxa "apagada". +# Cercle de llum centrat a l'antorxa. El radi és en píxels del canvas; +# el gradient global (data/config/lighting.yaml) hi posa la forma (3 +# anells). Amb `flicker: true` el radi efectiu oscil·la per frame +# (suma de dues sinusoides amb fase derivada de la posició) per a +# que el cercle "respiri" com una flama; els paràmetres del flicker +# també viuen al config global. Omet el bloc per fer una antorxa +# "apagada". light: radius: 40 + flicker: true diff --git a/source/core/rendering/lighting.cpp b/source/core/rendering/lighting.cpp index ed9076a..65b9802 100644 --- a/source/core/rendering/lighting.cpp +++ b/source/core/rendering/lighting.cpp @@ -1,8 +1,11 @@ #include "core/rendering/lighting.hpp" +#include + #include #include #include +#include #include #include "core/resources/resource_helper.hpp" @@ -36,6 +39,32 @@ namespace Lighting { return cfg_; } + auto activeFlicker() -> FlickerConfig& { + static FlickerConfig fc_{ + .wave_amplitude_px = Defaults::Lighting::FLICKER_WAVE_AMPLITUDE_PX, + .wave_frequency_hz = Defaults::Lighting::FLICKER_WAVE_FREQUENCY_HZ, + .noise_amplitude_px = Defaults::Lighting::FLICKER_NOISE_AMPLITUDE_PX, + .noise_frequency_hz = Defaults::Lighting::FLICKER_NOISE_FREQUENCY_HZ, + }; + return fc_; + } + + // Modula el radi base d'un emissor amb la suma de dues sinusoides + // (`wave` + `noise`) i una fase derivada de la posició. Per a dos + // antorxes diferents (cx, cy) les fases queden incommensurables + // entre si, així que mai oscil·len al unison — patró típic de + // foc. Multipliquem cx i cy per constants irracionals petites + // per evitar fases "rodones" quan la posició és múltiple de 16. + auto computeFlickerRadius(int base_radius, float cx, float cy, float t_s, const FlickerConfig& fc) -> int { + constexpr float TWO_PI = 2.0F * std::numbers::pi_v; + const float PHASE = (cx * 0.0731F) + (cy * 0.1373F); + const float W1 = std::sin((TWO_PI * fc.wave_frequency_hz * t_s) + PHASE); + const float W2 = std::sin((TWO_PI * fc.noise_frequency_hz * t_s) + (PHASE * 2.7F)); + const float DELTA = (fc.wave_amplitude_px * W1) + (fc.noise_amplitude_px * W2); + const int R = base_radius + static_cast(std::lround(DELTA)); + return std::max(1, R); + } + // Quantitza un factor 0..1 a 0..255. `clamp` per defensar-nos de // YAMLs que entreguen valors fora de rang sense forçar la mà. auto toByte(float v) -> std::uint8_t { @@ -154,6 +183,14 @@ namespace Lighting { readField(g, "mid_intensity", cfg.mid_intensity); readField(g, "outer_intensity", cfg.outer_intensity); } + if (yaml.contains("flicker")) { + const auto& f = yaml["flicker"]; + auto& fc = activeFlicker(); + readField(f, "wave_amplitude_px", fc.wave_amplitude_px); + readField(f, "wave_frequency_hz", fc.wave_frequency_hz); + readField(f, "noise_amplitude_px", fc.noise_amplitude_px); + readField(f, "noise_frequency_hz", fc.noise_frequency_hz); + } } catch (const std::exception& ex) { Log::warn("Lighting", "error parsejant {}: {} — s'usaran els defaults compilats", yaml_path, ex.what()); } @@ -175,10 +212,20 @@ namespace Lighting { std::ranges::fill(lum, ROOM); const auto& cfg = activeConfig(); + const auto& fc = activeFlicker(); + const float T_S = static_cast(SDL_GetTicks()) / 1000.0F; for (const auto& e : emitters) { if (e.radius <= 0) { continue; } - const auto RINGS = computeRings(e.radius, cfg); - rasterizeEmitter(e, RINGS, lum.data(), width, height); + const int R_EFF = e.flicker + ? computeFlickerRadius(e.radius, e.cx, e.cy, T_S, fc) + : e.radius; + const auto RINGS = computeRings(R_EFF, cfg); + // El rasteritzador llegeix el radi de l'`Emitter` per al + // càlcul de la bounding box, així que li passem un emissor + // "patxat" amb el radi efectiu d'aquest frame. Mantenim el + // valor original al `Emitter` del caller intacte. + const Emitter EFF{.cx = e.cx, .cy = e.cy, .radius = R_EFF, .flicker = e.flicker}; + rasterizeEmitter(EFF, RINGS, lum.data(), width, height); } modulateCanvas(canvas, lum.data(), width, height); diff --git a/source/core/rendering/lighting.hpp b/source/core/rendering/lighting.hpp index 54781d5..35ee5ca 100644 --- a/source/core/rendering/lighting.hpp +++ b/source/core/rendering/lighting.hpp @@ -27,10 +27,15 @@ namespace Lighting { // Posició al canvas (en píxels, mateixa unitat que la resta del // motor) i radi efectiu de l'emissor. El centre és el del sprite // visible: és l'entitat qui sap on cau (veure `Entity::getLightCenter`). + // `flicker` activa la modulació del radi per frame (només antorxes + // avui): el radi base es queda al YAML, l'oscil·lació en viu surt + // dels paràmetres globals i de la posició per generar fases + // distintes a cada emissor. struct Emitter { float cx{0.0F}; float cy{0.0F}; int radius{0}; + bool flicker{false}; }; // Forma del gradient. Compartida per tots els emissors per evitar @@ -43,6 +48,18 @@ namespace Lighting { float outer_intensity{0.8F}; }; + // Paràmetres del flicker per a emissors amb `flicker=true`. La + // forma final del radi és la suma d'una sinusoide "respiració" + // (wave_*) i una sinusoide secundària de freqüència més alta + // (noise_*) que aporta la sensació d'inquietud. Les fases es + // deriven de la posició de cada emissor — sense estat per emissor. + struct FlickerConfig { + float wave_amplitude_px{2.0F}; + float wave_frequency_hz{3.0F}; + float noise_amplitude_px{1.0F}; + float noise_frequency_hz{7.0F}; + }; + // Llegeix `yaml_path` (data/config/lighting.yaml) i pobla la // configuració global. Qualsevol clau absent cau als Defaults::Lighting. // No es crida més d'una vegada en un run normal; tornar-la a cridar diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index 0aa76f5..0477628 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -259,6 +259,13 @@ class Entity { [[nodiscard]] auto getLightRadius() const -> int { return light_radius_; } void setLightRadius(int r) { light_radius_ = r; } + // Activa la modulació "tipus foc" del radi a la pasada d'il·luminació + // (per a antorxes, fogueres, etc.). El bloc opcional + // `light: { flicker: true }` del YAML el pobla. Per defecte false: + // la llum queda perfectament estable (cas player, mòmia). + [[nodiscard]] auto getLightFlicker() const -> bool { return light_flicker_; } + void setLightFlicker(bool f) { light_flicker_ = f; } + // Centre de la llum en píxels del canvas. Per defecte és el centre del // `pixel_rect_` (que és la caixa de render: sprite top-left + mida). // Virtual perquè entitats amb desplaçament visual sobre la posició @@ -280,6 +287,7 @@ class Entity { bool blocks_movement_{false}; int height_{0}; int light_radius_{0}; + bool light_flicker_{false}; bool dead_{false}; SDL_FlipMode flip_{SDL_FLIP_NONE}; EventBus* bus_{nullptr}; diff --git a/source/game/entities/entity_manager_loadlevel.cpp b/source/game/entities/entity_manager_loadlevel.cpp index 6a1e9ab..cde7a88 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -155,15 +155,23 @@ namespace { return c["height"].get_value(); } - // Bloc opcional `light: { radius: N }` de la plantilla. Si manca, el - // radi és 0 i l'entitat no entra a la pasada d'il·luminació. La forma - // del gradient (3 anells) viu a data/config/lighting.yaml — no cal - // declarar-la per entitat. - auto readLightRadius(const fkyaml::node& tmpl) -> int { - if (!tmpl.contains("light")) { return 0; } + // Bloc opcional `light: { radius: N, flicker: bool }` de la plantilla. + // Si manca, el radi és 0 i l'entitat no entra a la pasada d'il·luminació. + // `flicker` activa la modulació "tipus foc" del radi (només antorxes + // l'haurien d'usar). La forma del gradient (3 anells) i els paràmetres + // del flicker viuen a data/config/lighting.yaml — no cal declarar-los + // per entitat. + struct LightTemplate { + int radius{0}; + bool flicker{false}; + }; + auto readLight(const fkyaml::node& tmpl) -> LightTemplate { + if (!tmpl.contains("light")) { return {}; } const auto& l = tmpl["light"]; - if (!l.contains("radius")) { return 0; } - return l["radius"].get_value(); + LightTemplate out; + if (l.contains("radius")) { out.radius = l["radius"].get_value(); } + if (l.contains("flicker")) { out.flicker = l["flicker"].get_value(); } + return out; } // Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }` @@ -288,7 +296,9 @@ namespace { entity->setCollisionBox(*BOX); } entity->setHeight(readHeight(tmpl)); - entity->setLightRadius(readLightRadius(tmpl)); + const auto LIGHT = readLight(tmpl); + entity->setLightRadius(LIGHT.radius); + entity->setLightFlicker(LIGHT.flicker); anchor_out = readAnchor(tmpl); return entity; } diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 2d807bb..2ffda5c 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -1021,13 +1021,13 @@ void GameScene::applyLightingPass() { for (const auto& p : players_) { if (p->isOutOfPlay()) { continue; } const auto C = p->getLightCenter(); - emitters.push_back({.cx = C.x, .cy = C.y, .radius = PLAYER_R}); + emitters.push_back({.cx = C.x, .cy = C.y, .radius = PLAYER_R, .flicker = false}); } for (const auto* e : entity_manager_.getEntities()) { const int R = e->getLightRadius(); if (R <= 0) { continue; } const auto C = e->getLightCenter(); - emitters.push_back({.cx = C.x, .cy = C.y, .radius = R}); + emitters.push_back({.cx = C.x, .cy = C.y, .radius = R, .flicker = e->getLightFlicker()}); } Lighting::apply(Screen::get().getCanvasArgb(), Screen::get().getCanvasWidth(), diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index 9eb0a70..7f11e7f 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -119,6 +119,13 @@ namespace Defaults { // Nivell d'il·luminació de sala per defecte. Una sala sense la // clau `light_level` queda totalment il·luminada (com hui). constexpr float ROOM_LIGHT_DEFAULT = 1.0F; + // Defaults del flicker. Els reemplaça el bloc `flicker:` de + // data/config/lighting.yaml. Veure `Lighting::FlickerConfig` + // per a la semàntica de cada camp. + constexpr float FLICKER_WAVE_AMPLITUDE_PX = 2.0F; + constexpr float FLICKER_WAVE_FREQUENCY_HZ = 3.0F; + constexpr float FLICKER_NOISE_AMPLITUDE_PX = 1.0F; + constexpr float FLICKER_NOISE_FREQUENCY_HZ = 7.0F; } // namespace Lighting // Fallbacks del tema d'UI: índexs de paleta, timings (ms) i fonts. From fc9947b87f8c2821c137b44c4ba429031e7b8732 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 12 May 2026 22:17:36 +0200 Subject: [PATCH 7/9] feat(data): flicker tambe a jugadors i momies --- data/config/lighting.yaml | 8 ++++---- data/entities/mummy/mummy.yaml | 1 + data/entities/player/morcus.yaml | 1 + data/entities/player/sam.yaml | 1 + 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/data/config/lighting.yaml b/data/config/lighting.yaml index e4000b7..9b8c3dd 100644 --- a/data/config/lighting.yaml +++ b/data/config/lighting.yaml @@ -15,11 +15,11 @@ # Les intensitats són factors 0..1 que es multipliquen sobre el píxel # final del canvas. inner=1.0 vol dir "píxel sense alterar". gradient: - inner_ratio: 0.8 - mid_ratio: 0.9 + inner_ratio: 0.5 + mid_ratio: 0.85 inner_intensity: 1.0 - mid_intensity: 0.9 - outer_intensity: 0.8 + mid_intensity: 0.8 + outer_intensity: 0.6 # Modulació del radi quan una entitat declara `flicker: true` al seu # bloc `light:`. El radi efectiu per frame és: diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index 779b86d..c783094 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -74,3 +74,4 @@ hurt: # la sala" — vol ser una pista visual a la foscor. light: radius: 24 + flicker: true diff --git a/data/entities/player/morcus.yaml b/data/entities/player/morcus.yaml index 93db1db..03c520a 100644 --- a/data/entities/player/morcus.yaml +++ b/data/entities/player/morcus.yaml @@ -147,3 +147,4 @@ immunity: # Omet el bloc per desactivar la llum d'aquest personatge. light: radius: 64 + flicker: true diff --git a/data/entities/player/sam.yaml b/data/entities/player/sam.yaml index b25f81b..f68197f 100644 --- a/data/entities/player/sam.yaml +++ b/data/entities/player/sam.yaml @@ -147,3 +147,4 @@ immunity: # Omet el bloc per desactivar la llum d'aquest personatge. light: radius: 64 + flicker: true From 0046d526b0dcf605150f968d697bf2a5c3ad6fa0 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 12 May 2026 22:26:17 +0200 Subject: [PATCH 8/9] fix(lighting): llig flicker tambe del YAML del jugador --- source/game/entities/player.cpp | 17 +++++++++++------ source/game/entities/player.hpp | 14 +++++++++++--- source/game/scenes/game_scene.cpp | 3 ++- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index 3fab317..e23564b 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -142,13 +142,18 @@ void Player::loadFrom(const std::string& yaml_path) { // desactivada i els hazards toquen al primer frame, com abans. readImmunityBlock(yaml); - // Bloc opcional `light: { radius: N }` — radi en píxels del cercle - // de llum emès pel jugador. 0 = no emet. La forma del gradient és - // global (data/config/lighting.yaml), no per jugador. + // Bloc opcional `light:` — radi i flicker. Extret a un mètode per + // mantenir `loadFrom` sota el llindar de complexitat de clang-tidy. + readLightBlock(yaml); +} + +void Player::readLightBlock(const fkyaml::node& yaml) { light_radius = 0; - if (yaml.contains("light") && yaml["light"].contains("radius")) { - light_radius = yaml["light"]["radius"].get_value(); - } + light_flicker = false; + if (!yaml.contains("light")) { return; } + const auto& l = yaml["light"]; + if (l.contains("radius")) { light_radius = l["radius"].get_value(); } + if (l.contains("flicker")) { light_flicker = l["flicker"].get_value(); } } void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) { diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index b311c60..776f8d1 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -125,10 +125,12 @@ class Player : public AnimatedSprite { inline static int body_height = 0; // Radi de la llum emesa pel jugador (en píxels del canvas), llegit - // del bloc opcional `light: { radius: N }` del YAML. 0 = no emet. - // Es declara estàtic com la resta de configs del jugador (immunity, - // jump, ...): tots els jugadors del mateix YAML comparteixen el valor. + // del bloc opcional `light: { radius: N, flicker: bool }` del YAML. + // 0 = no emet. Es declara estàtic com la resta de configs del jugador + // (immunity, jump, ...): tots els jugadors del mateix YAML comparteixen + // el valor. `light_flicker` activa la modulació "tipus foc" del radi. inline static int light_radius = 0; + inline static bool light_flicker = false; // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. static void loadFrom(const std::string& yaml_path); @@ -143,6 +145,7 @@ class Player : public AnimatedSprite { // sprite es dibuixa amb `pos_.y - jump_z_`, i la llum ha de seguir-lo // per a que el cercle "salti" amb el jugador, no es quedi al sòl. [[nodiscard]] static auto getLightRadius() -> int { return light_radius; } + [[nodiscard]] static auto getLightFlicker() -> bool { return light_flicker; } [[nodiscard]] auto getLightCenter() const -> SDL_FPoint; Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1); @@ -755,6 +758,11 @@ class Player : public AnimatedSprite { // readers — `loadFrom` el crida al final. static void readImmunityBlock(const fkyaml::node& yaml); + // Llig el bloc opcional `light: { radius: N, flicker: bool }` del + // YAML i pobla `light_radius` / `light_flicker`. Si manca, els + // valors queden a 0 / false (jugador sense llum). + static void readLightBlock(const fkyaml::node& yaml); + // Activa l'estat ATTACKING en la direcció cardinal indicada (la // direcció cap on mira el jugador) i publica `whip_spawn` al bus // perquè el GameScene cree el `WhipAttack` corresponent. Posa el diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 2ffda5c..bcb433b 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -1018,10 +1018,11 @@ void GameScene::applyLightingPass() { // `Lighting::apply` salta el cercle al filtre intern. No fem un // gating extern aquí (els jugadors són només 1-2 actors i el cost // d'afegir-los al vector és menyspreable). + const bool PLAYER_FLICKER = Player::getLightFlicker(); for (const auto& p : players_) { if (p->isOutOfPlay()) { continue; } const auto C = p->getLightCenter(); - emitters.push_back({.cx = C.x, .cy = C.y, .radius = PLAYER_R, .flicker = false}); + emitters.push_back({.cx = C.x, .cy = C.y, .radius = PLAYER_R, .flicker = PLAYER_FLICKER}); } for (const auto* e : entity_manager_.getEntities()) { const int R = e->getLightRadius(); From 5cc8e349a55cb4d347010df73ff9563e58969a0c Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Tue, 12 May 2026 22:37:27 +0200 Subject: [PATCH 9/9] feat(lighting): seed estable per emissor (no deriva pel moviment) --- source/core/rendering/lighting.cpp | 18 +++++++++--------- source/core/rendering/lighting.hpp | 9 +++++---- source/game/entities/entity.hpp | 14 ++++++++++++++ source/game/entities/player.hpp | 5 +++++ source/game/scenes/game_scene.cpp | 4 ++-- 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/source/core/rendering/lighting.cpp b/source/core/rendering/lighting.cpp index 65b9802..6265c51 100644 --- a/source/core/rendering/lighting.cpp +++ b/source/core/rendering/lighting.cpp @@ -50,14 +50,14 @@ namespace Lighting { } // Modula el radi base d'un emissor amb la suma de dues sinusoides - // (`wave` + `noise`) i una fase derivada de la posició. Per a dos - // antorxes diferents (cx, cy) les fases queden incommensurables - // entre si, així que mai oscil·len al unison — patró típic de - // foc. Multipliquem cx i cy per constants irracionals petites - // per evitar fases "rodones" quan la posició és múltiple de 16. - auto computeFlickerRadius(int base_radius, float cx, float cy, float t_s, const FlickerConfig& fc) -> int { + // (`wave` + `noise`) i una fase derivada de `seed`. La seed és un + // identificador estable de l'emissor (no canvia amb la posició) — + // així el patró no "deriva" quan l'emissor es mou. El factor + // ~φ (golden ratio) dispersa les fases per a seeds consecutives. + auto computeFlickerRadius(int base_radius, int seed, float t_s, const FlickerConfig& fc) -> int { constexpr float TWO_PI = 2.0F * std::numbers::pi_v; - const float PHASE = (cx * 0.0731F) + (cy * 0.1373F); + constexpr float GOLDEN_FRACTION = 0.6180339F; + const float PHASE = static_cast(seed) * GOLDEN_FRACTION; const float W1 = std::sin((TWO_PI * fc.wave_frequency_hz * t_s) + PHASE); const float W2 = std::sin((TWO_PI * fc.noise_frequency_hz * t_s) + (PHASE * 2.7F)); const float DELTA = (fc.wave_amplitude_px * W1) + (fc.noise_amplitude_px * W2); @@ -217,14 +217,14 @@ namespace Lighting { for (const auto& e : emitters) { if (e.radius <= 0) { continue; } const int R_EFF = e.flicker - ? computeFlickerRadius(e.radius, e.cx, e.cy, T_S, fc) + ? computeFlickerRadius(e.radius, e.seed, T_S, fc) : e.radius; const auto RINGS = computeRings(R_EFF, cfg); // El rasteritzador llegeix el radi de l'`Emitter` per al // càlcul de la bounding box, així que li passem un emissor // "patxat" amb el radi efectiu d'aquest frame. Mantenim el // valor original al `Emitter` del caller intacte. - const Emitter EFF{.cx = e.cx, .cy = e.cy, .radius = R_EFF, .flicker = e.flicker}; + const Emitter EFF{.cx = e.cx, .cy = e.cy, .radius = R_EFF, .seed = e.seed, .flicker = e.flicker}; rasterizeEmitter(EFF, RINGS, lum.data(), width, height); } diff --git a/source/core/rendering/lighting.hpp b/source/core/rendering/lighting.hpp index 35ee5ca..25b6e62 100644 --- a/source/core/rendering/lighting.hpp +++ b/source/core/rendering/lighting.hpp @@ -27,14 +27,15 @@ namespace Lighting { // Posició al canvas (en píxels, mateixa unitat que la resta del // motor) i radi efectiu de l'emissor. El centre és el del sprite // visible: és l'entitat qui sap on cau (veure `Entity::getLightCenter`). - // `flicker` activa la modulació del radi per frame (només antorxes - // avui): el radi base es queda al YAML, l'oscil·lació en viu surt - // dels paràmetres globals i de la posició per generar fases - // distintes a cada emissor. + // `flicker` activa la modulació del radi per frame; `seed` és un + // identificador estable per emissor (no canvia amb la posició) del + // qual es deriva la fase del flicker — així els emissors mòbils + // mantenen un patró propi que no "deriva" pel moviment. struct Emitter { float cx{0.0F}; float cy{0.0F}; int radius{0}; + int seed{0}; bool flicker{false}; }; diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index 0477628..9faca8c 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -266,6 +266,14 @@ class Entity { [[nodiscard]] auto getLightFlicker() const -> bool { return light_flicker_; } void setLightFlicker(bool f) { light_flicker_ = f; } + // Identificador estable per al flicker. Cada Entity rep un valor + // únic a la construcció (contador estàtic) i el conserva fins a la + // mort — així el patró del flicker no "deriva" quan l'entitat es + // mou. Si l'entitat respawneja, la nova instància tindrà una seed + // nova; com que el cicle visual ja queda trencat per la mort, no és + // un problema. + [[nodiscard]] auto getLightSeed() const -> int { return light_seed_; } + // Centre de la llum en píxels del canvas. Per defecte és el centre del // `pixel_rect_` (que és la caixa de render: sprite top-left + mida). // Virtual perquè entitats amb desplaçament visual sobre la posició @@ -287,6 +295,12 @@ class Entity { bool blocks_movement_{false}; int height_{0}; int light_radius_{0}; + // Comptador global d'instàncies; cada Entity nova s'emporta el + // valor incrementat com a seed propi. Estàtic perquè no calgui + // estat extern (sense risc de threads: les entitats es construeixen + // sempre des del fil del joc). + static inline int s_next_light_seed{0}; + int light_seed_{++s_next_light_seed}; bool light_flicker_{false}; bool dead_{false}; SDL_FlipMode flip_{SDL_FLIP_NONE}; diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index 776f8d1..d7b535e 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -146,6 +146,11 @@ class Player : public AnimatedSprite { // per a que el cercle "salti" amb el jugador, no es quedi al sòl. [[nodiscard]] static auto getLightRadius() -> int { return light_radius; } [[nodiscard]] static auto getLightFlicker() -> bool { return light_flicker; } + // Seed estable per al flicker: reusem `id_` (0 o 1 en multijugador + // local) amb un offset gran per evitar col·lisions amb el contador + // d'Entity (que arrenca des de 1). Així P1 i P2 oscil·len en fases + // pròpies que no s'encavalquen amb cap entitat. + [[nodiscard]] auto getLightSeed() const -> int { return id_ + 1000; } [[nodiscard]] auto getLightCenter() const -> SDL_FPoint; Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1); diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index bcb433b..137d851 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -1022,13 +1022,13 @@ void GameScene::applyLightingPass() { for (const auto& p : players_) { if (p->isOutOfPlay()) { continue; } const auto C = p->getLightCenter(); - emitters.push_back({.cx = C.x, .cy = C.y, .radius = PLAYER_R, .flicker = PLAYER_FLICKER}); + emitters.push_back({.cx = C.x, .cy = C.y, .radius = PLAYER_R, .seed = p->getLightSeed(), .flicker = PLAYER_FLICKER}); } for (const auto* e : entity_manager_.getEntities()) { const int R = e->getLightRadius(); if (R <= 0) { continue; } const auto C = e->getLightCenter(); - emitters.push_back({.cx = C.x, .cy = C.y, .radius = R, .flicker = e->getLightFlicker()}); + emitters.push_back({.cx = C.x, .cy = C.y, .radius = R, .seed = e->getLightSeed(), .flicker = e->getLightFlicker()}); } Lighting::apply(Screen::get().getCanvasArgb(), Screen::get().getCanvasWidth(),