Compare commits

...
32 Commits
Author SHA1 Message Date
JailDesigner 8eba52353b chore(version): bump 0.44.0 -> 0.45.0 2026-05-13 19:44:47 +02:00
JailDesigner 15a4016407 Merge branch 'feature/lighting-additive' 2026-05-13 19:44:31 +02:00
JailDesigner 2272d3a37a feat(lighting): flicker proporcional al radi amb overrides per emissor 2026-05-13 19:38:34 +02:00
JailDesigner 3a42c7958a feat(lighting): offset del centre de la llum per emissor 2026-05-13 18:26:19 +02:00
JailDesigner dacc4a9909 feat(lighting): corba additive separada al config 2026-05-13 17:53:32 +02:00
JailDesigner 771ff1f6cd feat(lighting): mode additive amb color per emissor 2026-05-13 14:11:51 +02:00
JailDesigner 24609d6c5c Merge branch 'feature/animation-loader-unification' 2026-05-13 13:27:51 +02:00
JailDesigner 9d0dde38f9 feat(lighting): override d'emissors per sala i per spawn 2026-05-13 12:52:08 +02:00
JailDesigner b76a3eb1f3 refactor(entities): unifica loader d'animacio via AnimationComponent::fromYaml i elimina fps: 2026-05-13 10:06:56 +02:00
JailDesigner a779533d61 fix(release): mou GIFs a la carpeta de l'entitat i silencia -Wcomment a director 2026-05-13 08:18:53 +02:00
JailDesigner bc6ad6f718 chore(version): bump 0.43.0 -> 0.44.0 2026-05-12 22:41:43 +02:00
JailDesigner 9bd5e7db33 Merge branch 'feature/lighting' 2026-05-12 22:40:55 +02:00
JailDesigner 5cc8e349a5 feat(lighting): seed estable per emissor (no deriva pel moviment) 2026-05-12 22:37:27 +02:00
JailDesigner 0046d526b0 fix(lighting): llig flicker tambe del YAML del jugador 2026-05-12 22:26:17 +02:00
JailDesigner fc9947b87f feat(data): flicker tambe a jugadors i momies 2026-05-12 22:17:36 +02:00
JailDesigner 35aaa6c519 feat(lighting): flicker tipus foc per antorxes 2026-05-12 22:07:25 +02:00
JailDesigner 3195746189 feat(data): activa la llum del jugador, antorxes i mòmies + sala test 2026-05-12 21:14:16 +02:00
JailDesigner d1d5679d14 feat(game_scene): wire lighting pass entre actors i HUD 2026-05-12 21:12:06 +02:00
JailDesigner ebb161f1f5 feat(entities): light_radius + getLightCenter a Entity i Player 2026-05-12 21:06:01 +02:00
JailDesigner 68be8e55ec feat(map): Map::light_level per sala des del YAML 2026-05-12 20:58:30 +02:00
JailDesigner 5ed963b53d feat(rendering): lighting module CPU + gradient config YAML 2026-05-12 20:56:27 +02:00
JailDesigner 5e5ff835a9 Merge branch 'feature/player-stats-bus' 2026-05-12 19:55:50 +02:00
JailDesigner f5d251c33b feat(stats): Player com a font de veritat de vides/score/tresors i sarcs amb recompensa 2026-05-12 19:51:42 +02:00
JailDesigner d186f66c2c art(player): paletes d'immunitat per-personatge (wash, ice, gold) 2026-05-12 19:10:00 +02:00
JailDesigner da361a1452 feat(editor): mostra mommies i altres dynamic hazards al mode editor 2026-05-12 13:58:45 +02:00
JailDesigner a43ae21014 feat(editor): preview del brush d'entitat i fons del picker mes clar 2026-05-12 13:35:01 +02:00
JailDesigner 6749780356 feat(entities): afegeix stone i column com a muros decoratius 2026-05-12 13:03:35 +02:00
JailDesigner 1c49248b72 art(debris): afegeix column.gif i stone.gif 2026-05-12 11:06:42 +02:00
JailDesigner 63a03ae632 refactor(pause): elimina pausa F11 (cobreix-la el menú de servei) 2026-05-12 10:52:46 +02:00
JailDesigner a8bff31a7c chore(fonts): regenera ari-w9500 sense accents 2026-05-12 10:39:10 +02:00
JailDesigner 793e87a203 feat(hud): parpadeig configurable als prompts inactius 2026-05-12 10:27:23 +02:00
JailDesigner 642e0653b2 fix(sarcophagus): inclou les cantonades diagonals al perímetre 2026-05-12 09:00:56 +02:00
75 changed files with 2706 additions and 660 deletions

No files matched your search

