Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc6ad6f718 | ||
|
|
9bd5e7db33 | ||
|
|
5cc8e349a5 | ||
|
|
0046d526b0 | ||
|
|
fc9947b87f | ||
|
|
35aaa6c519 | ||
|
|
3195746189 | ||
|
|
d1d5679d14 | ||
|
|
ebb161f1f5 | ||
|
|
68be8e55ec | ||
|
|
5ed963b53d | ||
|
|
5e5ff835a9 | ||
|
|
f5d251c33b | ||
|
|
d186f66c2c | ||
|
|
da361a1452 | ||
|
|
a43ae21014 | ||
|
|
6749780356 | ||
|
|
1c49248b72 | ||
|
|
63a03ae632 | ||
|
|
a8bff31a7c | ||
|
|
793e87a203 | ||
|
|
642e0653b2 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Configuració global del sistema d'il·luminació.
|
||||||
|
#
|
||||||
|
# Cada llum emissora (player, torxa, mòmia, ...) té un únic paràmetre
|
||||||
|
# propi: el radi en píxels. La forma de la llum dins d'aquest radi
|
||||||
|
# (els tres anells del gradient) viu en aquest fitxer perquè és una
|
||||||
|
# decisió de "feel" comuna a totes les entitats — així pots tocar la
|
||||||
|
# corba aquí sense haver de modificar 10 YAMLs.
|
||||||
|
#
|
||||||
|
# Forma del gradient (centre → vora):
|
||||||
|
# 0..inner_ratio del radi → inner_intensity (zona totalment il·luminada)
|
||||||
|
# inner_ratio..mid_ratio → mid_intensity (anell mig)
|
||||||
|
# mid_ratio..1.0 → outer_intensity (anell exterior)
|
||||||
|
# fora del radi → light_level de la sala
|
||||||
|
#
|
||||||
|
# Les intensitats són factors 0..1 que es multipliquen sobre el píxel
|
||||||
|
# final del canvas. inner=1.0 vol dir "píxel sense alterar".
|
||||||
|
gradient:
|
||||||
|
inner_ratio: 0.5
|
||||||
|
mid_ratio: 0.85
|
||||||
|
inner_intensity: 1.0
|
||||||
|
mid_intensity: 0.8
|
||||||
|
outer_intensity: 0.6
|
||||||
|
|
||||||
|
# Modulació del radi quan una entitat declara `flicker: true` al seu
|
||||||
|
# bloc `light:`. El radi efectiu per frame és:
|
||||||
|
# r_eff = r_base + A1·sin(2π·f1·t + φ1) + A2·sin(2π·f2·t + φ2)
|
||||||
|
# on (φ1, φ2) es deriven de la posició de l'emissor per a que cada
|
||||||
|
# antorxa balancegi en una fase diferent (sense estat per emissor).
|
||||||
|
# Combinant dues sinusoides amb freqüències incommensurables s'obté
|
||||||
|
# un patró "fluid" que el ull llegeix com a foc; pujar `noise_*`
|
||||||
|
# aporta més inquietud, baixar-ho fa la flama més serena.
|
||||||
|
flicker:
|
||||||
|
wave_amplitude_px: 2
|
||||||
|
wave_frequency_hz: 3.0
|
||||||
|
noise_amplitude_px: 1
|
||||||
|
noise_frequency_hz: 7.0
|
||||||
+6
-11
@@ -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ú.
|
||||||
|
|||||||
@@ -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: ../debris/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], fps: 1, loop: false }
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 805 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -68,3 +68,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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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: ../debris/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], fps: 1, loop: false }
|
||||||
@@ -16,3 +16,14 @@ flags:
|
|||||||
# 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], fps: 8, 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. Omet el bloc per fer una antorxa
|
||||||
|
# "apagada".
|
||||||
|
light:
|
||||||
|
radius: 40
|
||||||
|
flicker: true
|
||||||
@@ -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 |
@@ -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 |
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+32
-5
@@ -15,6 +15,12 @@ tileset: data/tilesets/pyramid_1.gif
|
|||||||
tile_size: 16
|
tile_size: 16
|
||||||
origin: [-8, 0]
|
origin: [-8, 0]
|
||||||
|
|
||||||
|
# Il·luminació base de la sala (0..1). 1.0 = sala totalment il·luminada
|
||||||
|
# (com hui, sense efecte visual); 0.0 = completament negra fora del radi
|
||||||
|
# d'un emissor. El gradient dels cercles d'emissor viu a
|
||||||
|
# data/config/lighting.yaml. Omet aquesta clau per deixar la sala neta.
|
||||||
|
light_level: 0.25
|
||||||
|
|
||||||
# Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl
|
# Í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
|
||||||
@@ -88,8 +94,6 @@ entities:
|
|||||||
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
|
||||||
@@ -116,8 +120,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
|
||||||
@@ -131,5 +133,30 @@ entities:
|
|||||||
- type: torch
|
- type: torch
|
||||||
position: [4, 2]
|
position: [4, 2]
|
||||||
- type: door
|
- type: door
|
||||||
|
- type: column
|
||||||
|
position: [3, 4]
|
||||||
|
frame: 2
|
||||||
|
- type: stone
|
||||||
|
position: [5, 4]
|
||||||
|
frame: 16
|
||||||
|
- type: stone
|
||||||
|
position: [4, 4]
|
||||||
|
frame: 19
|
||||||
|
- type: column
|
||||||
|
position: [13, 10]
|
||||||
|
frame: 4
|
||||||
|
- type: stone
|
||||||
|
position: [12, 10]
|
||||||
|
frame: 13
|
||||||
|
- type: stone
|
||||||
|
position: [11, 10]
|
||||||
|
frame: 23
|
||||||
|
- type: hole
|
||||||
|
position: [5, 9]
|
||||||
|
- type: hole
|
||||||
|
position: [9, 11]
|
||||||
|
- type: stone
|
||||||
|
position: [10, 10]
|
||||||
|
frame: 18
|
||||||
- type: mummy
|
- type: mummy
|
||||||
position: [10, 11]
|
position: [10, 13]
|
||||||
@@ -131,9 +131,6 @@ entities:
|
|||||||
- type: sarcophagus
|
- type: sarcophagus
|
||||||
sarc_position: [3, 4]
|
sarc_position: [3, 4]
|
||||||
- type: door
|
- type: door
|
||||||
# Sense `position`: la porta sempre viu al mur trasser. La columna
|
|
||||||
# transitable la dicta `geometry.door_col`; el render i la caixa
|
|
||||||
# de col·lisió es deriven d'aquesta dada al loadFromLevel.
|
|
||||||
- type: mummy
|
- type: mummy
|
||||||
position: [10, 7]
|
position: [10, 7]
|
||||||
- type: mummy
|
- type: mummy
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
#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_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuració global, inicialitzada amb els defaults compilats.
|
||||||
|
// loadConfig() la reemplaça si troba el fitxer.
|
||||||
|
auto activeConfig() -> GradientConfig& {
|
||||||
|
static GradientConfig cfg_{
|
||||||
|
.inner_ratio = Defaults::Lighting::INNER_RATIO,
|
||||||
|
.mid_ratio = Defaults::Lighting::MID_RATIO,
|
||||||
|
.inner_intensity = Defaults::Lighting::INNER_INTENSITY,
|
||||||
|
.mid_intensity = Defaults::Lighting::MID_INTENSITY,
|
||||||
|
.outer_intensity = Defaults::Lighting::OUTER_INTENSITY,
|
||||||
|
};
|
||||||
|
return cfg_;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto activeFlicker() -> FlickerConfig& {
|
||||||
|
static FlickerConfig fc_{
|
||||||
|
.wave_amplitude_px = Defaults::Lighting::FLICKER_WAVE_AMPLITUDE_PX,
|
||||||
|
.wave_frequency_hz = Defaults::Lighting::FLICKER_WAVE_FREQUENCY_HZ,
|
||||||
|
.noise_amplitude_px = Defaults::Lighting::FLICKER_NOISE_AMPLITUDE_PX,
|
||||||
|
.noise_frequency_hz = Defaults::Lighting::FLICKER_NOISE_FREQUENCY_HZ,
|
||||||
|
};
|
||||||
|
return fc_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modula el radi base d'un emissor amb la suma de dues sinusoides
|
||||||
|
// (`wave` + `noise`) i una fase derivada de `seed`. La seed és un
|
||||||
|
// identificador estable de l'emissor (no canvia amb la posició) —
|
||||||
|
// així el patró no "deriva" quan l'emissor es mou. El factor
|
||||||
|
// ~φ (golden ratio) dispersa les fases per a seeds consecutives.
|
||||||
|
auto computeFlickerRadius(int base_radius, int seed, float t_s, const FlickerConfig& fc) -> int {
|
||||||
|
constexpr float TWO_PI = 2.0F * std::numbers::pi_v<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 float DELTA = (fc.wave_amplitude_px * W1) + (fc.noise_amplitude_px * 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: 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
auto config() -> const GradientConfig& {
|
||||||
|
return activeConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadConfig(const std::string& yaml_path) {
|
||||||
|
auto bytes = Resource::Helper::loadFile(yaml_path);
|
||||||
|
if (bytes.empty()) {
|
||||||
|
Log::warn("Lighting", "no s'ha pogut obrir {} — s'usaran els defaults compilats", yaml_path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const auto* begin = reinterpret_cast<const char*>(bytes.data());
|
||||||
|
const auto* end = begin + bytes.size();
|
||||||
|
auto yaml = fkyaml::node::deserialize(begin, end);
|
||||||
|
if (yaml.contains("gradient")) {
|
||||||
|
const auto& g = yaml["gradient"];
|
||||||
|
auto& cfg = activeConfig();
|
||||||
|
readField(g, "inner_ratio", cfg.inner_ratio);
|
||||||
|
readField(g, "mid_ratio", cfg.mid_ratio);
|
||||||
|
readField(g, "inner_intensity", cfg.inner_intensity);
|
||||||
|
readField(g, "mid_intensity", cfg.mid_intensity);
|
||||||
|
readField(g, "outer_intensity", cfg.outer_intensity);
|
||||||
|
}
|
||||||
|
if (yaml.contains("flicker")) {
|
||||||
|
const auto& f = yaml["flicker"];
|
||||||
|
auto& fc = activeFlicker();
|
||||||
|
readField(f, "wave_amplitude_px", fc.wave_amplitude_px);
|
||||||
|
readField(f, "wave_frequency_hz", fc.wave_frequency_hz);
|
||||||
|
readField(f, "noise_amplitude_px", fc.noise_amplitude_px);
|
||||||
|
readField(f, "noise_frequency_hz", fc.noise_frequency_hz);
|
||||||
|
}
|
||||||
|
} catch (const std::exception& ex) {
|
||||||
|
Log::warn("Lighting", "error parsejant {}: {} — s'usaran els defaults compilats", yaml_path, ex.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void apply(std::uint32_t* canvas, int width, int height, float room_light, std::span<const Emitter> emitters) {
|
||||||
|
if (canvas == nullptr || width <= 0 || height <= 0) { return; }
|
||||||
|
|
||||||
|
// Atajo: si la sala està totalment il·luminada i no hi ha
|
||||||
|
// emissors, la pasada seria un `pixel * 1.0` global — no cal
|
||||||
|
// tocar res. És el camí curt habitual mentre no posem
|
||||||
|
// `light_level` a les sales.
|
||||||
|
const std::uint8_t ROOM = toByte(room_light);
|
||||||
|
if (ROOM == 255 && emitters.empty()) { return; }
|
||||||
|
|
||||||
|
auto& lum = luminanceBuffer();
|
||||||
|
const std::size_t TOTAL = static_cast<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 = activeConfig();
|
||||||
|
const auto& fc = activeFlicker();
|
||||||
|
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, fc)
|
||||||
|
: e.radius;
|
||||||
|
const auto RINGS = computeRings(R_EFF, cfg);
|
||||||
|
// El rasteritzador llegeix el radi de l'`Emitter` per al
|
||||||
|
// càlcul de la bounding box, així que li passem un emissor
|
||||||
|
// "patxat" amb el radi efectiu d'aquest frame. Mantenim el
|
||||||
|
// valor original al `Emitter` del caller intacte.
|
||||||
|
const Emitter EFF{.cx = e.cx, .cy = e.cy, .radius = R_EFF, .seed = e.seed, .flicker = e.flicker};
|
||||||
|
rasterizeEmitter(EFF, RINGS, lum.data(), width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
modulateCanvas(canvas, lum.data(), width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Lighting
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#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
|
||||||
|
// multiplica cada píxel del canvas per un factor de luminància 0..1
|
||||||
|
// derivat de la combinació de "llum de la sala" + cercles emissors al
|
||||||
|
// voltant de certes entitats.
|
||||||
|
//
|
||||||
|
// Forma de cada emissor (radial, 3 anells configurables al fitxer
|
||||||
|
// data/config/lighting.yaml; veure `GradientConfig`):
|
||||||
|
// 0..inner_ratio del radi → inner_intensity
|
||||||
|
// inner_ratio..mid_ratio → mid_intensity
|
||||||
|
// mid_ratio..1.0 → outer_intensity
|
||||||
|
// fora del radi → light_level de la sala
|
||||||
|
//
|
||||||
|
// Combinació entre emissors: per a cada píxel es pren el màxim del
|
||||||
|
// llum de sala i de la intensitat aportada per cada emissor que el
|
||||||
|
// solapa — no se "sumen" intensitats. Coherent amb la idea de "centre
|
||||||
|
// 1.0": el píxel que cau dins de qualsevol nucli no s'enfosqueix.
|
||||||
|
namespace Lighting {
|
||||||
|
|
||||||
|
// Posició al canvas (en píxels, mateixa unitat que la resta del
|
||||||
|
// motor) i radi efectiu de l'emissor. El centre és el del sprite
|
||||||
|
// visible: és l'entitat qui sap on cau (veure `Entity::getLightCenter`).
|
||||||
|
// `flicker` activa la modulació del radi per frame; `seed` és un
|
||||||
|
// identificador estable per emissor (no canvia amb la posició) del
|
||||||
|
// qual es deriva la fase del flicker — així els emissors mòbils
|
||||||
|
// mantenen un patró propi que no "deriva" pel moviment.
|
||||||
|
struct Emitter {
|
||||||
|
float cx{0.0F};
|
||||||
|
float cy{0.0F};
|
||||||
|
int radius{0};
|
||||||
|
int seed{0};
|
||||||
|
bool flicker{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 posició de cada emissor — sense estat per emissor.
|
||||||
|
struct FlickerConfig {
|
||||||
|
float wave_amplitude_px{2.0F};
|
||||||
|
float wave_frequency_hz{3.0F};
|
||||||
|
float noise_amplitude_px{1.0F};
|
||||||
|
float noise_frequency_hz{7.0F};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Llegeix `yaml_path` (data/config/lighting.yaml) i pobla la
|
||||||
|
// configuració global. Qualsevol clau absent cau als Defaults::Lighting.
|
||||||
|
// No es crida més d'una vegada en un run normal; tornar-la a cridar
|
||||||
|
// simplement reemplaça la configuració activa.
|
||||||
|
void loadConfig(const std::string& yaml_path);
|
||||||
|
|
||||||
|
// Configuració activa (per a debug/inspectors). Inicialitzada amb els
|
||||||
|
// Defaults::Lighting fins que loadConfig() es crida.
|
||||||
|
[[nodiscard]] auto config() -> const GradientConfig&;
|
||||||
|
|
||||||
|
// Aplica la pasada d'il·luminació al canvas in-place. `room_light`
|
||||||
|
// és l'atenuació base de la sala (0..1, 1 = sense atenuació). El
|
||||||
|
// span d'emissors pot ser buit — en aquest cas tota la sala queda
|
||||||
|
// multiplicada per `room_light`. Aquesta funció no fa cap allocació
|
||||||
|
// si es crida amb la mateixa mida de canvas múltiples cops: manté
|
||||||
|
// un buffer intern reutilitzable.
|
||||||
|
void apply(std::uint32_t* canvas, int width, int height, float room_light, std::span<const Emitter> emitters);
|
||||||
|
|
||||||
|
} // namespace Lighting
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")) {
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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,12 +890,24 @@ 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 {
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -142,18 +142,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 +172,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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,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 +106,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 +124,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 +144,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(); }
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,16 @@ auto Entity::getCollisionRect() const -> SDL_FRect {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto Entity::getLightCenter() const -> SDL_FPoint {
|
||||||
|
// Centre del rect de render. Per a entitats que es desplacen
|
||||||
|
// visualment sobre la seva posició lògica (p.ex. el Player saltant),
|
||||||
|
// la subclasse override aquest mètode per restar el desplaçament.
|
||||||
|
return {
|
||||||
|
.x = pixel_rect_.x + (pixel_rect_.w * 0.5F),
|
||||||
|
.y = pixel_rect_.y + (pixel_rect_.h * 0.5F),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
auto Entity::getFeetY() const -> float {
|
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
|
||||||
|
|||||||
@@ -84,8 +84,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 +106,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 +132,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 +203,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 +253,34 @@ 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; }
|
||||||
|
|
||||||
|
// Identificador estable per al flicker. Cada Entity rep un valor
|
||||||
|
// únic a la construcció (contador estàtic) i el conserva fins a la
|
||||||
|
// mort — així el patró del flicker no "deriva" quan l'entitat es
|
||||||
|
// mou. Si l'entitat respawneja, la nova instància tindrà una seed
|
||||||
|
// nova; com que el cicle visual ja queda trencat per la mort, no és
|
||||||
|
// un problema.
|
||||||
|
[[nodiscard]] auto getLightSeed() const -> int { return light_seed_; }
|
||||||
|
|
||||||
|
// Centre de la llum en píxels del canvas. Per defecte és el centre del
|
||||||
|
// `pixel_rect_` (que és la caixa de render: sprite top-left + mida).
|
||||||
|
// Virtual perquè entitats amb desplaçament visual sobre la posició
|
||||||
|
// lògica (p.ex. el Player en l'aire, que rebaixa visualment `pos_.y`
|
||||||
|
// amb `jump_z_`) puguin retornar el centre que realment es veu.
|
||||||
|
[[nodiscard]] virtual auto getLightCenter() const -> SDL_FPoint;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
std::string name_;
|
std::string name_;
|
||||||
TilePos tile_{};
|
TilePos tile_{};
|
||||||
@@ -240,7 +292,16 @@ 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};
|
||||||
bool dead_{false};
|
bool dead_{false};
|
||||||
SDL_FlipMode flip_{SDL_FLIP_NONE};
|
SDL_FlipMode flip_{SDL_FLIP_NONE};
|
||||||
EventBus* bus_{nullptr};
|
EventBus* bus_{nullptr};
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -155,6 +155,25 @@ namespace {
|
|||||||
return c["height"].get_value<int>();
|
return c["height"].get_value<int>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bloc opcional `light: { radius: N, flicker: bool }` de la plantilla.
|
||||||
|
// Si manca, el radi és 0 i l'entitat no entra a la pasada d'il·luminació.
|
||||||
|
// `flicker` activa la modulació "tipus foc" del radi (només antorxes
|
||||||
|
// l'haurien d'usar). La forma del gradient (3 anells) i els paràmetres
|
||||||
|
// del flicker viuen a data/config/lighting.yaml — no cal declarar-los
|
||||||
|
// per entitat.
|
||||||
|
struct LightTemplate {
|
||||||
|
int radius{0};
|
||||||
|
bool flicker{false};
|
||||||
|
};
|
||||||
|
auto readLight(const fkyaml::node& tmpl) -> LightTemplate {
|
||||||
|
if (!tmpl.contains("light")) { return {}; }
|
||||||
|
const auto& l = tmpl["light"];
|
||||||
|
LightTemplate out;
|
||||||
|
if (l.contains("radius")) { out.radius = l["radius"].get_value<int>(); }
|
||||||
|
if (l.contains("flicker")) { out.flicker = l["flicker"].get_value<bool>(); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +296,9 @@ namespace {
|
|||||||
entity->setCollisionBox(*BOX);
|
entity->setCollisionBox(*BOX);
|
||||||
}
|
}
|
||||||
entity->setHeight(readHeight(tmpl));
|
entity->setHeight(readHeight(tmpl));
|
||||||
|
const auto LIGHT = readLight(tmpl);
|
||||||
|
entity->setLightRadius(LIGHT.radius);
|
||||||
|
entity->setLightFlicker(LIGHT.flicker);
|
||||||
anchor_out = readAnchor(tmpl);
|
anchor_out = readAnchor(tmpl);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
@@ -395,6 +417,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({FRAME}, 1.0F, false));
|
||||||
|
}
|
||||||
|
|
||||||
manager.add(std::move(entity));
|
manager.add(std::move(entity));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -459,31 +492,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 +549,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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -141,6 +141,19 @@ 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;
|
||||||
|
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>(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +1285,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 +1327,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 +1475,16 @@ 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.
|
||||||
|
return {
|
||||||
|
.x = getPosX() + (static_cast<float>(sprite_w) * 0.5F),
|
||||||
|
.y = getPosY() - jump_z_ + (static_cast<float>(sprite_h) * 0.5F),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ 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 }` del YAML.
|
||||||
|
// 0 = no emet. Es declara estàtic com la resta de configs del jugador
|
||||||
|
// (immunity, jump, ...): tots els jugadors del mateix YAML comparteixen
|
||||||
|
// el valor. `light_flicker` activa la modulació "tipus foc" del radi.
|
||||||
|
inline static int light_radius = 0;
|
||||||
|
inline static bool light_flicker = false;
|
||||||
|
|
||||||
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
|
// 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 +140,19 @@ 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; }
|
||||||
|
// 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 +179,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 +763,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 +893,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`.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#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 +22,18 @@ 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;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void setEntityCollider(std::function<bool(const SDL_FRect&)> fn) {
|
||||||
|
g_entity_collider = std::move(fn);
|
||||||
|
}
|
||||||
|
|
||||||
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 +103,10 @@ 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. La clau és opcional a l'arrel del YAML;
|
||||||
|
// per defecte 1.0 (sala totalment il·luminada, comportament hui).
|
||||||
|
light_level = yaml.contains("light_level") ? yaml["light_level"].get_value<float>() : Defaults::Lighting::ROOM_LIGHT_DEFAULT;
|
||||||
|
|
||||||
Log::info("Map", "geometria {}x{} sarcs {}x{} pitch {}x{} door_col {} des de {}", grid_cols, grid_rows, sarc_w, sarc_h, pitch_x, pitch_y, door_col, yaml_path);
|
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 +140,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <SDL3/SDL.h>
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
@@ -49,6 +50,13 @@ 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.
|
||||||
|
inline float light_level = 1.0F;
|
||||||
|
|
||||||
// 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 +99,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
|
||||||
|
|||||||
+105
-152
@@ -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"
|
||||||
@@ -321,6 +323,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 +492,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);
|
||||||
@@ -740,6 +740,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 +1006,37 @@ 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();
|
||||||
|
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});
|
||||||
|
}
|
||||||
|
for (const auto* e : entity_manager_.getEntities()) {
|
||||||
|
const int R = e->getLightRadius();
|
||||||
|
if (R <= 0) { continue; }
|
||||||
|
const auto C = e->getLightCenter();
|
||||||
|
emitters.push_back({.cx = C.x, .cy = C.y, .radius = R, .seed = e->getLightSeed(), .flicker = e->getLightFlicker()});
|
||||||
|
}
|
||||||
|
Lighting::apply(Screen::get().getCanvasArgb(),
|
||||||
|
Screen::get().getCanvasWidth(),
|
||||||
|
Screen::get().getCanvasHeight(),
|
||||||
|
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 +1459,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 +1494,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 +1528,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 +1538,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 +1554,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 +1571,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 +1580,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 +1740,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 +1774,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();
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+28
-10
@@ -106,6 +106,28 @@ 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 {
|
||||||
|
constexpr float INNER_RATIO = 0.8F;
|
||||||
|
constexpr float MID_RATIO = 0.9F;
|
||||||
|
constexpr float INNER_INTENSITY = 1.0F;
|
||||||
|
constexpr float MID_INTENSITY = 0.9F;
|
||||||
|
constexpr float OUTER_INTENSITY = 0.8F;
|
||||||
|
// Nivell d'il·luminació de sala per defecte. Una sala sense la
|
||||||
|
// clau `light_level` queda totalment il·luminada (com hui).
|
||||||
|
constexpr float ROOM_LIGHT_DEFAULT = 1.0F;
|
||||||
|
// Defaults del flicker. Els reemplaça el bloc `flicker:` de
|
||||||
|
// data/config/lighting.yaml. Veure `Lighting::FlickerConfig`
|
||||||
|
// per a la semàntica de cada camp.
|
||||||
|
constexpr float FLICKER_WAVE_AMPLITUDE_PX = 2.0F;
|
||||||
|
constexpr float FLICKER_WAVE_FREQUENCY_HZ = 3.0F;
|
||||||
|
constexpr float FLICKER_NOISE_AMPLITUDE_PX = 1.0F;
|
||||||
|
constexpr float FLICKER_NOISE_FREQUENCY_HZ = 7.0F;
|
||||||
|
} // namespace Lighting
|
||||||
|
|
||||||
// Fallbacks del tema d'UI: índexs de paleta, timings (ms) i fonts.
|
// 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 +268,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 +520,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
|
||||||
|
|||||||
@@ -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.44.0"; // Single source of truth — Makefile el grep
|
||||||
} // namespace Texts
|
} // namespace Texts
|
||||||
|
|
||||||
namespace GameCanvas {
|
namespace GameCanvas {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -267,6 +272,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) {
|
||||||
try {
|
try {
|
||||||
@@ -336,11 +342,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 +370,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() {
|
||||||
|
|||||||
Reference in new issue
Block a user