diff --git a/data/config/lighting.yaml b/data/config/lighting.yaml index 9b8c3dd..2c1802b 100644 --- a/data/config/lighting.yaml +++ b/data/config/lighting.yaml @@ -12,8 +12,13 @@ # 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". +# Hi ha dues corbes paral·leles, una per mode, perquè la mateixa escala +# 0..1 vol dir coses molt diferents: +# `gradient` → subtractive: 1.0 = "no fosqueix" el píxel +# (l'emissor "trenca" una sala fosca). +# `gradient_additive` → additive: 1.0 = "afegeix el color sencer" +# (satura). Valors molt més baixos donen un +# halo cromàtic subtil sobre una sala normal. gradient: inner_ratio: 0.5 mid_ratio: 0.85 @@ -21,16 +26,31 @@ gradient: mid_intensity: 0.8 outer_intensity: 0.6 +# Corba del mode additive. Valors petits a propòsit: només volem un +# toc de tint cromàtic prop de l'emissor — fora del nucli, la sala +# original ha de quedar pràcticament intacta. Si pugeu massa l'inner, +# els colors de l'emissor "inunden" la sala (additiu satura ràpid). +gradient_additive: + inner_ratio: 0.5 + mid_ratio: 0.85 + inner_intensity: 0.15 + mid_intensity: 0.10 + outer_intensity: 0.05 + # 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 +# r_eff = r_base + (r_amp1·r_base)·sin(2π·f1·t + φ1) + (r_amp2·r_base)·sin(2π·f2·t + φ2) +# on (φ1, φ2) es deriven de la seed 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. +# Les amplituds són **ràtios** sobre el radi base: així el feel del +# flicker és independent del radi (una torxa de 24 px oscil·la el +# mateix % que una de 40 px). 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. Aquests són els defaults globals; cada tipus d'entitat +# (o cada spawn / sala) pot sobreescriure'ls al seu `light.flicker_params:`. flicker: - wave_amplitude_px: 2 + wave_amplitude_ratio: 0.05 # 5% del radi (per a radi 40 → ±2 px, feel actual) wave_frequency_hz: 3.0 - noise_amplitude_px: 1 + noise_amplitude_ratio: 0.025 # 2.5% (per a radi 40 → ±1 px, feel actual) noise_frequency_hz: 7.0 diff --git a/data/entities/torch/torch.yaml b/data/entities/torch/torch.yaml index 3afa6f4..89a83dc 100644 --- a/data/entities/torch/torch.yaml +++ b/data/entities/torch/torch.yaml @@ -22,8 +22,12 @@ animations: # 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". +# també viuen al config global. `offset: [x, y]` desplaça el centre +# del cercle: la flama del sprite no està al centre del bounding box +# sinó cap a dalt, així que pujem la llum 4 px (Y creix cap avall) +# per a que el halo "neixi" de la flama, no del peu. Omet el bloc +# per fer una antorxa "apagada". light: radius: 40 flicker: true + offset: [0, -4] diff --git a/data/maps/p1/r1.yaml b/data/maps/p1/r1.yaml index 10e1a00..675b992 100644 --- a/data/maps/p1/r1.yaml +++ b/data/maps/p1/r1.yaml @@ -10,35 +10,35 @@ # la cel·la buida (transparent), útil per caselles que ocuparà un actor. pyramid: 1 -room: 1 +room: 2 tileset: data/tilesets/pyramid_1.gif tile_size: 16 origin: [-8, 0] -# Il·luminació de la sala. Bloc opcional; si manca, la sala queda -# totalment il·luminada (1.0) i tots els tipus d'entitat emeten amb els -# valors declarats al seu YAML de tipus (comportament històric). -# -# level → atenuació base de la sala (0..1). 1.0 = res -# d'efecte, 0.0 = negre fora del radi dels emissors. -# El gradient dels emissors viu a -# data/config/lighting.yaml. -# emitters. → override per tipus d'entitat. Tres formes: -# false → apaga el tipus -# true → defaults del tipus -# { radius: N, flicker: B } → camps presents -# sobreescriuen el -# default; els -# absents cauen al -# default del tipus. -# Plural per llegir-se natural ("totes les momies de la sala"). Per a -# casos particulars, un spawn pot dur el seu propi `light:` (mateixes -# formes) que té prioritat sobre l'override de sala. La forma antiga -# (`light_level:` plà a l'arrel) també segueix sent vàlida per -# retrocompatibilitat. +# Banc de proves del mode `additive` d'il·luminació. La sala es veu +# "normal" (level 1.0, el GIF intacte fora del cercle de les torxes) +# i només les antorxes hi sumen un halo càlid suau. Player i mummies +# desactivats explícitament perquè aquí no volem cap halo al voltant +# d'actors — el toc cromàtic és només l'antorxa. La corba additiva +# (intensitats baixes a propòsit perquè additive satura ràpid) viu a +# `gradient_additive:` de data/config/lighting.yaml. lighting: - level: 0.75 + mode: additive + level: 1.0 emitters: + torches: + radius: 20 + color: [255, 170, 60] + # Valors default del flicker explicitats per anar-los ajustant + # in-situ. Coincideixen amb els del global a data/config/lighting.yaml; + # baixar wave_frequency_hz fa el "palpitar" més lent, baixar les + # ratios fa l'oscil·lació més subtil. Qualsevol camp es pot + # eliminar i caurà al valor del global. + flicker_params: + wave_amplitude_ratio: 0.04 + wave_frequency_hz: 3.0 + noise_amplitude_ratio: 0.025 + noise_frequency_hz: 3.0 players: false mummies: false @@ -109,12 +109,16 @@ data: # d'obertura (262/263). La momia al corredor central. L'antorxa al costat # superior esquerre per verificar render d'una entitat decorativa. entities: + - type: torch + position: [4, 2] - type: torch position: [8, 2] - type: torch position: [12, 2] - type: torch position: [16, 2] + - type: sarcophagus + sarc_position: [0, 0] - type: sarcophagus sarc_position: [1, 0] - type: sarcophagus @@ -141,6 +145,8 @@ entities: sarc_position: [0, 3] - type: sarcophagus sarc_position: [1, 3] + - type: sarcophagus + sarc_position: [2, 3] - type: sarcophagus sarc_position: [3, 3] - type: sarcophagus @@ -151,40 +157,8 @@ entities: sarc_position: [2, 4] - type: sarcophagus sarc_position: [3, 4] - - type: torch - position: [4, 2] - type: door - - type: column - position: [3, 4] - frame: 2 - - type: stone - position: [5, 4] - frame: 16 - - type: stone - position: [4, 4] - frame: 19 - - type: column - position: [13, 10] - frame: 4 - - type: stone - position: [12, 10] - frame: 13 - - type: stone - position: [11, 10] - frame: 23 - - type: hole - position: [5, 9] - - type: hole - position: [9, 11] - - type: stone - position: [10, 10] - frame: 18 - type: mummy - position: [10, 13] - # Override per-instància: la sala apaga les momies (emitters.mummies: - # false), però aquesta concreta sí emet, amb un radi més curt que el - # default del seu tipus (24 → 16). Bona prova de la tercera capa de - # resolució: tipus → sala → spawn. - light: - radius: 16 - flicker: true + position: [10, 7] + - type: mummy + position: [7, 9] diff --git a/data/maps/p1/r2.yaml b/data/maps/p1/r2.yaml index 8d08951..b1e55af 100644 --- a/data/maps/p1/r2.yaml +++ b/data/maps/p1/r2.yaml @@ -10,11 +10,38 @@ # la cel·la buida (transparent), útil per caselles que ocuparà un actor. pyramid: 1 -room: 2 +room: 1 tileset: data/tilesets/pyramid_1.gif tile_size: 16 origin: [-8, 0] +# Il·luminació de la sala. Bloc opcional; si manca, la sala queda +# totalment il·luminada (1.0) i tots els tipus d'entitat emeten amb els +# valors declarats al seu YAML de tipus (comportament històric). +# +# level → atenuació base de la sala (0..1). 1.0 = res +# d'efecte, 0.0 = negre fora del radi dels emissors. +# El gradient dels emissors viu a +# data/config/lighting.yaml. +# emitters. → override per tipus d'entitat. Tres formes: +# false → apaga el tipus +# true → defaults del tipus +# { radius: N, flicker: B } → camps presents +# sobreescriuen el +# default; els +# absents cauen al +# default del tipus. +# Plural per llegir-se natural ("totes les momies de la sala"). Per a +# casos particulars, un spawn pot dur el seu propi `light:` (mateixes +# formes) que té prioritat sobre l'override de sala. La forma antiga +# (`light_level:` plà a l'arrel) també segueix sent vàlida per +# retrocompatibilitat. +lighting: + level: 0.15 + emitters: + players: true + mummies: false + # Í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 @@ -82,16 +109,12 @@ data: # d'obertura (262/263). La momia al corredor central. L'antorxa al costat # superior esquerre per verificar render d'una entitat decorativa. entities: - - type: torch - position: [4, 2] - type: torch position: [8, 2] - type: torch position: [12, 2] - type: torch position: [16, 2] - - type: sarcophagus - sarc_position: [0, 0] - type: sarcophagus sarc_position: [1, 0] - type: sarcophagus @@ -118,8 +141,6 @@ entities: sarc_position: [0, 3] - type: sarcophagus sarc_position: [1, 3] - - type: sarcophagus - sarc_position: [2, 3] - type: sarcophagus sarc_position: [3, 3] - type: sarcophagus @@ -130,8 +151,33 @@ entities: sarc_position: [2, 4] - type: sarcophagus sarc_position: [3, 4] + - type: torch + position: [4, 2] - type: door + - type: column + position: [3, 4] + frame: 2 + - type: stone + position: [5, 4] + frame: 16 + - type: stone + position: [4, 4] + frame: 19 + - type: column + position: [13, 10] + frame: 4 + - type: stone + position: [12, 10] + frame: 13 + - type: stone + position: [11, 10] + frame: 23 + - type: hole + position: [5, 9] + - type: hole + position: [9, 11] + - type: stone + position: [10, 10] + frame: 18 - type: mummy - position: [10, 7] - - type: mummy - position: [7, 9] + position: [10, 13] diff --git a/source/core/rendering/lighting.cpp b/source/core/rendering/lighting.cpp index 6265c51..708bf58 100644 --- a/source/core/rendering/lighting.cpp +++ b/source/core/rendering/lighting.cpp @@ -26,9 +26,9 @@ namespace Lighting { return buf_; } - // Configuració global, inicialitzada amb els defaults compilats. - // loadConfig() la reemplaça si troba el fitxer. - auto activeConfig() -> GradientConfig& { + // Corba activa del mode subtractive. loadConfig() la reemplaça + // si troba el bloc `gradient:` al YAML. + auto activeSubtractiveConfig() -> GradientConfig& { static GradientConfig cfg_{ .inner_ratio = Defaults::Lighting::INNER_RATIO, .mid_ratio = Defaults::Lighting::MID_RATIO, @@ -39,11 +39,27 @@ namespace Lighting { return cfg_; } + // Corba activa del mode additive. Defaults baixos a propòsit: + // en additive la intensitat és l'alfa del color afegit, així + // que valors petits (0.05..0.15) donen el "halo subtil" que + // sol voler-se. loadConfig() la reemplaça si troba el bloc + // `gradient_additive:` al YAML. + auto activeAdditiveConfig() -> GradientConfig& { + static GradientConfig cfg_{ + .inner_ratio = Defaults::Lighting::INNER_RATIO_ADDITIVE, + .mid_ratio = Defaults::Lighting::MID_RATIO_ADDITIVE, + .inner_intensity = Defaults::Lighting::INNER_INTENSITY_ADDITIVE, + .mid_intensity = Defaults::Lighting::MID_INTENSITY_ADDITIVE, + .outer_intensity = Defaults::Lighting::OUTER_INTENSITY_ADDITIVE, + }; + return cfg_; + } + auto activeFlicker() -> FlickerConfig& { static FlickerConfig fc_{ - .wave_amplitude_px = Defaults::Lighting::FLICKER_WAVE_AMPLITUDE_PX, + .wave_amplitude_ratio = Defaults::Lighting::FLICKER_WAVE_AMPLITUDE_RATIO, .wave_frequency_hz = Defaults::Lighting::FLICKER_WAVE_FREQUENCY_HZ, - .noise_amplitude_px = Defaults::Lighting::FLICKER_NOISE_AMPLITUDE_PX, + .noise_amplitude_ratio = Defaults::Lighting::FLICKER_NOISE_AMPLITUDE_RATIO, .noise_frequency_hz = Defaults::Lighting::FLICKER_NOISE_FREQUENCY_HZ, }; return fc_; @@ -54,13 +70,16 @@ namespace Lighting { // 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. + // Les amplituds són ràtios sobre el radi base, multiplicades aquí + // → mateix percentatge d'oscil·lació per a qualsevol mida. auto computeFlickerRadius(int base_radius, int seed, float t_s, const FlickerConfig& fc) -> int { constexpr float TWO_PI = 2.0F * std::numbers::pi_v; 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); + const auto R_F = static_cast(base_radius); + const float DELTA = (fc.wave_amplitude_ratio * R_F * W1) + (fc.noise_amplitude_ratio * R_F * W2); const int R = base_radius + static_cast(std::lround(DELTA)); return std::max(1, R); } @@ -133,9 +152,9 @@ namespace Lighting { } } - // 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). + // Pasada final del mode subtractive: 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); @@ -158,12 +177,100 @@ namespace Lighting { } } + // Atenuació ambient del mode additive: aplica `pixel × level` per + // canvas abans del bucle d'emissors. Si `level == 255` (sala a + // 1.0) salta com a no-op — el cas comú quan només volem el tint + // dels emissors sense oscurir la sala. + void darkenCanvas(std::uint32_t* canvas, int width, int height, std::uint8_t level) { + if (level == 255) { return; } + for (int y = 0; y < height; ++y) { + std::uint32_t* px = canvas + (static_cast(y) * width); + for (int x = 0; x < width; ++x) { + 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 * level) / 255); + const auto G = static_cast((SG * level) / 255); + const auto B = static_cast((SB * level) / 255); + px[x] = (SRC & 0xFF000000U) | + (static_cast(R) << 16) | + (static_cast(G) << 8) | + static_cast(B); + } + } + } + + // Pasada del mode additive per a un emissor: suma `color × intensity` + // a cada píxel cobert pel cercle, saturant a 255 per canal. A + // diferència del subtractive, NO usa buffer intermig (no cal max()) + // perquè la suma és commutativa; emissors solapats acumulen + // contribucions de manera natural. Si dues llums de colors diferents + // es solapen, el resultat tendeix al blanc en la zona de coincidència + // (suma física): el solape s'aclareix més que un sol emissor. + void rasterizeEmitterAdditive(const Emitter& e, const EmitterRings& rings, std::uint32_t* canvas, 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::uint32_t* row = canvas + (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; + } + // contrib_channel = emitter_color_channel × intensity / 255 + // (intensity ja és 0..255 quantitzat a `toByte`). + const int CR = (e.color.r * intensity) / 255; + const int CG = (e.color.g * intensity) / 255; + const int CB = (e.color.b * intensity) / 255; + const std::uint32_t SRC = row[x]; + const int SR = static_cast((SRC >> 16) & 0xFF); + const int SG = static_cast((SRC >> 8) & 0xFF); + const int SB = static_cast(SRC & 0xFF); + const auto NR = static_cast(std::min(255, SR + CR)); + const auto NG = static_cast(std::min(255, SG + CG)); + const auto NB = static_cast(std::min(255, SB + CB)); + row[x] = (SRC & 0xFF000000U) | (NR << 16) | (NG << 8) | NB; + } + } + } + } // namespace auto config() -> const GradientConfig& { - return activeConfig(); + return activeSubtractiveConfig(); } + auto flickerConfig() -> const FlickerConfig& { + return activeFlicker(); + } + + namespace { + // Llegeix els 5 camps de `GradientConfig` d'un node YAML — qualsevol + // camp absent conserva el valor compilat dels defaults. Compartit + // entre els blocs `gradient:` i `gradient_additive:`. + void readGradient(const fkyaml::node& g, GradientConfig& cfg) { + 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); + } + } // namespace + void loadConfig(const std::string& yaml_path) { auto bytes = Resource::Helper::loadFile(yaml_path); if (bytes.empty()) { @@ -175,20 +282,17 @@ namespace Lighting { 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); + readGradient(yaml["gradient"], activeSubtractiveConfig()); + } + if (yaml.contains("gradient_additive")) { + readGradient(yaml["gradient_additive"], activeAdditiveConfig()); } if (yaml.contains("flicker")) { const auto& f = yaml["flicker"]; auto& fc = activeFlicker(); - readField(f, "wave_amplitude_px", fc.wave_amplitude_px); + readField(f, "wave_amplitude_ratio", fc.wave_amplitude_ratio); readField(f, "wave_frequency_hz", fc.wave_frequency_hz); - readField(f, "noise_amplitude_px", fc.noise_amplitude_px); + readField(f, "noise_amplitude_ratio", fc.noise_amplitude_ratio); readField(f, "noise_frequency_hz", fc.noise_frequency_hz); } } catch (const std::exception& ex) { @@ -196,39 +300,77 @@ namespace Lighting { } } - void apply(std::uint32_t* canvas, int width, int height, float room_light, std::span emitters) { + namespace { + + // Helper: emissor "patxat" amb el radi efectiu (post-flicker) per + // a aquest frame. Mantenim l'`Emitter` del caller intacte i la + // resta de camps (color, seed, flicker_params, ...) propagats. + auto withEffectiveRadius(const Emitter& e, int r_eff) -> Emitter { + return Emitter{ + .cx = e.cx, + .cy = e.cy, + .radius = r_eff, + .seed = e.seed, + .flicker = e.flicker, + .color = e.color, + .flicker_params = e.flicker_params, + }; + } + + // Camí del mode subtractive (històric): omple un buffer de + // luminància amb `max(room, emitter_intensity)` i finalment + // multiplica el canvas per ell. Color de l'emissor ignorat. + void applySubtractive(std::uint32_t* canvas, int width, int height, std::uint8_t room, std::span emitters) { + 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 = activeSubtractiveConfig(); + const float T_S = static_cast(SDL_GetTicks()) / 1000.0F; + for (const auto& e : emitters) { + if (e.radius <= 0) { continue; } + const int R_EFF = e.flicker ? computeFlickerRadius(e.radius, e.seed, T_S, e.flicker_params) : e.radius; + const auto RINGS = computeRings(R_EFF, cfg); + const Emitter EFF = withEffectiveRadius(e, R_EFF); + rasterizeEmitter(EFF, RINGS, lum.data(), width, height); + } + modulateCanvas(canvas, lum.data(), width, height); + } + + // Camí del mode additive: atenua el canvas per `room` (si <255) + // i després suma `color × intensity` per emissor directament al + // canvas, sense buffer intermig. + void applyAdditive(std::uint32_t* canvas, int width, int height, std::uint8_t room, std::span emitters) { + darkenCanvas(canvas, width, height, room); + const auto& cfg = activeAdditiveConfig(); + const float T_S = static_cast(SDL_GetTicks()) / 1000.0F; + for (const auto& e : emitters) { + if (e.radius <= 0) { continue; } + const int R_EFF = e.flicker ? computeFlickerRadius(e.radius, e.seed, T_S, e.flicker_params) : e.radius; + const auto RINGS = computeRings(R_EFF, cfg); + const Emitter EFF = withEffectiveRadius(e, R_EFF); + rasterizeEmitterAdditive(EFF, RINGS, canvas, width, height); + } + } + + } // namespace + + void apply(std::uint32_t* canvas, int width, int height, Mode mode, 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. + // Atajo per a tots dos modes: sala totalment "neutra" + cap + // emissor → la pasada no canviaria res al canvas. + // subtractive: room=1.0 i sense emissors → pixel * 1.0 global. + // additive: room=1.0 i sense emissors → cap aportació. 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(); - 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 int R_EFF = e.flicker - ? 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, .seed = e.seed, .flicker = e.flicker}; - rasterizeEmitter(EFF, RINGS, lum.data(), width, height); + if (mode == Mode::ADDITIVE) { + applyAdditive(canvas, width, height, ROOM, emitters); + } else { + applySubtractive(canvas, width, height, ROOM, emitters); } - - modulateCanvas(canvas, lum.data(), width, height); } } // namespace Lighting diff --git a/source/core/rendering/lighting.hpp b/source/core/rendering/lighting.hpp index 25b6e62..e63187f 100644 --- a/source/core/rendering/lighting.hpp +++ b/source/core/rendering/lighting.hpp @@ -7,36 +7,64 @@ // 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. +// modifica cada píxel del canvas en funció de la "llum de la sala" i +// dels cercles emissors al voltant de certes entitats. // -// Forma de cada emissor (radial, 3 anells configurables al fitxer -// data/config/lighting.yaml; veure `GradientConfig`): +// Dos modes seleccionables per la sala (`lighting.mode` al YAML): +// +// SUBTRACTIVE (mode històric, default si no es declara mode): +// out = src × max(room_light, max_emitter_intensity_at_pixel) +// Els emissors "trenquen" una capa fosca multiplicativa. Tot el +// trip cromàtic dels emissors s'ignora — només la intensitat +// compta. Pensat per a sales fosques (cripta, pirámide). +// +// ADDITIVE (mode nou): +// out = saturate( (src × room_light) + Σ (emitter_color × intensity) ) +// El canvas s'enfosqueix opcionalment per `room_light` (atenuació +// ambient) i els emissors hi sumen color cap a sobre. Les intensitats +// dels anells es llegeixen com a "alfa" del color afegit (0..1). +// Pensat per a sales clares on les antorxes aporten halo cromàtic +// sense restar contrast — o per a sales lleument fosques (0.85, p.ex.) +// on el halo de l'antorxa es veu càlid en lloc de "blanquinós". +// +// Forma del cercle 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. +// La corba viu en dos blocs separats al YAML perquè la mateixa escala +// 0..1 té significats molt diferents per mode: +// `gradient:` → corba del mode subtractive (1.0 = "no fosqueix") +// `gradient_additive:` → corba del mode additive (1.0 = "afegeix +// el color sencer i satura"). Es vol molt més +// baixa per a no inundar la sala de color. +// Cada mode llegeix la seva pròpia corba — no es comparteixen valors. +// +// Combinació entre emissors: +// SUBTRACTIVE → per a cada píxel es pren el màxim de la intensitat +// aportada per cada emissor que el solapa (no se sumen intensitats). +// ADDITIVE → per a cada píxel se sumen totes les contribucions +// (`color × intensity`), saturant a 255 per canal. Dues llums de +// colors diferents que solapen produeixen la suma natural (orange + +// blue → near-white) — comportament físic. 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; `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}; + // Mode de composició. La sala el selecciona via `lighting.mode: + // additive | subtractive` (default subtractive si manca, retrocompat). + enum class Mode : std::uint8_t { + SUBTRACTIVE, + ADDITIVE, + }; + + // Color emès. Default blanc (sense modificació quan s'usa en mode + // additive). En mode subtractive s'ignora — la pasada multiplicativa + // només té un canal (intensitat). El YAML accepta `color: [r, g, b]` + // a qualsevol nivell de la cadena de resolució (tipus, sala, spawn). + struct Color { + std::uint8_t r{255}; + std::uint8_t g{255}; + std::uint8_t b{255}; }; // Forma del gradient. Compartida per tots els emissors per evitar @@ -53,30 +81,70 @@ namespace Lighting { // 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. + // deriven de la seed estable de cada emissor — sense estat per + // emissor. Les amplituds són **ràtios** sobre el radi base (p.ex. + // 0.05 = 5% del radi): així una torxa de radi 40 i una de radi 24 + // tenen el mateix percentatge d'oscil·lació visual, no els mateixos + // píxels (que es notarien més en la petita). struct FlickerConfig { - float wave_amplitude_px{2.0F}; + float wave_amplitude_ratio{0.05F}; float wave_frequency_hz{3.0F}; - float noise_amplitude_px{1.0F}; + float noise_amplitude_ratio{0.025F}; float noise_frequency_hz{7.0F}; }; + // 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; `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. `color` + // només té efecte en mode additive. + struct Emitter { + float cx{0.0F}; + float cy{0.0F}; + int radius{0}; + int seed{0}; + bool flicker{false}; + Color color{}; + // Paràmetres del flicker resolts per a aquest emissor (combinació + // global + override del tipus/sala/spawn). Només té sentit si + // `flicker == true`; en cas contrari s'ignoren. Per defecte + // s'inicialitza amb els defaults compilats — el caller (game_scene) + // hi posa el resolt correcte. + FlickerConfig flicker_params{}; + }; + // 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 + // Configuració activa (per a debug/inspectors). Retorna sempre la + // corba subtractive — la corba additive viu en paral·lel al cpp i, + // si algun dia es necessita inspeccionar des de fora, caldrà afegir + // una variant `configAdditive()`. 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); + // Defaults globals del flicker (data/config/lighting.yaml, bloc + // `flicker:`). Els callers del construct path d'entitats el llegeixen + // per inicialitzar la `FlickerConfig` de cada emissor, i aplicar a + // sobre els overrides per tipus/sala/spawn. + [[nodiscard]] auto flickerConfig() -> const FlickerConfig&; + + // Aplica la pasada d'il·luminació al canvas in-place. `mode` + // selecciona la composició (vegeu el comentari de la capçalera). + // `room_light` és l'atenuació ambient de la sala (0..1, 1 = sense + // atenuació): en subtractive és el "terra" de la intensitat fora + // dels emissors; en additive és la multiplicació prèvia del canvas + // abans de sumar els emissors (es pot deixar a 1.0 per "només tint", + // o baixar per combinar oscuriment ambient + halo cromàtic). El + // span d'emissors pot ser buit. La funció no fa cap allocació si + // es crida amb la mateixa mida de canvas múltiples cops: manté un + // buffer intern reutilitzable (només l'usa el mode subtractive). + void apply(std::uint32_t* canvas, int width, int height, Mode mode, float room_light, std::span emitters); } // namespace Lighting diff --git a/source/game/entities/entity.cpp b/source/game/entities/entity.cpp index 1e4902e..219c1c4 100644 --- a/source/game/entities/entity.cpp +++ b/source/game/entities/entity.cpp @@ -64,12 +64,13 @@ 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. + // Centre del rect de render + offset configurat al YAML. 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 — i ha d'incloure l'offset igualment. return { - .x = pixel_rect_.x + (pixel_rect_.w * 0.5F), - .y = pixel_rect_.y + (pixel_rect_.h * 0.5F), + .x = pixel_rect_.x + (pixel_rect_.w * 0.5F) + static_cast(light_offset_x_), + .y = pixel_rect_.y + (pixel_rect_.h * 0.5F) + static_cast(light_offset_y_), }; } diff --git a/source/game/entities/entity.hpp b/source/game/entities/entity.hpp index 9faca8c..b58a437 100644 --- a/source/game/entities/entity.hpp +++ b/source/game/entities/entity.hpp @@ -2,10 +2,12 @@ #include +#include #include #include #include +#include "core/rendering/lighting.hpp" #include "game/entities/animation_component.hpp" // Forward-declaracions: mantenim aquest header lleuger. `Player` viu a @@ -266,6 +268,44 @@ class Entity { [[nodiscard]] auto getLightFlicker() const -> bool { return light_flicker_; } void setLightFlicker(bool f) { light_flicker_ = f; } + // Color emès en mode additive (RGB 0..255 per canal). Default blanc: + // en mode subtractive aquest valor s'ignora completament; en mode + // additive és el tint que l'emissor afegeix al canvas. La cadena de + // resolució (tipus → sala → spawn) el pobla via `setLightColor`. + [[nodiscard]] auto getLightColorR() const -> std::uint8_t { return light_color_r_; } + [[nodiscard]] auto getLightColorG() const -> std::uint8_t { return light_color_g_; } + [[nodiscard]] auto getLightColorB() const -> std::uint8_t { return light_color_b_; } + void setLightColor(std::uint8_t r, std::uint8_t g, std::uint8_t b) { + light_color_r_ = r; + light_color_g_ = g; + light_color_b_ = b; + } + + // Desplaçament en píxels del centre del cercle de llum respecte el + // centre visual del sprite (que és el que retornaria per defecte + // `getLightCenter`). Útil quan el "punt de llum" iconogràfic no + // coincideix amb el centre del bounding box — per exemple, la flama + // d'una antorxa està a la part de dalt del sprite i interessa pujar + // l'emissor 3-4 px per a que el cercle de llum surti de la flama, + // no del peu. Y creix cap avall al canvas, així que `y: -3` puja + // la llum 3 píxels. Cadena de resolució idèntica al color: tipus → + // sala → spawn. + [[nodiscard]] auto getLightOffsetX() const -> int { return light_offset_x_; } + [[nodiscard]] auto getLightOffsetY() const -> int { return light_offset_y_; } + void setLightOffset(int x, int y) { + light_offset_x_ = x; + light_offset_y_ = y; + } + + // Paràmetres del flicker resolts per a aquest emissor (combinació + // del global de `data/config/lighting.yaml` + overrides del tipus, + // sala i spawn). Només té efecte si `light_flicker_` és true. El + // construct path li passa una còpia ja resolta — el raster (lighting.cpp) + // només la llegeix. Es publica per camps perquè el bloc YAML + // `flicker_params:` permet sobreescriure qualsevol subconjunt. + [[nodiscard]] auto getFlickerParams() const -> const Lighting::FlickerConfig& { return flicker_params_; } + void setFlickerParams(const Lighting::FlickerConfig& fp) { flicker_params_ = fp; } + // 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 @@ -274,11 +314,13 @@ class Entity { // 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ó - // 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. + // Centre de la llum en píxels del canvas. Per defecte és el centre + // del `pixel_rect_` (caixa de render) més l'offset configurat al + // YAML (`light.offset: [x, y]`). 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 — l'override n'és + // responsable d'incloure l'offset (com fa `Player::getLightCenter`). [[nodiscard]] virtual auto getLightCenter() const -> SDL_FPoint; protected: @@ -302,6 +344,12 @@ class Entity { static inline int s_next_light_seed{0}; int light_seed_{++s_next_light_seed}; bool light_flicker_{false}; + std::uint8_t light_color_r_{255}; + std::uint8_t light_color_g_{255}; + std::uint8_t light_color_b_{255}; + int light_offset_x_{0}; + int light_offset_y_{0}; + Lighting::FlickerConfig flicker_params_{}; 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 981bb19..2d73116 100644 --- a/source/game/entities/entity_manager_loadlevel.cpp +++ b/source/game/entities/entity_manager_loadlevel.cpp @@ -4,6 +4,8 @@ // consumidors que només volen la base del manager (p.ex. el smoke test // stand-alone) enllacen `entity_manager.cpp` sense arrossegar aquest fitxer. +#include +#include #include #include #include @@ -155,22 +157,70 @@ namespace { return c["height"].get_value(); } - // 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. + // Bloc opcional `light: { radius: N, flicker: bool, color: [r,g,b], offset: [x,y] }` + // 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). `color` només té + // efecte en mode additive — defecte blanc (255,255,255). `offset` + // desplaça el centre del cercle de llum respecte el centre del + // sprite (default (0, 0); Y creix cap avall). 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}; + std::uint8_t color_r{255}; + std::uint8_t color_g{255}; + std::uint8_t color_b{255}; + int offset_x{0}; + int offset_y{0}; + // Inicialitzat al global a `readLight`; overrides parcials del + // tipus/sala/spawn modifiquen camps concrets sense tocar la resta. + Lighting::FlickerConfig flicker_params{}; }; + + // Helper compartit entre les tres capes (tipus, sala via Map, spawn) + // per llegir `color: [r, g, b]` amb clamp defensiu a 0..255. + auto readColorTriplet(const fkyaml::node& seq, std::uint8_t& r, std::uint8_t& g, std::uint8_t& b) -> bool { + if (!seq.is_sequence() || seq.size() != 3) { return false; } + r = static_cast(std::clamp(seq[0].get_value(), 0, 255)); + g = static_cast(std::clamp(seq[1].get_value(), 0, 255)); + b = static_cast(std::clamp(seq[2].get_value(), 0, 255)); + return true; + } + + // Helper paral·lel a `readColorTriplet` per a `offset: [x, y]` — + // píxels enters, sense clamp (offsets negatius són legítims; Y + // creix cap avall). + auto readOffsetPair(const fkyaml::node& seq, int& x, int& y) -> bool { + if (!seq.is_sequence() || seq.size() != 2) { return false; } + x = seq[0].get_value(); + y = seq[1].get_value(); + return true; + } + + // Aplica els camps presents al bloc `flicker_params: { ... }` sobre + // una `FlickerConfig` ja inicialitzada (típicament amb el global o + // amb el resultat d'una capa anterior). Camps absents es respecten + // — per a poder sobreescriure'n només un. + void applyFlickerOverrides(const fkyaml::node& fp, Lighting::FlickerConfig& out) { + if (fp.contains("wave_amplitude_ratio")) { out.wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value(); } + if (fp.contains("wave_frequency_hz")) { out.wave_frequency_hz = fp["wave_frequency_hz"].get_value(); } + if (fp.contains("noise_amplitude_ratio")) { out.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value(); } + if (fp.contains("noise_frequency_hz")) { out.noise_frequency_hz = fp["noise_frequency_hz"].get_value(); } + } + auto readLight(const fkyaml::node& tmpl) -> LightTemplate { - if (!tmpl.contains("light")) { return {}; } - const auto& l = tmpl["light"]; LightTemplate out; + // Punt de partida del flicker: el global de lighting.yaml. + out.flicker_params = Lighting::flickerConfig(); + if (!tmpl.contains("light")) { return out; } + const auto& l = tmpl["light"]; if (l.contains("radius")) { out.radius = l["radius"].get_value(); } if (l.contains("flicker")) { out.flicker = l["flicker"].get_value(); } + if (l.contains("color")) { readColorTriplet(l["color"], out.color_r, out.color_g, out.color_b); } + if (l.contains("offset")) { readOffsetPair(l["offset"], out.offset_x, out.offset_y); } + if (l.contains("flicker_params")) { applyFlickerOverrides(l["flicker_params"], out.flicker_params); } return out; } @@ -182,26 +232,41 @@ namespace { // Forma 3: la sala declara `: true` o `: { ... }` → els // camps presents a l'override sobreescriuen el default; els absents // (`std::nullopt`) cauen al default del tipus. - auto resolveLightWithRoom(const std::string& type, LightTemplate type_default) -> LightTemplate { + auto resolveLightWithRoom(const std::string& type, const LightTemplate& type_default) -> LightTemplate { const auto* room = Map::getRoomEmitterOverride(type); if (room == nullptr) { return type_default; } - if (!room->enabled) { return {.radius = 0, .flicker = false}; } + if (!room->enabled) { + LightTemplate empty; + empty.flicker_params = Lighting::flickerConfig(); + return empty; + } LightTemplate out = type_default; if (room->radius.has_value()) { out.radius = *room->radius; } if (room->flicker.has_value()) { out.flicker = *room->flicker; } + if (room->color.has_value()) { + out.color_r = (*room->color)[0]; + out.color_g = (*room->color)[1]; + out.color_b = (*room->color)[2]; + } + if (room->offset.has_value()) { + out.offset_x = (*room->offset)[0]; + out.offset_y = (*room->offset)[1]; + } + if (room->flicker_wave_amplitude_ratio.has_value()) { out.flicker_params.wave_amplitude_ratio = *room->flicker_wave_amplitude_ratio; } + if (room->flicker_wave_frequency_hz.has_value()) { out.flicker_params.wave_frequency_hz = *room->flicker_wave_frequency_hz; } + if (room->flicker_noise_amplitude_ratio.has_value()) { out.flicker_params.noise_amplitude_ratio = *room->flicker_noise_amplitude_ratio; } + if (room->flicker_noise_frequency_hz.has_value()) { out.flicker_params.noise_frequency_hz = *room->flicker_noise_frequency_hz; } return out; } // Aplica l'override per-spawn (si existeix) per damunt del que ja // viu a l'entitat (defaults del tipus + override de sala). Tres formes // a l'entry del YAML de nivell: - // `light: false` → apaga aquesta instància - // `light: true` → no canvia res; la línia és - // informativa (la llum ja la donen - // les capes inferiors) - // `light: { radius, flicker }` → els camps presents sobreescriuen; - // els absents respecten el que ja - // duia l'entitat + // `light: false` → apaga aquesta instància + // `light: true` → informatiu (cau a capes inferiors) + // `light: { radius, flicker, color: [r,g,b], offset: [x,y] }` → camps presents sobreescriuen; + // els absents respecten el que + // ja duia l'entitat void applySpawnLightOverride(Entity& entity, const fkyaml::node& spawn) { if (!spawn.contains("light")) { return; } const auto& l = spawn["light"]; @@ -215,6 +280,22 @@ namespace { if (!l.is_mapping()) { return; } if (l.contains("radius")) { entity.setLightRadius(l["radius"].get_value()); } if (l.contains("flicker")) { entity.setLightFlicker(l["flicker"].get_value()); } + if (l.contains("color")) { + std::uint8_t r{}; + std::uint8_t g{}; + std::uint8_t b{}; + if (readColorTriplet(l["color"], r, g, b)) { entity.setLightColor(r, g, b); } + } + if (l.contains("offset")) { + int x{}; + int y{}; + if (readOffsetPair(l["offset"], x, y)) { entity.setLightOffset(x, y); } + } + if (l.contains("flicker_params")) { + Lighting::FlickerConfig fp = entity.getFlickerParams(); + applyFlickerOverrides(l["flicker_params"], fp); + entity.setFlickerParams(fp); + } } // Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }` @@ -346,6 +427,9 @@ namespace { const auto LIGHT = resolveLightWithRoom(type, readLight(tmpl)); entity->setLightRadius(LIGHT.radius); entity->setLightFlicker(LIGHT.flicker); + entity->setLightColor(LIGHT.color_r, LIGHT.color_g, LIGHT.color_b); + entity->setLightOffset(LIGHT.offset_x, LIGHT.offset_y); + entity->setFlickerParams(LIGHT.flicker_params); anchor_out = readAnchor(tmpl); return entity; } diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index e23564b..c743632 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -150,10 +150,46 @@ void Player::loadFrom(const std::string& yaml_path) { void Player::readLightBlock(const fkyaml::node& yaml) { light_radius = 0; light_flicker = false; + light_color_r = 255; + light_color_g = 255; + light_color_b = 255; + light_offset_x = 0; + light_offset_y = 0; + // Punt de partida del flicker: el global de lighting.yaml. El bloc + // opcional `flicker_params:` del personatge sobreescriu camps concrets. + flicker_params = Lighting::flickerConfig(); 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(); } + // `color: [r, g, b]` opcional, bytes 0..255. Si manca, blanc (sense + // tint) — comportament idèntic al mode subtractive de hui. + if (l.contains("color")) { + const auto& c = l["color"]; + if (c.is_sequence() && c.size() == 3) { + light_color_r = static_cast(std::clamp(c[0].get_value(), 0, 255)); + light_color_g = static_cast(std::clamp(c[1].get_value(), 0, 255)); + light_color_b = static_cast(std::clamp(c[2].get_value(), 0, 255)); + } + } + // `offset: [x, y]` opcional, píxels enters. Default (0, 0) → centre + // del sprite. Y creix cap avall, així que `y: -3` puja la llum 3 px. + if (l.contains("offset")) { + const auto& o = l["offset"]; + if (o.is_sequence() && o.size() == 2) { + light_offset_x = o[0].get_value(); + light_offset_y = o[1].get_value(); + } + } + // `flicker_params: { ... }` opcional. Qualsevol dels 4 camps absent + // conserva el valor heretat del global (vegeu inicialització a sobre). + if (l.contains("flicker_params")) { + const auto& fp = l["flicker_params"]; + if (fp.contains("wave_amplitude_ratio")) { flicker_params.wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value(); } + if (fp.contains("wave_frequency_hz")) { flicker_params.wave_frequency_hz = fp["wave_frequency_hz"].get_value(); } + if (fp.contains("noise_amplitude_ratio")) { flicker_params.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value(); } + if (fp.contains("noise_frequency_hz")) { flicker_params.noise_frequency_hz = fp["noise_frequency_hz"].get_value(); } + } } void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) { @@ -1479,9 +1515,10 @@ 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. + // Sumem també l'offset estàtic configurat al YAML del personatge. return { - .x = getPosX() + (static_cast(sprite_w) * 0.5F), - .y = getPosY() - jump_z_ + (static_cast(sprite_h) * 0.5F), + .x = getPosX() + (static_cast(sprite_w) * 0.5F) + static_cast(light_offset_x), + .y = getPosY() - jump_z_ + (static_cast(sprite_h) * 0.5F) + static_cast(light_offset_y), }; } diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index d7b535e..149b59c 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -7,6 +7,7 @@ #include #include +#include "core/rendering/lighting.hpp" #include "core/rendering/sprite/animated_sprite.hpp" #include "core/rendering/surface.hpp" #include "external/fkyaml_node.hpp" @@ -125,12 +126,25 @@ 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, 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. + // del bloc opcional `light: { radius: N, flicker: bool, color: [r,g,b], offset: [x,y] }` + // 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; `light_color_*` són el tint quan la sala usa mode + // additive (en subtractive s'ignora); `light_offset_*` desplaça el + // centre del cercle respecte el centre del sprite (Y creix avall: + // `y: -3` puja la llum 3 px). inline static int light_radius = 0; inline static bool light_flicker = false; + inline static std::uint8_t light_color_r = 255; + inline static std::uint8_t light_color_g = 255; + inline static std::uint8_t light_color_b = 255; + inline static int light_offset_x = 0; + inline static int light_offset_y = 0; + // Paràmetres del flicker resolts per al jugador (combinació del + // global + override del YAML del personatge + override de sala). + // Només té efecte si `light_flicker` és true. + inline static Lighting::FlickerConfig flicker_params{}; // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. static void loadFrom(const std::string& yaml_path); @@ -146,6 +160,12 @@ 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; } + [[nodiscard]] static auto getLightColorR() -> std::uint8_t { return light_color_r; } + [[nodiscard]] static auto getLightColorG() -> std::uint8_t { return light_color_g; } + [[nodiscard]] static auto getLightColorB() -> std::uint8_t { return light_color_b; } + [[nodiscard]] static auto getLightOffsetX() -> int { return light_offset_x; } + [[nodiscard]] static auto getLightOffsetY() -> int { return light_offset_y; } + [[nodiscard]] static auto getFlickerParams() -> const Lighting::FlickerConfig& { return flicker_params; } // 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 diff --git a/source/game/map/map.cpp b/source/game/map/map.cpp index 9bdcd65..e4986c4 100644 --- a/source/game/map/map.cpp +++ b/source/game/map/map.cpp @@ -1,5 +1,6 @@ #include "game/map/map.hpp" +#include #include #include #include @@ -49,32 +50,99 @@ namespace Map { return yaml_key; } + // Parser comú d'un color YAML 3-tupla a bytes. Retorna nullopt + // si el node no és una seqüència de 3 elements. Usat pels + // overrides de sala i, en `entity_manager_loadlevel.cpp`, també + // pels defaults de tipus i overrides de spawn (passa pel mateix + // helper per coherència). `clamp` defensiu per a YAMLs que + // entreguen valors fora de 0..255. + auto parseColorTriplet(const fkyaml::node& node) -> std::optional> { + if (!node.is_sequence() || node.size() != 3) { return std::nullopt; } + auto clamp = [](int v) { return static_cast(std::clamp(v, 0, 255)); }; + return std::array{ + clamp(node[0].get_value()), + clamp(node[1].get_value()), + clamp(node[2].get_value()), + }; + } + + // Parser comú d'una 2-tupla d'enters (offset de llum). Retorna + // nullopt si el node no és una seqüència de 2 elements. Sense + // clamp: els offsets negatius són legítims (Y creix cap avall, + // així que `y: -3` puja la llum 3 px). + auto parseIntPair(const fkyaml::node& node) -> std::optional> { + if (!node.is_sequence() || node.size() != 2) { return std::nullopt; } + return std::array{ + node[0].get_value(), + node[1].get_value(), + }; + } + + // Llegeix el sub-bloc opcional `flicker_params:` d'un emissor de + // sala. Camps individuals — qualsevol absent conserva el valor + // del global (`Lighting::flickerConfig()`) o de la capa que ho + // precedeixi (tipus / sala). + void readFlickerOverrides(const fkyaml::node& fp, RoomEmitterOverride& ov) { + if (fp.contains("wave_amplitude_ratio")) { ov.flicker_wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value(); } + if (fp.contains("wave_frequency_hz")) { ov.flicker_wave_frequency_hz = fp["wave_frequency_hz"].get_value(); } + if (fp.contains("noise_amplitude_ratio")) { ov.flicker_noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value(); } + if (fp.contains("noise_frequency_hz")) { ov.flicker_noise_frequency_hz = fp["noise_frequency_hz"].get_value(); } + } + + // Llegeix una entrada del mapping `lighting.emitters.` en + // qualsevol de les seves 3 formes (bool curt o mapping detallat). + // Extret per mantenir `loadLightingBlock` sota el llindar de + // complexitat cognitiva de clang-tidy. + auto readEmitterEntry(const fkyaml::node& val) -> RoomEmitterOverride { + RoomEmitterOverride ov; + if (val.is_boolean()) { + ov.enabled = val.get_value(); + return ov; + } + if (!val.is_mapping()) { return ov; } + ov.enabled = true; + if (val.contains("radius")) { ov.radius = val["radius"].get_value(); } + if (val.contains("flicker")) { ov.flicker = val["flicker"].get_value(); } + if (val.contains("color")) { ov.color = parseColorTriplet(val["color"]); } + if (val.contains("offset")) { ov.offset = parseIntPair(val["offset"]); } + if (val.contains("flicker_params")) { readFlickerOverrides(val["flicker_params"], ov); } + return ov; + } + + // Llegeix la subclau `mode:` del bloc `lighting:` i actualitza + // `lighting_mode`. Avisa via Log si el valor no és reconegut. + // Extret de `loadLightingBlock` per la mateixa raó. + void readLightingMode(const fkyaml::node& lighting) { + if (!lighting.contains("mode")) { return; } + const auto MODE_STR = lighting["mode"].get_value(); + if (MODE_STR == "additive") { + lighting_mode = Lighting::Mode::ADDITIVE; + } else if (MODE_STR == "subtractive") { + lighting_mode = Lighting::Mode::SUBTRACTIVE; + } else { + Log::warn("Map", "lighting.mode '{}' desconegut — s'usa subtractive", MODE_STR); + } + } + // Llegeix el bloc opcional `lighting:` del YAML d'una sala i - // actualitza `light_level` (si declara `level`) i el mapping - // `g_room_emitters`. Idempotent: cada `Map::loadFrom` neteja el - // mapping abans de poblar-lo, així no s'arrosseguen overrides + // actualitza `light_level`, `lighting_mode` i el mapping + // `g_room_emitters`. Idempotent: cada `Map::loadFrom` reseteja + // tot l'estat abans de poblar-lo, així no s'arrosseguen valors // d'una sala anterior. void loadLightingBlock(const fkyaml::node& yaml) { g_room_emitters.clear(); + lighting_mode = Lighting::Mode::SUBTRACTIVE; if (!yaml.contains("lighting")) { return; } const auto& lighting = yaml["lighting"]; if (lighting.contains("level")) { light_level = lighting["level"].get_value(); } + readLightingMode(lighting); if (!lighting.contains("emitters") || !lighting["emitters"].is_mapping()) { return; } const auto& em = lighting["emitters"]; for (auto it = em.begin(); it != em.end(); ++it) { const auto KEY = normalizeEmitterKey(it.key().get_value()); - const auto& val = it.value(); - RoomEmitterOverride ov; - if (val.is_boolean()) { - ov.enabled = val.get_value(); - } else if (val.is_mapping()) { - ov.enabled = true; - if (val.contains("radius")) { ov.radius = val["radius"].get_value(); } - if (val.contains("flicker")) { ov.flicker = val["flicker"].get_value(); } - } - g_room_emitters[KEY] = ov; + g_room_emitters[KEY] = readEmitterEntry(it.value()); } } } // namespace diff --git a/source/game/map/map.hpp b/source/game/map/map.hpp index 3e84868..f001f55 100644 --- a/source/game/map/map.hpp +++ b/source/game/map/map.hpp @@ -2,11 +2,15 @@ #include +#include +#include #include #include #include #include +#include "core/rendering/lighting.hpp" + // Mapa d'una piràmide: sala rectangular amb marcador superior, muros pels // quatre costats i graella de sarcòfags envoltada per un passadís a cada // cantó. La geometria (graella, pitch, amplada de murs) es carrega des del @@ -60,6 +64,13 @@ namespace Map { // al root es manté per compatibilitat enrere. inline float light_level = 1.0F; + // Mode de composició de la pasada d'il·luminació (subtractive per + // defecte; additive si la sala declara `lighting.mode: additive`). + // Veure el comentari de `core/rendering/lighting.hpp` per a la + // semàntica de cada mode. En subtractive, els colors dels emissors + // s'ignoren — el camí és el de sempre. + inline Lighting::Mode lighting_mode = Lighting::Mode::SUBTRACTIVE; + // Override d'emissor declarat al bloc opcional `lighting.emitters.` // de la sala. Tres formes acceptades al YAML: // : false → desactiva la llum per a tot el tipus @@ -80,6 +91,22 @@ namespace Map { bool enabled{true}; std::optional radius; std::optional flicker; + // Color emès en mode additive. `nullopt` → conservar el color + // del tipus (default blanc 255,255,255 si el tipus no en declara). + // La forma YAML és `color: [r, g, b]` (bytes 0..255). + std::optional> color; + // Offset del centre del cercle de llum respecte el centre del + // sprite (píxels enters; Y creix avall). `nullopt` → conservar + // l'offset del tipus (default [0, 0]). Forma YAML: `offset: [x, y]`. + std::optional> offset; + // Overrides parcials per cada camp del `FlickerConfig` (ràtio + // d'amplitud i freqüència del wave + noise). `nullopt` → no + // toca aquell camp (conserva el del tipus, que al seu torn pot + // ser el global). Forma YAML: `flicker_params: { wave_frequency_hz: 1.0, ... }`. + std::optional flicker_wave_amplitude_ratio; + std::optional flicker_wave_frequency_hz; + std::optional flicker_noise_amplitude_ratio; + std::optional flicker_noise_frequency_hz; }; // Consulta l'override de sala per a un tipus d'entitat (clau singular, diff --git a/source/game/scenes/game_scene.cpp b/source/game/scenes/game_scene.cpp index 1a93382..bc8d7ad 100644 --- a/source/game/scenes/game_scene.cpp +++ b/source/game/scenes/game_scene.cpp @@ -250,6 +250,19 @@ namespace { } if (room->radius.has_value()) { Player::light_radius = *room->radius; } if (room->flicker.has_value()) { Player::light_flicker = *room->flicker; } + if (room->color.has_value()) { + Player::light_color_r = (*room->color)[0]; + Player::light_color_g = (*room->color)[1]; + Player::light_color_b = (*room->color)[2]; + } + if (room->offset.has_value()) { + Player::light_offset_x = (*room->offset)[0]; + Player::light_offset_y = (*room->offset)[1]; + } + if (room->flicker_wave_amplitude_ratio.has_value()) { Player::flicker_params.wave_amplitude_ratio = *room->flicker_wave_amplitude_ratio; } + if (room->flicker_wave_frequency_hz.has_value()) { Player::flicker_params.wave_frequency_hz = *room->flicker_wave_frequency_hz; } + if (room->flicker_noise_amplitude_ratio.has_value()) { Player::flicker_params.noise_amplitude_ratio = *room->flicker_noise_amplitude_ratio; } + if (room->flicker_noise_frequency_hz.has_value()) { Player::flicker_params.noise_frequency_hz = *room->flicker_noise_frequency_hz; } } } // namespace @@ -581,6 +594,14 @@ void GameScene::loadRoom(const std::string& yaml_path) { // CLOSED amb visited buit, momies i altres actors a la posició inicial. config_.map_yaml = yaml_path; Map::loadFrom(yaml_path); + // Refresquem els statics del Player a partir del YAML i tornem a + // aplicar l'override d'aquesta sala. Sense això, una sala amb + // `players: false` deixa `light_radius=0` i, en transicionar a una + // altra sala que no toqui el camp (o digui `players: true`), els + // jugadors no tornen a emetre — l'override actual és "additiu" + // sobre els statics existents, no un reset. + Player::loadFrom(config_.player_yaml); + applyRoomLightOverrideToPlayer(); // Tilemap pre-blita els tiles a un buffer ARGB intern al ctor — sense // reconstruir-lo, la imatge de la sala anterior es queda fixada per a // sempre. PaintLayer captura `tileset_path_`/`tileset_cols_` per valor @@ -1040,20 +1061,28 @@ void GameScene::applyLightingPass() { // 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(); + const Lighting::Color PLAYER_COLOR{ + .r = Player::getLightColorR(), + .g = Player::getLightColorG(), + .b = Player::getLightColorB(), + }; + const Lighting::FlickerConfig PLAYER_FP = Player::getFlickerParams(); 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, .seed = p->getLightSeed(), .flicker = PLAYER_FLICKER}); + emitters.push_back({.cx = C.x, .cy = C.y, .radius = PLAYER_R, .seed = p->getLightSeed(), .flicker = PLAYER_FLICKER, .color = PLAYER_COLOR, .flicker_params = PLAYER_FP}); } 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, .seed = e->getLightSeed(), .flicker = e->getLightFlicker()}); + const Lighting::Color COL{.r = e->getLightColorR(), .g = e->getLightColorG(), .b = e->getLightColorB()}; + emitters.push_back({.cx = C.x, .cy = C.y, .radius = R, .seed = e->getLightSeed(), .flicker = e->getLightFlicker(), .color = COL, .flicker_params = e->getFlickerParams()}); } Lighting::apply(Screen::get().getCanvasArgb(), Screen::get().getCanvasWidth(), Screen::get().getCanvasHeight(), + Map::lighting_mode, Map::light_level, emitters); } diff --git a/source/utils/defaults.hpp b/source/utils/defaults.hpp index 7f11e7f..194d096 100644 --- a/source/utils/defaults.hpp +++ b/source/utils/defaults.hpp @@ -111,20 +111,36 @@ namespace Defaults { // 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 { + // Defaults del gradient subtractive (mode històric). Les + // intensitats són multiplicadors 0..1 que es multipliquen sobre + // el píxel: 1.0 = no toca, 0.0 = negre. 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; + // Defaults del gradient additive. Les intensitats són "alfa" + // del color afegit (0..1): 1.0 = afegeix el color sencer i + // satura, 0.0 = no afegeix res. Valors baixos a propòsit perquè + // l'additive es nota fort molt fàcilment — només un toc de tint + // cromàtic al voltant de l'emissor. + constexpr float INNER_RATIO_ADDITIVE = 0.5F; + constexpr float MID_RATIO_ADDITIVE = 0.85F; + constexpr float INNER_INTENSITY_ADDITIVE = 0.15F; + constexpr float MID_INTENSITY_ADDITIVE = 0.10F; + constexpr float OUTER_INTENSITY_ADDITIVE = 0.05F; // 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; + // per a la semàntica de cada camp. Les amplituds són **ràtios** + // sobre el radi base de l'emissor: així el feel del flicker és + // independent del radi. Els valors per defecte conserven el feel + // anterior per a una torxa de radi 40 (2/40 = 0.05, 1/40 = 0.025). + constexpr float FLICKER_WAVE_AMPLITUDE_RATIO = 0.05F; constexpr float FLICKER_WAVE_FREQUENCY_HZ = 3.0F; - constexpr float FLICKER_NOISE_AMPLITUDE_PX = 1.0F; + constexpr float FLICKER_NOISE_AMPLITUDE_RATIO = 0.025F; constexpr float FLICKER_NOISE_FREQUENCY_HZ = 7.0F; } // namespace Lighting