+2
View File
@@ -52,6 +52,7 @@ set(ENTITY_SOURCES_BASE
source/game/entities/concrete/floor_trap.cpp source/game/entities/concrete/floor_trap.cpp
source/game/entities/concrete/hole.cpp source/game/entities/concrete/hole.cpp
source/game/entities/concrete/dust.cpp source/game/entities/concrete/dust.cpp
source/game/entities/concrete/solid_props.cpp
) )
# Entitats que criden directament símbols de Player (com WhipAttack via # Entitats que criden directament símbols de Player (com WhipAttack via
@@ -80,6 +81,7 @@ set(APP_SOURCES
source/core/input/mouse.cpp source/core/input/mouse.cpp
source/core/rendering/screen.cpp source/core/rendering/screen.cpp
source/core/rendering/canvas.cpp source/core/rendering/canvas.cpp
source/core/rendering/lighting.cpp
source/core/rendering/notifier.cpp source/core/rendering/notifier.cpp
source/core/rendering/render_info.cpp source/core/rendering/render_info.cpp
source/core/rendering/gif.cpp source/core/rendering/gif.cpp
+2 -2
View File
@@ -8,6 +8,6 @@ picker:
# Colors ARGB en hex. Accepta enter (0xFF2244FF) o cadena ("#FF2244FF"). # Colors ARGB en hex. Accepta enter (0xFF2244FF) o cadena ("#FF2244FF").
colors: colors:
border: "0xFF2244FF" # vora 1 px que envolta canvas i picker border: "0xFF6A86C8" # vora 1 px que envolta canvas i picker (blau suau)
picker_bg: "0xFF101824" # fons del picker picker_bg: "0xFFC8C0D8" # fons del picker (lavanda clara)
picker_hover: "0xFFFFFFFF" # vora 1 px del tile sota el cursor picker_hover: "0xFFFFFFFF" # vora 1 px del tile sota el cursor
-3
View File
@@ -35,9 +35,6 @@ keys:
- id: toggle_render_info - id: toggle_render_info
code: "F10" code: "F10"
desc: "Cicle de l'overlay de debug: apagat / dalt / baix" desc: "Cicle de l'overlay de debug: apagat / dalt / baix"
- id: pause
code: "F11"
desc: "Pausa el joc (o botó START del mando)"
- id: menu_toggle - id: menu_toggle
code: "F12" code: "F12"
desc: "Obri / tanca el menú de servei" desc: "Obri / tanca el menú de servei"
+56
View File
@@ -0,0 +1,56 @@
# 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
#
# 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
inner_intensity: 1.0
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 + (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).
# 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_ratio: 0.05 # 5% del radi (per a radi 40 → ±2 px, feel actual)
wave_frequency_hz: 3.0
noise_amplitude_ratio: 0.025 # 2.5% (per a radi 40 → ±1 px, feel actual)
noise_frequency_hz: 7.0
+6 -11
View File
@@ -73,6 +73,12 @@ hud:
entry_ms: 800 # durada de l'animació d'entrada dels tres blocs entry_ms: 800 # durada de l'animació d'entrada dels tres blocs
flash_ms: 250 # durada del flash quan un jugador rep un hit flash_ms: 250 # durada del flash quan un jugador rep un hit
shake_ms: 300 # durada del shake que acompanya el flash shake_ms: 300 # durada del shake que acompanya el flash
# Parpadeig dels prompts inactius ("Polsa start..." / "Espera..."). Un
# únic temporitzador global al Hud sincronitza P1 i P2 i no es reinicia
# quan el text canvia entre CAN_JOIN i WAIT. cycle<=0 o ratio>=1 →
# sempre visible (disable trivial).
blink_cycle_ms: 1000 # durada del cicle complet
blink_visible_ratio: 0.8 # proporció del cicle visible (0..1)
# Famílies de color del HUD per bloc. Cada bloc té `label` (to apagat # Famílies de color del HUD per bloc. Cada bloc té `label` (to apagat
# per etiquetes), `value` (to viu per a la xifra) i `shadow` (to fosc # per etiquetes), `value` (to viu per a la xifra) i `shadow` (to fosc
@@ -108,17 +114,6 @@ notifier:
slide_in_curve: outCubic slide_in_curve: outCubic
slide_out_curve: inCubic slide_out_curve: inCubic
# Pausa graduada de la GameScene: el món no para d'un colp, s'atura amb
# easing i torna a arrancar amb una altra corba. Els timings es poden
# afinar independentment, i les corbes permeten estils diferents entrant
# i sortint (p.ex. frenar suau i arrancar seca). Corbes disponibles:
# linear, outQuad, inQuad, inOutQuad, outCubic, inCubic.
pause:
enter_ms: 400
exit_ms: 250
enter_curve: outCubic
exit_curve: inCubic
# Sons del menú de servei. Els fitxers viuen a data/audio/sounds/. SELECT # Sons del menú de servei. Els fitxers viuen a data/audio/sounds/. SELECT
# acompanya navegació (amunt/avall) i back; ACCEPT acompanya modificació # acompanya navegació (amunt/avall) i back; ACCEPT acompanya modificació
# d'opció (esquerra/dreta/retorn) o entrada a un submenú. # d'opció (esquerra/dreta/retorn) o entrada a un submenú.
Binary file not shown.

After

Width:  |  Height:  |  Size: 805 B

+22
View File
@@ -0,0 +1,22 @@
name: column
type: decoration
editable: true
# Sub-picker de frames a l'editor: 6 variants (atlas 2×3 de sub-sprites 16×32).
editable_variants: true
sprite:
path: column.gif
size: [16, 32]
# Ancoratge "feet": el tile [col, row] del spawn és la base de la columna.
# El sprite de 16×32 sobresurt 16 px cap amunt sense afectar col·lisió ni
# z-order (la caixa lògica és el tile inferior).
anchor: feet
tile_size: [1, 1]
collision:
size: [16, 16]
# Mur infranquejable al salt: el jugador no pot passar pel damunt.
height: 999
flags:
collides: true
interactable: false
animations:
default: { frames: [0], frame_ms: 1000, loop: false }
+3 -3
View File
@@ -21,9 +21,9 @@ flags:
collides: true collides: true
interactable: true interactable: true
animations: animations:
closed: { frames: [1], fps: 1, loop: true } closed: { frames: [1], frame_ms: 1000, loop: true }
opening: { frames: [0], fps: 6, loop: false } opening: { frames: [0], frame_ms: 167, loop: false }
open: { frames: [0], fps: 1, loop: true } open: { frames: [0], frame_ms: 1000, loop: true }
behaviour: behaviour:
states: [CLOSED, OPENING, OPEN] states: [CLOSED, OPENING, OPEN]
initial_state: CLOSED initial_state: CLOSED
+3 -3
View File
@@ -16,7 +16,7 @@ flags:
# apropiada via `setVariant` abans del primer tick. # apropiada via `setVariant` abans del primer tick.
# jump: frames 0..6 (7 frames) → reproduït al despegue. # jump: frames 0..6 (7 frames) → reproduït al despegue.
# land: frames 7..12 (6 frames) → reproduït a l'aterratge. # land: frames 7..12 (6 frames) → reproduït a l'aterratge.
# fps: 10 = 100 ms per frame. # frame_ms: 67 ≈ 15 fps (1000 / 67 ≈ 14.9 fps).
animations: animations:
jump: { frames: [0, 1, 2, 3, 4, 5, 6], fps: 15, loop: false } jump: { frames: [0, 1, 2, 3, 4, 5, 6], frame_ms: 67, loop: false }
land: { frames: [7, 8, 9, 10, 11, 12], fps: 15, loop: false } land: { frames: [7, 8, 9, 10, 11, 12], frame_ms: 67, loop: false }
+2 -2
View File
@@ -13,8 +13,8 @@ flags:
collides: false collides: false
interactable: false interactable: false
animations: animations:
inactive: { frames: [0], fps: 1, loop: true } inactive: { frames: [0], frame_ms: 1000, loop: true }
active: { frames: [0], fps: 6, loop: true } active: { frames: [0], frame_ms: 167, loop: true }
cycle: cycle:
active_duration: 0.3 active_duration: 0.3
inactive_duration: 2.0 inactive_duration: 2.0
+1 -1
View File
@@ -22,6 +22,6 @@ flags:
# lectura genèrica de flags. # lectura genèrica de flags.
triggers_on_overlap: true triggers_on_overlap: true
animations: animations:
idle: { frames: [0], fps: 1, loop: true } idle: { frames: [0], frame_ms: 1000, loop: true }
stats: stats:
damage: 0 damage: 0
+19 -5
View File
@@ -32,11 +32,18 @@ flags:
# no decideix flip per direcció (només selecciona quina animació mostrar). # no decideix flip per direcció (només selecciona quina animació mostrar).
# La momia no té variants `stand_*` perquè mai no para. # La momia no té variants `stand_*` perquè mai no para.
animations: animations:
walk_south: { frames: [0, 1, 2, 3], fps: 8, loop: true } walk_south: { frames: [0, 1, 2, 3], frame_ms: 125, loop: true }
walk_north: { frames: [4, 5, 6, 7], fps: 8, loop: true } walk_north: { frames: [4, 5, 6, 7], frame_ms: 125, loop: true }
walk_east: { frames: [8, 9, 10, 11], fps: 8, loop: true } walk_east: { frames: [8, 9, 10, 11], frame_ms: 125, loop: true }
walk_west: { frames: [8, 9, 10, 11], fps: 8, loop: true, flip_h: true } walk_west: { frames: [8, 9, 10, 11], frame_ms: 125, loop: true, flip_h: true }
raise: { frames: [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27], fps: 8 , loop: false } # Alçament: els 15 primers frames passen ràpid (125 ms cadascun) i el
# darrer aguanta 1 s perquè el jugador pugui veure la mòmia ja alçada
# abans que comenci a deambular. `frame_ms` accepta una llista per-frame
# que mapeja 1:1 contra `frames`.
raise:
frames: [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27]
frame_ms: [125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 1000]
loop: false
ai: ai:
type: corridor_wanderer type: corridor_wanderer
direction_choice: random direction_choice: random
@@ -68,3 +75,10 @@ hurt:
# So opcional: basename del fitxer dins `data/audio/sounds/`. Es dispara # 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. # un sol cop per fuetada quan la mòmia entra a HURT. Si manca, no sona.
sound: mummy_hurt.wav 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
flicker: true
-35
View File
@@ -1,35 +0,0 @@
JASC-PAL
0100
32
0 0 0
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
+9 -1
View File
@@ -139,4 +139,12 @@ death:
immunity: immunity:
duration_ms: 3000 duration_ms: 3000
blink_period_ms: 100 blink_period_ms: 100
palette: immune 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
flicker: true
+19
View File
@@ -0,0 +1,19 @@
JASC-PAL
0100
16
0 0 0 255
150 133 88 255
253 233 185 255
237 215 165 255
214 193 145 255
255 252 208 255
252 234 189 255
239 222 176 255
175 190 128 255
173 175 115 255
159 159 104 255
246 211 157 255
229 192 139 255
213 177 127 255
243 226 184 255
0 0 0 255
+19
View File
@@ -0,0 +1,19 @@
JASC-PAL
0100
16
0 0 0 255
102 118 132 255
226 238 247 255
206 216 222 255
179 190 198 255
245 255 255 255
223 239 253 255
208 224 238 255
124 190 178 255
125 170 162 255
109 151 150 255
221 210 210 255
200 187 189 255
181 169 175 255
213 229 247 255
0 0 0 255
+19
View File
@@ -0,0 +1,19 @@
JASC-PAL
0100
16
0 0 0 255
190 190 190 255
255 255 255 255
255 248 239 255
239 233 226 255
255 255 255 255
255 255 255 255
254 254 254 255
184 244 213 255
193 229 202 255
184 216 198 255
255 242 225 255
255 228 213 255
251 216 207 255
255 255 255 255
0 0 0 255
+9 -1
View File
@@ -139,4 +139,12 @@ death:
immunity: immunity:
duration_ms: 3000 duration_ms: 3000
blink_period_ms: 100 blink_period_ms: 100
palette: immune 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
flicker: true
+19
View File
@@ -0,0 +1,19 @@
JASC-PAL
0100
16
0 0 0 255
150 133 88 255
202 176 123 255
191 165 112 255
181 156 103 255
217 158 115 255
194 142 101 255
188 143 98 255
214 193 145 255
196 176 128 255
181 161 115 255
246 211 157 255
229 192 139 255
213 177 127 255
243 226 184 255
0 0 0 255
+19
View File
@@ -0,0 +1,19 @@
JASC-PAL
0100
16
0 0 0 255
102 118 132 255
166 169 171 255
152 156 158 255
140 144 147 255
191 144 160 255
161 125 146 255
152 127 142 255
179 190 198 255
157 168 179 255
139 151 164 255
221 210 210 255
200 187 189 255
181 169 175 255
213 229 247 255
0 0 0 255
+19
View File
@@ -0,0 +1,19 @@
JASC-PAL
0100
16
0 0 0 255
190 190 190 255
235 220 205 255
226 212 197 255
219 205 191 255
255 194 197 255
248 184 193 255
238 188 189 255
239 233 226 255
226 220 216 255
214 210 208 255
255 242 225 255
255 228 213 255
251 216 207 255
255 255 255 255
0 0 0 255
+1 -1
View File
@@ -13,7 +13,7 @@ flags:
collides: true collides: true
interactable: false interactable: false
animations: animations:
roll: { frames: [0], fps: 10, loop: true } roll: { frames: [0], frame_ms: 100, loop: true }
ai: ai:
type: straight_line type: straight_line
initial_direction: right initial_direction: right
+9 -3
View File
@@ -19,9 +19,9 @@ flags:
collides: true collides: true
interactable: true interactable: true
animations: animations:
closed: { frames: [0], fps: 1, loop: true } closed: { frames: [0], frame_ms: 1000, loop: true }
opening: { frames: [1, 2], fps: 6, loop: false } opening: { frames: [1, 2], frame_ms: 167, loop: false }
open: { frames: [3], fps: 1, loop: true } open: { frames: [3], frame_ms: 1000, loop: true }
behaviour: behaviour:
states: [CLOSED, OPENING, OPEN] states: [CLOSED, OPENING, OPEN]
initial_state: CLOSED initial_state: CLOSED
@@ -40,3 +40,9 @@ behaviour:
sound: sound:
opening: open_sarc.wav opening: open_sarc.wav
opening_pitch_spread: 0.5 opening_pitch_spread: 0.5
# Recompensa que rep el jugador que pinta el tile que tanca el perímetre
# (el GameScene la reparteix consumint `sarcophagus_opened`). Si es lleva
# aquest bloc o un dels camps, el camp absent val 0 i no s'aplica.
reward:
points: 100
treasures: 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+22
View File
@@ -0,0 +1,22 @@
name: stone
type: decoration
editable: true
# Sub-picker de frames a l'editor: ofereix una graella amb les 24 variants
# del atlas (6×4 sub-sprites 16×16) per escollir quina representa cada
# instància col·locada.
editable_variants: true
sprite:
path: stone.gif
size: [16, 16]
tile_size: [1, 1]
collision:
size: [16, 16]
# Mur infranquejable al salt: el jugador no la pot saltar per damunt.
height: 999
flags:
collides: true
interactable: false
# Animació "default" amb un sol frame: el spawn YAML pot sobreescriure-la
# via `frame: N` per fixar la variant escollida al editor.
animations:
default: { frames: [0], frame_ms: 1000, loop: false }
+16 -1
View File
@@ -15,4 +15,19 @@ flags:
# El torch.gif és un strip vertical 16x64 amb 4 frames apilats. # El torch.gif és un strip vertical 16x64 amb 4 frames apilats.
# El render clipa cada frame per `anim.currentFrame()` × 16 píxels en Y. # El render clipa cada frame per `anim.currentFrame()` × 16 píxels en Y.
animations: animations:
idle: { frames: [0, 1, 2, 3], fps: 8, loop: true } idle: { frames: [0, 1, 2, 3], frame_ms: 125, loop: true }
# 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. `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]
+2 -2
View File
@@ -13,8 +13,8 @@ flags:
collides: false collides: false
interactable: false interactable: false
animations: animations:
inactive: { frames: [0], fps: 1, loop: true } inactive: { frames: [0], frame_ms: 1000, loop: true }
active: { frames: [0], fps: 6, loop: true } active: { frames: [0], frame_ms: 167, loop: true }
cycle: cycle:
active_duration: 0.5 active_duration: 0.5
inactive_duration: 1.5 inactive_duration: 1.5
+7 -7
View File
@@ -2,7 +2,7 @@
# Generado con font_gen.py # Generado con font_gen.py
box_width 12 box_width 12
box_height 14 box_height 13
columns 15 columns 15
# codepoint_decimal ancho_visual # codepoint_decimal ancho_visual
@@ -105,8 +105,8 @@ columns 15
193 8 # Á 193 8 # Á
200 7 # È 200 7 # È
201 7 # É 201 7 # É
204 4 # Ì 204 3 # Ì
205 4 # Í 205 3 # Í
210 9 # Ò 210 9 # Ò
211 9 # Ó 211 9 # Ó
217 7 # Ù 217 7 # Ù
@@ -117,8 +117,8 @@ columns 15
225 7 # á 225 7 # á
232 7 # è 232 7 # è
233 7 # é 233 7 # é
236 4 # ì 236 3 # ì
237 4 # í 237 3 # í
242 7 # ò 242 7 # ò
243 7 # ó 243 7 # ó
249 7 # ù 249 7 # ù
@@ -127,7 +127,7 @@ columns 15
231 7 # ç 231 7 # ç
161 3 # ¡ 161 3 # ¡
191 7 # ¿ 191 7 # ¿
171 10 # « 171 7 # «
187 10 # » 187 7 # »
183 3 # · 183 3 # ·
169 10 # © 169 10 # ©
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+7 -7
View File
@@ -2,7 +2,7 @@
# Generado con font_gen.py # Generado con font_gen.py
box_width 12 box_width 12
box_height 14 box_height 13
columns 15 columns 15
# codepoint_decimal ancho_visual # codepoint_decimal ancho_visual
@@ -105,8 +105,8 @@ columns 15
193 9 # Á 193 9 # Á
200 8 # È 200 8 # È
201 8 # É 201 8 # É
204 4 # Ì 204 3 # Ì
205 4 # Í 205 3 # Í
210 10 # Ò 210 10 # Ò
211 10 # Ó 211 10 # Ó
217 8 # Ù 217 8 # Ù
@@ -117,8 +117,8 @@ columns 15
225 8 # á 225 8 # á
232 8 # è 232 8 # è
233 8 # é 233 8 # é
236 4 # ì 236 3 # ì
237 4 # í 237 3 # í
242 8 # ò 242 8 # ò
243 8 # ó 243 8 # ó
249 8 # ù 249 8 # ù
@@ -127,7 +127,7 @@ columns 15
231 8 # ç 231 8 # ç
161 3 # ¡ 161 3 # ¡
191 8 # ¿ 191 8 # ¿
171 10 # « 171 7 # «
187 10 # » 187 7 # »
183 3 # · 183 3 # ·
169 10 # © 169 10 # ©
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

-1
View File
@@ -23,7 +23,6 @@ input:
scaling_overscan: "Escalat overscan" scaling_overscan: "Escalat overscan"
scaling_integer: "Escalat enter" scaling_integer: "Escalat enter"
press_again_exit: "Prem una altra volta per eixir" press_again_exit: "Prem una altra volta per eixir"
paused: "Pausa"
editor_on: "Editor ON" editor_on: "Editor ON"
editor_off: "Editor OFF" editor_off: "Editor OFF"
editor_save_error: "Editor: error guardant el mapa" editor_save_error: "Editor: error guardant el mapa"
-1
View File
@@ -23,7 +23,6 @@ input:
scaling_overscan: "Scaling overscan" scaling_overscan: "Scaling overscan"
scaling_integer: "Scaling integer" scaling_integer: "Scaling integer"
press_again_exit: "Press again to exit" press_again_exit: "Press again to exit"
paused: "Paused"
editor_on: "Editor ON" editor_on: "Editor ON"
editor_off: "Editor OFF" editor_off: "Editor OFF"
editor_save_error: "Editor: map save failed" editor_save_error: "Editor: map save failed"
+33 -4
View File
@@ -10,11 +10,38 @@
# la cel·la buida (transparent), útil per caselles que ocuparà un actor. # la cel·la buida (transparent), útil per caselles que ocuparà un actor.
pyramid: 1 pyramid: 1
room: 1 room: 2
tileset: data/tilesets/pyramid_1.gif tileset: data/tilesets/pyramid_1.gif
tile_size: 16 tile_size: 16
origin: [-8, 0] origin: [-8, 0]
# 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:
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
# Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl # Í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 # pintable. Un índex per jugador perquè a multijugador local el recorregut
# es distingeix a simple vista. Provisional fins que l'art del pintat # es distingeix a simple vista. Provisional fins que l'art del pintat
@@ -82,6 +109,8 @@ data:
# d'obertura (262/263). La momia al corredor central. L'antorxa al costat # d'obertura (262/263). La momia al corredor central. L'antorxa al costat
# superior esquerre per verificar render d'una entitat decorativa. # superior esquerre per verificar render d'una entitat decorativa.
entities: entities:
- type: torch
position: [4, 2]
- type: torch - type: torch
position: [8, 2] position: [8, 2]
- type: torch - type: torch
@@ -128,8 +157,8 @@ entities:
sarc_position: [2, 4] sarc_position: [2, 4]
- type: sarcophagus - type: sarcophagus
sarc_position: [3, 4] sarc_position: [3, 4]
- type: torch
position: [4, 2]
- type: door - type: door
- type: mummy - type: mummy
position: [10, 11] position: [10, 7]
- type: mummy
position: [7, 9]
+56 -13
View File
@@ -10,11 +10,38 @@
# la cel·la buida (transparent), útil per caselles que ocuparà un actor. # la cel·la buida (transparent), útil per caselles que ocuparà un actor.
pyramid: 1 pyramid: 1
room: 2 room: 1
tileset: data/tilesets/pyramid_1.gif tileset: data/tilesets/pyramid_1.gif
tile_size: 16 tile_size: 16
origin: [-8, 0] 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.<tipus> → 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 # Í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 # pintable. Un índex per jugador perquè a multijugador local el recorregut
# es distingeix a simple vista. Provisional fins que l'art del pintat # 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 # d'obertura (262/263). La momia al corredor central. L'antorxa al costat
# superior esquerre per verificar render d'una entitat decorativa. # superior esquerre per verificar render d'una entitat decorativa.
entities: entities:
- type: torch
position: [4, 2]
- type: torch - type: torch
position: [8, 2] position: [8, 2]
- type: torch - type: torch
position: [12, 2] position: [12, 2]
- type: torch - type: torch
position: [16, 2] position: [16, 2]
- type: sarcophagus
sarc_position: [0, 0]
- type: sarcophagus - type: sarcophagus
sarc_position: [1, 0] sarc_position: [1, 0]
- type: sarcophagus - type: sarcophagus
@@ -118,8 +141,6 @@ entities:
sarc_position: [0, 3] sarc_position: [0, 3]
- type: sarcophagus - type: sarcophagus
sarc_position: [1, 3] sarc_position: [1, 3]
- type: sarcophagus
sarc_position: [2, 3]
- type: sarcophagus - type: sarcophagus
sarc_position: [3, 3] sarc_position: [3, 3]
- type: sarcophagus - type: sarcophagus
@@ -130,11 +151,33 @@ entities:
sarc_position: [2, 4] sarc_position: [2, 4]
- type: sarcophagus - type: sarcophagus
sarc_position: [3, 4] sarc_position: [3, 4]
- type: torch
position: [4, 2]
- type: door - type: door
# Sense `position`: la porta sempre viu al mur trasser. La columna - type: column
# transitable la dicta `geometry.door_col`; el render i la caixa position: [3, 4]
# de col·lisió es deriven d'aquesta dada al loadFromLevel. 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 - type: mummy
position: [10, 7] position: [10, 13]
- type: mummy
position: [7, 9]
+1 -1
View File
@@ -1,6 +1,6 @@
# Configuració de la GameScene: timings i corbes de la FSM de sala # Configuració de la GameScene: timings i corbes de la FSM de sala
# (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria # (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria
# i font del text es reutilitzen del tema UI (data/config/ui.yaml > pause + # i font del text es reutilitzen del tema UI (data/config/ui.yaml >
# notification). Si manca el fitxer o alguna clau, GameScene cau als # notification). Si manca el fitxer o alguna clau, GameScene cau als
# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp. # fallbacks compilats; veure `readSceneConfig` a game_scene.cpp.
+376
View File
@@ -0,0 +1,376 @@
#include "core/rendering/lighting.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <numbers>
#include <vector>
#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<std::uint8_t>& {
static std::vector<std::uint8_t> buf_;
return buf_;
}
// 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,
.inner_intensity = Defaults::Lighting::INNER_INTENSITY,
.mid_intensity = Defaults::Lighting::MID_INTENSITY,
.outer_intensity = Defaults::Lighting::OUTER_INTENSITY,
};
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_ratio = Defaults::Lighting::FLICKER_WAVE_AMPLITUDE_RATIO,
.wave_frequency_hz = Defaults::Lighting::FLICKER_WAVE_FREQUENCY_HZ,
.noise_amplitude_ratio = Defaults::Lighting::FLICKER_NOISE_AMPLITUDE_RATIO,
.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 `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.
// 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<float>;
constexpr float GOLDEN_FRACTION = 0.6180339F;
const float PHASE = static_cast<float>(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 auto R_F = static_cast<float>(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<int>(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 {
const float CLAMPED = std::clamp(v, 0.0F, 1.0F);
return static_cast<std::uint8_t>(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<float>(); }
}
// 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<float>(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<int>(R_MID * R_MID),
.inner_sq = static_cast<int>(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<int>(e.cx);
const int CY = static_cast<int>(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<std::ptrdiff_t>(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 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<std::ptrdiff_t>(y) * width);
const std::uint8_t* lrow = lum + (static_cast<std::ptrdiff_t>(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<std::uint8_t>((SR * L) / 255);
const auto G = static_cast<std::uint8_t>((SG * L) / 255);
const auto B = static_cast<std::uint8_t>((SB * L) / 255);
px[x] = (SRC & 0xFF000000U) |
(static_cast<std::uint32_t>(R) << 16) |
(static_cast<std::uint32_t>(G) << 8) |
static_cast<std::uint32_t>(B);
}
}
}
// 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<std::ptrdiff_t>(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<std::uint8_t>((SR * level) / 255);
const auto G = static_cast<std::uint8_t>((SG * level) / 255);
const auto B = static_cast<std::uint8_t>((SB * level) / 255);
px[x] = (SRC & 0xFF000000U) |
(static_cast<std::uint32_t>(R) << 16) |
(static_cast<std::uint32_t>(G) << 8) |
static_cast<std::uint32_t>(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<int>(e.cx);
const int CY = static_cast<int>(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<std::ptrdiff_t>(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<int>((SRC >> 16) & 0xFF);
const int SG = static_cast<int>((SRC >> 8) & 0xFF);
const int SB = static_cast<int>(SRC & 0xFF);
const auto NR = static_cast<std::uint32_t>(std::min(255, SR + CR));
const auto NG = static_cast<std::uint32_t>(std::min(255, SG + CG));
const auto NB = static_cast<std::uint32_t>(std::min(255, SB + CB));
row[x] = (SRC & 0xFF000000U) | (NR << 16) | (NG << 8) | NB;
}
}
}
} // namespace
auto config() -> const GradientConfig& {
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()) {
Log::warn("Lighting", "no s'ha pogut obrir {} — s'usaran els defaults compilats", yaml_path);
return;
}
try {
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
if (yaml.contains("gradient")) {
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_ratio", fc.wave_amplitude_ratio);
readField(f, "wave_frequency_hz", fc.wave_frequency_hz);
readField(f, "noise_amplitude_ratio", fc.noise_amplitude_ratio);
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());
}
}
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<const Emitter> emitters) {
auto& lum = luminanceBuffer();
const std::size_t TOTAL = static_cast<std::size_t>(width) * static_cast<std::size_t>(height);
if (lum.size() != TOTAL) { lum.resize(TOTAL); }
std::ranges::fill(lum, room);
const auto& cfg = activeSubtractiveConfig();
const float T_S = static_cast<float>(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<const Emitter> emitters) {
darkenCanvas(canvas, width, height, room);
const auto& cfg = activeAdditiveConfig();
const float T_S = static_cast<float>(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<const Emitter> emitters) {
if (canvas == nullptr || width <= 0 || height <= 0) { return; }
// 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; }
if (mode == Mode::ADDITIVE) {
applyAdditive(canvas, width, height, ROOM, emitters);
} else {
applySubtractive(canvas, width, height, ROOM, emitters);
}
}
} // namespace Lighting
+150
View File
@@ -0,0 +1,150 @@
#pragma once
#include <cstdint>
#include <span>
#include <string>
// 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
// modifica cada píxel del canvas en funció de la "llum de la sala" i
// dels cercles emissors al voltant de certes entitats.
//
// 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
//
// 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 {
// 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
// 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};
};
// 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 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_ratio{0.05F};
float wave_frequency_hz{3.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). 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&;
// 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<const Emitter> emitters);
} // namespace Lighting
+1 -1
View File
@@ -72,7 +72,7 @@ class Screen {
// Alpha-blend sobre el canvas: `alpha=0` no fa res, `alpha=255` equival // Alpha-blend sobre el canvas: `alpha=0` no fa res, `alpha=255` equival
// a fillRect. El color entra com a ARGB little-endian; `alpha` és el // a fillRect. El color entra com a ARGB little-endian; `alpha` és el
// factor de mescla amb el que ja hi ha al canvas. Pensat per a overlays // factor de mescla amb el que ja hi ha al canvas. Pensat per a overlays
// (menú, pauses) que volen veure's a través el joc atenuat. // (menú de servei) que volen veure's a través el joc atenuat.
void blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha); void blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha);
// Hook invocat al principi de `present()`, abans de pujar els píxels al // Hook invocat al principi de `present()`, abans de pujar els píxels al
// backend. Serveix per a pintar overlays que han de quedar per damunt // backend. Serveix per a pintar overlays que han de quedar per damunt
+9 -4
View File
@@ -19,6 +19,7 @@
#include "core/input/mouse.hpp" #include "core/input/mouse.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/effects/shadow.hpp" #include "core/rendering/effects/shadow.hpp"
#include "core/rendering/lighting.hpp"
#include "core/rendering/notifier.hpp" #include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp" #include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
@@ -131,6 +132,10 @@ Director::Director() {
// Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a // Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a
// data/config/ui.yaml cau al valor compilat a Defaults::UI. // data/config/ui.yaml cau al valor compilat a Defaults::UI.
UiConfig::get().load(Defaults::Paths::UI_YAML); 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 // Configuració específica de les escenes TITLE i LOGO. Es carreguen
// additivament sobre el mateix UiConfig: cada fitxer només conté la // additivament sobre el mateix UiConfig: cada fitxer només conté la
// seua subclau (`title:` o `logo:`), i les claus absents a la base // seua subclau (`title:` o `logo:`), i les claus absents a la base
@@ -349,10 +354,10 @@ Director::~Director() {
void Director::createSystemFolder() { void Director::createSystemFolder() {
// Carpeta de configuració per plataforma: // Carpeta de configuració per plataforma:
// - Windows: %APPDATA%\jailgames\aee_arcade\ // - Windows: %APPDATA%\jailgames\aee_arcade
// - macOS: ~/Library/Application Support/jailgames/aee_arcade/ // - macOS: ~/Library/Application Support/jailgames/aee_arcade
// - Linux: $XDG_CONFIG_HOME/jailgames/aee_arcade/ (defecte // - Linux: $XDG_CONFIG_HOME/jailgames/aee_arcade (defecte
// ~/.config/jailgames/aee_arcade/) // ~/.config/jailgames/aee_arcade)
// No fem servir SDL_GetPrefPath: a Linux apunta a XDG_DATA_HOME // No fem servir SDL_GetPrefPath: a Linux apunta a XDG_DATA_HOME
// (~/.local/share), que és per a dades, no per a configuració. // (~/.local/share), que és per a dades, no per a configuració.
std::string base; std::string base;
+4 -12
View File
@@ -99,10 +99,8 @@ UiConfig::UiConfig()
hud_entry_ms_(Defaults::UI::HUD_ENTRY_MS), hud_entry_ms_(Defaults::UI::HUD_ENTRY_MS),
hud_flash_ms_(Defaults::UI::HUD_FLASH_MS), hud_flash_ms_(Defaults::UI::HUD_FLASH_MS),
hud_shake_ms_(Defaults::UI::HUD_SHAKE_MS), hud_shake_ms_(Defaults::UI::HUD_SHAKE_MS),
pause_enter_ms_(Defaults::UI::PAUSE_ENTER_MS), hud_blink_cycle_ms_(Defaults::UI::HUD_BLINK_CYCLE_MS),
pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS), hud_blink_visible_ratio_(Defaults::UI::HUD_BLINK_VISIBLE_RATIO),
pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)),
pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)),
title_duration_ms_(Defaults::UI::Title::DURATION_MS), title_duration_ms_(Defaults::UI::Title::DURATION_MS),
title_bg_color_(Defaults::UI::Title::BG_COLOR), title_bg_color_(Defaults::UI::Title::BG_COLOR),
title_text_color_(Defaults::UI::Title::TEXT_COLOR), title_text_color_(Defaults::UI::Title::TEXT_COLOR),
@@ -267,14 +265,8 @@ void UiConfig::load(const std::string& file_path) {
readField(h, "entry_ms", hud_entry_ms_); readField(h, "entry_ms", hud_entry_ms_);
readField(h, "flash_ms", hud_flash_ms_); readField(h, "flash_ms", hud_flash_ms_);
readField(h, "shake_ms", hud_shake_ms_); readField(h, "shake_ms", hud_shake_ms_);
} readField(h, "blink_cycle_ms", hud_blink_cycle_ms_);
readField(h, "blink_visible_ratio", hud_blink_visible_ratio_);
if (yaml.contains("pause")) {
const auto& p = yaml["pause"];
readField(p, "enter_ms", pause_enter_ms_);
readField(p, "exit_ms", pause_exit_ms_);
readCurve(p, "enter_curve", pause_enter_curve_);
readCurve(p, "exit_curve", pause_exit_curve_);
} }
if (yaml.contains("sounds")) { if (yaml.contains("sounds")) {
+4 -11
View File
@@ -89,8 +89,8 @@ class UiConfig {
[[nodiscard]] auto hudEntryMs() const -> int { return hud_entry_ms_; } [[nodiscard]] auto hudEntryMs() const -> int { return hud_entry_ms_; }
[[nodiscard]] auto hudFlashMs() const -> int { return hud_flash_ms_; } [[nodiscard]] auto hudFlashMs() const -> int { return hud_flash_ms_; }
[[nodiscard]] auto hudShakeMs() const -> int { return hud_shake_ms_; } [[nodiscard]] auto hudShakeMs() const -> int { return hud_shake_ms_; }
[[nodiscard]] auto pauseEnterMs() const -> int { return pause_enter_ms_; } [[nodiscard]] auto hudBlinkCycleMs() const -> int { return hud_blink_cycle_ms_; }
[[nodiscard]] auto pauseExitMs() const -> int { return pause_exit_ms_; } [[nodiscard]] auto hudBlinkVisibleRatio() const -> float { return hud_blink_visible_ratio_; }
// Escena TITLE (placeholder visual fins que tinguem artwork). // Escena TITLE (placeholder visual fins que tinguem artwork).
struct TitleHeatHaze { struct TitleHeatHaze {
@@ -169,11 +169,6 @@ class UiConfig {
[[nodiscard]] auto logoTimeline() const -> const LogoTimeline& { return logo_timeline_; } [[nodiscard]] auto logoTimeline() const -> const LogoTimeline& { return logo_timeline_; }
// Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa
// Easing::linear com a fallback.
[[nodiscard]] auto pauseEnterCurve() const -> CurveFn { return pause_enter_curve_; }
[[nodiscard]] auto pauseExitCurve() const -> CurveFn { return pause_exit_curve_; }
// Resol un nom de corba a la seua funció. Pública per permetre a // Resol un nom de corba a la seua funció. Pública per permetre a
// futurs camps d'UI triar corbes pel mateix mecanisme (ex.: HUD // futurs camps d'UI triar corbes pel mateix mecanisme (ex.: HUD
// entry, notifications slide...). Si el nom no està registrat, // entry, notifications slide...). Si el nom no està registrat,
@@ -238,10 +233,8 @@ class UiConfig {
int hud_entry_ms_; int hud_entry_ms_;
int hud_flash_ms_; int hud_flash_ms_;
int hud_shake_ms_; int hud_shake_ms_;
int pause_enter_ms_; int hud_blink_cycle_ms_;
int pause_exit_ms_; float hud_blink_visible_ratio_;
CurveFn pause_enter_curve_;
CurveFn pause_exit_curve_;
int title_duration_ms_; int title_duration_ms_;
Uint8 title_bg_color_; Uint8 title_bg_color_;
+193 -5
View File
@@ -176,6 +176,23 @@ namespace {
return {PX, PY}; return {PX, PY};
} }
// Compta sub-sprites del atlas i decideix si el template demana
// sub-picker de frames. Extret de `ensureEditableEntities` per acotar
// la complexitat cognitiva del callsite. Per a strips d'1 frame
// (la majoria del catàleg) retorna (1, false).
struct AtlasVariants {
int frame_count;
bool has_variants;
};
auto computeAtlasVariants(const Surface& surface, int frame_w, int frame_h, const fkyaml::node& tmpl) -> AtlasVariants {
const int COLS = (frame_w > 0) ? (surface.getWidth() / frame_w) : 1;
const int ROWS = (frame_h > 0) ? (surface.getHeight() / frame_h) : 1;
const int FRAME_COUNT = std::max(1, COLS * ROWS);
const bool FLAG = tmpl.contains("editable_variants") && tmpl["editable_variants"].get_value<bool>();
return {.frame_count = FRAME_COUNT, .has_variants = FLAG && FRAME_COUNT > 1};
}
} // namespace } // namespace
void Editor::enter() { void Editor::enter() {
@@ -190,6 +207,10 @@ void Editor::enter() {
picker_ = Picker::NONE; picker_ = Picker::NONE;
brush_tile_ = -1; brush_tile_ = -1;
brush_entity_.clear(); brush_entity_.clear();
brush_entity_frame_ = -1;
entity_frame_picker_source_ = nullptr;
entity_frame_picker_hover_ = -1;
entity_frame_picker_scroll_ = 0;
hand_selected_idx_ = -1; hand_selected_idx_ = -1;
hand_hover_idx_ = -1; hand_hover_idx_ = -1;
ensureEditableEntities(); ensureEditableEntities();
@@ -256,7 +277,15 @@ void Editor::ensureEditableEntities() {
} }
if (surface == nullptr) { continue; } if (surface == nullptr) { continue; }
editable_entities_.push_back({.name = name, .sprite = surface, .frame_w = FRAME_W, .frame_h = FRAME_H}); const auto [FRAME_COUNT, HAS_VARIANTS] = computeAtlasVariants(*surface, FRAME_W, FRAME_H, tmpl);
editable_entities_.push_back({
.name = name,
.sprite = surface,
.frame_w = FRAME_W,
.frame_h = FRAME_H,
.frame_count = FRAME_COUNT,
.has_variants = HAS_VARIANTS,
});
} }
} }
@@ -301,6 +330,13 @@ namespace {
s.col = (static_cast<int>(R.x) - Map::tilemap_origin_x) / Map::tile_size; s.col = (static_cast<int>(R.x) - Map::tilemap_origin_x) / Map::tile_size;
s.row = (static_cast<int>(R.y) - Map::tilemap_origin_y) / Map::tile_size; s.row = (static_cast<int>(R.y) - Map::tilemap_origin_y) / Map::tile_size;
} }
// Variant del atlas: si l'entitat té un AnimationComponent
// d'un sol frame, és una entitat amb variant escollida
// (debris, columna). Persistim l'índex perquè la pròxima
// càrrega restauri la mateixa cara del sub-sprite.
if (const auto& anim = e->getAnimation(); anim.has_value() && anim->frameCount() == 1) {
s.frame = anim->currentFrame();
}
} }
out.push_back(std::move(s)); out.push_back(std::move(s));
} }
@@ -374,6 +410,7 @@ auto Editor::applyHotkey(int scancode) -> bool {
// tracte per a la seva acció de confirmar-eixida. // tracte per a la seva acció de confirmar-eixida.
if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) { if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) {
picker_ = Picker::NONE; picker_ = Picker::NONE;
entity_frame_picker_source_ = nullptr;
return true; return true;
} }
return false; return false;
@@ -426,6 +463,8 @@ void Editor::update(int /*delta_ms*/, Tilemap& tilemap, EntityManager& entities)
updateTilePicker(tilemap, CX, CY); updateTilePicker(tilemap, CX, CY);
} else if (picker_ == Picker::ENTITY) { } else if (picker_ == Picker::ENTITY) {
updateEntityPicker(CX, CY); updateEntityPicker(CX, CY);
} else if (picker_ == Picker::ENTITY_FRAME) {
updateEntityFramePicker(CX, CY);
} else { } else {
switch (mode_) { switch (mode_) {
case Mode::TILE: case Mode::TILE:
@@ -514,7 +553,9 @@ namespace {
// Composa el spawn YAML per al brush actual. Retorna empty si el snap // Composa el spawn YAML per al brush actual. Retorna empty si el snap
// no és vàlid (fora de rejilla, solapament, o duplicat de porta). // no és vàlid (fora de rejilla, solapament, o duplicat de porta).
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing) -> std::string { // `frame` (-1 = no emetre) afegeix el camp `frame: N` per a entitats
// amb variants estàtiques (debris, columna).
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing, int frame) -> std::string {
std::ostringstream y; std::ostringstream y;
y << "type: " << brush << '\n'; y << "type: " << brush << '\n';
if (brush == "sarcophagus") { if (brush == "sarcophagus") {
@@ -530,6 +571,7 @@ namespace {
const auto DEST = tileCellRect(cursor_col, cursor_row, Map::tile_size); const auto DEST = tileCellRect(cursor_col, cursor_row, Map::tile_size);
if (cellOccupied(existing, DEST)) { return {}; } if (cellOccupied(existing, DEST)) { return {}; }
y << "position: [" << cursor_col << ", " << cursor_row << "]\n"; y << "position: [" << cursor_col << ", " << cursor_row << "]\n";
if (frame >= 0) { y << "frame: " << frame << '\n'; }
return y.str(); return y.str();
} }
@@ -546,6 +588,7 @@ void Editor::updateEntityBrush(EntityManager& entities, int /*canvas_x*/, int /*
// H (mà): manté la simetria amb el comportament del brush // H (mà): manté la simetria amb el comportament del brush
// de tiles. // de tiles.
brush_entity_.clear(); brush_entity_.clear();
brush_entity_frame_ = -1;
} }
} }
} }
@@ -665,7 +708,14 @@ auto Editor::tryMoveSelected(const Tilemap& tilemap, EntityManager& entities) co
filtered.reserve(list.size() - 1); filtered.reserve(list.size() - 1);
std::ranges::copy_if(list, std::back_inserter(filtered), [target](const Entity* e) { return e != target; }); std::ranges::copy_if(list, std::back_inserter(filtered), [target](const Entity* e) { return e != target; });
const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered); // Si l'entitat moguda té una variant fixada (AnimationComponent d'un
// sol frame, convenció per a debris/columnes), la preservem al spawn
// reconstruït — així moure no perd la cara escollida.
int frame = -1;
if (const auto& anim = target->getAnimation(); anim.has_value() && anim->frameCount() == 1) {
frame = anim->currentFrame();
}
const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered, frame);
if (STR.empty()) { return false; } if (STR.empty()) { return false; }
if (!entities.removeEntity(target)) { return false; } if (!entities.removeEntity(target)) { return false; }
try { try {
@@ -806,7 +856,7 @@ void Editor::renderEntityFrame(const Entity& e, std::uint32_t argb) {
auto Editor::tryPlaceEntity(EntityManager& entities) -> bool { auto Editor::tryPlaceEntity(EntityManager& entities) -> bool {
if (brush_entity_.empty() || cursor_col_ < 0 || cursor_row_ < 0) { return false; } if (brush_entity_.empty() || cursor_col_ < 0 || cursor_row_ < 0) { return false; }
const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities()); const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities(), brush_entity_frame_);
if (STR.empty()) { return false; } if (STR.empty()) { return false; }
try { try {
auto node = fkyaml::node::deserialize(STR.begin(), STR.end()); auto node = fkyaml::node::deserialize(STR.begin(), STR.end());
@@ -840,13 +890,25 @@ void Editor::updateEntityPicker(int canvas_x, int canvas_y) {
entity_picker_scroll_ = std::clamp(entity_picker_scroll_, 0, LAY.max_scroll); entity_picker_scroll_ = std::clamp(entity_picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) { } else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (entity_picker_hover_ >= 0 && entity_picker_hover_ < static_cast<int>(editable_entities_.size())) { if (entity_picker_hover_ >= 0 && entity_picker_hover_ < static_cast<int>(editable_entities_.size())) {
brush_entity_ = editable_entities_[entity_picker_hover_].name; const auto& sel = editable_entities_[entity_picker_hover_];
if (sel.has_variants) {
// Entitat amb variants: encara no fixem brush. Obrim
// el sub-picker de frames; el brush es fixarà només
// quan l'usuari trii un frame concret allà.
entity_frame_picker_source_ = &sel;
entity_frame_picker_scroll_ = 0;
entity_frame_picker_hover_ = -1;
picker_ = Picker::ENTITY_FRAME;
} else {
brush_entity_ = sel.name;
brush_entity_frame_ = -1;
mode_ = Mode::ENTITY; mode_ = Mode::ENTITY;
picker_ = Picker::NONE; picker_ = Picker::NONE;
} }
} }
} }
} }
}
void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const { void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const {
const auto LAY = layoutEntityPicker(static_cast<int>(editable_entities_.size())); const auto LAY = layoutEntityPicker(static_cast<int>(editable_entities_.size()));
@@ -896,6 +958,106 @@ void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const {
screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER); screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER);
} }
void Editor::updateEntityFramePicker(int canvas_x, int canvas_y) {
// Si la font ha desaparegut (no hauria de passar mentre el picker
// estigui obert, però defensem el cas), tanquem el sub-picker en lloc
// de fer crash.
if (entity_frame_picker_source_ == nullptr) {
picker_ = Picker::ENTITY;
return;
}
const auto LAY = layoutEntityPicker(entity_frame_picker_source_->frame_count);
entity_frame_picker_hover_ = -1;
const int INNER_X = LAY.x + PICKER_BORDER;
const int INNER_Y = LAY.y + PICKER_BORDER;
const int RX = canvas_x - INNER_X;
const int RY = canvas_y - INNER_Y;
if (RX >= 0 && RY >= 0 && LAY.pitch > 0 && LAY.cols > 0) {
const int VC = RX / LAY.pitch;
const int VR = RY / LAY.pitch;
const bool IN_SPACING_X = (RX % LAY.pitch) >= ENTITY_CELL;
const bool IN_SPACING_Y = (RY % LAY.pitch) >= ENTITY_CELL;
if (!IN_SPACING_X && !IN_SPACING_Y && VC < LAY.cols && VR < LAY.rows_visible) {
const int IDX = ((entity_frame_picker_scroll_ + VR) * LAY.cols) + VC;
if (IDX < LAY.total_items) { entity_frame_picker_hover_ = IDX; }
}
}
for (const auto& ev : EventBuffer::events) {
if (ev.type == SDL_EVENT_MOUSE_WHEEL) {
entity_frame_picker_scroll_ -= static_cast<int>(ev.wheel.y);
entity_frame_picker_scroll_ = std::clamp(entity_frame_picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (entity_frame_picker_hover_ >= 0) {
brush_entity_ = entity_frame_picker_source_->name;
brush_entity_frame_ = entity_frame_picker_hover_;
mode_ = Mode::ENTITY;
picker_ = Picker::NONE;
}
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_RIGHT) {
// Botó dret: tanca el sub-picker i torna al picker d'entitats
// sense fixar brush. Coherent amb la convenció de "right =
// cancel·la l'acció actual" dels altres modes.
picker_ = Picker::ENTITY;
entity_frame_picker_source_ = nullptr;
}
}
}
void Editor::renderEntityFramePicker() const {
if (entity_frame_picker_source_ == nullptr) { return; }
const auto& src = *entity_frame_picker_source_;
const auto LAY = layoutEntityPicker(src.frame_count);
if (LAY.cols <= 0 || LAY.rows_visible <= 0) { return; }
auto& screen = Screen::get();
const auto& cfg = EditorConfig::get();
screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, cfg.pickerBgArgb());
// Cols del atlas (sub-sprites per fila): es dedueix de la mida del
// surface i del frame, igual que fa `EntityManager::renderEntity`.
const int ATLAS_COLS = (src.frame_w > 0) ? (src.sprite->getWidth() / src.frame_w) : 1;
const int ATLAS_COLS_SAFE = (ATLAS_COLS > 0) ? ATLAS_COLS : 1;
for (int vr = 0; vr < LAY.rows_visible; ++vr) {
const int LOGICAL_ROW = entity_frame_picker_scroll_ + vr;
for (int vc = 0; vc < LAY.cols; ++vc) {
const int IDX = (LOGICAL_ROW * LAY.cols) + vc;
if (IDX >= LAY.total_items) { break; }
const auto [PX, PY] = entityCellOrigin(LAY, vc, vr);
const int OX = PX + ((ENTITY_CELL - src.frame_w) / 2);
const int OY = PY + ((ENTITY_CELL - src.frame_h) / 2);
const int A_COL = IDX % ATLAS_COLS_SAFE;
const int A_ROW = IDX / ATLAS_COLS_SAFE;
const SDL_FRect SRC{
.x = static_cast<float>(A_COL * src.frame_w),
.y = static_cast<float>(A_ROW * src.frame_h),
.w = static_cast<float>(src.frame_w),
.h = static_cast<float>(src.frame_h),
};
src.sprite->render(OX, OY, &SRC, SDL_FLIP_NONE);
}
}
if (entity_frame_picker_hover_ >= 0) {
const int VR = (entity_frame_picker_hover_ / LAY.cols) - entity_frame_picker_scroll_;
const int VC = entity_frame_picker_hover_ % LAY.cols;
if (VR >= 0 && VR < LAY.rows_visible) {
const auto [PX, PY] = entityCellOrigin(LAY, VC, VR);
const auto C = cfg.pickerHoverArgb();
screen.fillRect(PX, PY, ENTITY_CELL, 1, C);
screen.fillRect(PX, PY + ENTITY_CELL - 1, ENTITY_CELL, 1, C);
screen.fillRect(PX, PY, 1, ENTITY_CELL, C);
screen.fillRect(PX + ENTITY_CELL - 1, PY, 1, ENTITY_CELL, C);
}
}
const auto BORDER = cfg.borderArgb();
screen.fillRect(LAY.x, LAY.y, LAY.w, 1, BORDER);
screen.fillRect(LAY.x, LAY.y + LAY.h - 1, LAY.w, 1, BORDER);
screen.fillRect(LAY.x, LAY.y, 1, LAY.h, BORDER);
screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER);
}
void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) { void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
const auto LAY = layoutPicker(tilemap); const auto LAY = layoutPicker(tilemap);
tile_picker_hover_ = -1; tile_picker_hover_ = -1;
@@ -930,6 +1092,28 @@ void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y
} }
} }
void Editor::renderEntityBrushPreview(const Tilemap& tilemap) const {
auto it = std::ranges::find_if(editable_entities_, [this](const auto& e) { return e.name == brush_entity_; });
if (it == editable_entities_.end() || it->sprite == nullptr) { return; }
const int FRAME = (brush_entity_frame_ >= 0) ? brush_entity_frame_ : 0;
const int ATLAS_COLS = (it->frame_w > 0) ? (it->sprite->getWidth() / it->frame_w) : 1;
const int ATLAS_COLS_SAFE = (ATLAS_COLS > 0) ? ATLAS_COLS : 1;
const int A_COL = FRAME % ATLAS_COLS_SAFE;
const int A_ROW = FRAME / ATLAS_COLS_SAFE;
const SDL_FRect SRC{
.x = static_cast<float>(A_COL * it->frame_w),
.y = static_cast<float>(A_ROW * it->frame_h),
.w = static_cast<float>(it->frame_w),
.h = static_cast<float>(it->frame_h),
};
const int TS = tilemap.getTileSize();
const int TILE_PX = tilemap.getOriginX() + (cursor_col_ * TS);
const int TILE_PY = tilemap.getOriginY() + (cursor_row_ * TS);
const int DST_X = TILE_PX + ((TS - it->frame_w) / 2);
const int DST_Y = TILE_PY + TS - it->frame_h;
it->sprite->render(DST_X, DST_Y, &SRC, SDL_FLIP_NONE);
}
void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const { void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const {
if (!active_) { return; } if (!active_) { return; }
auto& screen = Screen::get(); auto& screen = Screen::get();
@@ -940,11 +1124,15 @@ void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const
renderTilePicker(tilemap); renderTilePicker(tilemap);
} else if (picker_ == Picker::ENTITY) { } else if (picker_ == Picker::ENTITY) {
renderEntityPicker(tilemap); renderEntityPicker(tilemap);
} else if (picker_ == Picker::ENTITY_FRAME) {
renderEntityFramePicker();
} else if (mode_ == Mode::TILE && brush_tile_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) { } else if (mode_ == Mode::TILE && brush_tile_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
// Preview del brush sota el cursor (només quan el picker és tancat). // Preview del brush sota el cursor (només quan el picker és tancat).
const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize()); const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize());
const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize()); const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize());
tilemap.renderTilePreview(PX, PY, brush_tile_); tilemap.renderTilePreview(PX, PY, brush_tile_);
} else if (mode_ == Mode::ENTITY && !brush_entity_.empty() && cursor_col_ >= 0 && cursor_row_ >= 0) {
renderEntityBrushPreview(tilemap);
} else if (mode_ == Mode::HAND && picker_ == Picker::NONE) { } else if (mode_ == Mode::HAND && picker_ == Picker::NONE) {
// Marcs d'entitat: hover (tenue) i selecció (brillant). El // Marcs d'entitat: hover (tenue) i selecció (brillant). El
// pickerHoverArgb el reusem com a "selecció brillant"; el // pickerHoverArgb el reusem com a "selecció brillant"; el
+37 -7
View File
@@ -78,9 +78,13 @@ class Editor {
HAND }; HAND };
// Quin picker està obert (si n'hi ha). Mútuament excloents. // Quin picker està obert (si n'hi ha). Mútuament excloents.
// - ENTITY_FRAME: sub-picker que apareix després de clicar una entitat
// amb variants al picker principal. Mostra tots els sub-sprites del
// atlas (índex frame) per a triar quina cara representa la instància.
enum class Picker : std::uint8_t { NONE, enum class Picker : std::uint8_t { NONE,
TILE, TILE,
ENTITY }; ENTITY,
ENTITY_FRAME };
// Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`. // Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`.
// Es crida al principi d'`update` perquè altres handlers no vegin // Es crida al principi d'`update` perquè altres handlers no vegin
@@ -100,6 +104,7 @@ class Editor {
void updateHand(const Tilemap& tilemap, EntityManager& entities, int canvas_x, int canvas_y); void updateHand(const Tilemap& tilemap, EntityManager& entities, int canvas_x, int canvas_y);
void updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y); void updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y);
void updateEntityPicker(int canvas_x, int canvas_y); void updateEntityPicker(int canvas_x, int canvas_y);
void updateEntityFramePicker(int canvas_x, int canvas_y);
// Intent de col·locació segons brush_entity_ a la cel·la del cursor. // Intent de col·locació segons brush_entity_ a la cel·la del cursor.
// Construeix un node spawn sintètic, fa el snap concret per tipus // Construeix un node spawn sintètic, fa el snap concret per tipus
@@ -138,6 +143,13 @@ class Editor {
void renderTilePicker(const Tilemap& tilemap) const; void renderTilePicker(const Tilemap& tilemap) const;
void renderEntityPicker(const Tilemap& tilemap) const; void renderEntityPicker(const Tilemap& tilemap) const;
void renderEntityFramePicker() const;
// Preview del sprite de l'entitat seleccionada com a brush, snapped al
// tile sota el cursor. Anchor "feet": base del sprite a la base inferior
// del tile, centrat horitzontalment — així stone cau exactament i column
// sobresurt cap amunt, coincidint amb el resultat de col·locar.
void renderEntityBrushPreview(const Tilemap& tilemap) const;
bool active_{false}; bool active_{false};
Mode mode_{Mode::TILE}; Mode mode_{Mode::TILE};
@@ -153,21 +165,22 @@ class Editor {
int tile_picker_scroll_{0}; int tile_picker_scroll_{0};
int tile_picker_hover_{-1}; int tile_picker_hover_{-1};
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
std::string brush_entity_;
// Estat de l'EntityPicker.
int entity_picker_scroll_{0};
int entity_picker_hover_{-1}; // Índex a la llista de noms.
// Entrada editable del picker. Cada item recorda el nom (clau de // Entrada editable del picker. Cada item recorda el nom (clau de
// factory + YAML), el surface del sprite (frame 0 com a icona) i la // factory + YAML), el surface del sprite (frame 0 com a icona) i la
// mida de cada frame al GIF — necessària per clipar el primer frame // mida de cada frame al GIF — necessària per clipar el primer frame
// quan el GIF és un strip multi-frame. // quan el GIF és un strip multi-frame.
// - `frame_count` és el nombre total de sub-sprites del atlas
// (cols × rows segons les dimensions del surface i del frame).
// - `has_variants` és `true` quan el template declara `editable_variants:
// true` i hi ha més d'un frame: aleshores clicar al picker obre el
// sub-picker de frames en lloc de fixar el brush directament.
struct EditableEntity { struct EditableEntity {
std::string name; std::string name;
std::shared_ptr<Surface> sprite; std::shared_ptr<Surface> sprite;
int frame_w{0}; int frame_w{0};
int frame_h{0}; int frame_h{0};
int frame_count{1};
bool has_variants{false};
}; };
// Construïda lazy al primer enter() i preservada entre sessions: // Construïda lazy al primer enter() i preservada entre sessions:
@@ -178,6 +191,23 @@ class Editor {
std::vector<EditableEntity> editable_entities_; std::vector<EditableEntity> editable_entities_;
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
std::string brush_entity_;
// Frame escollit per al brush actiu (-1 = no aplicable: entitat sense
// variants o brush encara per fixar). El emet el sub-picker en clicar
// un frame; `tryPlaceEntity` el passa al spawn YAML com a `frame: N`.
int brush_entity_frame_{-1};
// Estat de l'EntityPicker.
int entity_picker_scroll_{0};
int entity_picker_hover_{-1}; // Índex a la llista de noms.
// Estat del sub-picker de frames. `entity_frame_picker_source_` apunta
// a la entitat activa (vista al EntityPicker) per a la qual el
// sub-picker mostra les variants. Quan el sub-picker es tanca sense
// selecció, ho deixem a nullptr i `brush_entity_frame_ = -1`.
int entity_frame_picker_scroll_{0};
int entity_frame_picker_hover_{-1};
const EditableEntity* entity_frame_picker_source_{nullptr};
// Font compartida amb el Notifier/HUD: el surface el carrega Director // Font compartida amb el Notifier/HUD: el surface el carrega Director
// a l'arrencada, ací només prenem la referència del cache. shared_ptr // a l'arrencada, ací només prenem la referència del cache. shared_ptr
// perquè `Text` accepta el surface per shared_ptr i així no copiem. // perquè `Text` accepta el surface per shared_ptr i així no copiem.
+119 -7
View File
@@ -1,14 +1,87 @@
#include "game/entities/animation_component.hpp" #include "game/entities/animation_component.hpp"
#include <cmath> #include <cmath>
#include <stdexcept>
#include <utility> #include <utility>
AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool loop) namespace {
: AnimationComponent(std::move(frames), fps, loop, false, false) {}
AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool loop, bool flip_h, bool flip_v) // Quan el caller no especifica temps, considerem un frame "estàtic" de
// 1s. El valor és arbitrari: amb 1 frame el component mai no avança,
// així que el speed concret és inert. Amb N frames és el fallback que
// assumeix la factory (mateix valor que el default del format antic).
constexpr float K_DEFAULT_FRAME_SECONDS = 1.0F;
// Si el YAML declara temps <= 0 per a algun frame, el saturem a aquest
// mínim per a evitar bucles infinits a `tick` (que avancen frame
// mentre `accumulator >= speeds[i]`). 1 ms és prou petit per a no
// alterar mai una temporització real i prou gran per a no col·lapsar.
constexpr float K_MIN_FRAME_SECONDS = 0.001F;
auto sanitizeSpeed(float seconds) -> float {
return seconds > 0.0F ? seconds : K_MIN_FRAME_SECONDS;
}
// Expandeix una llista de temps a la mida de `frame_count`:
// - mida = N: 1:1.
// - mida < N: emplena la resta amb l'últim valor (o el default si
// la llista era buida).
// - mida > N: talla a N.
// Tots els valors es passen per `sanitizeSpeed`.
auto fitSpeeds(std::vector<float> speeds, std::size_t frame_count) -> std::vector<float> {
if (frame_count == 0) { return {}; }
if (speeds.empty()) {
return std::vector<float>(frame_count, K_DEFAULT_FRAME_SECONDS);
}
// cppcheck-suppress useStlAlgorithm
for (auto& s : speeds) { s = sanitizeSpeed(s); }
if (speeds.size() == frame_count) { return speeds; }
if (speeds.size() > frame_count) {
speeds.resize(frame_count);
return speeds;
}
const float TAIL = speeds.back();
speeds.resize(frame_count, TAIL);
return speeds;
}
// Llig `animations.<key>.frames` com a llista d'enters. Garanteix
// almenys 1 frame (fallback a [0]) per a poder construir sempre un
// component vàlid.
auto parseFrames(const fkyaml::node& anim) -> std::vector<int> {
std::vector<int> frames;
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
return frames;
}
// Llig `animations.<key>.frame_ms` (escalar o llista) i retorna els
// temps en segons. Si és escalar, retorna una llista uniforme de
// `frame_count` elements; si és llista, copia tal qual (l'ajust de
// mida el farà `fitSpeeds` al constructor).
auto parseFrameMs(const fkyaml::node& anim, std::size_t frame_count) -> std::vector<float> {
std::vector<float> speeds_secs;
const auto& fm = anim["frame_ms"];
if (fm.is_sequence()) {
for (const auto& v : fm) {
// cppcheck-suppress useStlAlgorithm
speeds_secs.push_back(static_cast<float>(v.get_value<int>()) / 1000.0F);
}
} else {
const float SECS = static_cast<float>(fm.get_value<int>()) / 1000.0F;
speeds_secs.assign(frame_count, SECS);
}
return speeds_secs;
}
} // namespace
AnimationComponent::AnimationComponent(std::vector<int> frames, std::vector<float> speeds, bool loop, bool flip_h, bool flip_v)
: frames_(std::move(frames)), : frames_(std::move(frames)),
fps_(fps > 0.0F ? fps : 1.0F), speeds_(fitSpeeds(std::move(speeds), frames_.size())),
loop_(loop), loop_(loop),
flip_h_(flip_h), flip_h_(flip_h),
flip_v_(flip_v) { flip_v_(flip_v) {
@@ -19,13 +92,52 @@ AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool
if (frames_.size() <= 1 && !loop_) { completed_ = true; } if (frames_.size() <= 1 && !loop_) { completed_ = true; }
} }
auto AnimationComponent::uniform(std::vector<int> frames, float frame_ms, bool loop) -> AnimationComponent {
return uniform(std::move(frames), frame_ms, loop, false, false);
}
auto AnimationComponent::uniform(std::vector<int> frames, float frame_ms, bool loop, bool flip_h, bool flip_v) -> AnimationComponent {
const float SECS = sanitizeSpeed(frame_ms / 1000.0F);
std::vector<float> speeds(frames.size(), SECS);
return {std::move(frames), std::move(speeds), loop, flip_h, flip_v};
}
auto AnimationComponent::fromYaml(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
// Defaults conservadors per a quan el bloc `animations.<key>` no
// existeix: 1 frame loopant a 1s — visualment estàtic, semàntica neutra.
std::vector<int> frames = {0};
std::vector<float> speeds_secs; // buit ⇒ fitSpeeds emplenarà amb default
bool loop = true;
bool flip_h = false;
bool flip_v = false;
if (!yaml.contains("animations") || !yaml["animations"].contains(key)) {
return {std::move(frames), std::move(speeds_secs), loop, flip_h, flip_v};
}
const auto& anim = yaml["animations"][key];
// L'antic format `fps: N` ja no és suportat. Si apareix, peguem hard
// perquè la migració sigui visible (no silenciosa). El missatge inclou
// la clau d'animació per a facilitar localitzar el YAML problemàtic.
if (anim.contains("fps")) {
throw std::runtime_error("AnimationComponent: 'fps:' is no longer supported, use 'frame_ms:' instead (animation '" + key + "')");
}
if (anim.contains("frames")) { frames = parseFrames(anim); }
if (anim.contains("frame_ms")) { speeds_secs = parseFrameMs(anim, frames.size()); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
if (anim.contains("flip_h")) { flip_h = anim["flip_h"].get_value<bool>(); }
if (anim.contains("flip_v")) { flip_v = anim["flip_v"].get_value<bool>(); }
return {std::move(frames), std::move(speeds_secs), loop, flip_h, flip_v};
}
void AnimationComponent::tick(float delta_time) { void AnimationComponent::tick(float delta_time) {
if (frames_.size() <= 1 || completed_) { return; } if (frames_.size() <= 1 || completed_) { return; }
accumulator_ += delta_time; accumulator_ += delta_time;
const float FRAME_TIME = 1.0F / fps_; while (accumulator_ >= speeds_[static_cast<std::size_t>(current_)]) {
while (accumulator_ >= FRAME_TIME) { accumulator_ -= speeds_[static_cast<std::size_t>(current_)];
accumulator_ -= FRAME_TIME;
++current_; ++current_;
if (static_cast<std::size_t>(current_) >= frames_.size()) { if (static_cast<std::size_t>(current_) >= frames_.size()) {
if (loop_) { if (loop_) {
+37 -8
View File
@@ -3,8 +3,11 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <cstddef> #include <cstddef>
#include <string>
#include <vector> #include <vector>
#include "external/fkyaml_node.hpp"
// Component reutilitzable d'animació: qualsevol entitat que necessiti // Component reutilitzable d'animació: qualsevol entitat que necessiti
// reproduir una seqüència de frames l'instancia. Es manté com a camp // reproduir una seqüència de frames l'instancia. Es manté com a camp
// opcional dins d'Entity (veure `entity.hpp`) i NO hereta de cap classe // opcional dins d'Entity (veure `entity.hpp`) i NO hereta de cap classe
@@ -22,16 +25,41 @@
// canvien d'animació, sense decidir-lo per direcció. Així una mòmia que va // canvien d'animació, sense decidir-lo per direcció. Així una mòmia que va
// cap a l'oest reprodueix `walk_west` (frames de l'est + `flip_h: true`) // cap a l'oest reprodueix `walk_west` (frames de l'est + `flip_h: true`)
// sense que el codi de la mòmia toqui flip directament. // sense que el codi de la mòmia toqui flip directament.
//
// El temps es modela com a `speeds_[i]` segons per frame (per-frame), no com
// a `fps` uniforme. Això permet expressar holds al YAML via `frame_ms: [..]`
// (llista) sense haver de repetir el mateix índex de frame. Per a casos
// uniformes la factory omple `speeds_` amb el mateix valor a tot el vector.
class AnimationComponent { class AnimationComponent {
public: public:
AnimationComponent() = default; AnimationComponent() = default;
AnimationComponent(std::vector<int> frames, float fps, bool loop);
AnimationComponent(std::vector<int> frames, float fps, bool loop, bool flip_h, bool flip_v); // Factory canònica: llig el bloc `animations.<key>` del YAML.
//
// Camps llegits:
// - `frames` : llista d'índexs sencers (obligatori per animar).
// - `frame_ms` : escalar ms (uniforme) o llista de ms (per-frame).
// Default 1000 ms (animació estàtica).
// - `loop` : bool (default true).
// - `flip_h` : bool (default false).
// - `flip_v` : bool (default false).
//
// Si la clau no existeix retorna un component "estàtic" (1 frame, loop
// true). Si el YAML conté `fps:`, llença `std::runtime_error`: el
// format antic ja no és suportat — cal migrar a `frame_ms:`.
static auto fromYaml(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent;
// Helper uniforme: construeix un component on tots els frames duren
// `frame_ms` mil·lisegons. Útil per a usos programàtics (tests,
// imposició d'un frame estàtic des del loader de nivell). Per al cas
// típic guiat per YAML, usar `fromYaml`.
static auto uniform(std::vector<int> frames, float frame_ms, bool loop) -> AnimationComponent;
static auto uniform(std::vector<int> frames, float frame_ms, bool loop, bool flip_h, bool flip_v) -> AnimationComponent;
// Avanç del temps. Si `frames_` està buit o té un sol element, no fa // Avanç del temps. Si `frames_` està buit o té un sol element, no fa
// res. En mode loop, `current_frame_` rebota a 0 en passar del final; // res. En mode loop, `current_` rebota a 0 en passar del final; en
// en mode no-loop, es queda al darrer frame i `completed()` passa a // mode no-loop, es queda al darrer frame i `completed()` passa a true.
// true. // Cada frame consumeix `speeds_[current_]` segons.
void tick(float delta_time); void tick(float delta_time);
// Torna al primer frame i reinicia l'acumulador. Útil en transicions // Torna al primer frame i reinicia l'acumulador. Útil en transicions
@@ -39,8 +67,7 @@ class AnimationComponent {
void reset(); void reset();
// Índex actual dins `frames_`. Si `frames_` està buit retorna 0 com // Índex actual dins `frames_`. Si `frames_` està buit retorna 0 com
// a fallback tolerant (el loader ja ha emès warning si hi havia // a fallback tolerant.
// frames fora de rang; aquí preferim no crashejar).
[[nodiscard]] auto currentFrame() const -> int; [[nodiscard]] auto currentFrame() const -> int;
[[nodiscard]] auto frameCount() const -> std::size_t { return frames_.size(); } [[nodiscard]] auto frameCount() const -> std::size_t { return frames_.size(); }
@@ -55,8 +82,10 @@ class AnimationComponent {
[[nodiscard]] auto flipMode() const -> SDL_FlipMode; [[nodiscard]] auto flipMode() const -> SDL_FlipMode;
private: private:
AnimationComponent(std::vector<int> frames, std::vector<float> speeds, bool loop, bool flip_h, bool flip_v);
std::vector<int> frames_; std::vector<int> frames_;
float fps_{1.0F}; std::vector<float> speeds_; // segons per frame; size == frames_.size()
bool loop_{true}; bool loop_{true};
bool flip_h_{false}; bool flip_h_{false};
bool flip_v_{false}; bool flip_v_{false};
+4 -33
View File
@@ -1,45 +1,16 @@
#include "game/entities/concrete/door.hpp" #include "game/entities/concrete/door.hpp"
#include <string> #include <string>
#include <vector>
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp" #include "game/entities/player.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
namespace {
// Carrega una animació d'un bloc concret (closed/opening/open) amb
// fallbacks tolerants. Si la clau no existeix, retorna una animació
// d'un sol frame loopant: no fa estat invàlid, simplement no hi ha res
// per reproduir.
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = true;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace
Door::Door(const fkyaml::node& yaml) Door::Door(const fkyaml::node& yaml)
: anim_closed_(buildAnimation(yaml, "closed")), : anim_closed_(AnimationComponent::fromYaml(yaml, "closed")),
anim_opening_(buildAnimation(yaml, "opening")), anim_opening_(AnimationComponent::fromYaml(yaml, "opening")),
anim_open_(buildAnimation(yaml, "open")) { anim_open_(AnimationComponent::fromYaml(yaml, "open")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("door"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("door");
collides_ = true; collides_ = true;
interactable_ = true; interactable_ = true;
@@ -99,7 +70,7 @@ void Door::open() {
// placeholders defineixen `loop: false` explícitament al YAML, així // placeholders defineixen `loop: false` explícitament al YAML, així
// que aquest fallback només actua si algú dissenya malament. // que aquest fallback només actua si algú dissenya malament.
if (anim_.has_value() && anim_->frameCount() <= 1 && anim_->loops()) { if (anim_.has_value() && anim_->frameCount() <= 1 && anim_->loops()) {
anim_ = AnimationComponent({0}, 6.0F, false); anim_ = AnimationComponent::uniform({0}, 167.0F, false);
} }
} }
+2 -32
View File
@@ -1,42 +1,12 @@
#include "game/entities/concrete/dust.hpp" #include "game/entities/concrete/dust.hpp"
#include <string> #include <string>
#include <vector>
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
namespace {
// Llig una animació opcional del bloc `animations` del YAML, amb
// valors per defecte segurs (1 frame, fps=1, sense loop). Si la
// clau no existeix, retornem una animació "ja completada" — així,
// un Dust spawnejat amb una variant inexistent al YAML mor al
// pròxim frame sense petar.
auto buildAnim(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = false;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace
Dust::Dust(const fkyaml::node& yaml) Dust::Dust(const fkyaml::node& yaml)
: anim_jump_(buildAnim(yaml, "jump")), : anim_jump_(AnimationComponent::fromYaml(yaml, "jump")),
anim_land_(buildAnim(yaml, "land")) { anim_land_(AnimationComponent::fromYaml(yaml, "land")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("dust"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("dust");
collides_ = false; collides_ = false;
interactable_ = false; interactable_ = false;
+23 -47
View File
@@ -29,36 +29,6 @@ namespace {
return k_rng_; return k_rng_;
} }
// Llegeix un bloc d'animació del YAML amb fallbacks tolerants (mateix
// patró que Door/Sarcophagus). Si la clau no apareix, retorna una
// animació d'1 frame loopant, neutra — la momia es podrà moure però
// visualment es quedarà estàtica. Llegeix `flip_h`/`flip_v` (default
// false) i els passa al component perquè el motor sàpiga aplicar-los
// al sprite quan aquesta animació estigui activa.
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 8.0F;
bool loop = true;
bool flip_h = false;
bool flip_v = false;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
if (anim.contains("flip_h")) { flip_h = anim["flip_h"].get_value<bool>(); }
if (anim.contains("flip_v")) { flip_v = anim["flip_v"].get_value<bool>(); }
}
return {frames, fps, loop, flip_h, flip_v};
}
// Busca si el segment [prev, prev+delta] travessa una lane del patró // Busca si el segment [prev, prev+delta] travessa una lane del patró
// `base + k·pitch` (k ∈ [0..max_idx]). Retorna el delta que cal aplicar // `base + k·pitch` (k ∈ [0..max_idx]). Retorna el delta que cal aplicar
// per arribar exactament a la primera línia travessada. Iterem en el // per arribar exactament a la primera línia travessada. Iterem en el
@@ -103,11 +73,11 @@ namespace {
} // namespace } // namespace
Mummy::Mummy(const fkyaml::node& yaml) Mummy::Mummy(const fkyaml::node& yaml)
: anim_walk_south_(buildAnimation(yaml, "walk_south")), : anim_walk_south_(AnimationComponent::fromYaml(yaml, "walk_south")),
anim_walk_north_(buildAnimation(yaml, "walk_north")), anim_walk_north_(AnimationComponent::fromYaml(yaml, "walk_north")),
anim_walk_east_(buildAnimation(yaml, "walk_east")), anim_walk_east_(AnimationComponent::fromYaml(yaml, "walk_east")),
anim_walk_west_(buildAnimation(yaml, "walk_west")), anim_walk_west_(AnimationComponent::fromYaml(yaml, "walk_west")),
anim_raise_(buildAnimation(yaml, "raise")) { anim_raise_(AnimationComponent::fromYaml(yaml, "raise")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy");
collides_ = true; collides_ = true;
interactable_ = false; interactable_ = false;
@@ -142,18 +112,10 @@ Mummy::Mummy(const fkyaml::node& yaml)
changeDirection(0.0F, 1.0F); changeDirection(0.0F, 1.0F);
} }
// Estat SPAWN inicial: la mòmia naix en SPAWNING amb l'animació // L'estat SPAWNING amb l'animació `raise` s'arma a `onJustSpawned`,
// `raise` activa (16 frames @ 8fps, no-loop). La duració del SPAWN // no al ctor. Així el manager pot instanciar la mòmia sense disparar
// la marca la pròpia animació — `Mummy::update` la tickeja, i quan // la cinemàtica (cas del mode editor) i veure-la ja en ROAMING amb
// `completed()`, crida `endSpawn()` i restaura la `walk_*` per la // la `walk_*` triada per `changeDirection` més amunt.
// direcció actual. Passem 0 al countdown del DynamicHazard perquè
// no s'utilitza en aquest cas (el nostre `update` retorna abans).
enterSpawn(0.0F);
anim_ = anim_raise_;
if (anim_.has_value()) {
anim_->reset();
flip_ = anim_->flipMode();
}
// Configuració de l'estat HURT: opcional. Si el bloc no apareix, la // Configuració de l'estat HURT: opcional. Si el bloc no apareix, la
// mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la // mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la
@@ -180,6 +142,20 @@ Mummy::Mummy(const fkyaml::node& yaml)
} }
} }
void Mummy::onJustSpawned() {
// Cinemàtica d'aparició: la mòmia "s'aixeca" reproduint `raise`
// (16 frames no-loop). El countdown del DynamicHazard NO s'utilitza
// (passem 0): `Mummy::update` controla la transició a ACTIVE per
// `anim_->completed()`. Cap crida a la base perquè el `triggerSpawnExit`
// del DynamicHazard arma aquest mateix countdown que aquí no toca.
enterSpawn(0.0F);
anim_ = anim_raise_;
if (anim_.has_value()) {
anim_->reset();
flip_ = anim_->flipMode();
}
}
void Mummy::changeDirection(float dx, float dy) { void Mummy::changeDirection(float dx, float dy) {
setDirection(dx, dy); setDirection(dx, dy);
// Una animació per cada cardinal — el flip viu al YAML // Una animació per cada cardinal — el flip viu al YAML
+6
View File
@@ -61,6 +61,12 @@ class Mummy : public DynamicHazard {
// En estat ROAMING delega a `step()` com sempre via la base. // En estat ROAMING delega a `step()` com sempre via la base.
void update(float delta_time) override; void update(float delta_time) override;
// Inicia la fase SPAWNING amb l'animació `raise`. Es crida al
// spawnPendingActors() del joc, no al ctor: així l'editor (que
// materialitza les entitats sense aquest hook) veu la mòmia ja en
// ROAMING visible, sense quedar-se "soterrada".
void onJustSpawned() override;
// Contacte amb el jugador: publica `player_hit` amb el dany. // Contacte amb el jugador: publica `player_hit` amb el dany.
void onPlayerContact(Player* player) override; void onPlayerContact(Player* player) override;
+40 -36
View File
@@ -2,7 +2,6 @@
#include <random> #include <random>
#include <string> #include <string>
#include <vector>
#include "core/audio/audio.hpp" #include "core/audio/audio.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
@@ -21,26 +20,6 @@ namespace {
return k_rng_; return k_rng_;
} }
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = true;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
// Llig `tile_size: [w, h]` del YAML. Fallback [1,1] si no existeix. // Llig `tile_size: [w, h]` del YAML. Fallback [1,1] si no existeix.
void readTileSize(const fkyaml::node& yaml, int& w, int& h) { void readTileSize(const fkyaml::node& yaml, int& w, int& h) {
w = 1; w = 1;
@@ -61,9 +40,9 @@ Sarcophagus::Sarcophagus(const fkyaml::node& yaml, int anchor_col, int anchor_ro
} }
Sarcophagus::Sarcophagus(const fkyaml::node& yaml) Sarcophagus::Sarcophagus(const fkyaml::node& yaml)
: anim_closed_(buildAnimation(yaml, "closed")), : anim_closed_(AnimationComponent::fromYaml(yaml, "closed")),
anim_opening_(buildAnimation(yaml, "opening")), anim_opening_(AnimationComponent::fromYaml(yaml, "opening")),
anim_open_(buildAnimation(yaml, "open")) { anim_open_(AnimationComponent::fromYaml(yaml, "open")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("sarcophagus"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("sarcophagus");
collides_ = true; collides_ = true;
interactable_ = true; interactable_ = true;
@@ -83,6 +62,15 @@ Sarcophagus::Sarcophagus(const fkyaml::node& yaml)
opening_pitch_spread_ = s["opening_pitch_spread"].get_value<float>(); opening_pitch_spread_ = s["opening_pitch_spread"].get_value<float>();
} }
} }
// Bloc `reward:` opcional — la recompensa que rebrà el jugador que
// tanqui el sarcòfag. Si no hi és, els camps queden a 0 i el GameScene
// simplement no aplica res al consumir `sarcophagus_opened`.
if (yaml.contains("reward")) {
const auto& r = yaml["reward"];
if (r.contains("points")) { points_reward_ = r["points"].get_value<int>(); }
if (r.contains("treasures")) { treasures_reward_ = r["treasures"].get_value<int>(); }
}
} }
void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) { void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) {
@@ -97,14 +85,16 @@ void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate&
void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) { void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) {
perimeter_tiles_.clear(); perimeter_tiles_.clear();
// El perímetre ortogonal d'un rectangle (w,h) de tiles són les files // El perímetre d'un rectangle (w,h) de tiles són les files de tiles
// de tiles immediatament N, S, E, O. No s'hi inclouen les cantonades // immediatament adjacents: N, S, E, O *més* les quatre cantonades
// diagonals (tile NO, NE, SO, SE), perquè el jugador fa passes pels // diagonals (NO, NE, SO, SE). En la pràctica el jugador trepitja
// quatre costats i no pot "tallar" per una diagonal: això garanteix // aquestes cantonades en doblar les cantonades del sarcòfag, així que
// que el recorregut completa només si passa per cada costat. // si no s'hi compten, el cerco no es pot tancar visualment encara
// que es completi a nivell de codi (o, pitjor, s'obre sense que el
// jugador hagi clos realment el contorn).
// //
// - Fila Nord: (col, row-1) per col ∈ [col0, col0+w-1] // - Fila Nord: (col, row-1) per col ∈ [col0-1, col0+w] (inclou NO i NE)
// - Fila Sud: (col, row+h) per col ∈ [col0, col0+w-1] // - Fila Sud: (col, row+h) per col ∈ [col0-1, col0+w] (inclou SO i SE)
// - Columna O: (col0-1, row) per row ∈ [row0, row0+h-1] // - Columna O: (col0-1, row) per row ∈ [row0, row0+h-1]
// - Columna E: (col0+w, row) per row ∈ [row0, row0+h-1] // - Columna E: (col0+w, row) per row ∈ [row0, row0+h-1]
@@ -113,12 +103,12 @@ void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) {
perimeter_tiles_.insert({col, row}); perimeter_tiles_.insert({col, row});
}; };
// Nord i Sud // Nord i Sud (incloent les quatre cantonades diagonals)
for (int dc = 0; dc < tile_size_w_; ++dc) { for (int dc = -1; dc <= tile_size_w_; ++dc) {
maybe_insert(anchor_col_ + dc, anchor_row_ - 1); maybe_insert(anchor_col_ + dc, anchor_row_ - 1);
maybe_insert(anchor_col_ + dc, anchor_row_ + tile_size_h_); maybe_insert(anchor_col_ + dc, anchor_row_ + tile_size_h_);
} }
// Oest i Est // Oest i Est (les cantonades ja les ha posat el bucle N/S)
for (int dr = 0; dr < tile_size_h_; ++dr) { for (int dr = 0; dr < tile_size_h_; ++dr) {
maybe_insert(anchor_col_ - 1, anchor_row_ + dr); maybe_insert(anchor_col_ - 1, anchor_row_ + dr);
maybe_insert(anchor_col_ + tile_size_w_, anchor_row_ + dr); maybe_insert(anchor_col_ + tile_size_w_, anchor_row_ + dr);
@@ -133,18 +123,32 @@ void Sarcophagus::update(float delta_time) {
anim_ = anim_open_; anim_ = anim_open_;
if (anim_.has_value()) { anim_->reset(); } if (anim_.has_value()) { anim_->reset(); }
if (!opened_event_emitted_ && bus_ != nullptr) { if (!opened_event_emitted_ && bus_ != nullptr) {
bus_->publish("sarcophagus_opened", static_cast<Entity*>(this)); // Publica amb `player_id = last_closer_player_id_` perquè el
// GameScene pugui atribuir la recompensa al jugador correcte.
// El payload manté el punter al sarc perquè el consumidor
// pugui llegir `getPointsReward()` i `getTreasuresReward()`.
bus_->publish(Event{
.name = "sarcophagus_opened",
.payload = static_cast<Entity*>(this),
.player_id = last_closer_player_id_,
});
opened_event_emitted_ = true; opened_event_emitted_ = true;
} }
} }
} }
void Sarcophagus::notifyTilePainted(int col, int row) { void Sarcophagus::notifyTilePainted(int col, int row, int player_id) {
if (state_ != State::CLOSED) { return; } if (state_ != State::CLOSED) { return; }
const TileKey KEY{.col = col, .row = row}; const TileKey KEY{.col = col, .row = row};
if (perimeter_tiles_.find(KEY) == perimeter_tiles_.end()) { return; } if (perimeter_tiles_.find(KEY) == perimeter_tiles_.end()) { return; }
visited_.insert(KEY); visited_.insert(KEY);
if (visited_.size() >= perimeter_tiles_.size()) { if (visited_.size() >= perimeter_tiles_.size()) {
// Atribució: el jugador que pinta el tile que tanca el cercle es
// queda amb la recompensa. Si el broadcast inicial de càrrega
// arribara a tancar un sarc (no hauria de passar perquè els tiles
// d'aquest broadcast són decals/murs, no de corredor), `player_id`
// seria -1 i el GameScene saltarà el repartiment.
last_closer_player_id_ = player_id;
state_ = State::OPENING; state_ = State::OPENING;
anim_ = anim_opening_; anim_ = anim_opening_;
if (anim_.has_value()) { anim_->reset(); } if (anim_.has_value()) { anim_->reset(); }
+27 -2
View File
@@ -18,7 +18,10 @@
// El càlcul de `perimeter_tiles_` es fa al ctor a partir de la posició del // El càlcul de `perimeter_tiles_` es fa al ctor a partir de la posició del
// sarcòfag a la graella del Map (`anchor_col`, `anchor_row`) i de les // sarcòfag a la graella del Map (`anchor_col`, `anchor_row`) i de les
// dimensions en tiles (`tile_size_w_`, `tile_size_h_`, llegits del YAML // dimensions en tiles (`tile_size_w_`, `tile_size_h_`, llegits del YAML
// plantilla). Es recorren els tiles adjacents per N/S/E/O; els que // plantilla). S'inclouen els tiles adjacents per N/S/E/O i també les
// quatre cantonades diagonals (NO/NE/SO/SE), perquè el jugador les
// trepitja en doblar les cantonades del sarcòfag i, sense comptar-les,
// el cerco s'obriria abans d'haver-se tancat visualment. Els tiles que
// cauen dins d'un altre sarcòfag es descarten (predicat opcional). Si no // cauen dins d'un altre sarcòfag es descarten (predicat opcional). Si no
// s'aporta el predicat, s'inclouen tots. // s'aporta el predicat, s'inclouen tots.
class Sarcophagus : public Interactable { class Sarcophagus : public Interactable {
@@ -43,7 +46,7 @@ class Sarcophagus : public Interactable {
void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked); void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked);
void update(float delta_time) override; void update(float delta_time) override;
void notifyTilePainted(int col, int row) override; void notifyTilePainted(int col, int row, int player_id) override;
void onRemoved() override; void onRemoved() override;
[[nodiscard]] auto getState() const -> State { return state_; } [[nodiscard]] auto getState() const -> State { return state_; }
@@ -55,6 +58,17 @@ class Sarcophagus : public Interactable {
[[nodiscard]] auto getTileWidth() const -> int { return tile_size_w_; } [[nodiscard]] auto getTileWidth() const -> int { return tile_size_w_; }
[[nodiscard]] auto getTileHeight() const -> int { return tile_size_h_; } [[nodiscard]] auto getTileHeight() const -> int { return tile_size_h_; }
// Recompensa que es donarà al jugador que pinta el tile que tanca el
// perímetre (el `last_closer_player_id_`). Llegits del YAML del sarc;
// 0/0 si el bloc `reward:` no existeix. El GameScene escolta
// `sarcophagus_opened` i aplica aquests valors al Player corresponent.
[[nodiscard]] auto getPointsReward() const -> int { return points_reward_; }
[[nodiscard]] auto getTreasuresReward() const -> int { return treasures_reward_; }
// Jugador atribuit (qui pinta el tile que dispara CLOSED→OPENING). -1
// si el sarcòfag s'ha tancat per pintades sense atribució (p.ex. el
// broadcast inicial de càrrega) — cas que el GameScene ignora.
[[nodiscard]] auto getLastCloserPlayerId() const -> int { return last_closer_player_id_; }
private: private:
struct TileKey { struct TileKey {
int col; int col;
@@ -91,5 +105,16 @@ class Sarcophagus : public Interactable {
// obertura sorteja [1 - spread, 1 + spread]. 0 ⇒ ratio fix a 1.0. // obertura sorteja [1 - spread, 1 + spread]. 0 ⇒ ratio fix a 1.0.
float opening_pitch_spread_{0.0F}; float opening_pitch_spread_{0.0F};
// Recompenses llegides del YAML (bloc `reward:`). 0/0 si el bloc no
// hi és — el GameScene salta el pas de repartiment quan són 0.
int points_reward_{0};
int treasures_reward_{0};
// Jugador que ha pintat el tile que tanca el perímetre. Es fixa una
// sola vegada, en la transició CLOSED→OPENING dins de notifyTilePainted.
// -1 mentre està a CLOSED, o si la pintada que el tanca no porta
// atribució (player_id == -1 al broadcast inicial).
int last_closer_player_id_{-1};
bool opened_event_emitted_{false}; bool opened_event_emitted_{false};
}; };
@@ -0,0 +1,27 @@
#include <string>
#include "external/fkyaml_node.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/solid_decoration.hpp"
// Murs decoratius estàtics que comparteixen el mateix codi C++ però es
// registren amb claus de factory diferents per a que el YAML els pugui
// referenciar pel seu tipus i el editor els pugui distingir al picker.
// La selecció del sub-sprite (frame del atlas) viatja al spawn YAML
// (`frame: N`); l'aplica `placeGeneric` via `Entity::setAnimation`. Aquí no
// cal cap lògica específica per a la variant.
namespace {
class StaticSolid : public SolidDecoration {
public:
explicit StaticSolid(const fkyaml::node& yaml) {
if (yaml.contains("name")) {
name_ = yaml["name"].get_value<std::string>();
}
}
};
} // namespace
REGISTER_ENTITY_AS("stone", StaticSolid, Stone)
REGISTER_ENTITY_AS("column", StaticSolid, Column)
+1 -28
View File
@@ -1,41 +1,14 @@
#include "game/entities/concrete/torch.hpp" #include "game/entities/concrete/torch.hpp"
#include <string> #include <string>
#include <vector>
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
namespace {
// Ajut: extreu l'animació `idle` del bloc `animations` del YAML si existeix,
// amb valors per defecte que sempre produïxen una animació vàlida (1 frame).
auto buildIdleAnimation(const fkyaml::node& yaml) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = true;
if (yaml.contains("animations") && yaml["animations"].contains("idle")) {
const auto& anim = yaml["animations"]["idle"];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace
Torch::Torch(const fkyaml::node& yaml) { Torch::Torch(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("torch"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("torch");
collides_ = false; collides_ = false;
interactable_ = false; interactable_ = false;
anim_ = buildIdleAnimation(yaml); anim_ = AnimationComponent::fromYaml(yaml, "idle");
} }
REGISTER_ENTITY("torch", Torch) REGISTER_ENTITY("torch", Torch)
+9 -32
View File
@@ -33,43 +33,19 @@ namespace {
return sprite; return sprite;
} }
// Construeix l'`AnimationComponent` del swing a partir del bloc // Llig el camp `active_frames` del bloc `animations.swing` — específic
// `animations.swing`. Convenció igual que la resta d'entitats: // del látigo: els índexs llistats són els frames on la hitbox aplica
// - `frames` : llista d'índexs dins l'spritesheet (obligatori). // dany (wind-up i recovery no danyen). La resta de l'animació la
// - `frame_ms` : durada uniforme per frame (ms). Alternativament `fps`. // construeix `AnimationComponent::fromYaml`.
// - `loop` : per defecte true; al látigo el volem false (mor en void readActiveFrames(const fkyaml::node& yaml, std::vector<int>& active_out) {
// completar). if (!yaml.contains("animations") || !yaml["animations"].contains("swing")) { return; }
auto buildSwingAnim(const fkyaml::node& yaml, std::vector<int>& active_out)
-> AnimationComponent {
std::vector<int> frames = {0};
float fps = 8.0F;
bool loop = false;
if (yaml.contains("animations") && yaml["animations"].contains("swing")) {
const auto& sw = yaml["animations"]["swing"]; const auto& sw = yaml["animations"]["swing"];
if (sw.contains("frames")) { if (!sw.contains("active_frames")) { return; }
frames.clear();
for (const auto& f : sw["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (sw.contains("frame_ms")) {
const auto MS = static_cast<float>(sw["frame_ms"].get_value<int>());
if (MS > 0.0F) { fps = 1000.0F / MS; }
} else if (sw.contains("fps")) {
fps = sw["fps"].get_value<float>();
}
if (sw.contains("loop")) { loop = sw["loop"].get_value<bool>(); }
if (sw.contains("active_frames")) {
for (const auto& f : sw["active_frames"]) { for (const auto& f : sw["active_frames"]) {
// cppcheck-suppress useStlAlgorithm // cppcheck-suppress useStlAlgorithm
active_out.push_back(f.get_value<int>()); active_out.push_back(f.get_value<int>());
} }
} }
}
return {frames, fps, loop};
}
} // namespace } // namespace
@@ -86,7 +62,8 @@ WhipAttack::WhipAttack(const fkyaml::node& yaml) {
if (s.contains("west")) { sprite_west_ = readDirSprite(s["west"]); } if (s.contains("west")) { sprite_west_ = readDirSprite(s["west"]); }
} }
anim_swing_ = buildSwingAnim(yaml, active_frames_); anim_swing_ = AnimationComponent::fromYaml(yaml, "swing");
readActiveFrames(yaml, active_frames_);
anim_ = anim_swing_; anim_ = anim_swing_;
} }
+12 -1
View File
@@ -24,7 +24,7 @@ void Entity::onPlayerContact(Player* /*player*/) {
// No-op per defecte. // No-op per defecte.
} }
void Entity::notifyTilePainted(int /*col*/, int /*row*/) { void Entity::notifyTilePainted(int /*col*/, int /*row*/, int /*player_id*/) {
// No-op per defecte; només `Sarcophagus` l'usa. // No-op per defecte; només `Sarcophagus` l'usa.
} }
@@ -63,6 +63,17 @@ auto Entity::getCollisionRect() const -> SDL_FRect {
}; };
} }
auto Entity::getLightCenter() const -> SDL_FPoint {
// 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) + static_cast<float>(light_offset_x_),
.y = pixel_rect_.y + (pixel_rect_.h * 0.5F) + static_cast<float>(light_offset_y_),
};
}
auto Entity::getFeetY() const -> float { auto Entity::getFeetY() const -> float {
// Decal de sòl: sentinel mínim per quedar al principi del Y-sort // 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 // ascendent de `GameScene` (stable_sort) i així pintar-se ABANS de
+112 -3
View File
@@ -2,10 +2,12 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <cstdint>
#include <memory> #include <memory>
#include <optional> #include <optional>
#include <string> #include <string>
#include "core/rendering/lighting.hpp"
#include "game/entities/animation_component.hpp" #include "game/entities/animation_component.hpp"
// Forward-declaracions: mantenim aquest header lleuger. `Player` viu a // Forward-declaracions: mantenim aquest header lleuger. `Player` viu a
@@ -84,8 +86,12 @@ class Entity {
// Hook que el `EntityManager` invoca per a cada entitat cada vegada // Hook que el `EntityManager` invoca per a cada entitat cada vegada
// que un tile es pinta (via PaintLayer, com a conseqüència d'un // que un tile es pinta (via PaintLayer, com a conseqüència d'un
// jugador trepitjant un sòl pintable). Només `Sarcophagus` ho consumeix // jugador trepitjant un sòl pintable). Només `Sarcophagus` ho consumeix
// (per marcar perímetre); per defecte no-op. // (per marcar perímetre); per defecte no-op. `player_id` és l'id del
virtual void notifyTilePainted(int col, int row); // jugador que ha pintat el tile, o -1 si la pintada ve del broadcast
// inicial de càrrega de nivell (decals + murs, sense atribució). Els
// consumidors han de filtrar per `player_id >= 0` si necessiten
// atribució.
virtual void notifyTilePainted(int col, int row, int player_id);
// L'entitat ha rebut un fueteig amb solapament. Es passa el punter al // L'entitat ha rebut un fueteig amb solapament. Es passa el punter al
// `WhipAttack` perquè la subclasse pugui llegir-ne direcció o atributs // `WhipAttack` perquè la subclasse pugui llegir-ne direcció o atributs
@@ -102,7 +108,10 @@ class Entity {
// entitat. La base no fa res; `DynamicHazard` override per disparar // entitat. La base no fa res; `DynamicHazard` override per disparar
// el seu `triggerSpawnExit` (anima la fase EMERGING). Substitueix el // el seu `triggerSpawnExit` (anima la fase EMERGING). Substitueix el
// `dynamic_cast<DynamicHazard*>(observer)->triggerSpawnExit()` que el // `dynamic_cast<DynamicHazard*>(observer)->triggerSpawnExit()` que el
// manager feia abans — ara el dispatch és polimòrfic. // manager feia abans — ara el dispatch és polimòrfic. Entitats que
// requereixen una fase d'aparició (mòmia amb animació `raise`)
// l'inicien aquí, NO al ctor: així `materializePendingActorsStatic`
// (editor) les salta i queden visibles directament.
virtual void onJustSpawned(); virtual void onJustSpawned();
// Hook just abans que el manager esborri l'entitat (via // Hook just abans que el manager esborri l'entitat (via
@@ -125,6 +134,15 @@ class Entity {
[[nodiscard]] auto collides() const -> bool { return collides_; } [[nodiscard]] auto collides() const -> bool { return collides_; }
[[nodiscard]] auto interactable() const -> bool { return interactable_; } [[nodiscard]] auto interactable() const -> bool { return interactable_; }
// Marca d'entitat que ha de bloquejar el moviment del jugador (com un
// mur). `collides_=true` només garanteix que l'entitat reb el callback
// `onPlayerContact` quan el jugador la toca, no que el seu rect sigui
// sòlid: dynamic hazards (mòmies, roques) tenen `collides_=true` però
// no han de bloquejar el pas — el contacte resol el dany i la roca es
// mou independentment. Per a "murs passius" (sarcs, columnes, debris)
// posem aquest flag a true i `Map::collides` els tracta com a obstacle.
[[nodiscard]] auto blocksMovement() const -> bool { return blocks_movement_; }
// Trigger d'àrea: l'entitat NO bloqueja el moviment (`collides_=false`) // Trigger d'àrea: l'entitat NO bloqueja el moviment (`collides_=false`)
// però sí vol rebre `onPlayerContact` quan el jugador la solapa. Pensat // però sí vol rebre `onPlayerContact` quan el jugador la solapa. Pensat
// per a hazards "no físics" (forats al sòl, picos retràctils, plaques // per a hazards "no físics" (forats al sòl, picos retràctils, plaques
@@ -187,6 +205,14 @@ class Entity {
// Entitats sense animació retornen `nullopt` → frame 0 per defecte. // Entitats sense animació retornen `nullopt` → frame 0 per defecte.
[[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; } [[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; }
// Sobreescriu l'`AnimationComponent` actual. L'usa el loader del nivell
// per aplicar el camp opcional `frame: N` del spawn a entitats amb
// variants estàtiques (debris, columnes, etc.): el spawn fixa una
// animació de 1 frame al índex escollit i el render del manager clipa
// el sub-sprite corresponent. També és útil per a tests que volen
// controlar el frame mostrat sense passar pel YAML.
void setAnimation(AnimationComponent anim) { anim_ = std::move(anim); }
// Alçada del cos de l'entitat en píxels. La consulten els filtres // Alçada del cos de l'entitat en píxels. La consulten els filtres
// de col·lisió per altura: si un jugador en l'aire passa per damunt // de col·lisió per altura: si un jugador en l'aire passa per damunt
// d'aquest valor (jump_z_ > height), no col·lisiona amb l'entitat — // d'aquest valor (jump_z_ > height), no col·lisiona amb l'entitat —
@@ -229,6 +255,74 @@ class Entity {
[[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; } [[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; }
void setFlip(SDL_FlipMode flip) { flip_ = 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; }
// 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; }
// 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
// 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_` (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: protected:
std::string name_; std::string name_;
TilePos tile_{}; TilePos tile_{};
@@ -240,7 +334,22 @@ class Entity {
bool interactable_{false}; bool interactable_{false};
bool triggers_on_overlap_{false}; bool triggers_on_overlap_{false};
bool is_floor_decal_{false}; bool is_floor_decal_{false};
bool blocks_movement_{false};
int height_{0}; 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};
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}; bool dead_{false};
SDL_FlipMode flip_{SDL_FLIP_NONE}; SDL_FlipMode flip_{SDL_FLIP_NONE};
EventBus* bus_{nullptr}; EventBus* bus_{nullptr};
+30 -6
View File
@@ -7,6 +7,7 @@
#include "game/entities/concrete/door.hpp" #include "game/entities/concrete/door.hpp"
#include "game/entities/concrete/sarcophagus.hpp" #include "game/entities/concrete/sarcophagus.hpp"
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
#include "game/map/map.hpp"
// NOTA: `loadFromLevel` viu a `entity_manager_loadlevel.cpp` per evitar // NOTA: `loadFromLevel` viu a `entity_manager_loadlevel.cpp` per evitar
// arrossegar `Resource::Cache`, `Surface` i les seues dependències (GIF, // arrossegar `Resource::Cache`, `Surface` i les seues dependències (GIF,
@@ -30,7 +31,7 @@ EntityManager::EntityManager() {
// els sarcòfags puguin actualitzar el seu `visited_`. // els sarcòfags puguin actualitzar el seu `visited_`.
tile_sub_id_ = bus_.subscribe("tile_painted", [this](const Event& ev) { tile_sub_id_ = bus_.subscribe("tile_painted", [this](const Event& ev) {
const auto* tile = std::get_if<TilePos>(&ev.payload); const auto* tile = std::get_if<TilePos>(&ev.payload);
if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row); } if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row, ev.player_id); }
}); });
// Subscripció a `player_exited` (publicat per Door en travessar la // Subscripció a `player_exited` (publicat per Door en travessar la
@@ -51,12 +52,23 @@ EntityManager::EntityManager() {
}); });
} }
}); });
// Connectar amb el check global de col·lisió del Map: les entitats
// sòlides genèriques (debris, columnes, futurs murs) viuen al manager,
// no a la graella fixa de sarcs, així que el `Map::collides` del Player
// les ha de consultar via callback. Es desregistra al dtor.
Map::setEntityCollider([this](const SDL_FRect& box) {
return entityCollidesAt(box);
});
} }
EntityManager::~EntityManager() { EntityManager::~EntityManager() {
bus_.unsubscribe(sarc_sub_id_); bus_.unsubscribe(sarc_sub_id_);
bus_.unsubscribe(tile_sub_id_); bus_.unsubscribe(tile_sub_id_);
bus_.unsubscribe(exit_sub_id_); bus_.unsubscribe(exit_sub_id_);
// Desconnectar del Map: si quedem en namespace global apuntant a un
// `this` que mor, qualsevol `Map::collides` posterior cauria en UB.
Map::setEntityCollider(nullptr);
} }
void EntityManager::setExpectedPlayerCount(int n) { void EntityManager::setExpectedPlayerCount(int n) {
@@ -129,12 +141,13 @@ void EntityManager::update(float delta_time) {
std::erase_if(entities_, [](const auto& e) { return e->isDead(); }); std::erase_if(entities_, [](const auto& e) { return e->isDead(); });
} }
void EntityManager::notifyPlayerTilePainted(int col, int row) { void EntityManager::notifyPlayerTilePainted(int col, int row, int player_id) {
// Fan-out polimòrfic a totes les entitats: cada una decideix què fer // Fan-out polimòrfic a totes les entitats: cada una decideix què fer
// (`Sarcophagus` actualitza el seu perímetre, la resta queden en // (`Sarcophagus` actualitza el seu perímetre i recorda qui va tancar
// no-op). Cost d'iterar 40 elements amb crida virtual buida ~ 1 µs; // el cercle, la resta queden en no-op). `player_id` ve del bus
// qui paga el cost guanya simplicitat al callsite. // (`ev.player_id`) — pot ser -1 al broadcast de càrrega inicial de
for (auto& e : entities_) { e->notifyTilePainted(col, row); } // nivell, els consumidors filtren si necessiten atribució.
for (auto& e : entities_) { e->notifyTilePainted(col, row, player_id); }
} }
auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* { auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* {
@@ -153,6 +166,17 @@ auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Ent
return nullptr; return nullptr;
} }
auto EntityManager::entityCollidesAt(const SDL_FRect& box) const -> bool {
// Iteració lineal sobre entitats amb `blocksMovement()`. La convenció
// d'overlap és la mateixa que `Map::collides` (estricta: el toc al
// píxel no compta) per coherència en els passadissos del laberint.
return std::ranges::any_of(entities_, [&box](const auto& e) {
if (!e->blocksMovement()) { return false; }
const auto R = e->getCollisionRect();
return (R.x + R.w) > box.x && (box.x + box.w) > R.x && (R.y + R.h) > box.y && (box.y + box.h) > R.y;
});
}
auto EntityManager::getEntities() const -> std::vector<Entity*> { auto EntityManager::getEntities() const -> std::vector<Entity*> {
std::vector<Entity*> out; std::vector<Entity*> out;
out.reserve(entities_.size()); out.reserve(entities_.size());
+19 -4
View File
@@ -21,9 +21,10 @@ class DynamicHazard;
// 2. `update(dt)` propaga el tick a totes. // 2. `update(dt)` propaga el tick a totes.
// 3. Comptador de sarcòfags oberts — quan iguala el total registrat, // 3. Comptador de sarcòfags oberts — quan iguala el total registrat,
// crida `door_->open()` automàticament. // crida `door_->open()` automàticament.
// 4. `notifyPlayerTilePainted(col, row)` — fan-out del hook per a tots // 4. `notifyPlayerTilePainted(col, row, player_id)` — fan-out del hook
// els sarcòfags (i qualsevol entitat futura que s'hi subscrigui via // per a tots els sarcòfags (i qualsevol entitat futura que s'hi
// `notifyTilePainted`). // subscrigui via `notifyTilePainted`). El `player_id` permet
// atribuir l'acció al jugador concret (p.ex. qui tanca un sarc).
// //
// NO és un singleton: es crea una instància per nivell. De moment no // NO és un singleton: es crea una instància per nivell. De moment no
// s'usa encara al GameScene (andamiatge pur). // s'usa encara al GameScene (andamiatge pur).
@@ -103,7 +104,7 @@ class EntityManager {
// notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte // notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte
// (veure Entity::notifyTilePainted). Invocat com a reacció a // (veure Entity::notifyTilePainted). Invocat com a reacció a
// `tile_painted` al bus (re-publicat pel PaintLayer un cop filtrat). // `tile_painted` al bus (re-publicat pel PaintLayer un cop filtrat).
void notifyPlayerTilePainted(int col, int row); void notifyPlayerTilePainted(int col, int row, int player_id);
// Carrega els spawns d'un YAML de nivell i els afegeix al manager: // Carrega els spawns d'un YAML de nivell i els afegeix al manager:
// 1. Llegeix el bloc `entities:` via EntityLoader. // 1. Llegeix el bloc `entities:` via EntityLoader.
@@ -134,6 +135,15 @@ class EntityManager {
// `respawnPlayersInstant` (salt debug). // `respawnPlayersInstant` (salt debug).
void spawnPendingActors(); void spawnPendingActors();
// Materialitza els pending actors al món sense disparar la cinemàtica
// de SPAWN (`onJustSpawned`). Pensat per a l'editor: vol veure les
// mòmies a la seva posició inicial per poder editar-les, però no
// disparar l'animació EMERGING (l'editor congela el món, l'animació
// no avançaria i quedaria invisible). En sortir de l'editor, GameScene
// fa un `reload` que torna els actors a `pending_actors_` i el flux
// normal els spawneja amb la cinemàtica al final del ROOM_INTRO.
void materializePendingActorsStatic();
// Crea un actor on-demand i el deixa en EMERGING (com a // Crea un actor on-demand i el deixa en EMERGING (com a
// `spawnPendingActors` però per a un sol). Pensat per a futurs // `spawnPendingActors` però per a un sol). Pensat per a futurs
// generadors (StaticHazards que produeixen DynamicHazards en runtime, // generadors (StaticHazards que produeixen DynamicHazards en runtime,
@@ -181,6 +191,11 @@ class EntityManager {
} }
private: private:
// Suport per al callback registrat a `Map::setEntityCollider`. Itera
// les entitats amb `blocksMovement()` i fa overlap estricte (mateixa
// convenció que `Map::collides`). El callback el registra el ctor.
[[nodiscard]] auto entityCollidesAt(const SDL_FRect& box) const -> bool;
EventBus bus_; EventBus bus_;
// Un sol contenidor lògic per a totes les entitats instanciades. La // Un sol contenidor lògic per a totes les entitats instanciades. La
// discriminació de tipus la fan els virtuals (`onPlayerContact`, // discriminació de tipus la fan els virtuals (`onPlayerContact`,
+205 -15
View File
@@ -4,6 +4,8 @@
// consumidors que només volen la base del manager (p.ex. el smoke test // consumidors que només volen la base del manager (p.ex. el smoke test
// stand-alone) enllacen `entity_manager.cpp` sense arrossegar aquest fitxer. // stand-alone) enllacen `entity_manager.cpp` sense arrossegar aquest fitxer.
#include <algorithm>
#include <cstdint>
#include <filesystem> #include <filesystem>
#include <memory> #include <memory>
#include <string> #include <string>
@@ -155,6 +157,147 @@ namespace {
return c["height"].get_value<int>(); return c["height"].get_value<int>();
} }
// 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::uint8_t>(std::clamp(seq[0].get_value<int>(), 0, 255));
g = static_cast<std::uint8_t>(std::clamp(seq[1].get_value<int>(), 0, 255));
b = static_cast<std::uint8_t>(std::clamp(seq[2].get_value<int>(), 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<int>();
y = seq[1].get_value<int>();
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<float>(); }
if (fp.contains("wave_frequency_hz")) { out.wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
if (fp.contains("noise_amplitude_ratio")) { out.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
if (fp.contains("noise_frequency_hz")) { out.noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
}
auto readLight(const fkyaml::node& tmpl) -> LightTemplate {
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<int>(); }
if (l.contains("flicker")) { out.flicker = l["flicker"].get_value<bool>(); }
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;
}
// Combina els defaults del tipus amb l'override de sala (si n'hi ha).
// Forma 1: la sala no diu res del tipus → es retornen els defaults
// del tipus tal qual (cas habitual / retrocompat).
// Forma 2: la sala declara `<tipus>: false` → radi 0 (entitat sense
// llum encara que el seu YAML en declari).
// Forma 3: la sala declara `<tipus>: true` o `<tipus>: { ... }` → els
// camps presents a l'override sobreescriuen el default; els absents
// (`std::nullopt`) cauen al default del tipus.
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) {
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` → 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"];
if (l.is_boolean()) {
if (!l.get_value<bool>()) {
entity.setLightRadius(0);
entity.setLightFlicker(false);
}
return;
}
if (!l.is_mapping()) { return; }
if (l.contains("radius")) { entity.setLightRadius(l["radius"].get_value<int>()); }
if (l.contains("flicker")) { entity.setLightFlicker(l["flicker"].get_value<bool>()); }
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]? }` // Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }`
// de la plantilla d'entitat. // de la plantilla d'entitat.
// - Sense `base_size` ni `offset`: la caja queda alineada a la vora // - Sense `base_size` ni `offset`: la caja queda alineada a la vora
@@ -277,6 +420,16 @@ namespace {
entity->setCollisionBox(*BOX); entity->setCollisionBox(*BOX);
} }
entity->setHeight(readHeight(tmpl)); entity->setHeight(readHeight(tmpl));
// Defaults del tipus → resolució amb override de sala. El layer
// del spawn (per-instància) viu fora de `buildEntity`, perquè
// aquí encara no tenim el node del spawn — el sobreposem a
// `addSpawn` just després.
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); anchor_out = readAnchor(tmpl);
return entity; return entity;
} }
@@ -395,6 +548,17 @@ namespace {
} }
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height}); entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
// Variant del atlas: el spawn pot fixar un sub-sprite estàtic via
// `frame: N`. El render del manager ja sap clipar (col, row) a
// partir d'un índex lineal i la mida del frame; ací només cal
// muntar un `AnimationComponent` d'un sol frame i imposar-lo a la
// entitat. Cap entitat existent al joc emet aquest camp; només
// els muros decoratius amb variants (stone, column, ...) l'usen.
if (spawn.contains("frame")) {
const int FRAME = spawn["frame"].get_value<int>();
entity->setAnimation(AnimationComponent::uniform({FRAME}, 1000.0F, false));
}
manager.add(std::move(entity)); manager.add(std::move(entity));
return true; return true;
} }
@@ -428,6 +592,10 @@ auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* {
std::string anchor; std::string anchor;
auto entity = buildEntity(TYPE, sprite, anchor); auto entity = buildEntity(TYPE, sprite, anchor);
if (entity == nullptr) { return nullptr; } if (entity == nullptr) { return nullptr; }
// Tercera capa de resolució de llum: override per-instància declarat
// al spawn del YAML de la sala. Capes prèvies (tipus, sala) ja viuen
// dins de l'entitat. Aplica abans del std::move per no perdre l'accés.
applySpawnLightOverride(*entity, spawn);
Entity* observer = entity.get(); Entity* observer = entity.get();
bool ok = false; bool ok = false;
@@ -459,31 +627,45 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
if (addSpawn(spawn) != nullptr) { ++loaded; } if (addSpawn(spawn) != nullptr) { ++loaded; }
} }
// Broadcast inicial: marca com a "pintats" tots els tiles ocupats per // Broadcast inicial: marca com a "pintats" tots els tiles que el
// decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...). // jugador no pot trepitjar mai, de manera que el perímetre dels
// Imprescindible perquè el jugador no pot trepitjar aquests tiles // sarcòfags vecins ja els consideri visitats i la sala sigui
// (cauria al forat) i els sarcòfags vecins no podrien completar el // completable. Inclou dues famílies:
// perímetre — la sala seria incompletable. Usa el canal semàntic // - Decals de sòl (Hole, i futurs pinchos plans, plaques, charcos):
// `tile_painted` (lògica de joc), no `on_tile_entered` (presència // trigger-on-overlap que mataria/atraparia al jugador en trepitjar.
// física del jugador). Ho fem aquí, després del bucle de spawns, // - Entitats sòlides genèriques (debris, columnes...): obstacles
// perquè tots els sarcòfags ja estan registrats al fan-out (la // físics que `Map::collides` bloqueja, així `isPaintable` retorna
// subscripció del manager és al ctor, anterior a qualsevol load). // false per als seus tiles i ningú els pot pintar.
// Usa el canal semàntic `tile_painted` (lògica de joc), no
// `on_tile_entered` (presència física). Es fa aquí, després del bucle
// de spawns, perquè tots els sarcòfags ja estan registrats al fan-out
// (la subscripció del manager és al ctor, anterior a qualsevol load).
int decal_broadcast = 0; int decal_broadcast = 0;
// Els decals de sòl (Hole, futurs pinchos plans...) són StaticHazards;
// iterem tot `entities_` i el filtre per `isFloorDecal()` els tria.
for (const auto& entity : entities_) { for (const auto& entity : entities_) {
if (!entity->isFloorDecal()) { continue; } const bool IS_DECAL = entity->isFloorDecal();
const bool IS_SOLID = entity->blocksMovement();
if (!IS_DECAL && !IS_SOLID) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
const auto [COL, ROW] = Map::pixelToTile(BOX.x, BOX.y); // Per a entitats que ocupen més d'un tile (futurs murs 32×16,
// 16×32, etc.), iterem tota la zona coberta pel rect. Per a decals
// i blocs d'un sol tile cau a un sol broadcast (mateix comportament
// que abans).
const auto [COL0, ROW0] = Map::pixelToTile(BOX.x, BOX.y);
const int COLS = (Map::tile_size > 0) ? std::max(1, static_cast<int>(BOX.w) / Map::tile_size) : 1;
const int ROWS = (Map::tile_size > 0) ? std::max(1, static_cast<int>(BOX.h) / Map::tile_size) : 1;
for (int dr = 0; dr < ROWS; ++dr) {
for (int dc = 0; dc < COLS; ++dc) {
bus_.publish(Event{ bus_.publish(Event{
.name = "tile_painted", .name = "tile_painted",
.payload = TilePos{.col = COL, .row = ROW}, .payload = TilePos{.col = COL0 + dc, .row = ROW0 + dr},
.player_id = -1, .player_id = -1,
}); });
++decal_broadcast; ++decal_broadcast;
} }
}
}
if (decal_broadcast > 0) { if (decal_broadcast > 0) {
Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast); Log::info("EntityManager", "{} tile_painted broadcast inicial (decals + murs)", decal_broadcast);
} }
Log::info("EntityManager", "{} entitats + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path); Log::info("EntityManager", "{} entitats + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path);
@@ -502,6 +684,14 @@ void EntityManager::spawnPendingActors() {
pending_actors_.clear(); pending_actors_.clear();
} }
void EntityManager::materializePendingActorsStatic() {
// Igual que `spawnPendingActors` però sense `onJustSpawned`: l'actor
// queda en el seu estat inicial (mòmia: ROAMING, walk_east) en lloc
// d'EMERGING. Vegeu el comentari del header per la motivació.
for (const auto& pa : pending_actors_) { addSpawn(pa.spawn_node); }
pending_actors_.clear();
}
auto EntityManager::spawnActor(const std::string& type, int col, int row) -> DynamicHazard* { auto EntityManager::spawnActor(const std::string& type, int col, int row) -> DynamicHazard* {
// Construïm un node de spawn sintètic amb el mínim que `placeGeneric` // Construïm un node de spawn sintètic amb el mínim que `placeGeneric`
// necessita: `type` i `position: [col, row]`. Si el caller vol // necessita: `type` i `position: [col, row]`. Si el caller vol
+3
View File
@@ -60,6 +60,9 @@ namespace EntityWriter {
} else if (s.col.has_value() && s.row.has_value()) { } else if (s.col.has_value() && s.row.has_value()) {
out += " position: [" + std::to_string(*s.col) + ", " + std::to_string(*s.row) + "]\n"; out += " position: [" + std::to_string(*s.col) + ", " + std::to_string(*s.row) + "]\n";
} }
if (s.frame.has_value()) {
out += " frame: " + std::to_string(*s.frame) + "\n";
}
} }
return out; return out;
} }
+4
View File
@@ -35,12 +35,16 @@ namespace EntityWriter {
// Cada spawn duplica l'estructura mínima del que el lector espera: // Cada spawn duplica l'estructura mínima del que el lector espera:
// type sempre, i un dels (position, sarc_position) o cap (cas door). // type sempre, i un dels (position, sarc_position) o cap (cas door).
// `frame` és opcional i només l'emeten les entitats amb variants
// estàtiques (debris, columnes); fixa quin sub-sprite del atlas
// representa aquesta instància concreta.
struct Spawn { struct Spawn {
std::string type; std::string type;
std::optional<int> col; std::optional<int> col;
std::optional<int> row; std::optional<int> row;
std::optional<int> sarc_col; std::optional<int> sarc_col;
std::optional<int> sarc_row; std::optional<int> sarc_row;
std::optional<int> frame;
}; };
auto saveEntitiesBlock(const std::string& path, const std::vector<Spawn>& spawns) -> bool; auto saveEntitiesBlock(const std::string& path, const std::vector<Spawn>& spawns) -> bool;
+26 -3
View File
@@ -19,12 +19,21 @@ class Player;
// Event pub/sub minimalista. Hi ha un únic `EventBus` per mundus (propietat // Event pub/sub minimalista. Hi ha un únic `EventBus` per mundus (propietat
// de l'`EntityManager`); les entitats que el necessiten en reben un punter // de l'`EntityManager`); les entitats que el necessiten en reben un punter
// via `Entity::setEventBus`. Aquest sistema cobreix les necessitats // via `Entity::setEventBus`. Aquest sistema cobreix les necessitats
// actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered) // actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered,
// sense portar-hi senyalització més pesada. // player_stats_changed) sense portar-hi senyalització més pesada.
// //
// Decisió: `std::variant` per al payload — evita any/void* i facilita // Decisió: `std::variant` per al payload — evita any/void* i facilita
// consumir events tipats amb `std::get_if`. Si apareixen més tipus de // consumir events tipats amb `std::get_if`. Si apareixen més tipus de
// payload, s'amplia aquí sense tocar cridants existents. // payload, s'amplia aquí sense tocar cridants existents.
//
// `player_stats_changed`: l'únic canal numèric que toca el HUD. El `Player`
// és la font de veritat de vides/score/tresors i el publica cada vegada
// que muta qualsevol d'aquests camps. El HUD el consumeix com a "snapshot
// total" (no com a delta) — sobreescriu la seua caché amb el payload. Així
// qualsevol via que modifique stats al Player (mort per forat, recompensa
// de sarc, etc.) queda automàticament reflectida sense que cada caller
// haja de recordar publicar res addicional. `player_hit` queda només per
// al feedback visual (flash/shake), separat del comptador.
// Tile (col, row) — payload de `tile_painted` (i altres events de tile sense // Tile (col, row) — payload de `tile_painted` (i altres events de tile sense
// metadada extra). No és un `pair<int,int>` per evitar alias ambigus al // metadada extra). No és un `pair<int,int>` per evitar alias ambigus al
@@ -74,9 +83,20 @@ struct WhipSpawnPayload {
float range_px{0.0F}; float range_px{0.0F};
}; };
// Snapshot complet dels stats d'un jugador — payload de `player_stats_changed`.
// El Player publica un d'aquests cada vegada que muta vides/score/tresors.
// El HUD el consumeix com a estat absolut (no delta): sobreescriu la seua
// caché amb aquests valors. El `player_id` de l'`Event` indica a quin
// bloc del HUD va dirigit.
struct PlayerStatsPayload {
int lives{0};
int score{0};
int treasures{0};
};
struct Event { struct Event {
std::string name; std::string name;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos, WhipSpawnPayload>; using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos, WhipSpawnPayload, PlayerStatsPayload>;
Payload payload; Payload payload;
// Jugador que ha originat (o provocat) l'event. -1 = sense jugador // Jugador que ha originat (o provocat) l'event. -1 = sense jugador
// associat (events del món, com `sarcophagus_opened` des de la pròpia // associat (events del món, com `sarcophagus_opened` des de la pròpia
@@ -129,6 +149,9 @@ class EventBus {
void publish(const std::string& name, const WhipSpawnPayload& whip) { void publish(const std::string& name, const WhipSpawnPayload& whip) {
publish(Event{.name = name, .payload = whip}); publish(Event{.name = name, .payload = whip});
} }
void publish(const std::string& name, const PlayerStatsPayload& stats) {
publish(Event{.name = name, .payload = stats});
}
private: private:
struct Subscription { struct Subscription {
+97
View File
@@ -141,6 +141,55 @@ void Player::loadFrom(const std::string& yaml_path) {
// `duration_ms` és 0 (o el bloc manca) la mecànica queda // `duration_ms` és 0 (o el bloc manca) la mecànica queda
// desactivada i els hazards toquen al primer frame, com abans. // desactivada i els hazards toquen al primer frame, com abans.
readImmunityBlock(yaml); readImmunityBlock(yaml);
// 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;
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<int>(); }
if (l.contains("flicker")) { light_flicker = l["flicker"].get_value<bool>(); }
// `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::uint8_t>(std::clamp(c[0].get_value<int>(), 0, 255));
light_color_g = static_cast<std::uint8_t>(std::clamp(c[1].get_value<int>(), 0, 255));
light_color_b = static_cast<std::uint8_t>(std::clamp(c[2].get_value<int>(), 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<int>();
light_offset_y = o[1].get_value<int>();
}
}
// `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<float>(); }
if (fp.contains("wave_frequency_hz")) { flicker_params.wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
if (fp.contains("noise_amplitude_ratio")) { flicker_params.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
if (fp.contains("noise_frequency_hz")) { flicker_params.noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
}
} }
void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) { void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) {
@@ -1272,6 +1321,37 @@ void Player::applyDyingMovement(float delta_time) {
void Player::resetForJoin() { void Player::resetForJoin() {
lives_remaining_ = death_config.initial_lives; lives_remaining_ = death_config.initial_lives;
score_ = 0;
treasures_ = 0;
// Publica l'estat inicial perquè el HUD reompla el bloc en el moment
// de fer join (no només quan el comptador canvia després). Sense açò
// el HUD es quedaria amb la caché prèvia fins al primer event.
publishStatsChanged();
}
void Player::publishStatsChanged() {
if (bus_ == nullptr) { return; }
bus_->publish(Event{
.name = "player_stats_changed",
.payload = PlayerStatsPayload{
.lives = lives_remaining_,
.score = score_,
.treasures = treasures_,
},
.player_id = id_,
});
}
void Player::addScore(int delta) {
if (delta == 0) { return; }
score_ += delta;
publishStatsChanged();
}
void Player::addTreasures(int delta) {
if (delta == 0) { return; }
treasures_ += delta;
publishStatsChanged();
} }
void Player::respawnFromDying() { void Player::respawnFromDying() {
@@ -1283,6 +1363,12 @@ void Player::respawnFromDying() {
--lives_remaining_; --lives_remaining_;
death_animation_complete_ = false; death_animation_complete_ = false;
respawn_remaining_ms_ = 0.0F; respawn_remaining_ms_ = 0.0F;
// Notifica al HUD del nou comptador de vides abans de qualsevol
// transició d'estat. Aquesta crida tanca el bucle de qualsevol via
// de mort (mòmia, traps, rolling rock, FORAT…): totes acaben aquí, i
// ningú més ha de recordar publicar res. Era exactament el punt que
// s'oblidava al camí del forat.
publishStatsChanged();
if (lives_remaining_ <= 0) { if (lives_remaining_ <= 0) {
disable(); disable();
if (bus_ != nullptr) { if (bus_ != nullptr) {
@@ -1425,6 +1511,17 @@ void Player::render() {
if (CLIP_HOLE) { Rendering::popClip(); } 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.
// Sumem també l'offset estàtic configurat al YAML del personatge.
return {
.x = getPosX() + (static_cast<float>(sprite_w) * 0.5F) + static_cast<float>(light_offset_x),
.y = getPosY() - jump_z_ + (static_cast<float>(sprite_h) * 0.5F) + static_cast<float>(light_offset_y),
};
}
void Player::renderShadow() const { void Player::renderShadow() const {
// Sense salt actiu, no hi ha sombra: això simplifica els callsites, // Sense salt actiu, no hi ha sombra: això simplifica els callsites,
// que poden cridar `renderShadow` per a tots els jugadors sense // que poden cridar `renderShadow` per a tots els jugadors sense
+72
View File
@@ -7,6 +7,7 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
#include "core/rendering/lighting.hpp"
#include "core/rendering/sprite/animated_sprite.hpp" #include "core/rendering/sprite/animated_sprite.hpp"
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
@@ -124,6 +125,27 @@ class Player : public AnimatedSprite {
// creua mentre `jump_z_` és superior a aquest valor, no col·lisionem. // creua mentre `jump_z_` és superior a aquest valor, no col·lisionem.
inline static int body_height = 0; 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, 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. // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
static void loadFrom(const std::string& yaml_path); static void loadFrom(const std::string& yaml_path);
@@ -132,6 +154,25 @@ class Player : public AnimatedSprite {
// marcador i `Player::resetForJoin` per a re-entrades mid-partida. // marcador i `Player::resetForJoin` per a re-entrades mid-partida.
[[nodiscard]] static auto getInitialLives() -> int { return death_config.initial_lives; } [[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]] 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
// 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); 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 // Update en una sola crida: manté l'API anterior per als tests i
@@ -158,6 +199,21 @@ class Player : public AnimatedSprite {
// xarxa — és únic per dins del procés. // xarxa — és únic per dins del procés.
[[nodiscard]] auto getId() const -> int { return id_; } [[nodiscard]] auto getId() const -> int { return id_; }
// Stats que pinta el HUD. El Player n'és la font de veritat; cada
// mutació es publica via `player_stats_changed` al bus (vegeu
// `publishStatsChanged`). Els getters són per a tests i diagnòstic;
// el HUD no els consulta — escolta el bus.
[[nodiscard]] auto getLives() const -> int { return lives_remaining_; }
[[nodiscard]] auto getScore() const -> int { return score_; }
[[nodiscard]] auto getTreasures() const -> int { return treasures_; }
// Acumuladors. Deltas positius i negatius (sense clamp). Publiquen
// `player_stats_changed` només si el delta no és 0, per estalviar
// soroll al bus. Els consumidors típics: GameScene reparteix puntuació
// de sarcs i pickups (futur) cridant aquests mètodes.
void addScore(int delta);
void addTreasures(int delta);
// Accés i mutació de la velocitat intencionada d'aquest frame. El // Accés i mutació de la velocitat intencionada d'aquest frame. El
// GameScene els toca entre `tick` i `applyMovement` per implementar // GameScene els toca entre `tick` i `applyMovement` per implementar
// col·lisions jugador↔jugador (escalar a push_factor, anul·lar en // col·lisions jugador↔jugador (escalar a push_factor, anul·lar en
@@ -727,6 +783,11 @@ class Player : public AnimatedSprite {
// readers — `loadFrom` el crida al final. // readers — `loadFrom` el crida al final.
static void readImmunityBlock(const fkyaml::node& yaml); 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 // Activa l'estat ATTACKING en la direcció cardinal indicada (la
// direcció cap on mira el jugador) i publica `whip_spawn` al bus // direcció cap on mira el jugador) i publica `whip_spawn` al bus
// perquè el GameScene cree el `WhipAttack` corresponent. Posa el // perquè el GameScene cree el `WhipAttack` corresponent. Posa el
@@ -852,6 +913,17 @@ class Player : public AnimatedSprite {
// inicial quan el jugador re-entra mid-partida. // inicial quan el jugador re-entra mid-partida.
int lives_remaining_{0}; int lives_remaining_{0};
// Puntuació acumulada i nombre de tresors recollits. Es modifiquen
// via `addScore` / `addTreasures` (o reset a `resetForJoin`). El HUD
// els sincronitza via `player_stats_changed`.
int score_{0};
int treasures_{0};
// Publica un snapshot complet (vides/score/tresors) al bus si està
// injectat. Centralitza la generació de l'event perquè cap mutador
// se l'oblide.
void publishStatsChanged();
// Coordenades del spawn de respawn (cantó superior-esquerra del // Coordenades del spawn de respawn (cantó superior-esquerra del
// sprite). Les assigna el GameScene via `setSpawnPoint`. 0/0 fins que // sprite). Les assigna el GameScene via `setSpawnPoint`. 0/0 fins que
// el GameScene n'invoque el setter al ctor / `loadRoom`. // el GameScene n'invoque el setter al ctor / `loadRoom`.
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "game/entities/entity.hpp"
// Mur passiu: bloqueja moviment i salt però no té ni estat ni acció. Paral·lel
// a `Decoration` (sense col·lisió) i `Interactable` (col·lisiona i té
// estat/interact). Pensat per a debris/obstacles purament visuals que sí
// han de detenir el jugador: pedres, columnes, restes.
//
// Diferència amb `Interactable`: no override `interact()` ni
// `notifyTilePainted`. La caixa de col·lisió i alçada efectiva surten del
// YAML (`collision.size`, `collision.height`); aquest ctor només fixa els
// flags i un default raonable per a "mur infranquejable".
class SolidDecoration : public Entity {
public:
using Entity::Entity;
SolidDecoration() {
collides_ = true;
interactable_ = false;
height_ = 999;
// Sí bloqueja el moviment del jugador: `Map::collides` el consulta
// via callback per parar el moviment com si fos una paret sòlida.
blocks_movement_ = true;
}
};
+151
View File
@@ -1,10 +1,13 @@
#include "game/map/map.hpp" #include "game/map/map.hpp"
#include <algorithm>
#include <cmath> #include <cmath>
#include <stdexcept> #include <stdexcept>
#include <unordered_map>
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp" #include "utils/log.hpp"
namespace { namespace {
@@ -21,6 +24,138 @@ namespace {
namespace Map { namespace Map {
namespace {
// Predicat extra que el `collides` consulta com a sobre-capa,
// injectat des de `EntityManager` per a entitats sòlides genèriques
// (debris, columnes). El TU local manté l'estat sense exposar-lo
// al header.
std::function<bool(const SDL_FRect&)> g_entity_collider;
// Overrides d'emissor declarats al bloc `lighting.emitters` del YAML
// de la sala. Clau = tipus d'entitat en singular (per concordar amb
// el camp `type:` de cada plantilla). Buit per defecte: una sala
// sense bloc `lighting.emitters` deixa que cada entitat utilitze els
// seus propis defaults del seu YAML de tipus.
std::unordered_map<std::string, RoomEmitterOverride> g_room_emitters;
// Plural → singular per a tipus coneguts. El YAML de sala fa servir
// el plural perquè es llegeix natural ("mummies: false"); el codi
// treballa amb la forma singular que coincideix amb el camp `type:`
// del spawn ("type: mummy"). Si la clau no és reconeguda, passa tal
// qual (suposa que l'autor ja l'ha escrit en singular).
auto normalizeEmitterKey(const std::string& yaml_key) -> std::string {
if (yaml_key == "players") { return "player"; }
if (yaml_key == "mummies") { return "mummy"; }
if (yaml_key == "torches") { return "torch"; }
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<std::array<std::uint8_t, 3>> {
if (!node.is_sequence() || node.size() != 3) { return std::nullopt; }
auto clamp = [](int v) { return static_cast<std::uint8_t>(std::clamp(v, 0, 255)); };
return std::array<std::uint8_t, 3>{
clamp(node[0].get_value<int>()),
clamp(node[1].get_value<int>()),
clamp(node[2].get_value<int>()),
};
}
// 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<std::array<int, 2>> {
if (!node.is_sequence() || node.size() != 2) { return std::nullopt; }
return std::array<int, 2>{
node[0].get_value<int>(),
node[1].get_value<int>(),
};
}
// 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<float>(); }
if (fp.contains("wave_frequency_hz")) { ov.flicker_wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
if (fp.contains("noise_amplitude_ratio")) { ov.flicker_noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
if (fp.contains("noise_frequency_hz")) { ov.flicker_noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
}
// Llegeix una entrada del mapping `lighting.emitters.<tipus>` 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<bool>();
return ov;
}
if (!val.is_mapping()) { return ov; }
ov.enabled = true;
if (val.contains("radius")) { ov.radius = val["radius"].get_value<int>(); }
if (val.contains("flicker")) { ov.flicker = val["flicker"].get_value<bool>(); }
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<std::string>();
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`, `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<float>();
}
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<std::string>());
g_room_emitters[KEY] = readEmitterEntry(it.value());
}
}
} // namespace
void setEntityCollider(std::function<bool(const SDL_FRect&)> fn) {
g_entity_collider = std::move(fn);
}
auto getRoomEmitterOverride(const std::string& type) -> const RoomEmitterOverride* {
const auto IT = g_room_emitters.find(type);
return IT == g_room_emitters.end() ? nullptr : &IT->second;
}
void loadFrom(const std::string& yaml_path) { void loadFrom(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path); auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) { if (bytes.empty()) {
@@ -90,6 +225,17 @@ namespace Map {
// canviarà el flag a true quan transiti a OPEN. // canviarà el flag a true quan transiti a OPEN.
door_open = false; door_open = false;
// Llum base de la sala. Dues formes acceptades:
// - `light_level: <float>` a l'arrel (forma antiga, encara
// suportada per retrocompat).
// - `lighting.level: <float>` dins del bloc nou `lighting:`,
// que també hostatja `lighting.emitters` (overrides per tipus).
// Si manca tot, queda al default 1.0 (sala totalment il·luminada).
// `loadLightingBlock` aplica el `lighting.level` per damunt del
// `light_level:` antic, així el nou bloc guanya si conviuen.
light_level = yaml.contains("light_level") ? yaml["light_level"].get_value<float>() : Defaults::Lighting::ROOM_LIGHT_DEFAULT;
loadLightingBlock(yaml);
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); 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);
} }
@@ -123,6 +269,11 @@ namespace Map {
}; };
if (overlapStrict(base_box, CORRIDOR)) { return true; } if (overlapStrict(base_box, CORRIDOR)) { return true; }
} }
// Capa final: entitats sòlides genèriques registrades pel manager.
// S'avalua al final perquè els obstacles fixos (sarcs, porta) són
// l'origen del laberint i el camí més curt; les entitats addicionals
// són normalment poques.
if (g_entity_collider && g_entity_collider(base_box)) { return true; }
return false; return false;
} }
+77 -1
View File
@@ -2,9 +2,15 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <array>
#include <cstdint>
#include <functional>
#include <optional>
#include <string> #include <string>
#include <utility> #include <utility>
#include "core/rendering/lighting.hpp"
// Mapa d'una piràmide: sala rectangular amb marcador superior, muros pels // 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 // 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 // cantó. La geometria (graella, pitch, amplada de murs) es carrega des del
@@ -49,6 +55,68 @@ namespace Map {
inline int back_wall_h = 0; inline int back_wall_h = 0;
inline int side_wall_w = 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. La forma nova és
// declarar-ho dins del bloc `lighting:` (vegeu més avall); `light_level:`
// 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.<tipus>`
// de la sala. Tres formes acceptades al YAML:
// <tipus>: false → desactiva la llum per a tot el tipus
// <tipus>: true → activa amb els defaults del tipus
// <tipus>: { radius: N, flicker: B } → activa i sobreescriu camps
// Els camps absents (`std::nullopt`) cauen al default del tipus
// d'entitat (radi/flicker del seu YAML). Els tipus no presents al
// mapping conserven el seu comportament per defecte (com avui).
//
// Conversió de clau YAML → tipus d'entitat: el YAML usa plural
// ("players", "mummies", "torches") perquè es llegeix natural per a
// l'autor de la sala; `loadFrom` normalitza a la clau singular
// ("player", "mummy", "torch") que coincideix amb el camp `type:` de
// cada plantilla d'entitat. Tipus no reconeguts s'accepten "tal qual"
// (singular pass-through) per si en el futur s'hi afegeixen emissors
// nous sense tocar el mapatge.
struct RoomEmitterOverride {
bool enabled{true};
std::optional<int> radius;
std::optional<bool> 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<std::array<std::uint8_t, 3>> 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<std::array<int, 2>> 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<float> flicker_wave_amplitude_ratio;
std::optional<float> flicker_wave_frequency_hz;
std::optional<float> flicker_noise_amplitude_ratio;
std::optional<float> flicker_noise_frequency_hz;
};
// Consulta l'override de sala per a un tipus d'entitat (clau singular,
// p.ex. "player" / "mummy" / "torch"). Retorna `nullptr` si la sala no
// declara res per aquest tipus — el caller usa els defaults del tipus
// sense modificació. El punter retornat és vàlid fins a la propera
// crida a `Map::loadFrom` (cada càrrega de nivell substitueix el
// mapping intern).
[[nodiscard]] auto getRoomEmitterOverride(const std::string& type) -> const RoomEmitterOverride*;
// Derivats de la geometria base: el pas entre sarcòfags ha de cabre // 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. // exactament el jugador, i el mateix passadís s'usa a l'exterior.
inline int corridor_w = 0; inline int corridor_w = 0;
@@ -91,9 +159,17 @@ namespace Map {
// Mapa de colisions (separat del mapa de dibuix): retorna true si el // Mapa de colisions (separat del mapa de dibuix): retorna true si el
// rectangle `base_box` (la base del jugador, per exemple) s'encavalca // rectangle `base_box` (la base del jugador, per exemple) s'encavalca
// amb cap sarcòfag. // amb cap sarcòfag, el passadís de la porta tancada, o cap entitat
// sòlida genèrica registrada via `setEntityCollider`.
[[nodiscard]] auto collides(const SDL_FRect& base_box) -> bool; [[nodiscard]] auto collides(const SDL_FRect& base_box) -> bool;
// Registre d'un predicat extern que `collides` consulta com a sobre-
// capa: l'usa `EntityManager` per a entitats amb `blocksMovement()`
// (debris, columnes...) que no estan a la graella fixa de sarcòfags.
// Passa `nullptr` per netejar. Reset implícit a `loadFrom` (cada
// càrrega de nivell sol portar un manager nou).
void setEntityCollider(std::function<bool(const SDL_FRect&)> fn);
// true si la caixa `box` queda completament dins del playfield (zona // true si la caixa `box` queda completament dins del playfield (zona
// interior, sense tocar els murs exteriors). El Player ja té clamp // interior, sense tocar els murs exteriors). El Player ja té clamp
// propi que aplica el mateix constrenyiment; això serveix a les // propi que aplica el mateix constrenyiment; això serveix a les
+155 -152
View File
@@ -16,6 +16,7 @@
#include "core/input/global_inputs.hpp" #include "core/input/global_inputs.hpp"
#include "core/input/key_config.hpp" #include "core/input/key_config.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/lighting.hpp"
#include "core/rendering/notifier.hpp" #include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp" #include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
@@ -26,6 +27,7 @@
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "game/entities/concrete/dust.hpp" #include "game/entities/concrete/dust.hpp"
#include "game/entities/concrete/sarcophagus.hpp"
#include "game/entities/concrete/whip_attack.hpp" #include "game/entities/concrete/whip_attack.hpp"
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
@@ -230,6 +232,39 @@ namespace {
return out; return out;
} }
// Override de sala per a la llum del jugador. Si el YAML de la sala
// declara `lighting.emitters.players`, substitueix els valors carregats
// per `Player::loadFrom`. Extret aquí per no inflar la complexitat
// cognitiva del ctor de GameScene (clang-tidy: cognitive-complexity).
// No s'aplica una "tercera capa" per-instància al jugador perquè no
// neix com a spawn del nivell; el dia d'un ítem "linterna" aquell
// sistema mutarà els mateixos camps en runtime — ortogonal a aquest
// override.
void applyRoomLightOverrideToPlayer() {
const auto* room = Map::getRoomEmitterOverride("player");
if (room == nullptr) { return; }
if (!room->enabled) {
Player::light_radius = 0;
Player::light_flicker = false;
return;
}
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 } // namespace
auto GameScene::defaultConfig() -> Config { auto GameScene::defaultConfig() -> Config {
@@ -287,6 +322,7 @@ GameScene::GameScene(Config config)
// del YAML de P1 és suficient (els dos personatges comparteixen // del YAML de P1 és suficient (els dos personatges comparteixen
// geometria — morcus és un repaletat de sam). // geometria — morcus és un repaletat de sam).
Player::loadFrom(config_.player_yaml); Player::loadFrom(config_.player_yaml);
applyRoomLightOverrideToPlayer();
// Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia, // Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia,
// etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa // etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa
@@ -321,6 +357,12 @@ GameScene::GameScene(Config config)
entity_manager_.getBus().subscribe("player_disabled", [this](const Event& ev) { entity_manager_.getBus().subscribe("player_disabled", [this](const Event& ev) {
onPlayerDisabled(ev.player_id); onPlayerDisabled(ev.player_id);
}); });
// `sarcophagus_opened`: extret a `onSarcophagusOpened` per acotar
// la complexitat cognitiva del ctor (mateix patró que `player_killed`
// i `player_disabled`).
entity_manager_.getBus().subscribe("sarcophagus_opened", [this](const Event& ev) {
onSarcophagusOpened(ev);
});
// Polvet: precarreguem el surface del Dust una sola vegada perquè // Polvet: precarreguem el surface del Dust una sola vegada perquè
// els spawns runtime de l'entitat no facin disc al primer salt. // els spawns runtime de l'entitat no facin disc al primer salt.
@@ -484,16 +526,8 @@ GameScene::GameScene(Config config)
// al Resource::Cache (preload al Director), per tant la primera // al Resource::Cache (preload al Director), per tant la primera
// crida a playMusic és immediata. // crida a playMusic és immediata.
// Font de l'overlay de pausa: reutilitzem la Surface ja cachejada al // Font del rètol de transició de sala. Reutilitzem la Surface ja
// Director (PLACEHOLDER_NAME + ".gif"). Text només parseja el .fnt a // cachejada al Director; només construïm el `Text` ací.
// l'arrencada de l'escena, un sol cop.
const std::string PAUSE_FONT_KEY = UiConfig::get().placeholderFont().name + ".gif";
auto pause_surface = Resource::Cache::get().getSurface(PAUSE_FONT_KEY);
pause_font_ = std::make_shared<Text>(pause_surface, UiConfig::get().placeholderFont().fnt);
pause_font_->setDefaultKerning(UiConfig::get().placeholderFont().kerning);
// Font del rètol de transició de sala. Mateixa convenció que la pausa
// (Surface preloaded pel Director, només construïm el `Text` ací).
const std::string ROOM_TEXT_FONT_KEY = UiConfig::get().roomTextFont().name + ".gif"; const std::string ROOM_TEXT_FONT_KEY = UiConfig::get().roomTextFont().name + ".gif";
auto room_text_surface = Resource::Cache::get().getSurface(ROOM_TEXT_FONT_KEY); auto room_text_surface = Resource::Cache::get().getSurface(ROOM_TEXT_FONT_KEY);
room_text_font_ = std::make_shared<Text>(room_text_surface, UiConfig::get().roomTextFont().fnt); room_text_font_ = std::make_shared<Text>(room_text_surface, UiConfig::get().roomTextFont().fnt);
@@ -560,6 +594,14 @@ void GameScene::loadRoom(const std::string& yaml_path) {
// CLOSED amb visited buit, momies i altres actors a la posició inicial. // CLOSED amb visited buit, momies i altres actors a la posició inicial.
config_.map_yaml = yaml_path; config_.map_yaml = yaml_path;
Map::loadFrom(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 // 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 // reconstruir-lo, la imatge de la sala anterior es queda fixada per a
// sempre. PaintLayer captura `tileset_path_`/`tileset_cols_` per valor // sempre. PaintLayer captura `tileset_path_`/`tileset_cols_` per valor
@@ -740,6 +782,21 @@ void GameScene::onPlayerDisabled(int player_id) {
players_active_[player_id] = false; players_active_[player_id] = false;
} }
void GameScene::onSarcophagusOpened(const Event& ev) {
// `player_id < 0`: pintada sense atribució (broadcast inicial). No
// hauria de tancar mai un sarc, però per defensa ignorem.
if (ev.player_id < 0) { return; }
const auto IDX = static_cast<std::size_t>(ev.player_id);
if (IDX >= players_.size()) { return; }
const auto* ent = std::get_if<Entity*>(&ev.payload);
if (ent == nullptr || *ent == nullptr) { return; }
auto* sarc = dynamic_cast<Sarcophagus*>(*ent);
if (sarc == nullptr) { return; }
auto& p = *players_[IDX];
p.addScore(sarc->getPointsReward());
p.addTreasures(sarc->getTreasuresReward());
}
void GameScene::preloadGameOverAssets() const { void GameScene::preloadGameOverAssets() const {
// Pre-load del so del countdown: el dispara `updateGameOverFlow` // Pre-load del so del countdown: el dispara `updateGameOverFlow`
// cada vegada que canvia el número visible de CONTINUE? (incloent // cada vegada que canvia el número visible de CONTINUE? (incloent
@@ -991,6 +1048,45 @@ 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<Lighting::Emitter> 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).
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, .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();
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);
}
void GameScene::renderRoomFade() { void GameScene::renderRoomFade() {
// Delegació als FadeToBlack: les durades, la direcció i el progrés // Delegació als FadeToBlack: les durades, la direcció i el progrés
// viuen dins de cada instància. Comportament: // viuen dins de cada instància. Comportament:
@@ -1413,81 +1509,6 @@ void GameScene::spawnWhip(const WhipSpawnPayload& payload) {
entity_manager_.add(std::move(whip)); entity_manager_.add(std::move(whip));
} }
void GameScene::handlePauseInput() {
#ifdef _DEBUG
// Mutex amb l'editor: si està actiu, ignorem la tecla de pausa.
if (editor_.active()) { return; }
#endif
const SDL_Scancode PAUSE_SC = KeyConfig::get().scancode("pause");
for (const auto& event : EventBuffer::events) {
const bool KEY_HIT = (event.type == SDL_EVENT_KEY_DOWN && PAUSE_SC != SDL_SCANCODE_UNKNOWN && event.key.scancode == PAUSE_SC);
if (!KEY_HIT) { continue; }
// Determinem cap a quin estat anem. Si estem quiets (IDLE o
// PAUSED) arranquem la transició contrària. Si estem en marxa
// (ENTERING o EXITING) invertim: la `scale` actual es manté com
// a `from_scale` nou i la durada es recalcula proporcional a la
// distància restant — així mai es produeix un salt de velocitat.
const bool GOING_TO_PAUSE = (pause_.state == PauseState::IDLE || pause_.state == PauseState::EXITING);
// Reprenem l'stream ABANS de començar a pujar el volum: quan
// venim de PAUSED l'àudio està aturat de debò (ho fem al final
// d'ENTERING, veure updatePauseTransition); reprenem just al
// moment de començar EXITING per no perdre el primer frame
// d'stream. A les inversions mid-flight (ENTERING → EXITING
// sense tocar PAUSED) l'stream no s'ha aturat mai, així que no
// cal reprendre.
if (!GOING_TO_PAUSE && pause_.state == PauseState::PAUSED) {
Audio::get()->resumeMusic();
}
pause_.from_scale = pause_.scale;
pause_.to_scale = GOING_TO_PAUSE ? 0.0F : 1.0F;
const int TARGET_MS = GOING_TO_PAUSE ? UiConfig::get().pauseEnterMs() : UiConfig::get().pauseExitMs();
const float DISTANCE = std::fabs(pause_.to_scale - pause_.from_scale);
pause_.duration_ms = std::max(1, static_cast<int>(static_cast<float>(TARGET_MS) * DISTANCE));
pause_.elapsed_ms = 0;
pause_.curve = GOING_TO_PAUSE ? UiConfig::get().pauseEnterCurve() : UiConfig::get().pauseExitCurve();
pause_.state = GOING_TO_PAUSE ? PauseState::ENTERING : PauseState::EXITING;
Log::info("PAUSE", "{} from_scale={} duration_ms={}", GOING_TO_PAUSE ? "entering" : "exiting", pause_.from_scale, pause_.duration_ms);
}
}
void GameScene::updatePauseTransition(int delta_ms) {
if (pause_.state != PauseState::ENTERING && pause_.state != PauseState::EXITING) { return; }
pause_.elapsed_ms += delta_ms;
bool just_reached_pause = false;
if (pause_.elapsed_ms >= pause_.duration_ms) {
// Transició completa: enganxem al valor objectiu exacte i
// promocionem l'estat al terminal corresponent.
pause_.scale = pause_.to_scale;
const PauseState NEXT = (pause_.to_scale == 0.0F) ? PauseState::PAUSED : PauseState::IDLE;
just_reached_pause = (NEXT == PauseState::PAUSED);
pause_.state = NEXT;
} else {
const float T = static_cast<float>(pause_.elapsed_ms) / static_cast<float>(pause_.duration_ms);
const auto CURVE = (pause_.curve != nullptr) ? pause_.curve : &Easing::linear;
pause_.scale = std::lerp(pause_.from_scale, pause_.to_scale, CURVE(T));
}
// Fade del volum de música durant la transició. La base és el que
// l'usuari ha configurat a Options; multipliquem per la `scale` per
// obtenir el volum efectiu aquest frame.
const float BASE_VOLUME = Options::audio().music.volume;
Audio::get()->setMusicVolume(BASE_VOLUME * pause_.scale);
// En arribar a PAUSED aturem l'stream: així quan despausem la música
// tornarà des d'on s'ha quedat, no des d'on hauria anat a parar
// durant el temps que el joc ha estat en pausa. El `resumeMusic` es
// fa a `handlePauseInput` al començar l'EXITING, perquè la música
// sone ja mentre el volum puja des de 0.
if (just_reached_pause) {
Audio::get()->pauseMusic();
}
}
#ifdef _DEBUG #ifdef _DEBUG
namespace { namespace {
auto detectEditorToggle() -> bool { auto detectEditorToggle() -> bool {
@@ -1523,33 +1544,33 @@ namespace {
#endif #endif
void GameScene::update(int delta_ms) { void GameScene::update(int delta_ms) {
handlePauseInput(); // Congelació instantània mentre el menú de servei està obert: el
// món no avança gens. La música segueix sonant perquè el menú serveix
// Congelació instantània mentre el menú de servei està obert. A // entre altres coses per calibrar les opcions d'àudio.
// diferència de la pausa de gameplay (que és un dial amb easing i
// fade de música), aquesta és binària: el món no avança gens i la
// música no es toca. També posem en pausa el dial de la pausa
// regular si estava a mig camí, perquè no continui mentre l'usuari
// navega el menú.
const bool MENU_FROZEN = ServiceMenu::get().isOpen(); const bool MENU_FROZEN = ServiceMenu::get().isOpen();
if (!MENU_FROZEN) {
updatePauseTransition(delta_ms);
}
// Transició de sala: viu en temps real (no escalada per la pausa) // Transició de sala i sub-FSM de game over: viuen en temps real
// perquè és UI. Avança avans del bloc de món perquè quan PLAYING // perquè són UI. Avancen abans del bloc de món perquè quan PLAYING
// arrenque ja sigui efectiu en el mateix frame. // arrenque ja sigui efectiu en el mateix frame.
updateRoomTransition(delta_ms); updateRoomTransition(delta_ms);
// Game-over flow pausat mentre el menú de servei o l'editor estan
// Sub-FSM de game over (CONTINUE? + GAME OVER + fade-to-LOGO). // oberts. L'editor fa `loadRoom` complet i deixa els jugadors DISABLED
// També en temps real: el countdown de CONTINUE? no es congela // (`isOutOfPlay()==true`) esperant respawn instantani a la sortida;
// amb la pausa de gameplay. La detecció de "tots fora de partida" // si el flux corre durant aquest interval, `activePlayerCount()==0`
// s'arma dins quan estem a PLAYING. // mentre `state_==PLAYING` dispara un CONTINUE_PROMPT espuri que
// acaba portant al LOGO. La pausa aquí és simètrica amb com el menú
// i l'editor congelen la resta del món.
#ifdef _DEBUG
const bool GAME_OVER_PAUSED = MENU_FROZEN || editor_.active();
#else
const bool GAME_OVER_PAUSED = MENU_FROZEN;
#endif
if (!GAME_OVER_PAUSED) {
updateGameOverFlow(delta_ms); updateGameOverFlow(delta_ms);
}
#ifdef _DEBUG #ifdef _DEBUG
// Toggle de l'editor: només si no hi ha cap transició de pausa ni // Toggle de l'editor. L'editor mateix no consumeix la tecla — la
// pausa consolidada. L'editor mateix no consumeix la tecla — la
// llegim ací per decidir la coexistència. Tant a l'entrada com a la // llegim ací per decidir la coexistència. Tant a l'entrada com a la
// sortida fem `loadRoom` complet per partir sempre del YAML del // sortida fem `loadRoom` complet per partir sempre del YAML del
// disc: les entitats tornen a la posició inicial (momies, sarcs), // disc: les entitats tornen a la posició inicial (momies, sarcs),
@@ -1557,7 +1578,7 @@ void GameScene::update(int delta_ms) {
// l'editor sempre veu l'estat-base de la sala (no el d'una partida // l'editor sempre veu l'estat-base de la sala (no el d'una partida
// a meitat) i el joc, en sortir, arrenca fresc amb les edicions ja // a meitat) i el joc, en sortir, arrenca fresc amb les edicions ja
// persistides al YAML. // persistides al YAML.
if (pause_.state == PauseState::IDLE && detectEditorToggle()) { if (!MENU_FROZEN && detectEditorToggle()) {
const bool WAS_ACTIVE = editor_.active(); const bool WAS_ACTIVE = editor_.active();
editor_.toggle(tilemap_, entity_manager_); editor_.toggle(tilemap_, entity_manager_);
const bool NOW_ACTIVE = editor_.active(); const bool NOW_ACTIVE = editor_.active();
@@ -1567,6 +1588,14 @@ void GameScene::update(int delta_ms) {
// totes maneres) i es reactivaran amb el spawn instantani // totes maneres) i es reactivaran amb el spawn instantani
// a la sortida. // a la sortida.
loadRoom(Levels::LevelManager::get().currentPath()); loadRoom(Levels::LevelManager::get().currentPath());
// Materialitzem els dynamic hazards (mòmies, roques) en estat
// estàtic perquè es vegin a la posició inicial: així l'editor
// les pot seleccionar i moure com qualsevol altra entitat. El
// flux normal del joc les enruta a `pending_actors_` per a
// disparar la cinemàtica EMERGING al final del ROOM_INTRO; a
// l'editor el món està congelat i aquesta animació quedaria
// travada.
entity_manager_.materializePendingActorsStatic();
} else if (WAS_ACTIVE && !NOW_ACTIVE) { } else if (WAS_ACTIVE && !NOW_ACTIVE) {
// Acabem de sortir: el writer ja ha persistit el YAML. // Acabem de sortir: el writer ja ha persistit el YAML.
// Carreguem fresh i fem spawn instantani sense cinemàtica. // Carreguem fresh i fem spawn instantani sense cinemàtica.
@@ -1575,10 +1604,10 @@ void GameScene::update(int delta_ms) {
} }
} }
// Salts debug entre sales (`[` / `]`). Mútuament excloents amb la // Salts debug entre sales (`[` / `]`). Mútuament excloents amb el
// pausa i amb l'editor: si qualsevol està actiu, ignorem la tecla // menú de servei i amb l'editor: si qualsevol està actiu, ignorem la
// perquè el salt recarrega entitats i invalidaria l'estat editat. // tecla perquè el salt recarrega entitats i invalidaria l'estat editat.
if (pause_.state == PauseState::IDLE && !editor_.active()) { if (!MENU_FROZEN && !editor_.active()) {
const int DELTA = detectRoomJump(); const int DELTA = detectRoomJump();
if (DELTA != 0) { if (DELTA != 0) {
auto& mgr = Levels::LevelManager::get(); auto& mgr = Levels::LevelManager::get();
@@ -1592,7 +1621,7 @@ void GameScene::update(int delta_ms) {
// pintat/sarcs durant proves de progressió. La resta del flux // pintat/sarcs durant proves de progressió. La resta del flux
// (creuar → player_exited → level_complete → OUTRO) es manté. // (creuar → player_exited → level_complete → OUTRO) es manté.
// Mateixos gates que el salt entre sales. // Mateixos gates que el salt entre sales.
if (pause_.state == PauseState::IDLE && !editor_.active() && detectOpenDoor()) { if (!MENU_FROZEN && !editor_.active() && detectOpenDoor()) {
Log::info("DEBUG", "força obertura de la porta"); Log::info("DEBUG", "força obertura de la porta");
entity_manager_.forceOpenDoor(); entity_manager_.forceOpenDoor();
} }
@@ -1601,33 +1630,31 @@ void GameScene::update(int delta_ms) {
// Join in progress (regla 1): un slot DISABLED pot entrar a partida // Join in progress (regla 1): un slot DISABLED pot entrar a partida
// pulsant START durant PLAYING. Es processa abans del bloc de món // pulsant START durant PLAYING. Es processa abans del bloc de món
// per que el spawn aeri ja sigui efectiu en aquest mateix frame. // per que el spawn aeri ja sigui efectiu en aquest mateix frame.
// Gateat per pausa/menú/editor: no es vol que un START accidental // Gateat per menú/editor: no es vol que un START accidental mentre
// mentre l'usuari navega per un menú trigueri un join. // l'usuari navega per un menú trigueri un join.
#ifdef _DEBUG #ifdef _DEBUG
const bool EDITOR_OPEN = editor_.active(); const bool EDITOR_OPEN = editor_.active();
#else #else
constexpr bool EDITOR_OPEN = false; constexpr bool EDITOR_OPEN = false;
#endif #endif
if (pause_.state == PauseState::IDLE && !MENU_FROZEN && !EDITOR_OPEN) { if (!MENU_FROZEN && !EDITOR_OPEN) {
handleJoinInput(); handleJoinInput();
} }
GlobalInputs::update(delta_ms); GlobalInputs::update(delta_ms);
if (GlobalInputs::wantsQuit()) { wants_quit_ = true; } if (GlobalInputs::wantsQuit()) { wants_quit_ = true; }
// El món avança amb `delta_ms * pause_.scale`: scale=1 corre a velocitat // El bloc de món no s'executa si el menú de servei o l'editor de
// real, scale=0 el congela. Quan l'editor de debug està actiu també // debug estan oberts: tots dos congelen el joc instantàniament.
// es salta completament (no l'escalem, el saltem). Notifier, // Notifier, RenderInfo i HUD viuen fora d'aquest bloc perquè s'han
// RenderInfo i HUD reben el delta real, per això viuen fora d'aquest // de seguir actualitzant en aquests casos.
// bloc i no es ralenteixen amb el dial de pausa.
#ifdef _DEBUG #ifdef _DEBUG
const bool EDITOR_FROZEN = editor_.active(); const bool EDITOR_FROZEN = editor_.active();
#else #else
constexpr bool EDITOR_FROZEN = false; constexpr bool EDITOR_FROZEN = false;
#endif #endif
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale); if (!EDITOR_FROZEN && !MENU_FROZEN && delta_ms > 0) {
if (!EDITOR_FROZEN && !MENU_FROZEN && SCALED_DELTA_MS > 0) { const float DT = static_cast<float>(delta_ms) / 1000.0F;
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
// Pipeline de moviment en tres passes per poder intercalar la // Pipeline de moviment en tres passes per poder intercalar la
// resolució de contactes jugador↔jugador entre input i posició: // resolució de contactes jugador↔jugador entre input i posició:
@@ -1763,6 +1790,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 // Fade del món: pintat ENTRE actors i HUD perquè el marcador, les
// notificacions i el service menu queden per damunt sense afectar-se. // notificacions i el service menu queden per damunt sense afectar-se.
// Inactiu si l'estat és PLAYING. // Inactiu si l'estat és PLAYING.
@@ -1791,36 +1824,6 @@ void GameScene::render() {
editor_.render(tilemap_, entity_manager_); editor_.render(tilemap_, entity_manager_);
#endif #endif
// Overlay de pausa: text amb stroke blanc (índex 255) centrat amb X
// depenent de l'estat del dial. Entra lliscant des de l'esquerra
// (ENTERING), es queda al centre (PAUSED) i surt cap a la dreta
// (EXITING). La X es deriva directament de `pause_.scale`, que ja
// porta la corba aplicada — així text i món frenen alhora.
if (pause_.state != PauseState::IDLE && pause_font_) {
constexpr Uint8 OUTLINE_WHITE = 255;
constexpr Uint8 STROKE_DIST = 1;
constexpr int PAUSE_KERNING = 0;
const std::string TEXT = Locale::get().text("input.paused");
const int TEXT_WIDTH = pause_font_->length(TEXT, PAUSE_KERNING);
const int OFF_LEFT = -TEXT_WIDTH;
const int OFF_RIGHT = GameCanvas::WIDTH + TEXT_WIDTH;
int x = GameCanvas::CENTER_X;
if (pause_.state == PauseState::ENTERING) {
x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, 1.0F - pause_.scale);
} else if (pause_.state == PauseState::EXITING) {
x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, pause_.scale);
}
const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2);
pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG,
x,
Y,
TEXT,
PAUSE_KERNING,
UiConfig::get().notificationText(),
STROKE_DIST,
OUTLINE_WHITE);
}
Notifier::get().render(); Notifier::get().render();
RenderInfo::get().render(); RenderInfo::get().render();
Screen::get().present(); Screen::get().present();
+24 -53
View File
@@ -55,24 +55,11 @@ class GameScene : public Scene {
void setState(RoomFsm::State new_state); void setState(RoomFsm::State new_state);
// Avança la transició activa via `RoomFsm::advance`. S'executa cada // Avança la transició activa via `RoomFsm::advance`. S'executa cada
// frame amb delta real (no escalat per la pausa) perquè la transició // frame amb delta real perquè la transició viu en temps de UI. Si
// viu en temps de UI. Si l'estat retornat és diferent de l'actual, // l'estat retornat és diferent de l'actual, crida `setState` (que
// crida `setState` (que reseteja la `Transition`). // reseteja la `Transition`).
void updateRoomTransition(int delta_ms); void updateRoomTransition(int delta_ms);
// Consumeix events d'aquest frame (F11 del KeyConfig "pause") per
// disparar les transicions de pausa. El mando ja no participa: el
// botó START és ara una acció remapeable per dispositiu i el menú
// de servei fa de pausa quan cal. No alterna un flag binari: inicia
// (o inverteix mid-flight) un "dial" de temps que frena el món amb
// easing.
void handlePauseInput();
// Avança la transició activa, recalcula `pause_.scale` i aplica el
// fade de volum de música. S'executa cada frame amb el delta real
// (no escalat) perquè la transició viu en temps real.
void updatePauseTransition(int delta_ms);
// Spawna un Dust transitori centrat a (`feet_x_center`, `feet_y`). // Spawna un Dust transitori centrat a (`feet_x_center`, `feet_y`).
// Cridat per la subscripció del bus a `player_jumped` / // Cridat per la subscripció del bus a `player_jumped` /
// `player_landed` per pintar el polvet de despegue / aterratge. // `player_landed` per pintar el polvet de despegue / aterratge.
@@ -140,6 +127,14 @@ class GameScene : public Scene {
// mètode per acotar la complexitat cognitiva del subscriber/ctor. // mètode per acotar la complexitat cognitiva del subscriber/ctor.
void onPlayerDisabled(int player_id); void onPlayerDisabled(int player_id);
// Handler de l'event `sarcophagus_opened` (un jugador acaba de
// tancar el perímetre). Reparteix la recompensa (`points`,
// `treasures` del YAML del sarc) al Player corresponent, que al
// mutar score/treasures publicarà `player_stats_changed` i el HUD
// s'actualitzarà sol. Extret a mètode per acotar la complexitat
// cognitiva del ctor.
void onSarcophagusOpened(const Event& ev);
// Pre-load dels recursos d'àudio que s'usen mid-partida i no els // Pre-load dels recursos d'àudio que s'usen mid-partida i no els
// toca el Director (so del countdown, etc.). Si la clau és buida // toca el Director (so del countdown, etc.). Si la clau és buida
// o ja està al cache, és no-op. Extret a mètode per acotar la // o ja està al cache, és no-op. Extret a mètode per acotar la
@@ -239,41 +234,11 @@ class GameScene : public Scene {
// la destrucció. // la destrucció.
std::unique_ptr<Hud> hud_; std::unique_ptr<Hud> hud_;
// Pausa de gameplay com a "dial" de temps. En lloc d'un interruptor,
// `pause_.scale` va de 1 (món corrent) a 0 (món congelat) amb una
// corba d'easing durant ENTERING, i al revés durant EXITING. El
// `delta_ms` del món (players + EntityManager) es multiplica per
// aquesta escala; HUD, Notifier i GlobalInputs reben sempre el
// delta real. Les inversions mid-flight capturen la `scale` actual
// com a `from_scale` i escalen la durada a la distància restant per
// mantenir la sensació de velocitat. La font és la mateixa que
// abans — ja cachejada al Director.
std::shared_ptr<Text> pause_font_;
// Font del rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). // Font del rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO).
// Distinta de `pause_font_` perquè el rètol de fase té estètica // Té estètica pròpia (CaesarDressing per defecte, configurable via
// pròpia (CaesarDressing per defecte, configurable via // `data/config/ui.yaml > fonts.room_text`).
// `data/config/ui.yaml > fonts.room_text`). La pausa segueix amb la
// placeholder.
std::shared_ptr<Text> room_text_font_; std::shared_ptr<Text> room_text_font_;
enum class PauseState : std::uint8_t {
IDLE, // Joc corrent; cap transició en marxa. scale == 1.
ENTERING, // Frenant cap a la pausa. scale decreix a 0.
PAUSED, // Món congelat. scale == 0.
EXITING, // Accelerant de tornada. scale creix a 1.
};
struct PauseTransition {
PauseState state{PauseState::IDLE};
float scale{1.0F}; // 0 = parat, 1 = corrent.
float from_scale{1.0F}; // Valor de `scale` al començar la transició.
float to_scale{1.0F}; // Valor objectiu (0.0 o 1.0).
int elapsed_ms{0}; // Temps transcorregut dins la transició.
int duration_ms{0}; // Durada total (escalada a la distància).
UiConfig::CurveFn curve{nullptr}; // Corba activa.
} pause_;
// Configuració de la FSM de sala (intro/outro). Carregada al ctor des // Configuració de la FSM de sala (intro/outro). Carregada al ctor des
// de `data/scenes/game/game.yaml`; valors per defecte si manca el YAML. // de `data/scenes/game/game.yaml`; valors per defecte si manca el YAML.
RoomFsm::Config transition_config_{}; RoomFsm::Config transition_config_{};
@@ -306,6 +271,13 @@ class GameScene : public Scene {
void renderRoomFade(); void renderRoomFade();
void renderRoomText(); 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 // Configuració, estat i transició de la sub-FSM de game over
// (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la // (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la
// RoomFsm via `GameOverFsm::loadConfig`. // RoomFsm via `GameOverFsm::loadConfig`.
@@ -445,11 +417,10 @@ class GameScene : public Scene {
#endif #endif
#ifdef _DEBUG #ifdef _DEBUG
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la // Editor modal del tilemap. Mútuament excloent amb el menú de servei:
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i // si el menú està obert, la tecla de toggle de l'editor s'ignora.
// viceversa. Quan `editor_.active()` és true, saltem entitats i // Quan `editor_.active()` és true, saltem entitats i jugador (update
// jugador (update i render) i deixem el tilemap visible perquè es // i render) i deixem el tilemap visible perquè es pugui editar.
// pugui editar.
Editor editor_; Editor editor_;
#endif #endif
}; };
+43 -9
View File
@@ -135,10 +135,14 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font, int initial_lives)
players_[1].active = false; players_[1].active = false;
players_[1].lives = initial_lives_; players_[1].lives = initial_lives_;
// Subscripcions al bus de l'EntityManager. De moment només `player_hit` // Subscripcions al bus de l'EntityManager.
// afecta el HUD visualment: decrementa vides, dispara flash i shake al //
// bloc del jugador. Quan els events evolucionin per portar `player_id` // `player_hit` només fa feedback visual (flash + shake) al bloc del
// i puntuacions, ampliarem aquí sense tocar callers. // jugador colpejat. **No** toca el comptador de vides — la veritat
// viu al Player i arriba per `player_stats_changed`. Així si un hit
// no és mortal (HURT recoverable) el flash es manté coherent sense
// mentir el comptador, i si és mortal el comptador baixa pel canal
// numèric quan el cicle DYING acaba a `respawnFromDying`.
subs_.push_back(bus_.subscribe("player_hit", [this](const Event& ev) { subs_.push_back(bus_.subscribe("player_hit", [this](const Event& ev) {
const auto* dmg = std::get_if<int>(&ev.payload); const auto* dmg = std::get_if<int>(&ev.payload);
// Dispatch pel `player_id` de l'event. 0 = P1 (bloc LEFT), 1 = P2 // Dispatch pel `player_id` de l'event. 0 = P1 (bloc LEFT), 1 = P2
@@ -147,14 +151,28 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font, int initial_lives)
// publishers de `player_hit` actuals sí que porten id. // publishers de `player_hit` actuals sí que porten id.
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U; const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
if (!players_[IDX].active) { return; } if (!players_[IDX].active) { return; }
if (players_[IDX].lives > 0) { --players_[IDX].lives; }
const std::size_t BLK_IDX = (IDX == 0) ? static_cast<std::size_t>(BlockId::LEFT) const std::size_t BLK_IDX = (IDX == 0) ? static_cast<std::size_t>(BlockId::LEFT)
: static_cast<std::size_t>(BlockId::RIGHT); : static_cast<std::size_t>(BlockId::RIGHT);
auto& blk = blocks_[BLK_IDX]; auto& blk = blocks_[BLK_IDX];
blk.flash_ms_left = UiConfig::get().hudFlashMs(); blk.flash_ms_left = UiConfig::get().hudFlashMs();
blk.shake_ms_left = UiConfig::get().hudShakeMs(); blk.shake_ms_left = UiConfig::get().hudShakeMs();
blk.shake_intensity = 1.5F; blk.shake_intensity = 1.5F;
Log::info("HUD", "player_hit p{} dmg={} lives={}", IDX + 1, dmg != nullptr ? *dmg : 0, players_[IDX].lives); Log::info("HUD", "player_hit p{} dmg={}", IDX + 1, dmg != nullptr ? *dmg : 0);
}));
// `player_stats_changed`: snapshot total (vides/score/tresors) emès
// pel Player cada vegada que muta qualsevol dels tres. El HUD el
// consumeix com a estat absolut: sobreescriu la caché local sense
// raonament addicional. Això unifica totes les fonts de canvi (mort
// per qualsevol causa, recompensa de sarc, futurs pickups…) en un
// únic canal.
subs_.push_back(bus_.subscribe("player_stats_changed", [this](const Event& ev) {
const auto* st = std::get_if<PlayerStatsPayload>(&ev.payload);
if (st == nullptr) { return; }
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
players_[IDX].lives = st->lives;
players_[IDX].score = st->score;
players_[IDX].treasures = st->treasures;
})); }));
subs_.push_back(bus_.subscribe("level_complete", [](const Event& /*ev*/) { subs_.push_back(bus_.subscribe("level_complete", [](const Event& /*ev*/) {
@@ -188,6 +206,10 @@ void Hud::update(int delta_ms) {
if (b.shake_ms_left > 0) { b.shake_ms_left = std::max(0, b.shake_ms_left - delta_ms); } if (b.shake_ms_left > 0) { b.shake_ms_left = std::max(0, b.shake_ms_left - delta_ms); }
if (b.flash_ms_left > 0) { b.flash_ms_left = std::max(0, b.flash_ms_left - delta_ms); } if (b.flash_ms_left > 0) { b.flash_ms_left = std::max(0, b.flash_ms_left - delta_ms); }
} }
const int CYCLE = UiConfig::get().hudBlinkCycleMs();
if (CYCLE > 0) {
blink_elapsed_ms_ = (blink_elapsed_ms_ + delta_ms) % CYCLE;
}
} }
void Hud::setMode(BlockId block, Mode mode) { void Hud::setMode(BlockId block, Mode mode) {
@@ -203,10 +225,11 @@ void Hud::activatePlayer(int player_one_based) {
if (player_one_based < 1 || player_one_based > 2) { return; } if (player_one_based < 1 || player_one_based > 2) { return; }
auto& p = players_[static_cast<std::size_t>(player_one_based - 1)]; auto& p = players_[static_cast<std::size_t>(player_one_based - 1)];
p.active = true; p.active = true;
p.lives = initial_lives_;
p.score = 0;
p.treasures = 0;
p.join_prompt = JoinPromptMode::NONE; p.join_prompt = JoinPromptMode::NONE;
// Els camps numèrics (lives/score/treasures) ja els ha sobreescrit el
// `player_stats_changed` que el Player publica dins de `resetForJoin`,
// així que aquí no els toquem — evitem trepitjar la veritat del bus
// amb defaults locals.
} }
void Hud::setJoinPrompt(int player_one_based, JoinPromptMode mode) { void Hud::setJoinPrompt(int player_one_based, JoinPromptMode mode) {
@@ -238,6 +261,16 @@ auto Hud::computeOffset(const Block& b) -> SDL_FPoint {
return off; return off;
} }
auto Hud::blinkVisible() const -> bool {
const auto& ui = UiConfig::get();
const int CYCLE = ui.hudBlinkCycleMs();
const float RATIO = ui.hudBlinkVisibleRatio();
if (CYCLE <= 0 || RATIO >= 1.0F) { return true; }
if (RATIO <= 0.0F) { return false; }
const int VISIBLE_MS = static_cast<int>(static_cast<float>(CYCLE) * RATIO);
return blink_elapsed_ms_ < VISIBLE_MS;
}
void Hud::render() const { void Hud::render() const {
// Backdrop de la franja del HUD. Es pinta abans dels tres blocs perquè, // Backdrop de la franja del HUD. Es pinta abans dels tres blocs perquè,
// quan un bloc es mou (entry slide o shake), la zona que deixa al // quan un bloc es mou (entry slide o shake), la zona que deixa al
@@ -295,6 +328,7 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
// el mode fixat per la GameScene cada frame. El render de zeros // el mode fixat per la GameScene cada frame. El render de zeros
// queda com a fallback (mode NONE o slot actiu). // queda com a fallback (mode NONE o slot actiu).
if (!p.active && p.join_prompt != JoinPromptMode::NONE) { if (!p.active && p.join_prompt != JoinPromptMode::NONE) {
if (!blinkVisible()) { return; }
const bool CAN_JOIN = (p.join_prompt == JoinPromptMode::CAN_JOIN); const bool CAN_JOIN = (p.join_prompt == JoinPromptMode::CAN_JOIN);
const std::string L1 = Locale::get().text( const std::string L1 = Locale::get().text(
CAN_JOIN ? "hud.join_prompt_can_join_l1" : "hud.join_prompt_wait_l1"); CAN_JOIN ? "hud.join_prompt_can_join_l1" : "hud.join_prompt_wait_l1");
+8
View File
@@ -170,6 +170,10 @@ class Hud {
// les constants de classe (no toca cap altre membre). // les constants de classe (no toca cap altre membre).
[[nodiscard]] static auto computeOffset(const Block& b) -> SDL_FPoint; [[nodiscard]] static auto computeOffset(const Block& b) -> SDL_FPoint;
// Estat actual del parpadeig dels prompts inactius. Llegit per
// renderIdleSide() per saltar el draw quan toca ocult.
[[nodiscard]] auto blinkVisible() const -> bool;
EventBus& bus_; EventBus& bus_;
std::vector<EventBus::SubId> subs_; std::vector<EventBus::SubId> subs_;
@@ -179,6 +183,10 @@ class Hud {
std::array<PlayerStats, 2> players_; std::array<PlayerStats, 2> players_;
int pyramid_{1}; int pyramid_{1};
int room_{1}; int room_{1};
// Acumulador continu (ms) per al parpadeig dels prompts inactius.
// Es manté entre canvis de mode (CAN_JOIN ↔ WAIT) per evitar salts
// visuals i sincronitza els dos slots — vegeu Hud::blinkVisible().
int blink_elapsed_ms_{0};
// Vides amb què s'arrenca un slot (`activatePlayer`). Cachejat al // Vides amb què s'arrenca un slot (`activatePlayer`). Cachejat al
// ctor des de `Player::death_config.initial_lives` per no llegir el // ctor des de `Player::death_config.initial_lives` per no llegir el
// singleton estàtic en el camí calent del bus de events. // singleton estàtic en el camí calent del bus de events.
+44 -10
View File
@@ -106,6 +106,44 @@ namespace Defaults {
constexpr float PUSH_FACTOR = 0.30F; constexpr float PUSH_FACTOR = 0.30F;
} // namespace Player } // 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 {
// 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. 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_RATIO = 0.025F;
constexpr float FLICKER_NOISE_FREQUENCY_HZ = 7.0F;
} // namespace Lighting
// Fallbacks del tema d'UI: índexs de paleta, timings (ms) i fonts. // 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 // 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 // l'arrencada; aquests valors només s'apliquen si el fitxer falta o
@@ -246,16 +284,11 @@ namespace Defaults {
constexpr int HUD_FLASH_MS = 250; constexpr int HUD_FLASH_MS = 250;
constexpr int HUD_SHAKE_MS = 300; constexpr int HUD_SHAKE_MS = 300;
// Pausa graduada de la GameScene: el món no para d'un colp, es // Parpadeig dels prompts inactius del HUD ("Polsa start..." /
// frena amb una corba i torna a arrancar amb una altra. Els noms // "Espera..."). Un únic temporitzador global al Hud sincronitza
// de corba han de coincidir amb els registrats a // P1 i P2. cycle<=0 o ratio>=1 → sempre visible (disable trivial).
// UiConfig::curveByName (linear / outQuad / inQuad / inOutQuad / constexpr int HUD_BLINK_CYCLE_MS = 1000;
// outCubic / inCubic). Si un nom no es reconeix, es cau a constexpr float HUD_BLINK_VISIBLE_RATIO = 0.8F;
// Easing::linear.
constexpr int PAUSE_ENTER_MS = 400;
constexpr int PAUSE_EXIT_MS = 250;
constexpr const char* PAUSE_ENTER_CURVE = "outCubic";
constexpr const char* PAUSE_EXIT_CURVE = "inCubic";
// Sons del menú de servei. SELECT acompanya navegació (amunt/avall) // Sons del menú de servei. SELECT acompanya navegació (amunt/avall)
// i back; ACCEPT acompanya modificació d'opció (esq/dret/retorn). // i back; ACCEPT acompanya modificació d'opció (esq/dret/retorn).
@@ -503,6 +536,7 @@ namespace Defaults {
constexpr const char* SOUNDS_YAML = "data/config/sounds.yaml"; constexpr const char* SOUNDS_YAML = "data/config/sounds.yaml";
constexpr const char* INPUT_YAML = "data/config/input.yaml"; constexpr const char* INPUT_YAML = "data/config/input.yaml";
constexpr const char* EDITOR_YAML = "data/config/editor.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"; constexpr const char* UI_PALETTE = "data/palettes/uzebox.pal";
// Configuració d'escenes (yaml). El de la GameScene viu a // Configuració d'escenes (yaml). El de la GameScene viu a
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts { namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner"; constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.43.0"; // Single source of truth — Makefile el grep constexpr const char* VERSION = "0.45.0"; // Single source of truth — Makefile el grep
} // namespace Texts } // namespace Texts
namespace GameCanvas { namespace GameCanvas {
+126 -29
View File
@@ -14,7 +14,8 @@
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions. // T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
// T8 decoration — Torch no col·lisiona ni és interactable. // T8 decoration — Torch no col·lisiona ni és interactable.
// T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame. // T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame.
// T11 sarcofag — tile_size [3,1] llegit; perímetre 8; falsa perspectiva: // T11 sarcofag — tile_size [3,1] llegit; perímetre 12 (incloent les
// quatre cantonades diagonals); falsa perspectiva:
// el sprite.height no canvia la caixa lògica 48×16. // el sprite.height no canvia la caixa lògica 48×16.
// T12 collision — `Entity::CollisionBox` encastada al sprite: la caixa // T12 collision — `Entity::CollisionBox` encastada al sprite: la caixa
// retornada coincideix amb les dimensions del bloc // retornada coincideix amb les dimensions del bloc
@@ -86,6 +87,7 @@ namespace {
const std::vector<std::string> TYPES = { const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy", "torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap", "hole", "rolling_rock", "wall_trap", "floor_trap", "hole",
"stone", "column",
}; };
for (const auto& type : TYPES) { for (const auto& type : TYPES) {
fkyaml::node tmpl = EntityLoader::loadTemplate(type); fkyaml::node tmpl = EntityLoader::loadTemplate(type);
@@ -143,27 +145,29 @@ namespace {
fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus"); fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus");
auto sarc = std::make_unique<Sarcophagus>(tmpl); auto sarc = std::make_unique<Sarcophagus>(tmpl);
// Ancorat a (col=1, row=1) amb tile_size=[3,1] → rectangle de tiles // Ancorat a (col=1, row=1) amb tile_size=[3,1] → rectangle de tiles
// cobreix (1,1), (2,1), (3,1). Perímetre ortogonal esperat (sense // cobreix (1,1), (2,1), (3,1). Perímetre esperat (sense
// BlockedPredicate): // BlockedPredicate), incloent les quatre cantonades diagonals:
// Nord: (1,0), (2,0), (3,0) // Nord (amb NO i NE): (0,0), (1,0), (2,0), (3,0), (4,0)
// Sud: (1,2), (2,2), (3,2) // Sud (amb SO i SE): (0,2), (1,2), (2,2), (3,2), (4,2)
// Oest: (0,1) // Oest: (0,1)
// Est: (4,1) // Est: (4,1)
// Total: 8 tiles. // Total: 12 tiles.
sarc->place(1, 1, {}); sarc->place(1, 1, {});
REQUIRE(sarc->getPerimeterSize() == 8); REQUIRE(sarc->getPerimeterSize() == 12);
REQUIRE(sarc->getState() == Sarcophagus::State::CLOSED); REQUIRE(sarc->getState() == Sarcophagus::State::CLOSED);
// Visitem un tile que NO és al perímetre: res canvia. // Visitem un tile que NO és al perímetre: res canvia.
sarc->notifyTilePainted(5, 5); sarc->notifyTilePainted(5, 5, /*player_id=*/0);
REQUIRE(sarc->getVisitedCount() == 0); REQUIRE(sarc->getVisitedCount() == 0);
// Visitem tots els del perímetre. // Visitem tots els del perímetre.
const std::vector<std::pair<int, int>> PERIM = { const std::vector<std::pair<int, int>> PERIM = {
{1, 0}, {2, 0}, {3, 0}, {1, 2}, {2, 2}, {3, 2}, {0, 1}, {4, 1}, {0, 0}, {1, 0}, {2, 0}, {3, 0}, {4, 0},
{0, 2}, {1, 2}, {2, 2}, {3, 2}, {4, 2},
{0, 1}, {4, 1},
}; };
for (const auto& [c, r] : PERIM) { sarc->notifyTilePainted(c, r); } for (const auto& [c, r] : PERIM) { sarc->notifyTilePainted(c, r, /*player_id=*/0); }
REQUIRE(sarc->getVisitedCount() == 8); REQUIRE(sarc->getVisitedCount() == 12);
REQUIRE(sarc->getState() == Sarcophagus::State::OPENING); REQUIRE(sarc->getState() == Sarcophagus::State::OPENING);
// Tickem prou per consumir l'animació `opening` (4 frames @ 6fps = // Tickem prou per consumir l'animació `opening` (4 frames @ 6fps =
@@ -192,19 +196,20 @@ namespace {
manager.add(std::move(door)); manager.add(std::move(door));
manager.setDoor(door_ptr); manager.setDoor(door_ptr);
// Forcem l'obertura dels dos sarcs visitant els seus perímetres. // Forcem l'obertura dels dos sarcs visitant els seus perímetres
// (incloent les cantonades diagonals NO/NE/SO/SE).
auto forceOpen = [](Sarcophagus* s) { auto forceOpen = [](Sarcophagus* s) {
const int COL0 = s->getTilePos().col; const int COL0 = s->getTilePos().col;
const int ROW0 = s->getTilePos().row; const int ROW0 = s->getTilePos().row;
const int W = s->getTileWidth(); const int W = s->getTileWidth();
const int H = s->getTileHeight(); const int H = s->getTileHeight();
for (int dc = 0; dc < W; ++dc) { for (int dc = -1; dc <= W; ++dc) {
s->notifyTilePainted(COL0 + dc, ROW0 - 1); s->notifyTilePainted(COL0 + dc, ROW0 - 1, /*player_id=*/0);
s->notifyTilePainted(COL0 + dc, ROW0 + H); s->notifyTilePainted(COL0 + dc, ROW0 + H, /*player_id=*/0);
} }
for (int dr = 0; dr < H; ++dr) { for (int dr = 0; dr < H; ++dr) {
s->notifyTilePainted(COL0 - 1, ROW0 + dr); s->notifyTilePainted(COL0 - 1, ROW0 + dr, /*player_id=*/0);
s->notifyTilePainted(COL0 + W, ROW0 + dr); s->notifyTilePainted(COL0 + W, ROW0 + dr, /*player_id=*/0);
} }
}; };
forceOpen(s1); forceOpen(s1);
@@ -237,11 +242,12 @@ namespace {
void testAnimationComponent() { void testAnimationComponent() {
std::cout << "[T6] animation component\n"; std::cout << "[T6] animation component\n";
AnimationComponent anim({10, 20, 30}, 10.0F, true); // 100 ms per frame: a tick(0.15) avança 1 frame; tick(0.10) un altre.
AnimationComponent anim = AnimationComponent::uniform({10, 20, 30}, 100.0F, true);
REQUIRE(anim.frameCount() == 3); REQUIRE(anim.frameCount() == 3);
REQUIRE(anim.currentFrame() == 10); REQUIRE(anim.currentFrame() == 10);
anim.tick(0.15F); // 1.5 frames de 10 fps → hauria d'avançar a l'índex 1 anim.tick(0.15F); // 1.5 frames de 100 ms → hauria d'avançar a l'índex 1
REQUIRE(anim.currentFrame() == 20); REQUIRE(anim.currentFrame() == 20);
anim.tick(0.10F); anim.tick(0.10F);
@@ -252,21 +258,107 @@ namespace {
REQUIRE(anim.currentFrame() == 10); REQUIRE(anim.currentFrame() == 10);
// Variant no-loop: es para al darrer i queda completed. // Variant no-loop: es para al darrer i queda completed.
AnimationComponent once({1, 2}, 10.0F, false); AnimationComponent once = AnimationComponent::uniform({1, 2}, 100.0F, false);
once.tick(1.0F); // molts frames; ha d'arribar al final i quedar-se. once.tick(1.0F); // molts frames; ha d'arribar al final i quedar-se.
REQUIRE(once.currentFrame() == 2); REQUIRE(once.currentFrame() == 2);
REQUIRE(once.completed()); REQUIRE(once.completed());
// Variant d'1 frame no-loop: completed des del principi. // Variant d'1 frame no-loop: completed des del principi.
AnimationComponent single({5}, 1.0F, false); AnimationComponent single = AnimationComponent::uniform({5}, 1000.0F, false);
REQUIRE(single.completed()); REQUIRE(single.completed());
} }
// Cobertura específica de `AnimationComponent::fromYaml`: format
// `frame_ms` escalar i llista per-frame, gestió de `fps:` (rebutjat),
// i defaults quan la clau no existeix.
void testAnimationFromYaml() {
std::cout << "[T6b] animation fromYaml\n";
// 1) Format escalar `frame_ms`: tots els frames duren el mateix.
{
const std::string YAML = R"(
animations:
walk: { frames: [0, 1, 2, 3], frame_ms: 100, loop: true }
)";
auto node = fkyaml::node::deserialize(YAML);
auto anim = AnimationComponent::fromYaml(node, "walk");
REQUIRE(anim.frameCount() == 4);
REQUIRE(anim.currentFrame() == 0);
anim.tick(0.10F);
REQUIRE(anim.currentFrame() == 1);
}
// 2) Format llista `frame_ms`: el darrer frame aguanta més.
{
const std::string YAML = R"(
animations:
raise:
frames: [10, 11, 12]
frame_ms: [100, 100, 500]
loop: false
)";
auto node = fkyaml::node::deserialize(YAML);
auto anim = AnimationComponent::fromYaml(node, "raise");
REQUIRE(anim.frameCount() == 3);
anim.tick(0.10F);
REQUIRE(anim.currentFrame() == 11);
anim.tick(0.10F);
REQUIRE(anim.currentFrame() == 12);
// El darrer frame dura 500 ms: tick de 400 ms no completa l'anim.
anim.tick(0.40F);
REQUIRE(!anim.completed());
// 100 ms més (total 500 ms al frame 12) sí la completa.
anim.tick(0.10F);
REQUIRE(anim.completed());
}
// 3) Clau no existent: defaults segurs (1 frame loop, no peta).
{
const std::string YAML = R"(
animations:
walk: { frames: [0], frame_ms: 100, loop: true }
)";
auto node = fkyaml::node::deserialize(YAML);
auto anim = AnimationComponent::fromYaml(node, "missing");
REQUIRE(anim.frameCount() == 1);
REQUIRE(anim.loops());
}
// 4) `fps:` no és suportat: ha de llençar excepció.
{
const std::string YAML = R"(
animations:
walk: { frames: [0, 1], fps: 8, loop: true }
)";
auto node = fkyaml::node::deserialize(YAML);
bool threw = false;
try {
(void) AnimationComponent::fromYaml(node, "walk");
} catch (const std::runtime_error&) {
threw = true;
}
REQUIRE(threw);
}
// 5) Flip_h al YAML es preserva al component.
{
const std::string YAML = R"(
animations:
west: { frames: [0, 1], frame_ms: 100, loop: true, flip_h: true }
)";
auto node = fkyaml::node::deserialize(YAML);
auto anim = AnimationComponent::fromYaml(node, "west");
REQUIRE(anim.flipH());
REQUIRE(anim.flipMode() == SDL_FLIP_HORIZONTAL);
}
}
void testAllTemplatesLoad() { void testAllTemplatesLoad() {
std::cout << "[T7] plantilles YAML\n"; std::cout << "[T7] plantilles YAML\n";
const std::vector<std::string> TYPES = { const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy", "torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap", "hole", "rolling_rock", "wall_trap", "floor_trap", "hole",
"stone", "column",
}; };
for (const auto& type : TYPES) { for (const auto& type : TYPES) {
try { try {
@@ -336,11 +428,14 @@ namespace {
auto sarc = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus")); auto sarc = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
sarc->place(3, 4, {}); sarc->place(3, 4, {});
Sarcophagus* sarc_raw = manager.addSarcophagus(std::move(sarc)); Sarcophagus* sarc_raw = manager.addSarcophagus(std::move(sarc));
REQUIRE(sarc_raw->getPerimeterSize() == 8); REQUIRE(sarc_raw->getPerimeterSize() == 12);
// Les 8 tiles del perímetre: N (3..5,3), S (3..5,5), O (2,4), E (6,4). // Les 12 tiles del perímetre, incloent les cantonades diagonals:
// N (amb NO/NE): (2..6, 3)
// S (amb SO/SE): (2..6, 5)
// O: (2,4) E: (6,4)
const std::pair<int, int> PERIM[] = { const std::pair<int, int> PERIM[] = {
{3, 3}, {4, 3}, {5, 3}, {2, 3}, {3, 3}, {4, 3}, {5, 3}, {6, 3},
{3, 5}, {4, 5}, {5, 5}, {2, 5}, {3, 5}, {4, 5}, {5, 5}, {6, 5},
{2, 4}, {6, 4}, {2, 4}, {6, 4},
}; };
for (const auto& [c, r] : PERIM) { for (const auto& [c, r] : PERIM) {
@@ -361,12 +456,13 @@ namespace {
REQUIRE(tmpl["sprite"]["size"][0].get_value<int>() == 48); REQUIRE(tmpl["sprite"]["size"][0].get_value<int>() == 48);
REQUIRE(tmpl["sprite"]["size"][1].get_value<int>() == 32); REQUIRE(tmpl["sprite"]["size"][1].get_value<int>() == 32);
// Amb tile_size [3,1], un sarc a ancoratge (3,4) conserva els 8 // Amb tile_size [3,1], un sarc a ancoratge (3,4) té 12 tiles de
// tiles de perímetre (no es converteix en 10 per culpa del [3,2] // perímetre: 5 al nord (amb NO/NE), 5 al sud (amb SO/SE), 1 a
// antic). Això blinda la geometria contra regressions al YAML. // l'oest i 1 a l'est. Això blinda la geometria contra regressions
// al YAML (p. ex. un retorn accidental al tile_size [3,2] antic).
auto sarc = std::make_unique<Sarcophagus>(tmpl); auto sarc = std::make_unique<Sarcophagus>(tmpl);
sarc->place(3, 4, {}); sarc->place(3, 4, {});
REQUIRE(sarc->getPerimeterSize() == 8); REQUIRE(sarc->getPerimeterSize() == 12);
} }
void testCollisionBox() { void testCollisionBox() {
@@ -687,6 +783,7 @@ auto main() -> int {
testSarcophagusPerimeter(); testSarcophagusPerimeter();
testEntityManagerEndToEnd(); testEntityManagerEndToEnd();
testAnimationComponent(); testAnimationComponent();
testAnimationFromYaml();
testAllTemplatesLoad(); testAllTemplatesLoad();
testDecorationFlags(); testDecorationFlags();
testLoadFromLevel(); testLoadFromLevel();