Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eba52353b | ||
|
|
15a4016407 | ||
|
|
2272d3a37a | ||
|
|
3a42c7958a | ||
|
|
dacc4a9909 | ||
|
|
771ff1f6cd | ||
|
|
24609d6c5c | ||
|
|
9d0dde38f9 | ||
|
|
b76a3eb1f3 | ||
|
|
a779533d61 | ||
|
|
bc6ad6f718 | ||
|
|
9bd5e7db33 | ||
|
|
5cc8e349a5 | ||
|
|
0046d526b0 | ||
|
|
fc9947b87f | ||
|
|
35aaa6c519 | ||
|
|
3195746189 | ||
|
|
d1d5679d14 | ||
|
|
ebb161f1f5 | ||
|
|
68be8e55ec | ||
|
|
5ed963b53d | ||
|
|
5e5ff835a9 | ||
|
|
f5d251c33b | ||
|
|
d186f66c2c | ||
|
|
da361a1452 | ||
|
|
a43ae21014 | ||
|
|
6749780356 | ||
|
|
1c49248b72 | ||
|
|
63a03ae632 | ||
|
|
a8bff31a7c | ||
|
|
793e87a203 | ||
|
|
642e0653b2 | ||
|
|
d26abcb9d5 | ||
|
|
a358d8ddbd | ||
|
|
f7c4d62234 | ||
|
|
06a49e09ba | ||
|
|
629baaaec3 | ||
|
|
932d195eb0 | ||
|
|
3e31a88f78 | ||
|
|
cba46642c6 | ||
|
|
8c84892250 | ||
|
|
d14da0e61f | ||
|
|
8bdc994b75 | ||
|
|
6a70d6de9f | ||
|
|
2523661c8f | ||
|
|
fa27308897 | ||
|
|
e58eb10d6d | ||
|
|
a29ea641ab | ||
|
|
28ff425c89 |
No files matched your search
@@ -52,6 +52,7 @@ set(ENTITY_SOURCES_BASE
|
||||
source/game/entities/concrete/floor_trap.cpp
|
||||
source/game/entities/concrete/hole.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
|
||||
@@ -80,6 +81,7 @@ set(APP_SOURCES
|
||||
source/core/input/mouse.cpp
|
||||
source/core/rendering/screen.cpp
|
||||
source/core/rendering/canvas.cpp
|
||||
source/core/rendering/lighting.cpp
|
||||
source/core/rendering/notifier.cpp
|
||||
source/core/rendering/render_info.cpp
|
||||
source/core/rendering/gif.cpp
|
||||
@@ -94,6 +96,7 @@ set(APP_SOURCES
|
||||
source/core/rendering/sprite/animated_sprite_parser.cpp
|
||||
source/core/rendering/effects/heat_haze.cpp
|
||||
source/core/rendering/effects/scan_reveal.cpp
|
||||
source/core/rendering/effects/shadow.cpp
|
||||
source/core/rendering/transitions/mosaic.cpp
|
||||
source/core/rendering/transitions/fade_to_black.cpp
|
||||
source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp
|
||||
|
||||
@@ -293,8 +293,14 @@ rebuild: clean all
|
||||
# Empaqueta data/ a $(BUILDDIR)/resources.pack (format AE26). Força un rebuild
|
||||
# del pack encara que res no hagi canviat dins data/. L'empaquetat també es fa
|
||||
# automàticament a cada `make`/`make debug` via el target CMake `resource_pack`.
|
||||
#
|
||||
# NO passem `-DCMAKE_BUILD_TYPE`: així respectem el tipus ja configurat al
|
||||
# `CMakeCache.txt` (Debug si vens de `make debug`, Release si vens de
|
||||
# `make release`). Si forcéssim Release ací, el següent build de debug
|
||||
# invalidaria la cache i recompilaria-ho tot. El binari `pack_resources`
|
||||
# és una utilitat petita on el build type és irrellevant.
|
||||
pack:
|
||||
@cmake -S . -B $(BUILDDIR) $(CMAKE_GEN) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
|
||||
@cmake -S . -B $(BUILDDIR) $(CMAKE_GEN) $(CMAKE_DEFS)
|
||||
@cmake --build $(BUILDDIR) --target pack_resources
|
||||
@./$(BUILDDIR)/pack_resources data $(BUILDDIR)/resources.pack
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@ picker:
|
||||
|
||||
# Colors ARGB en hex. Accepta enter (0xFF2244FF) o cadena ("#FF2244FF").
|
||||
colors:
|
||||
border: "0xFF2244FF" # vora 1 px que envolta canvas i picker
|
||||
picker_bg: "0xFF101824" # fons del picker
|
||||
border: "0xFF6A86C8" # vora 1 px que envolta canvas i picker (blau suau)
|
||||
picker_bg: "0xFFC8C0D8" # fons del picker (lavanda clara)
|
||||
picker_hover: "0xFFFFFFFF" # vora 1 px del tile sota el cursor
|
||||
@@ -35,9 +35,6 @@ keys:
|
||||
- id: toggle_render_info
|
||||
code: "F10"
|
||||
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
|
||||
code: "F12"
|
||||
desc: "Obri / tanca el menú de servei"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Configuració global del sistema d'il·luminació.
|
||||
#
|
||||
# Cada llum emissora (player, torxa, mòmia, ...) té un únic paràmetre
|
||||
# propi: el radi en píxels. La forma de la llum dins d'aquest radi
|
||||
# (els tres anells del gradient) viu en aquest fitxer perquè és una
|
||||
# decisió de "feel" comuna a totes les entitats — així pots tocar la
|
||||
# corba aquí sense haver de modificar 10 YAMLs.
|
||||
#
|
||||
# Forma del gradient (centre → vora):
|
||||
# 0..inner_ratio del radi → inner_intensity (zona totalment il·luminada)
|
||||
# inner_ratio..mid_ratio → mid_intensity (anell mig)
|
||||
# mid_ratio..1.0 → outer_intensity (anell exterior)
|
||||
# fora del radi → light_level de la sala
|
||||
#
|
||||
# Hi ha dues corbes paral·leles, una per mode, perquè la mateixa escala
|
||||
# 0..1 vol dir coses molt diferents:
|
||||
# `gradient` → subtractive: 1.0 = "no fosqueix" el píxel
|
||||
# (l'emissor "trenca" una sala fosca).
|
||||
# `gradient_additive` → additive: 1.0 = "afegeix el color sencer"
|
||||
# (satura). Valors molt més baixos donen un
|
||||
# halo cromàtic subtil sobre una sala normal.
|
||||
gradient:
|
||||
inner_ratio: 0.5
|
||||
mid_ratio: 0.85
|
||||
inner_intensity: 1.0
|
||||
mid_intensity: 0.8
|
||||
outer_intensity: 0.6
|
||||
|
||||
# Corba del mode additive. Valors petits a propòsit: només volem un
|
||||
# toc de tint cromàtic prop de l'emissor — fora del nucli, la sala
|
||||
# original ha de quedar pràcticament intacta. Si pugeu massa l'inner,
|
||||
# els colors de l'emissor "inunden" la sala (additiu satura ràpid).
|
||||
gradient_additive:
|
||||
inner_ratio: 0.5
|
||||
mid_ratio: 0.85
|
||||
inner_intensity: 0.15
|
||||
mid_intensity: 0.10
|
||||
outer_intensity: 0.05
|
||||
|
||||
# Modulació del radi quan una entitat declara `flicker: true` al seu
|
||||
# bloc `light:`. El radi efectiu per frame és:
|
||||
# r_eff = r_base + (r_amp1·r_base)·sin(2π·f1·t + φ1) + (r_amp2·r_base)·sin(2π·f2·t + φ2)
|
||||
# on (φ1, φ2) es deriven de la seed de l'emissor per a que cada
|
||||
# antorxa balancegi en una fase diferent (sense estat per emissor).
|
||||
# Les amplituds són **ràtios** sobre el radi base: així el feel del
|
||||
# flicker és independent del radi (una torxa de 24 px oscil·la el
|
||||
# mateix % que una de 40 px). Combinant dues sinusoides amb freqüències
|
||||
# incommensurables s'obté un patró "fluid" que el ull llegeix com a
|
||||
# foc; pujar `noise_*` aporta més inquietud, baixar-ho fa la flama
|
||||
# més serena. Aquests són els defaults globals; cada tipus d'entitat
|
||||
# (o cada spawn / sala) pot sobreescriure'ls al seu `light.flicker_params:`.
|
||||
flicker:
|
||||
wave_amplitude_ratio: 0.05 # 5% del radi (per a radi 40 → ±2 px, feel actual)
|
||||
wave_frequency_hz: 3.0
|
||||
noise_amplitude_ratio: 0.025 # 2.5% (per a radi 40 → ±1 px, feel actual)
|
||||
noise_frequency_hz: 7.0
|
||||
@@ -17,15 +17,15 @@ colors:
|
||||
menu_cursor: 71
|
||||
menu_disabled: 16
|
||||
hud_bg: 9
|
||||
hud_label: 58
|
||||
hud_value: 187
|
||||
hud_flash: 237
|
||||
hud_flash: 63
|
||||
# Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). Independents
|
||||
# de notification_* perquè la fase i les notificacions són dos canals
|
||||
# visuals distints: ací pots canviar el color del rètol sense tocar
|
||||
# els missatges del HUD.
|
||||
room_text: 160
|
||||
room_text_outline: 253
|
||||
# els missatges del HUD. Comparteix la família del CENTER del HUD
|
||||
# (or sobre marró) perquè "Piràmide N / Sala N" sigui sempre daurat
|
||||
# arreu del joc.
|
||||
room_text: 55 # or brillant (#FFDA00, = hud_center.value)
|
||||
room_text_outline: 10 # marró cremat (#482400, = hud_center.shadow)
|
||||
|
||||
timings:
|
||||
notification_ms: 4000
|
||||
@@ -73,6 +73,30 @@ hud:
|
||||
entry_ms: 800 # durada de l'animació d'entrada dels tres blocs
|
||||
flash_ms: 250 # durada del flash quan un jugador rep un hit
|
||||
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
|
||||
# per etiquetes), `value` (to viu per a la xifra) i `shadow` (to fosc
|
||||
# del mateix matís — efecte embossing 1 px a baix-dreta). Si manca un
|
||||
# camp, cau al default compilat a `Defaults::UI`. El `colors.hud_flash`
|
||||
# és comú a totes tres famílies.
|
||||
hud_p1:
|
||||
label: 7 # vermell pur
|
||||
value: 183 # crema-préssec
|
||||
shadow: 2 # vermell fosc
|
||||
hud_center:
|
||||
label: 29 # ambre cremat
|
||||
value: 55 # or brillant
|
||||
shadow: 10 # marró cremat
|
||||
hud_p2:
|
||||
label: 248 # cian pur
|
||||
value: 253 # blau pàl·lid
|
||||
shadow: 64 # blau marí
|
||||
|
||||
# Geometria del popup superior (Notifier). En píxels del canvas, llevat de
|
||||
# slide_ms (durada en ms de l'animació d'entrada i d'eixida; constant
|
||||
@@ -90,17 +114,6 @@ notifier:
|
||||
slide_in_curve: outCubic
|
||||
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
|
||||
# acompanya navegació (amunt/avall) i back; ACCEPT acompanya modificació
|
||||
# d'opció (esquerra/dreta/retorn) o entrada a un submenú.
|
||||
|
||||
|
After Width: | Height: | Size: 805 B |
@@ -0,0 +1,22 @@
|
||||
name: column
|
||||
type: decoration
|
||||
editable: true
|
||||
# Sub-picker de frames a l'editor: 6 variants (atlas 2×3 de sub-sprites 16×32).
|
||||
editable_variants: true
|
||||
sprite:
|
||||
path: column.gif
|
||||
size: [16, 32]
|
||||
# Ancoratge "feet": el tile [col, row] del spawn és la base de la columna.
|
||||
# El sprite de 16×32 sobresurt 16 px cap amunt sense afectar col·lisió ni
|
||||
# z-order (la caixa lògica és el tile inferior).
|
||||
anchor: feet
|
||||
tile_size: [1, 1]
|
||||
collision:
|
||||
size: [16, 16]
|
||||
# Mur infranquejable al salt: el jugador no pot passar pel damunt.
|
||||
height: 999
|
||||
flags:
|
||||
collides: true
|
||||
interactable: false
|
||||
animations:
|
||||
default: { frames: [0], frame_ms: 1000, loop: false }
|
||||
@@ -21,9 +21,9 @@ flags:
|
||||
collides: true
|
||||
interactable: true
|
||||
animations:
|
||||
closed: { frames: [1], fps: 1, loop: true }
|
||||
opening: { frames: [0], fps: 6, loop: false }
|
||||
open: { frames: [0], fps: 1, loop: true }
|
||||
closed: { frames: [1], frame_ms: 1000, loop: true }
|
||||
opening: { frames: [0], frame_ms: 167, loop: false }
|
||||
open: { frames: [0], frame_ms: 1000, loop: true }
|
||||
behaviour:
|
||||
states: [CLOSED, OPENING, OPEN]
|
||||
initial_state: CLOSED
|
||||
|
||||
|
Before Width: | Height: | Size: 306 B After Width: | Height: | Size: 303 B |
@@ -16,7 +16,7 @@ flags:
|
||||
# apropiada via `setVariant` abans del primer tick.
|
||||
# jump: frames 0..6 (7 frames) → reproduït al despegue.
|
||||
# land: frames 7..12 (6 frames) → reproduït a l'aterratge.
|
||||
# fps: 10 = 100 ms per frame.
|
||||
# frame_ms: 67 ≈ 15 fps (1000 / 67 ≈ 14.9 fps).
|
||||
animations:
|
||||
jump: { frames: [0, 1, 2, 3, 4, 5, 6], fps: 15, loop: false }
|
||||
land: { frames: [7, 8, 9, 10, 11, 12], fps: 15, loop: false }
|
||||
jump: { frames: [0, 1, 2, 3, 4, 5, 6], frame_ms: 67, loop: false }
|
||||
land: { frames: [7, 8, 9, 10, 11, 12], frame_ms: 67, loop: false }
|
||||
@@ -13,8 +13,8 @@ flags:
|
||||
collides: false
|
||||
interactable: false
|
||||
animations:
|
||||
inactive: { frames: [0], fps: 1, loop: true }
|
||||
active: { frames: [0], fps: 6, loop: true }
|
||||
inactive: { frames: [0], frame_ms: 1000, loop: true }
|
||||
active: { frames: [0], frame_ms: 167, loop: true }
|
||||
cycle:
|
||||
active_duration: 0.3
|
||||
inactive_duration: 2.0
|
||||
|
||||
@@ -22,6 +22,6 @@ flags:
|
||||
# lectura genèrica de flags.
|
||||
triggers_on_overlap: true
|
||||
animations:
|
||||
idle: { frames: [0], fps: 1, loop: true }
|
||||
idle: { frames: [0], frame_ms: 1000, loop: true }
|
||||
stats:
|
||||
damage: 0
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -32,22 +32,28 @@ flags:
|
||||
# no decideix flip per direcció (només selecciona quina animació mostrar).
|
||||
# La momia no té variants `stand_*` perquè mai no para.
|
||||
animations:
|
||||
walk_south: { frames: [0, 1, 2, 3], fps: 8, loop: true }
|
||||
walk_north: { frames: [4, 5, 6, 7], fps: 8, loop: true }
|
||||
walk_east: { frames: [8, 9, 10, 11], fps: 8, loop: true }
|
||||
walk_west: { frames: [8, 9, 10, 11], fps: 8, loop: true, flip_h: true }
|
||||
walk_south: { frames: [0, 1, 2, 3], frame_ms: 125, loop: true }
|
||||
walk_north: { frames: [4, 5, 6, 7], frame_ms: 125, loop: true }
|
||||
walk_east: { frames: [8, 9, 10, 11], frame_ms: 125, loop: true }
|
||||
walk_west: { frames: [8, 9, 10, 11], frame_ms: 125, loop: true, flip_h: true }
|
||||
# Alçament: els 15 primers frames passen ràpid (125 ms cadascun) i el
|
||||
# darrer aguanta 1 s perquè el jugador pugui veure la mòmia ja alçada
|
||||
# abans que comenci a deambular. `frame_ms` accepta una llista per-frame
|
||||
# que mapeja 1:1 contra `frames`.
|
||||
raise:
|
||||
frames: [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27]
|
||||
frame_ms: [125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 1000]
|
||||
loop: false
|
||||
ai:
|
||||
type: corridor_wanderer
|
||||
direction_choice: random
|
||||
initial_direction: east
|
||||
|
||||
# Estat SPAWN: la mòmia s'instancia visible al pixel_rect_ inicial just
|
||||
# quan GameScene crida `spawnPendingActors()` (a la subfase text-exit
|
||||
# de ROOM_INTRO, fase 4). El `triggerSpawnExit` es dispara llavors
|
||||
# mateix; passats `duration_s` segons transita a ROAMING i empieza a
|
||||
# deambular. Sense aquest bloc, default 0 ⇒ ACTIVE immediat.
|
||||
spawn:
|
||||
duration_s: 1.0
|
||||
# Estat SPAWN: la mòmia s'instancia al pixel_rect_ inicial quan
|
||||
# GameScene crida `spawnPendingActors()` (subfase text-exit de
|
||||
# ROOM_INTRO, fase 4) amb l'animació `raise` activa. La duració del
|
||||
# SPAWN la marca la pròpia animació (no-loop): al `completed()`,
|
||||
# transita a ROAMING i comença a deambular.
|
||||
stats:
|
||||
speed: 30
|
||||
damage: 1
|
||||
@@ -69,3 +75,10 @@ hurt:
|
||||
# So opcional: basename del fitxer dins `data/audio/sounds/`. Es dispara
|
||||
# un sol cop per fuetada quan la mòmia entra a HURT. Si manca, no sona.
|
||||
sound: mummy_hurt.wav
|
||||
|
||||
# Halo tènue al voltant de la mòmia. Radi més curt que el del jugador
|
||||
# perquè el rastre lumínic delati la seva posició sense "il·luminar
|
||||
# la sala" — vol ser una pista visual a la foscor.
|
||||
light:
|
||||
radius: 24
|
||||
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
|
||||
@@ -30,10 +30,10 @@ animations:
|
||||
stun_east: { frames: [18] }
|
||||
stun_west: { frames: [18], flip_h: true }
|
||||
|
||||
walk_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
|
||||
walk_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
|
||||
walk_east: { frames: [8, 9, 10, 11], frame_ms: 100 }
|
||||
walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true }
|
||||
walk_south: { frames: [1, 2, 3, 0], frame_ms: 100 }
|
||||
walk_north: { frames: [5, 6, 7, 4], frame_ms: 100 }
|
||||
walk_east: { frames: [9, 10, 11, 8], frame_ms: 100 }
|
||||
walk_west: { frames: [9, 10, 11, 8], frame_ms: 100, flip_h: true }
|
||||
|
||||
hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
|
||||
hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
|
||||
@@ -97,13 +97,10 @@ turn:
|
||||
south_to_north: west
|
||||
|
||||
# Salt vertical (px de z, no toca la posició al món).
|
||||
# `shadow_y_offset`: puja la sombra per compensar la franja
|
||||
# transparent sota els peus del sprite.
|
||||
jump:
|
||||
impulse: 200
|
||||
gravity: 600
|
||||
landing_stun_ms: 400
|
||||
shadow_y_offset: 3
|
||||
sound: jump.wav
|
||||
land_sound: land.wav
|
||||
|
||||
@@ -142,4 +139,12 @@ death:
|
||||
immunity:
|
||||
duration_ms: 3000
|
||||
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
|
||||
@@ -30,10 +30,10 @@ animations:
|
||||
stun_east: { frames: [18] }
|
||||
stun_west: { frames: [18], flip_h: true }
|
||||
|
||||
walk_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
|
||||
walk_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
|
||||
walk_east: { frames: [8, 9, 10, 11], frame_ms: 100 }
|
||||
walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true }
|
||||
walk_south: { frames: [1, 2, 3, 0], frame_ms: 100 }
|
||||
walk_north: { frames: [5, 6, 7, 4], frame_ms: 100 }
|
||||
walk_east: { frames: [9, 10, 11, 8], frame_ms: 100 }
|
||||
walk_west: { frames: [9, 10, 11, 8], frame_ms: 100, flip_h: true }
|
||||
|
||||
hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
|
||||
hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
|
||||
@@ -97,13 +97,10 @@ turn:
|
||||
south_to_north: west
|
||||
|
||||
# Salt vertical (px de z, no toca la posició al món).
|
||||
# `shadow_y_offset`: puja la sombra per compensar la franja
|
||||
# transparent sota els peus del sprite.
|
||||
jump:
|
||||
impulse: 200
|
||||
gravity: 600
|
||||
landing_stun_ms: 400
|
||||
shadow_y_offset: 3
|
||||
sound: jump.wav
|
||||
land_sound: land.wav
|
||||
|
||||
@@ -142,4 +139,12 @@ death:
|
||||
immunity:
|
||||
duration_ms: 3000
|
||||
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
|
||||
@@ -13,7 +13,7 @@ flags:
|
||||
collides: true
|
||||
interactable: false
|
||||
animations:
|
||||
roll: { frames: [0], fps: 10, loop: true }
|
||||
roll: { frames: [0], frame_ms: 100, loop: true }
|
||||
ai:
|
||||
type: straight_line
|
||||
initial_direction: right
|
||||
|
||||
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 1.6 KiB |
@@ -19,12 +19,30 @@ flags:
|
||||
collides: true
|
||||
interactable: true
|
||||
animations:
|
||||
closed: { frames: [0], fps: 1, loop: true }
|
||||
opening: { frames: [0], fps: 6, loop: false }
|
||||
open: { frames: [0], fps: 1, loop: true }
|
||||
closed: { frames: [0], frame_ms: 1000, loop: true }
|
||||
opening: { frames: [1, 2], frame_ms: 167, loop: false }
|
||||
open: { frames: [3], frame_ms: 1000, loop: true }
|
||||
behaviour:
|
||||
states: [CLOSED, OPENING, OPEN]
|
||||
initial_state: CLOSED
|
||||
on_perimeter_complete: OPENING
|
||||
on_opening_end: OPEN
|
||||
emits_event_on_open: sarcophagus_opened
|
||||
# Sons opcionals. `opening` es dispara un sol cop en la transició
|
||||
# CLOSED → OPENING (quan el jugador completa el perímetre). Si la clau
|
||||
# manca, no sona res. El basename val tant de clau al Resource::Cache
|
||||
# com de path (`Defaults::Paths::AUDIO_SOUNDS_DIR + key`).
|
||||
#
|
||||
# `opening_pitch_spread`: variació uniforme del ratio de reproducció
|
||||
# centrada a 1.0. Amb 0.05 cada obertura sona entre 0.95× i 1.05× la
|
||||
# velocitat normal (puja/baixa també el to, efecte arcade clàssic).
|
||||
# 0 ⇒ sempre 1.0 (sense variació).
|
||||
sound:
|
||||
opening: open_sarc.wav
|
||||
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
|
||||
|
After Width: | Height: | Size: 98 B |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,22 @@
|
||||
name: stone
|
||||
type: decoration
|
||||
editable: true
|
||||
# Sub-picker de frames a l'editor: ofereix una graella amb les 24 variants
|
||||
# del atlas (6×4 sub-sprites 16×16) per escollir quina representa cada
|
||||
# instància col·locada.
|
||||
editable_variants: true
|
||||
sprite:
|
||||
path: stone.gif
|
||||
size: [16, 16]
|
||||
tile_size: [1, 1]
|
||||
collision:
|
||||
size: [16, 16]
|
||||
# Mur infranquejable al salt: el jugador no la pot saltar per damunt.
|
||||
height: 999
|
||||
flags:
|
||||
collides: true
|
||||
interactable: false
|
||||
# Animació "default" amb un sol frame: el spawn YAML pot sobreescriure-la
|
||||
# via `frame: N` per fixar la variant escollida al editor.
|
||||
animations:
|
||||
default: { frames: [0], frame_ms: 1000, loop: false }
|
||||
@@ -15,4 +15,19 @@ flags:
|
||||
# El torch.gif és un strip vertical 16x64 amb 4 frames apilats.
|
||||
# El render clipa cada frame per `anim.currentFrame()` × 16 píxels en Y.
|
||||
animations:
|
||||
idle: { frames: [0, 1, 2, 3], fps: 8, loop: true }
|
||||
idle: { frames: [0, 1, 2, 3], frame_ms: 125, loop: true }
|
||||
|
||||
# Cercle de llum centrat a l'antorxa. El radi és en píxels del canvas;
|
||||
# el gradient global (data/config/lighting.yaml) hi posa la forma (3
|
||||
# anells). Amb `flicker: true` el radi efectiu oscil·la per frame
|
||||
# (suma de dues sinusoides amb fase derivada de la posició) per a
|
||||
# que el cercle "respiri" com una flama; els paràmetres del flicker
|
||||
# també viuen al config global. `offset: [x, y]` desplaça el centre
|
||||
# del cercle: la flama del sprite no està al centre del bounding box
|
||||
# sinó cap a dalt, així que pujem la llum 4 px (Y creix cap avall)
|
||||
# per a que el halo "neixi" de la flama, no del peu. Omet el bloc
|
||||
# per fer una antorxa "apagada".
|
||||
light:
|
||||
radius: 40
|
||||
flicker: true
|
||||
offset: [0, -4]
|
||||
@@ -13,8 +13,8 @@ flags:
|
||||
collides: false
|
||||
interactable: false
|
||||
animations:
|
||||
inactive: { frames: [0], fps: 1, loop: true }
|
||||
active: { frames: [0], fps: 6, loop: true }
|
||||
inactive: { frames: [0], frame_ms: 1000, loop: true }
|
||||
active: { frames: [0], frame_ms: 167, loop: true }
|
||||
cycle:
|
||||
active_duration: 0.5
|
||||
inactive_duration: 1.5
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Generado con font_gen.py
|
||||
|
||||
box_width 12
|
||||
box_height 14
|
||||
box_height 13
|
||||
columns 15
|
||||
|
||||
# codepoint_decimal ancho_visual
|
||||
@@ -105,8 +105,8 @@ columns 15
|
||||
193 8 # Á
|
||||
200 7 # È
|
||||
201 7 # É
|
||||
204 4 # Ì
|
||||
205 4 # Í
|
||||
204 3 # Ì
|
||||
205 3 # Í
|
||||
210 9 # Ò
|
||||
211 9 # Ó
|
||||
217 7 # Ù
|
||||
@@ -117,8 +117,8 @@ columns 15
|
||||
225 7 # á
|
||||
232 7 # è
|
||||
233 7 # é
|
||||
236 4 # ì
|
||||
237 4 # í
|
||||
236 3 # ì
|
||||
237 3 # í
|
||||
242 7 # ò
|
||||
243 7 # ó
|
||||
249 7 # ù
|
||||
@@ -127,7 +127,7 @@ columns 15
|
||||
231 7 # ç
|
||||
161 3 # ¡
|
||||
191 7 # ¿
|
||||
171 10 # «
|
||||
187 10 # »
|
||||
171 7 # «
|
||||
187 7 # »
|
||||
183 3 # ·
|
||||
169 10 # ©
|
||||
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -2,7 +2,7 @@
|
||||
# Generado con font_gen.py
|
||||
|
||||
box_width 12
|
||||
box_height 14
|
||||
box_height 13
|
||||
columns 15
|
||||
|
||||
# codepoint_decimal ancho_visual
|
||||
@@ -105,8 +105,8 @@ columns 15
|
||||
193 9 # Á
|
||||
200 8 # È
|
||||
201 8 # É
|
||||
204 4 # Ì
|
||||
205 4 # Í
|
||||
204 3 # Ì
|
||||
205 3 # Í
|
||||
210 10 # Ò
|
||||
211 10 # Ó
|
||||
217 8 # Ù
|
||||
@@ -117,8 +117,8 @@ columns 15
|
||||
225 8 # á
|
||||
232 8 # è
|
||||
233 8 # é
|
||||
236 4 # ì
|
||||
237 4 # í
|
||||
236 3 # ì
|
||||
237 3 # í
|
||||
242 8 # ò
|
||||
243 8 # ó
|
||||
249 8 # ù
|
||||
@@ -127,7 +127,7 @@ columns 15
|
||||
231 8 # ç
|
||||
161 3 # ¡
|
||||
191 8 # ¿
|
||||
171 10 # «
|
||||
187 10 # »
|
||||
171 7 # «
|
||||
187 7 # »
|
||||
183 3 # ·
|
||||
169 10 # ©
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -14,7 +14,7 @@ input:
|
||||
shader_off: "Shaders desactivats"
|
||||
vsync_on: "VSync activat"
|
||||
vsync_off: "VSync desactivat"
|
||||
screenshot: "Captura"
|
||||
screenshot: "Imatge {} guardada a {}"
|
||||
shader: "Shader"
|
||||
preset: "Preset"
|
||||
scaling_disabled: "Escalat sense"
|
||||
@@ -23,7 +23,6 @@ input:
|
||||
scaling_overscan: "Escalat overscan"
|
||||
scaling_integer: "Escalat enter"
|
||||
press_again_exit: "Prem una altra volta per eixir"
|
||||
paused: "Pausa"
|
||||
editor_on: "Editor ON"
|
||||
editor_off: "Editor OFF"
|
||||
editor_save_error: "Editor: error guardant el mapa"
|
||||
@@ -41,10 +40,10 @@ hud:
|
||||
# Prompts mostrats al bloc d'un slot inactiu (en lloc dels zeros)
|
||||
# quan un jugador encara no ha pulsat START o ha esgotat vides.
|
||||
# Dues línies per fer-los caber al bloc lateral (~106 px).
|
||||
join_prompt_can_join_l1: "POLSA START"
|
||||
join_prompt_can_join_l2: "PER JUGAR"
|
||||
join_prompt_wait_l1: "ESPERA"
|
||||
join_prompt_wait_l2: "PER JUGAR"
|
||||
join_prompt_can_join_l1: "Polsa start"
|
||||
join_prompt_can_join_l2: "per jugar"
|
||||
join_prompt_wait_l1: "Espera"
|
||||
join_prompt_wait_l2: "per jugar"
|
||||
|
||||
# Textos de l'escena TITLE.
|
||||
title:
|
||||
|
||||
@@ -14,7 +14,7 @@ input:
|
||||
shader_off: "Shaders off"
|
||||
vsync_on: "VSync on"
|
||||
vsync_off: "VSync off"
|
||||
screenshot: "Screenshot"
|
||||
screenshot: "Image {} saved at {}"
|
||||
shader: "Shader"
|
||||
preset: "Preset"
|
||||
scaling_disabled: "Scaling off"
|
||||
@@ -23,7 +23,6 @@ input:
|
||||
scaling_overscan: "Scaling overscan"
|
||||
scaling_integer: "Scaling integer"
|
||||
press_again_exit: "Press again to exit"
|
||||
paused: "Paused"
|
||||
editor_on: "Editor ON"
|
||||
editor_off: "Editor OFF"
|
||||
editor_save_error: "Editor: map save failed"
|
||||
@@ -41,10 +40,10 @@ hud:
|
||||
# Prompts shown in an inactive slot's block (instead of zeros) while
|
||||
# a player hasn't pressed START yet or has run out of lives. Two
|
||||
# short lines so they fit in the side block (~106 px wide).
|
||||
join_prompt_can_join_l1: "PRESS START"
|
||||
join_prompt_can_join_l2: "TO PLAY"
|
||||
join_prompt_wait_l1: "PLEASE"
|
||||
join_prompt_wait_l2: "WAIT"
|
||||
join_prompt_can_join_l1: "Press start"
|
||||
join_prompt_can_join_l2: "to play"
|
||||
join_prompt_wait_l1: "Please"
|
||||
join_prompt_wait_l2: "wait"
|
||||
|
||||
# Texts of the TITLE scene.
|
||||
title:
|
||||
|
||||
@@ -10,11 +10,38 @@
|
||||
# la cel·la buida (transparent), útil per caselles que ocuparà un actor.
|
||||
|
||||
pyramid: 1
|
||||
room: 1
|
||||
room: 2
|
||||
tileset: data/tilesets/pyramid_1.gif
|
||||
tile_size: 16
|
||||
origin: [-8, 0]
|
||||
|
||||
# Banc de proves del mode `additive` d'il·luminació. La sala es veu
|
||||
# "normal" (level 1.0, el GIF intacte fora del cercle de les torxes)
|
||||
# i només les antorxes hi sumen un halo càlid suau. Player i mummies
|
||||
# desactivats explícitament perquè aquí no volem cap halo al voltant
|
||||
# d'actors — el toc cromàtic és només l'antorxa. La corba additiva
|
||||
# (intensitats baixes a propòsit perquè additive satura ràpid) viu a
|
||||
# `gradient_additive:` de data/config/lighting.yaml.
|
||||
lighting:
|
||||
mode: additive
|
||||
level: 1.0
|
||||
emitters:
|
||||
torches:
|
||||
radius: 20
|
||||
color: [255, 170, 60]
|
||||
# Valors default del flicker explicitats per anar-los ajustant
|
||||
# in-situ. Coincideixen amb els del global a data/config/lighting.yaml;
|
||||
# baixar wave_frequency_hz fa el "palpitar" més lent, baixar les
|
||||
# ratios fa l'oscil·lació més subtil. Qualsevol camp es pot
|
||||
# eliminar i caurà al valor del global.
|
||||
flicker_params:
|
||||
wave_amplitude_ratio: 0.04
|
||||
wave_frequency_hz: 3.0
|
||||
noise_amplitude_ratio: 0.025
|
||||
noise_frequency_hz: 3.0
|
||||
players: false
|
||||
mummies: false
|
||||
|
||||
# Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl
|
||||
# pintable. Un índex per jugador perquè a multijugador local el recorregut
|
||||
# es distingeix a simple vista. Provisional fins que l'art del pintat
|
||||
@@ -82,6 +109,8 @@ data:
|
||||
# d'obertura (262/263). La momia al corredor central. L'antorxa al costat
|
||||
# superior esquerre per verificar render d'una entitat decorativa.
|
||||
entities:
|
||||
- type: torch
|
||||
position: [4, 2]
|
||||
- type: torch
|
||||
position: [8, 2]
|
||||
- type: torch
|
||||
@@ -128,8 +157,8 @@ entities:
|
||||
sarc_position: [2, 4]
|
||||
- type: sarcophagus
|
||||
sarc_position: [3, 4]
|
||||
- type: torch
|
||||
position: [4, 2]
|
||||
- type: door
|
||||
- type: mummy
|
||||
position: [10, 11]
|
||||
position: [10, 7]
|
||||
- type: mummy
|
||||
position: [7, 9]
|
||||
@@ -10,11 +10,38 @@
|
||||
# la cel·la buida (transparent), útil per caselles que ocuparà un actor.
|
||||
|
||||
pyramid: 1
|
||||
room: 2
|
||||
room: 1
|
||||
tileset: data/tilesets/pyramid_1.gif
|
||||
tile_size: 16
|
||||
origin: [-8, 0]
|
||||
|
||||
# Il·luminació de la sala. Bloc opcional; si manca, la sala queda
|
||||
# totalment il·luminada (1.0) i tots els tipus d'entitat emeten amb els
|
||||
# valors declarats al seu YAML de tipus (comportament històric).
|
||||
#
|
||||
# level → atenuació base de la sala (0..1). 1.0 = res
|
||||
# d'efecte, 0.0 = negre fora del radi dels emissors.
|
||||
# El gradient dels emissors viu a
|
||||
# data/config/lighting.yaml.
|
||||
# emitters.<tipus> → override per tipus d'entitat. Tres formes:
|
||||
# false → apaga el tipus
|
||||
# true → defaults del tipus
|
||||
# { radius: N, flicker: B } → camps presents
|
||||
# sobreescriuen el
|
||||
# default; els
|
||||
# absents cauen al
|
||||
# default del tipus.
|
||||
# Plural per llegir-se natural ("totes les momies de la sala"). Per a
|
||||
# casos particulars, un spawn pot dur el seu propi `light:` (mateixes
|
||||
# formes) que té prioritat sobre l'override de sala. La forma antiga
|
||||
# (`light_level:` plà a l'arrel) també segueix sent vàlida per
|
||||
# retrocompatibilitat.
|
||||
lighting:
|
||||
level: 0.15
|
||||
emitters:
|
||||
players: true
|
||||
mummies: false
|
||||
|
||||
# Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl
|
||||
# pintable. Un índex per jugador perquè a multijugador local el recorregut
|
||||
# es distingeix a simple vista. Provisional fins que l'art del pintat
|
||||
@@ -82,16 +109,12 @@ data:
|
||||
# d'obertura (262/263). La momia al corredor central. L'antorxa al costat
|
||||
# superior esquerre per verificar render d'una entitat decorativa.
|
||||
entities:
|
||||
- type: torch
|
||||
position: [4, 2]
|
||||
- type: torch
|
||||
position: [8, 2]
|
||||
- type: torch
|
||||
position: [12, 2]
|
||||
- type: torch
|
||||
position: [16, 2]
|
||||
- type: sarcophagus
|
||||
sarc_position: [0, 0]
|
||||
- type: sarcophagus
|
||||
sarc_position: [1, 0]
|
||||
- type: sarcophagus
|
||||
@@ -118,8 +141,6 @@ entities:
|
||||
sarc_position: [0, 3]
|
||||
- type: sarcophagus
|
||||
sarc_position: [1, 3]
|
||||
- type: sarcophagus
|
||||
sarc_position: [2, 3]
|
||||
- type: sarcophagus
|
||||
sarc_position: [3, 3]
|
||||
- type: sarcophagus
|
||||
@@ -130,11 +151,33 @@ entities:
|
||||
sarc_position: [2, 4]
|
||||
- type: sarcophagus
|
||||
sarc_position: [3, 4]
|
||||
- type: torch
|
||||
position: [4, 2]
|
||||
- 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: column
|
||||
position: [3, 4]
|
||||
frame: 2
|
||||
- type: stone
|
||||
position: [5, 4]
|
||||
frame: 16
|
||||
- type: stone
|
||||
position: [4, 4]
|
||||
frame: 19
|
||||
- type: column
|
||||
position: [13, 10]
|
||||
frame: 4
|
||||
- type: stone
|
||||
position: [12, 10]
|
||||
frame: 13
|
||||
- type: stone
|
||||
position: [11, 10]
|
||||
frame: 23
|
||||
- type: hole
|
||||
position: [5, 9]
|
||||
- type: hole
|
||||
position: [9, 11]
|
||||
- type: stone
|
||||
position: [10, 10]
|
||||
frame: 18
|
||||
- type: mummy
|
||||
position: [10, 7]
|
||||
- type: mummy
|
||||
position: [7, 9]
|
||||
position: [10, 13]
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
# 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
|
||||
# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp.
|
||||
|
||||
@@ -43,8 +43,11 @@ text:
|
||||
# text i decreix amb l'exit_curve. `color` són tres canals RGB (0..255);
|
||||
# `alpha` és la transparència del blend (0 = invisible, 255 = opac).
|
||||
text_panel:
|
||||
color: [36, 182, 170]
|
||||
alpha: 128
|
||||
# Violeta cerimonial: contrasta amb l'or del text (room_text), no
|
||||
# compite amb el blau del bloc P2 del HUD, i deixa la sorra de les
|
||||
# sales respirar al darrere.
|
||||
color: [72, 36, 85]
|
||||
alpha: 160
|
||||
height_px: 32
|
||||
|
||||
# Sub-FSM de game over: dispara CONTINUE? quan tots els jugadors
|
||||
|
||||
@@ -136,6 +136,22 @@ void Audio::playSound(Ja::Sound* sound, Group group) {
|
||||
}
|
||||
}
|
||||
|
||||
// Variant amb velocitat (i to) escalats. Apliquem el ratio al canal
|
||||
// just retornat per `playSound`: així el `SDL_AudioStream` recent creat
|
||||
// processa tot el sample amb el ratio des del primer pull del callback.
|
||||
// Si l'engine torna -1 (sense canal lliure) o el so no existeix, no fem
|
||||
// la crida al ratio — sense efectes col·laterals.
|
||||
// NOLINTNEXTLINE(readability-make-member-function-const)
|
||||
void Audio::playSound(const std::string& name, Group group, float speed) {
|
||||
if (!sound_enabled_) { return; }
|
||||
auto* sound = AudioResource::getSound(name);
|
||||
if (sound == nullptr) { return; }
|
||||
const int CH = engine_->playSound(sound, 0, static_cast<int>(group));
|
||||
if (CH >= 0 && speed != 1.0F) {
|
||||
engine_->setChannelSpeed(CH, speed);
|
||||
}
|
||||
}
|
||||
|
||||
// Reprodueix un so processat per un eco definit a sounds.yaml. Si el preset no
|
||||
// existeix o l'engine retorna -1 (cap de canals d'efecte plé), cau a playSound
|
||||
// sec — l'usuari sent el so encara que la cua no s'apliqui.
|
||||
|
||||
@@ -85,6 +85,12 @@ class Audio {
|
||||
// --- Control de sons ---
|
||||
void playSound(const std::string& name, Group group = Group::GAME); // Reproduir so puntual per nom (muta globals de Ja)
|
||||
void playSound(Ja::Sound* sound, Group group = Group::GAME); // Reproduir so puntual per punter (muta globals de Ja)
|
||||
// Reprodueix un so amb la velocitat (i to) escalats per `speed`:
|
||||
// 1.0 = normal, 0.95 ≈ -5% (més greu i lent), 1.05 ≈ +5% (més
|
||||
// agut i ràpid). Mateixa semàntica que `setMusicSpeed`. Útil per a
|
||||
// variacions subtils que eviten la fatiga d'escoltar el mateix
|
||||
// sample idèntic (p.ex. obertures de sarcòfag, picks d'ítems).
|
||||
void playSound(const std::string& name, Group group, float speed);
|
||||
// Reprodueix un so processat per un efecte definit a data/config/sounds.yaml
|
||||
// (preset_name busca a SoundEffectsConfig). Si el preset no existeix
|
||||
// o el motor està al cap de canals amb efecte, fa fallback a playSound
|
||||
|
||||
@@ -430,6 +430,12 @@ namespace Ja {
|
||||
return channel;
|
||||
}
|
||||
|
||||
void Engine::setChannelSpeed(const int channel, const float ratio) {
|
||||
if (channel < 0 || channel >= MAX_SIMULTANEOUS_CHANNELS) { return; }
|
||||
if (channels_[channel].stream == nullptr) { return; }
|
||||
SDL_SetAudioStreamFrequencyRatio(channels_[channel].stream, ratio);
|
||||
}
|
||||
|
||||
void Engine::pauseChannel(const int channel) {
|
||||
forEachTargetChannel(channel, [](Channel& ch) {
|
||||
if (ch.state == ChannelState::PLAYING) {
|
||||
|
||||
@@ -192,6 +192,14 @@ namespace Ja {
|
||||
// --- So ---
|
||||
auto playSound(Sound* sound, int loop = 0, int group = 0) -> int;
|
||||
auto playSoundOnChannel(Sound* sound, int channel, int loop = 0, int group = 0) -> int;
|
||||
// Ajusta la velocitat de reproducció d'un canal actiu via
|
||||
// `SDL_SetAudioStreamFrequencyRatio`. 1.0 = normal. Igual que a
|
||||
// `setMusicSpeed`, puja/baixa el to junt amb la velocitat
|
||||
// (efecte "chipmunk"); per a SFX curts arcade és el que volem.
|
||||
// No-op si el canal no està actiu. Cridar-lo just després de
|
||||
// `playSound`/`playSoundOnChannel` perquè el ratio cobreixi
|
||||
// tota la reproducció.
|
||||
void setChannelSpeed(int channel, float ratio);
|
||||
// Reproducció amb so processat per un efecte. Retorna el canal
|
||||
// assignat o -1 si no queden slots d'efecte (MAX_EFFECT_CHANNELS).
|
||||
// El sound original només s'usa per consultar el spec/buffer; el
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <format>
|
||||
#include <string>
|
||||
|
||||
#include "core/input/key_config.hpp"
|
||||
@@ -69,15 +70,26 @@ namespace GlobalInputs {
|
||||
notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off");
|
||||
}
|
||||
|
||||
// Volca el canvas ARGB actual a PNG dins de `screenshots/`. Si ha
|
||||
// fallat l'escriptura, el handler no mostra notificació (retorn buit).
|
||||
// Volca el canvas ARGB actual a PNG dins del directori de
|
||||
// captures (configurat pel Director). La notificació informa
|
||||
// del nom i de la ruta absoluta — així l'usuari sap on anar
|
||||
// a buscar-la encara que el binari s'executi des d'un sitio
|
||||
// estrany. Si l'escriptura falla, no es mostra notificació.
|
||||
void onScreenshot() {
|
||||
const auto* buffer = Screen::get().getCanvasArgb();
|
||||
const int W = Screen::get().getCanvasWidth();
|
||||
const int H = Screen::get().getCanvasHeight();
|
||||
const std::string FILENAME = Screenshot::save(buffer, W, H);
|
||||
if (!FILENAME.empty()) {
|
||||
Notification::notify(Locale::get().text("input.screenshot") + ' ' + FILENAME);
|
||||
const auto R = Screenshot::save(buffer, W, H);
|
||||
if (!R.filename.empty()) {
|
||||
// `make_format_args` retorna un store que conté referències
|
||||
// als arguments; cal lligar-lo a una lvalue local perquè
|
||||
// sobrevisqui fins que `vformat` el consumeixi. Passar-lo
|
||||
// directament a `vformat` és UB en alguns compiladors
|
||||
// (DR de C++20 / fix de C++23) i ha provocat un segfault
|
||||
// post-captura en aquest projecte.
|
||||
const std::string TEMPLATE = Locale::get().text("input.screenshot");
|
||||
auto args = std::make_format_args(R.filename, R.folder);
|
||||
Notification::notify(std::vformat(TEMPLATE, args));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "core/rendering/effects/shadow.hpp"
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "core/rendering/surface.hpp"
|
||||
#include "core/resources/resource_cache.hpp"
|
||||
|
||||
namespace Effects::Shadow {
|
||||
|
||||
void render(int center_x, int feet_y, float height_factor) {
|
||||
// Lookup únic al primer ús: la Surface viu a Cache durant tota la
|
||||
// sessió, així evitem la cerca lineal a cada frame.
|
||||
static const auto SURFACE = Resource::Cache::get().getSurface(CACHE_KEY);
|
||||
|
||||
const int FRAME_COUNT = SURFACE->getWidth() / FRAME_SIZE;
|
||||
if (FRAME_COUNT <= 0) { return; }
|
||||
|
||||
// Mapping a bandes uniformes: factor 0 → últim (gran), factor 1 →
|
||||
// primer (petit). Per a 3 fotogrames: [0, 1/3) → 2, [1/3, 2/3) → 1,
|
||||
// [2/3, 1] → 0. Generalitza a qualsevol nombre de fotogrames per
|
||||
// si afegim mides intermèdies.
|
||||
const float CLAMPED = std::clamp(height_factor, 0.0F, 1.0F);
|
||||
int frame_index = static_cast<int>((1.0F - CLAMPED) * static_cast<float>(FRAME_COUNT));
|
||||
frame_index = std::clamp(frame_index, 0, FRAME_COUNT - 1);
|
||||
|
||||
SDL_FRect clip{
|
||||
static_cast<float>(frame_index * FRAME_SIZE),
|
||||
0.0F,
|
||||
static_cast<float>(FRAME_SIZE),
|
||||
static_cast<float>(FRAME_SIZE),
|
||||
};
|
||||
|
||||
// Ancoratge: vora inferior del fotograma a `feet_y` — així el
|
||||
// quadrat 16x16 del sprite encaixa exactament al 16x16 inferior
|
||||
// d'una entitat 16x16/16x32 centrada en `center_x`.
|
||||
const int X = center_x - (FRAME_SIZE / 2);
|
||||
const int Y = feet_y - FRAME_SIZE;
|
||||
SURFACE->render(X, Y, &clip);
|
||||
}
|
||||
|
||||
} // namespace Effects::Shadow
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
namespace Effects::Shadow {
|
||||
|
||||
// Mida de cada fotograma del sprite compartit de la sombra.
|
||||
// El sprite és una tira horitzontal de fotogrames de 16x16, ordenats
|
||||
// del més petit (índex 0) al més gran (últim índex).
|
||||
constexpr int FRAME_SIZE = 16;
|
||||
|
||||
// Clau amb què `Resource::Cache` té carregada la Surface compartida.
|
||||
// El precarrega el Director al boot.
|
||||
constexpr const char* CACHE_KEY = "shadow";
|
||||
|
||||
// Pinta la sombra al canvas. `height_factor` selecciona el fotograma:
|
||||
// 0.0 (entitat al terra) → fotograma més gran, 1.0 (pic del salt o
|
||||
// entitat més alta del terra) → fotograma més petit. La tira es
|
||||
// divideix en bandes uniformes; valors fora de [0, 1] es clampen.
|
||||
//
|
||||
// El fotograma s'ancora al quadrat 16x16 inferior d'una entitat de
|
||||
// 16x16/16x32 centrada en `center_x` amb peus a `feet_y`: la vora
|
||||
// inferior del sprite queda exactament a `feet_y`. La posició
|
||||
// visual de l'oval dins del fotograma la decideix l'artista al GIF.
|
||||
void render(int center_x, int feet_y, float height_factor);
|
||||
|
||||
} // namespace Effects::Shadow
|
||||
@@ -0,0 +1,376 @@
|
||||
#include "core/rendering/lighting.hpp"
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <numbers>
|
||||
#include <vector>
|
||||
|
||||
#include "core/resources/resource_helper.hpp"
|
||||
#include "external/fkyaml_node.hpp"
|
||||
#include "utils/defaults.hpp"
|
||||
#include "utils/log.hpp"
|
||||
|
||||
namespace Lighting {
|
||||
|
||||
namespace {
|
||||
|
||||
// Buffer de luminància compartit entre frames. Es redimensiona
|
||||
// només si canvia la mida del canvas (mai en un run normal: el
|
||||
// canvas del joc és 320×240 fix). El reusem perquè una `apply`
|
||||
// allotjant cada frame seria un cost evitable.
|
||||
auto luminanceBuffer() -> std::vector<std::uint8_t>& {
|
||||
static std::vector<std::uint8_t> buf_;
|
||||
return buf_;
|
||||
}
|
||||
|
||||
// Corba activa del mode subtractive. loadConfig() la reemplaça
|
||||
// si troba el bloc `gradient:` al YAML.
|
||||
auto activeSubtractiveConfig() -> GradientConfig& {
|
||||
static GradientConfig cfg_{
|
||||
.inner_ratio = Defaults::Lighting::INNER_RATIO,
|
||||
.mid_ratio = Defaults::Lighting::MID_RATIO,
|
||||
.inner_intensity = Defaults::Lighting::INNER_INTENSITY,
|
||||
.mid_intensity = Defaults::Lighting::MID_INTENSITY,
|
||||
.outer_intensity = Defaults::Lighting::OUTER_INTENSITY,
|
||||
};
|
||||
return cfg_;
|
||||
}
|
||||
|
||||
// Corba activa del mode additive. Defaults baixos a propòsit:
|
||||
// en additive la intensitat és l'alfa del color afegit, així
|
||||
// que valors petits (0.05..0.15) donen el "halo subtil" que
|
||||
// sol voler-se. loadConfig() la reemplaça si troba el bloc
|
||||
// `gradient_additive:` al YAML.
|
||||
auto activeAdditiveConfig() -> GradientConfig& {
|
||||
static GradientConfig cfg_{
|
||||
.inner_ratio = Defaults::Lighting::INNER_RATIO_ADDITIVE,
|
||||
.mid_ratio = Defaults::Lighting::MID_RATIO_ADDITIVE,
|
||||
.inner_intensity = Defaults::Lighting::INNER_INTENSITY_ADDITIVE,
|
||||
.mid_intensity = Defaults::Lighting::MID_INTENSITY_ADDITIVE,
|
||||
.outer_intensity = Defaults::Lighting::OUTER_INTENSITY_ADDITIVE,
|
||||
};
|
||||
return cfg_;
|
||||
}
|
||||
|
||||
auto activeFlicker() -> FlickerConfig& {
|
||||
static FlickerConfig fc_{
|
||||
.wave_amplitude_ratio = Defaults::Lighting::FLICKER_WAVE_AMPLITUDE_RATIO,
|
||||
.wave_frequency_hz = Defaults::Lighting::FLICKER_WAVE_FREQUENCY_HZ,
|
||||
.noise_amplitude_ratio = Defaults::Lighting::FLICKER_NOISE_AMPLITUDE_RATIO,
|
||||
.noise_frequency_hz = Defaults::Lighting::FLICKER_NOISE_FREQUENCY_HZ,
|
||||
};
|
||||
return fc_;
|
||||
}
|
||||
|
||||
// Modula el radi base d'un emissor amb la suma de dues sinusoides
|
||||
// (`wave` + `noise`) i una fase derivada de `seed`. La seed és un
|
||||
// identificador estable de l'emissor (no canvia amb la posició) —
|
||||
// així el patró no "deriva" quan l'emissor es mou. El factor
|
||||
// ~φ (golden ratio) dispersa les fases per a seeds consecutives.
|
||||
// Les amplituds són ràtios sobre el radi base, multiplicades aquí
|
||||
// → mateix percentatge d'oscil·lació per a qualsevol mida.
|
||||
auto computeFlickerRadius(int base_radius, int seed, float t_s, const FlickerConfig& fc) -> int {
|
||||
constexpr float TWO_PI = 2.0F * std::numbers::pi_v<float>;
|
||||
constexpr float GOLDEN_FRACTION = 0.6180339F;
|
||||
const float PHASE = static_cast<float>(seed) * GOLDEN_FRACTION;
|
||||
const float W1 = std::sin((TWO_PI * fc.wave_frequency_hz * t_s) + PHASE);
|
||||
const float W2 = std::sin((TWO_PI * fc.noise_frequency_hz * t_s) + (PHASE * 2.7F));
|
||||
const auto R_F = static_cast<float>(base_radius);
|
||||
const float DELTA = (fc.wave_amplitude_ratio * R_F * W1) + (fc.noise_amplitude_ratio * R_F * W2);
|
||||
const int R = base_radius + static_cast<int>(std::lround(DELTA));
|
||||
return std::max(1, R);
|
||||
}
|
||||
|
||||
// Quantitza un factor 0..1 a 0..255. `clamp` per defensar-nos de
|
||||
// YAMLs que entreguen valors fora de rang sense forçar la mà.
|
||||
auto toByte(float v) -> std::uint8_t {
|
||||
const float CLAMPED = std::clamp(v, 0.0F, 1.0F);
|
||||
return static_cast<std::uint8_t>(std::lround(CLAMPED * 255.0F));
|
||||
}
|
||||
|
||||
void readField(const fkyaml::node& node, const char* key, float& dst) {
|
||||
if (node.contains(key)) { dst = node[key].get_value<float>(); }
|
||||
}
|
||||
|
||||
// Pre-calcula els 3 quadrats de radi i les intensitats que farem
|
||||
// servir dins del bucle per píxel. Es resol una sola vegada per
|
||||
// emissor i s'evita arrastrar 6 paràmetres a `rasterizeEmitter`.
|
||||
struct EmitterRings {
|
||||
int radius_sq{0};
|
||||
int mid_sq{0};
|
||||
int inner_sq{0};
|
||||
std::uint8_t inner{0};
|
||||
std::uint8_t mid{0};
|
||||
std::uint8_t outer{0};
|
||||
};
|
||||
|
||||
auto computeRings(int radius, const GradientConfig& cfg) -> EmitterRings {
|
||||
const auto R_F = static_cast<float>(radius);
|
||||
const float R_INNER = R_F * cfg.inner_ratio;
|
||||
const float R_MID = R_F * cfg.mid_ratio;
|
||||
return EmitterRings{
|
||||
.radius_sq = radius * radius,
|
||||
.mid_sq = static_cast<int>(R_MID * R_MID),
|
||||
.inner_sq = static_cast<int>(R_INNER * R_INNER),
|
||||
.inner = toByte(cfg.inner_intensity),
|
||||
.mid = toByte(cfg.mid_intensity),
|
||||
.outer = toByte(cfg.outer_intensity),
|
||||
};
|
||||
}
|
||||
|
||||
// Itera la bounding box de l'emissor clipada al canvas i actualitza
|
||||
// la fila de luminància corresponent amb `max(actual, ring)`. Es
|
||||
// crida un cop per emissor, així extreure-la deixa `apply` simple.
|
||||
void rasterizeEmitter(const Emitter& e, const EmitterRings& rings, std::uint8_t* lum, int width, int height) {
|
||||
const int R = e.radius;
|
||||
const int CX = static_cast<int>(e.cx);
|
||||
const int CY = static_cast<int>(e.cy);
|
||||
const int X0 = std::max(0, CX - R);
|
||||
const int Y0 = std::max(0, CY - R);
|
||||
const int X1 = std::min(width - 1, CX + R);
|
||||
const int Y1 = std::min(height - 1, CY + R);
|
||||
for (int y = Y0; y <= Y1; ++y) {
|
||||
const int DY = y - CY;
|
||||
const int DY_SQ = DY * DY;
|
||||
if (DY_SQ > rings.radius_sq) { continue; }
|
||||
std::uint8_t* row = lum + (static_cast<std::ptrdiff_t>(y) * width);
|
||||
for (int x = X0; x <= X1; ++x) {
|
||||
const int DX = x - CX;
|
||||
const int DIST_SQ = (DX * DX) + DY_SQ;
|
||||
if (DIST_SQ > rings.radius_sq) { continue; }
|
||||
std::uint8_t intensity = rings.outer;
|
||||
if (DIST_SQ <= rings.inner_sq) {
|
||||
intensity = rings.inner;
|
||||
} else if (DIST_SQ <= rings.mid_sq) {
|
||||
intensity = rings.mid;
|
||||
}
|
||||
row[x] = std::max(intensity, row[x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pasada final del mode subtractive: pixel = pixel * lum / 255
|
||||
// per canal. El canal alpha es preserva (la resta del pipeline
|
||||
// assumeix opacitat total al canvas; veure Rendering::OPAQUE_ALPHA).
|
||||
void modulateCanvas(std::uint32_t* canvas, const std::uint8_t* lum, int width, int height) {
|
||||
for (int y = 0; y < height; ++y) {
|
||||
std::uint32_t* px = canvas + (static_cast<std::ptrdiff_t>(y) * width);
|
||||
const std::uint8_t* lrow = lum + (static_cast<std::ptrdiff_t>(y) * width);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const std::uint8_t L = lrow[x];
|
||||
if (L == 255) { continue; } // píxel intacte.
|
||||
const std::uint32_t SRC = px[x];
|
||||
const std::uint8_t SR = (SRC >> 16) & 0xFF;
|
||||
const std::uint8_t SG = (SRC >> 8) & 0xFF;
|
||||
const std::uint8_t SB = SRC & 0xFF;
|
||||
const auto R = static_cast<std::uint8_t>((SR * L) / 255);
|
||||
const auto G = static_cast<std::uint8_t>((SG * L) / 255);
|
||||
const auto B = static_cast<std::uint8_t>((SB * L) / 255);
|
||||
px[x] = (SRC & 0xFF000000U) |
|
||||
(static_cast<std::uint32_t>(R) << 16) |
|
||||
(static_cast<std::uint32_t>(G) << 8) |
|
||||
static_cast<std::uint32_t>(B);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Atenuació ambient del mode additive: aplica `pixel × level` per
|
||||
// canvas abans del bucle d'emissors. Si `level == 255` (sala a
|
||||
// 1.0) salta com a no-op — el cas comú quan només volem el tint
|
||||
// dels emissors sense oscurir la sala.
|
||||
void darkenCanvas(std::uint32_t* canvas, int width, int height, std::uint8_t level) {
|
||||
if (level == 255) { return; }
|
||||
for (int y = 0; y < height; ++y) {
|
||||
std::uint32_t* px = canvas + (static_cast<std::ptrdiff_t>(y) * width);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const std::uint32_t SRC = px[x];
|
||||
const std::uint8_t SR = (SRC >> 16) & 0xFF;
|
||||
const std::uint8_t SG = (SRC >> 8) & 0xFF;
|
||||
const std::uint8_t SB = SRC & 0xFF;
|
||||
const auto R = static_cast<std::uint8_t>((SR * level) / 255);
|
||||
const auto G = static_cast<std::uint8_t>((SG * level) / 255);
|
||||
const auto B = static_cast<std::uint8_t>((SB * level) / 255);
|
||||
px[x] = (SRC & 0xFF000000U) |
|
||||
(static_cast<std::uint32_t>(R) << 16) |
|
||||
(static_cast<std::uint32_t>(G) << 8) |
|
||||
static_cast<std::uint32_t>(B);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pasada del mode additive per a un emissor: suma `color × intensity`
|
||||
// a cada píxel cobert pel cercle, saturant a 255 per canal. A
|
||||
// diferència del subtractive, NO usa buffer intermig (no cal max())
|
||||
// perquè la suma és commutativa; emissors solapats acumulen
|
||||
// contribucions de manera natural. Si dues llums de colors diferents
|
||||
// es solapen, el resultat tendeix al blanc en la zona de coincidència
|
||||
// (suma física): el solape s'aclareix més que un sol emissor.
|
||||
void rasterizeEmitterAdditive(const Emitter& e, const EmitterRings& rings, std::uint32_t* canvas, int width, int height) {
|
||||
const int R = e.radius;
|
||||
const int CX = static_cast<int>(e.cx);
|
||||
const int CY = static_cast<int>(e.cy);
|
||||
const int X0 = std::max(0, CX - R);
|
||||
const int Y0 = std::max(0, CY - R);
|
||||
const int X1 = std::min(width - 1, CX + R);
|
||||
const int Y1 = std::min(height - 1, CY + R);
|
||||
for (int y = Y0; y <= Y1; ++y) {
|
||||
const int DY = y - CY;
|
||||
const int DY_SQ = DY * DY;
|
||||
if (DY_SQ > rings.radius_sq) { continue; }
|
||||
std::uint32_t* row = canvas + (static_cast<std::ptrdiff_t>(y) * width);
|
||||
for (int x = X0; x <= X1; ++x) {
|
||||
const int DX = x - CX;
|
||||
const int DIST_SQ = (DX * DX) + DY_SQ;
|
||||
if (DIST_SQ > rings.radius_sq) { continue; }
|
||||
std::uint8_t intensity = rings.outer;
|
||||
if (DIST_SQ <= rings.inner_sq) {
|
||||
intensity = rings.inner;
|
||||
} else if (DIST_SQ <= rings.mid_sq) {
|
||||
intensity = rings.mid;
|
||||
}
|
||||
// contrib_channel = emitter_color_channel × intensity / 255
|
||||
// (intensity ja és 0..255 quantitzat a `toByte`).
|
||||
const int CR = (e.color.r * intensity) / 255;
|
||||
const int CG = (e.color.g * intensity) / 255;
|
||||
const int CB = (e.color.b * intensity) / 255;
|
||||
const std::uint32_t SRC = row[x];
|
||||
const int SR = static_cast<int>((SRC >> 16) & 0xFF);
|
||||
const int SG = static_cast<int>((SRC >> 8) & 0xFF);
|
||||
const int SB = static_cast<int>(SRC & 0xFF);
|
||||
const auto NR = static_cast<std::uint32_t>(std::min(255, SR + CR));
|
||||
const auto NG = static_cast<std::uint32_t>(std::min(255, SG + CG));
|
||||
const auto NB = static_cast<std::uint32_t>(std::min(255, SB + CB));
|
||||
row[x] = (SRC & 0xFF000000U) | (NR << 16) | (NG << 8) | NB;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto config() -> const GradientConfig& {
|
||||
return activeSubtractiveConfig();
|
||||
}
|
||||
|
||||
auto flickerConfig() -> const FlickerConfig& {
|
||||
return activeFlicker();
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Llegeix els 5 camps de `GradientConfig` d'un node YAML — qualsevol
|
||||
// camp absent conserva el valor compilat dels defaults. Compartit
|
||||
// entre els blocs `gradient:` i `gradient_additive:`.
|
||||
void readGradient(const fkyaml::node& g, GradientConfig& cfg) {
|
||||
readField(g, "inner_ratio", cfg.inner_ratio);
|
||||
readField(g, "mid_ratio", cfg.mid_ratio);
|
||||
readField(g, "inner_intensity", cfg.inner_intensity);
|
||||
readField(g, "mid_intensity", cfg.mid_intensity);
|
||||
readField(g, "outer_intensity", cfg.outer_intensity);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void loadConfig(const std::string& yaml_path) {
|
||||
auto bytes = Resource::Helper::loadFile(yaml_path);
|
||||
if (bytes.empty()) {
|
||||
Log::warn("Lighting", "no s'ha pogut obrir {} — s'usaran els defaults compilats", yaml_path);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const auto* begin = reinterpret_cast<const char*>(bytes.data());
|
||||
const auto* end = begin + bytes.size();
|
||||
auto yaml = fkyaml::node::deserialize(begin, end);
|
||||
if (yaml.contains("gradient")) {
|
||||
readGradient(yaml["gradient"], activeSubtractiveConfig());
|
||||
}
|
||||
if (yaml.contains("gradient_additive")) {
|
||||
readGradient(yaml["gradient_additive"], activeAdditiveConfig());
|
||||
}
|
||||
if (yaml.contains("flicker")) {
|
||||
const auto& f = yaml["flicker"];
|
||||
auto& fc = activeFlicker();
|
||||
readField(f, "wave_amplitude_ratio", fc.wave_amplitude_ratio);
|
||||
readField(f, "wave_frequency_hz", fc.wave_frequency_hz);
|
||||
readField(f, "noise_amplitude_ratio", fc.noise_amplitude_ratio);
|
||||
readField(f, "noise_frequency_hz", fc.noise_frequency_hz);
|
||||
}
|
||||
} catch (const std::exception& ex) {
|
||||
Log::warn("Lighting", "error parsejant {}: {} — s'usaran els defaults compilats", yaml_path, ex.what());
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Helper: emissor "patxat" amb el radi efectiu (post-flicker) per
|
||||
// a aquest frame. Mantenim l'`Emitter` del caller intacte i la
|
||||
// resta de camps (color, seed, flicker_params, ...) propagats.
|
||||
auto withEffectiveRadius(const Emitter& e, int r_eff) -> Emitter {
|
||||
return Emitter{
|
||||
.cx = e.cx,
|
||||
.cy = e.cy,
|
||||
.radius = r_eff,
|
||||
.seed = e.seed,
|
||||
.flicker = e.flicker,
|
||||
.color = e.color,
|
||||
.flicker_params = e.flicker_params,
|
||||
};
|
||||
}
|
||||
|
||||
// Camí del mode subtractive (històric): omple un buffer de
|
||||
// luminància amb `max(room, emitter_intensity)` i finalment
|
||||
// multiplica el canvas per ell. Color de l'emissor ignorat.
|
||||
void applySubtractive(std::uint32_t* canvas, int width, int height, std::uint8_t room, std::span<const Emitter> emitters) {
|
||||
auto& lum = luminanceBuffer();
|
||||
const std::size_t TOTAL = static_cast<std::size_t>(width) * static_cast<std::size_t>(height);
|
||||
if (lum.size() != TOTAL) { lum.resize(TOTAL); }
|
||||
std::ranges::fill(lum, room);
|
||||
|
||||
const auto& cfg = activeSubtractiveConfig();
|
||||
const float T_S = static_cast<float>(SDL_GetTicks()) / 1000.0F;
|
||||
for (const auto& e : emitters) {
|
||||
if (e.radius <= 0) { continue; }
|
||||
const int R_EFF = e.flicker ? computeFlickerRadius(e.radius, e.seed, T_S, e.flicker_params) : e.radius;
|
||||
const auto RINGS = computeRings(R_EFF, cfg);
|
||||
const Emitter EFF = withEffectiveRadius(e, R_EFF);
|
||||
rasterizeEmitter(EFF, RINGS, lum.data(), width, height);
|
||||
}
|
||||
modulateCanvas(canvas, lum.data(), width, height);
|
||||
}
|
||||
|
||||
// Camí del mode additive: atenua el canvas per `room` (si <255)
|
||||
// i després suma `color × intensity` per emissor directament al
|
||||
// canvas, sense buffer intermig.
|
||||
void applyAdditive(std::uint32_t* canvas, int width, int height, std::uint8_t room, std::span<const Emitter> emitters) {
|
||||
darkenCanvas(canvas, width, height, room);
|
||||
const auto& cfg = activeAdditiveConfig();
|
||||
const float T_S = static_cast<float>(SDL_GetTicks()) / 1000.0F;
|
||||
for (const auto& e : emitters) {
|
||||
if (e.radius <= 0) { continue; }
|
||||
const int R_EFF = e.flicker ? computeFlickerRadius(e.radius, e.seed, T_S, e.flicker_params) : e.radius;
|
||||
const auto RINGS = computeRings(R_EFF, cfg);
|
||||
const Emitter EFF = withEffectiveRadius(e, R_EFF);
|
||||
rasterizeEmitterAdditive(EFF, RINGS, canvas, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void apply(std::uint32_t* canvas, int width, int height, Mode mode, float room_light, std::span<const Emitter> emitters) {
|
||||
if (canvas == nullptr || width <= 0 || height <= 0) { return; }
|
||||
|
||||
// Atajo per a tots dos modes: sala totalment "neutra" + cap
|
||||
// emissor → la pasada no canviaria res al canvas.
|
||||
// subtractive: room=1.0 i sense emissors → pixel * 1.0 global.
|
||||
// additive: room=1.0 i sense emissors → cap aportació.
|
||||
const std::uint8_t ROOM = toByte(room_light);
|
||||
if (ROOM == 255 && emitters.empty()) { return; }
|
||||
|
||||
if (mode == Mode::ADDITIVE) {
|
||||
applyAdditive(canvas, width, height, ROOM, emitters);
|
||||
} else {
|
||||
applySubtractive(canvas, width, height, ROOM, emitters);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Lighting
|
||||
@@ -0,0 +1,150 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string>
|
||||
|
||||
// Sistema d'il·luminació CPU. Treballa sobre el mateix canvas ARGB del
|
||||
// joc (320×240) que tot el pipeline de render: després que GameScene
|
||||
// haja pintat tilemap, paint layer, sombres i actors, una pasada final
|
||||
// modifica cada píxel del canvas en funció de la "llum de la sala" i
|
||||
// dels cercles emissors al voltant de certes entitats.
|
||||
//
|
||||
// Dos modes seleccionables per la sala (`lighting.mode` al YAML):
|
||||
//
|
||||
// SUBTRACTIVE (mode històric, default si no es declara mode):
|
||||
// out = src × max(room_light, max_emitter_intensity_at_pixel)
|
||||
// Els emissors "trenquen" una capa fosca multiplicativa. Tot el
|
||||
// trip cromàtic dels emissors s'ignora — només la intensitat
|
||||
// compta. Pensat per a sales fosques (cripta, pirámide).
|
||||
//
|
||||
// ADDITIVE (mode nou):
|
||||
// out = saturate( (src × room_light) + Σ (emitter_color × intensity) )
|
||||
// El canvas s'enfosqueix opcionalment per `room_light` (atenuació
|
||||
// ambient) i els emissors hi sumen color cap a sobre. Les intensitats
|
||||
// dels anells es llegeixen com a "alfa" del color afegit (0..1).
|
||||
// Pensat per a sales clares on les antorxes aporten halo cromàtic
|
||||
// sense restar contrast — o per a sales lleument fosques (0.85, p.ex.)
|
||||
// on el halo de l'antorxa es veu càlid en lloc de "blanquinós".
|
||||
//
|
||||
// Forma del cercle de cada emissor (radial, 3 anells configurables al
|
||||
// fitxer data/config/lighting.yaml; veure `GradientConfig`):
|
||||
// 0..inner_ratio del radi → inner_intensity
|
||||
// inner_ratio..mid_ratio → mid_intensity
|
||||
// mid_ratio..1.0 → outer_intensity
|
||||
//
|
||||
// La corba viu en dos blocs separats al YAML perquè la mateixa escala
|
||||
// 0..1 té significats molt diferents per mode:
|
||||
// `gradient:` → corba del mode subtractive (1.0 = "no fosqueix")
|
||||
// `gradient_additive:` → corba del mode additive (1.0 = "afegeix
|
||||
// el color sencer i satura"). Es vol molt més
|
||||
// baixa per a no inundar la sala de color.
|
||||
// Cada mode llegeix la seva pròpia corba — no es comparteixen valors.
|
||||
//
|
||||
// Combinació entre emissors:
|
||||
// SUBTRACTIVE → per a cada píxel es pren el màxim de la intensitat
|
||||
// aportada per cada emissor que el solapa (no se sumen intensitats).
|
||||
// ADDITIVE → per a cada píxel se sumen totes les contribucions
|
||||
// (`color × intensity`), saturant a 255 per canal. Dues llums de
|
||||
// colors diferents que solapen produeixen la suma natural (orange +
|
||||
// blue → near-white) — comportament físic.
|
||||
namespace Lighting {
|
||||
|
||||
// Mode de composició. La sala el selecciona via `lighting.mode:
|
||||
// additive | subtractive` (default subtractive si manca, retrocompat).
|
||||
enum class Mode : std::uint8_t {
|
||||
SUBTRACTIVE,
|
||||
ADDITIVE,
|
||||
};
|
||||
|
||||
// Color emès. Default blanc (sense modificació quan s'usa en mode
|
||||
// additive). En mode subtractive s'ignora — la pasada multiplicativa
|
||||
// només té un canal (intensitat). El YAML accepta `color: [r, g, b]`
|
||||
// a qualsevol nivell de la cadena de resolució (tipus, sala, spawn).
|
||||
struct Color {
|
||||
std::uint8_t r{255};
|
||||
std::uint8_t g{255};
|
||||
std::uint8_t b{255};
|
||||
};
|
||||
|
||||
// Forma del gradient. Compartida per tots els emissors per evitar
|
||||
// duplicar la corba a cada YAML d'entitat. Càrrega via loadConfig.
|
||||
struct GradientConfig {
|
||||
float inner_ratio{0.8F};
|
||||
float mid_ratio{0.9F};
|
||||
float inner_intensity{1.0F};
|
||||
float mid_intensity{0.9F};
|
||||
float outer_intensity{0.8F};
|
||||
};
|
||||
|
||||
// Paràmetres del flicker per a emissors amb `flicker=true`. La
|
||||
// forma final del radi és la suma d'una sinusoide "respiració"
|
||||
// (wave_*) i una sinusoide secundària de freqüència més alta
|
||||
// (noise_*) que aporta la sensació d'inquietud. Les fases es
|
||||
// deriven de la seed estable de cada emissor — sense estat per
|
||||
// emissor. Les amplituds són **ràtios** sobre el radi base (p.ex.
|
||||
// 0.05 = 5% del radi): així una torxa de radi 40 i una de radi 24
|
||||
// tenen el mateix percentatge d'oscil·lació visual, no els mateixos
|
||||
// píxels (que es notarien més en la petita).
|
||||
struct FlickerConfig {
|
||||
float wave_amplitude_ratio{0.05F};
|
||||
float wave_frequency_hz{3.0F};
|
||||
float noise_amplitude_ratio{0.025F};
|
||||
float noise_frequency_hz{7.0F};
|
||||
};
|
||||
|
||||
// Posició al canvas (en píxels, mateixa unitat que la resta del
|
||||
// motor) i radi efectiu de l'emissor. El centre és el del sprite
|
||||
// visible: és l'entitat qui sap on cau (veure `Entity::getLightCenter`).
|
||||
// `flicker` activa la modulació del radi per frame; `seed` és un
|
||||
// identificador estable per emissor (no canvia amb la posició) del
|
||||
// qual es deriva la fase del flicker — així els emissors mòbils
|
||||
// mantenen un patró propi que no "deriva" pel moviment. `color`
|
||||
// només té efecte en mode additive.
|
||||
struct Emitter {
|
||||
float cx{0.0F};
|
||||
float cy{0.0F};
|
||||
int radius{0};
|
||||
int seed{0};
|
||||
bool flicker{false};
|
||||
Color color{};
|
||||
// Paràmetres del flicker resolts per a aquest emissor (combinació
|
||||
// global + override del tipus/sala/spawn). Només té sentit si
|
||||
// `flicker == true`; en cas contrari s'ignoren. Per defecte
|
||||
// s'inicialitza amb els defaults compilats — el caller (game_scene)
|
||||
// hi posa el resolt correcte.
|
||||
FlickerConfig flicker_params{};
|
||||
};
|
||||
|
||||
// Llegeix `yaml_path` (data/config/lighting.yaml) i pobla la
|
||||
// configuració global. Qualsevol clau absent cau als Defaults::Lighting.
|
||||
// No es crida més d'una vegada en un run normal; tornar-la a cridar
|
||||
// simplement reemplaça la configuració activa.
|
||||
void loadConfig(const std::string& yaml_path);
|
||||
|
||||
// Configuració activa (per a debug/inspectors). Retorna sempre la
|
||||
// corba subtractive — la corba additive viu en paral·lel al cpp i,
|
||||
// si algun dia es necessita inspeccionar des de fora, caldrà afegir
|
||||
// una variant `configAdditive()`. Inicialitzada amb els
|
||||
// Defaults::Lighting fins que loadConfig() es crida.
|
||||
[[nodiscard]] auto config() -> const GradientConfig&;
|
||||
|
||||
// Defaults globals del flicker (data/config/lighting.yaml, bloc
|
||||
// `flicker:`). Els callers del construct path d'entitats el llegeixen
|
||||
// per inicialitzar la `FlickerConfig` de cada emissor, i aplicar a
|
||||
// sobre els overrides per tipus/sala/spawn.
|
||||
[[nodiscard]] auto flickerConfig() -> const FlickerConfig&;
|
||||
|
||||
// Aplica la pasada d'il·luminació al canvas in-place. `mode`
|
||||
// selecciona la composició (vegeu el comentari de la capçalera).
|
||||
// `room_light` és l'atenuació ambient de la sala (0..1, 1 = sense
|
||||
// atenuació): en subtractive és el "terra" de la intensitat fora
|
||||
// dels emissors; en additive és la multiplicació prèvia del canvas
|
||||
// abans de sumar els emissors (es pot deixar a 1.0 per "només tint",
|
||||
// o baixar per combinar oscuriment ambient + halo cromàtic). El
|
||||
// span d'emissors pot ser buit. La funció no fa cap allocació si
|
||||
// es crida amb la mateixa mida de canvas múltiples cops: manté un
|
||||
// buffer intern reutilitzable (només l'usa el mode subtractive).
|
||||
void apply(std::uint32_t* canvas, int width, int height, Mode mode, float room_light, std::span<const Emitter> emitters);
|
||||
|
||||
} // namespace Lighting
|
||||
@@ -12,11 +12,52 @@
|
||||
namespace {
|
||||
|
||||
// Parteix `text` en una llista de línies que caben cadascuna dins
|
||||
// `max_width`. Algoritme de projecte_2026: tall per espais, la paraula
|
||||
// que no cap passa a la línia següent. Les paraules aïllades més
|
||||
// llargues que `max_width` es queden a la seva pròpia línia sense
|
||||
// trencar-les (cas improbable amb els pretty_names de gamepad retallats
|
||||
// a ~25 caràcters).
|
||||
// Trenca una "paraula" llarga (sense espais) en trossos contigus
|
||||
// que càpiguen a `max_width`. Si un sol caràcter ja excedeix
|
||||
// (font enorme), el deixa sol per garantir progrés. La part final
|
||||
// que encara cap queda a `current` per a fusionar-se amb paraules
|
||||
// posteriors via espai.
|
||||
void hardWrapWord(const std::string& word, int max_width, const Text& font, int kerning, std::vector<std::string>& lines, std::string& current) {
|
||||
std::string buf;
|
||||
for (char c : word) {
|
||||
std::string candidate = buf;
|
||||
candidate += c;
|
||||
if (buf.empty() || font.length(candidate, kerning) <= max_width) {
|
||||
buf = std::move(candidate);
|
||||
} else {
|
||||
lines.push_back(buf);
|
||||
buf = std::string(1, c);
|
||||
}
|
||||
}
|
||||
current = std::move(buf);
|
||||
}
|
||||
|
||||
// Posa la `word` a la `current` line si hi cap (amb separador
|
||||
// implícit si current no està buida), o fa flush + reintenta a
|
||||
// línia neta. Si tampoc cap sola, hard-wrap caràcter a caràcter.
|
||||
void appendWord(const std::string& word, int max_width, const Text& font, int kerning, std::vector<std::string>& lines, std::string& current) {
|
||||
if (!current.empty()) {
|
||||
std::string candidate = current;
|
||||
candidate += ' ';
|
||||
candidate += word;
|
||||
if (font.length(candidate, kerning) <= max_width) {
|
||||
current = std::move(candidate);
|
||||
return;
|
||||
}
|
||||
lines.push_back(current);
|
||||
current.clear();
|
||||
}
|
||||
if (font.length(word, kerning) <= max_width) {
|
||||
current = word;
|
||||
} else {
|
||||
hardWrapWord(word, max_width, font, kerning, lines, current);
|
||||
}
|
||||
}
|
||||
|
||||
// `max_width`. Tall per espais; les paraules més llargues que
|
||||
// `max_width` (rutes, URLs...) es trenquen caràcter a caràcter
|
||||
// — sense això, una caixa que excedís el canvas faria que
|
||||
// `fillRect` rebés rangs invertits i petés.
|
||||
auto wrapToWidth(const std::string& text, int max_width, const Text& font, int kerning) -> std::vector<std::string> {
|
||||
std::vector<std::string> lines;
|
||||
if (text.empty()) {
|
||||
@@ -30,26 +71,12 @@ namespace {
|
||||
const std::size_t SPACE_POS = text.find(' ', i);
|
||||
const bool LAST = (SPACE_POS == std::string::npos);
|
||||
const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i);
|
||||
|
||||
if (current.empty()) {
|
||||
current = WORD;
|
||||
} else {
|
||||
std::string candidate = current;
|
||||
candidate += ' ';
|
||||
candidate += WORD;
|
||||
if (font.length(candidate, kerning) <= max_width) {
|
||||
current = std::move(candidate);
|
||||
} else {
|
||||
lines.push_back(current);
|
||||
current = WORD;
|
||||
}
|
||||
}
|
||||
|
||||
appendWord(WORD, max_width, font, kerning, lines, current);
|
||||
if (LAST) { break; }
|
||||
i = SPACE_POS + 1;
|
||||
}
|
||||
|
||||
lines.push_back(current);
|
||||
if (!current.empty()) { lines.push_back(current); }
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,12 @@ void Screen::fillRect(int x, int y, int w, int h, std::uint32_t argb) {
|
||||
const int Y0 = std::max(y, CLIP.y);
|
||||
const int X1 = std::min(x + w, CLIP.x + CLIP.w);
|
||||
const int Y1 = std::min(y + h, CLIP.y + CLIP.h);
|
||||
// Bail-out defensiu: si el rang és buit o invertit després del clip,
|
||||
// `std::fill(first, last)` amb `first > last` és UB i a la pràctica
|
||||
// entra en bucle infinit (vist en un crash a l'overflow del Notifier
|
||||
// quan la caixa quedava més ampla que el canvas). El `blendRect`
|
||||
// d'aquí baix ja és segur perquè usa `for(ix; ix < X1; ...)`.
|
||||
if (X1 <= X0 || Y1 <= Y0) { return; }
|
||||
for (int iy = Y0; iy < Y1; ++iy) {
|
||||
std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_);
|
||||
std::fill(row + X0, row + X1, argb);
|
||||
|
||||
@@ -72,7 +72,7 @@ class Screen {
|
||||
// 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
|
||||
// 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);
|
||||
// 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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "external/stb_image_write.h"
|
||||
@@ -13,21 +14,18 @@ namespace Screenshot {
|
||||
|
||||
namespace {
|
||||
|
||||
// Crea la carpeta `screenshots/` si no existeix i torna la ruta
|
||||
// completa al fitxer. El paràmetre `filename` rep el nom nu (sense
|
||||
// directori) per poder mostrar-lo a la notificació.
|
||||
auto generateFilePath(std::string& filename) -> std::string {
|
||||
std::filesystem::create_directories("screenshots");
|
||||
// Estat de mòdul: ruta base on s'escriu la subcarpeta
|
||||
// `screenshots/`. Buida per defecte ⇒ relativa al CWD (fallback
|
||||
// si el Director no ha pogut establir la ruta per plataforma).
|
||||
std::string g_base_dir; // NOLINT(*-avoid-non-const-global-variables)
|
||||
|
||||
const auto NOW = std::chrono::system_clock::now();
|
||||
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW);
|
||||
const std::tm* tm = std::localtime(&TIME);
|
||||
|
||||
char timestamp[20];
|
||||
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm);
|
||||
|
||||
filename = std::string("scr_") + timestamp + ".png";
|
||||
return "screenshots/" + filename;
|
||||
// Construeix la ruta del directori on van les captures:
|
||||
// `<base>/screenshots/`. Si la base és buida, retorna
|
||||
// "screenshots/" relativa al CWD.
|
||||
auto screenshotsDir() -> std::string {
|
||||
if (g_base_dir.empty()) { return "screenshots/"; }
|
||||
const bool ENDS_WITH_SEP = (g_base_dir.back() == '/' || g_base_dir.back() == '\\');
|
||||
return g_base_dir + (ENDS_WITH_SEP ? "" : "/") + "screenshots/";
|
||||
}
|
||||
|
||||
// Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write
|
||||
@@ -47,14 +45,28 @@ namespace Screenshot {
|
||||
|
||||
} // namespace
|
||||
|
||||
auto save(const std::uint32_t* buffer, int width, int height) -> std::string {
|
||||
void setBaseDir(std::string dir) {
|
||||
g_base_dir = std::move(dir);
|
||||
}
|
||||
|
||||
auto save(const std::uint32_t* buffer, int width, int height) -> Result {
|
||||
const std::string FOLDER = screenshotsDir();
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(FOLDER, ec);
|
||||
|
||||
const auto NOW = std::chrono::system_clock::now();
|
||||
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW);
|
||||
const std::tm* tm = std::localtime(&TIME);
|
||||
char timestamp[20];
|
||||
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm);
|
||||
|
||||
const std::string FILENAME = std::string("scr_") + timestamp + ".png";
|
||||
const std::string FILEPATH = FOLDER + FILENAME;
|
||||
|
||||
// Còpia local per no tocar el canvas original durant la conversió.
|
||||
std::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height));
|
||||
|
||||
std::string filename;
|
||||
const std::string FILEPATH = generateFilePath(filename);
|
||||
|
||||
return writePng(copy.data(), width, height, FILEPATH) ? filename : "";
|
||||
if (!writePng(copy.data(), width, height, FILEPATH)) { return {}; }
|
||||
return {.filename = FILENAME, .folder = FOLDER};
|
||||
}
|
||||
|
||||
} // namespace Screenshot
|
||||
@@ -3,11 +3,30 @@
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
// Volca un buffer ARGB8888 a un fitxer PNG dins de la carpeta `screenshots/`
|
||||
// (relativa al CWD). El nom es genera amb un timestamp i la funció retorna
|
||||
// el nom del fitxer creat, o una cadena buida si l'escriptura ha fallat.
|
||||
// Volca un buffer ARGB8888 a un fitxer PNG dins de la subcarpeta
|
||||
// `screenshots/` del directori configurat amb `setBaseDir`. El nom es
|
||||
// genera amb un timestamp i la funció retorna el basename i la ruta
|
||||
// del directori on s'ha escrit (o un Result amb `filename` buit si
|
||||
// l'escriptura ha fallat).
|
||||
namespace Screenshot {
|
||||
|
||||
auto save(const std::uint32_t* buffer, int width, int height) -> std::string;
|
||||
// Configura el directori base on s'escriuran les captures (s'hi
|
||||
// crea una subcarpeta `screenshots/`). El Director el crida una
|
||||
// vegada al boot amb `system_folder_` perquè les captures vagin
|
||||
// al mateix lloc per plataforma que la resta de fitxers generats
|
||||
// (config.yaml, gamepad_configs.yaml, shaders/, etc.). Si no es
|
||||
// crida, el fallback és el CWD — comportament antic.
|
||||
void setBaseDir(std::string dir);
|
||||
|
||||
// Resultat d'una captura: el `filename` és el basename del PNG
|
||||
// (per a notificacions/títol) i el `folder` és la ruta absoluta
|
||||
// on s'ha guardat (per a poder dir "guardada a {folder}"). Si
|
||||
// l'escriptura falla, ambdós camps tornen buits.
|
||||
struct Result {
|
||||
std::string filename;
|
||||
std::string folder;
|
||||
};
|
||||
|
||||
auto save(const std::uint32_t* buffer, int width, int height) -> Result;
|
||||
|
||||
} // namespace Screenshot
|
||||
@@ -18,9 +18,12 @@
|
||||
#include "core/input/key_config.hpp"
|
||||
#include "core/input/mouse.hpp"
|
||||
#include "core/locale/locale.hpp"
|
||||
#include "core/rendering/effects/shadow.hpp"
|
||||
#include "core/rendering/lighting.hpp"
|
||||
#include "core/rendering/notifier.hpp"
|
||||
#include "core/rendering/render_info.hpp"
|
||||
#include "core/rendering/screen.hpp"
|
||||
#include "core/rendering/screenshot.hpp"
|
||||
#include "core/rendering/surface.hpp"
|
||||
#include "core/rendering/text.hpp"
|
||||
#include "core/resources/resource_cache.hpp"
|
||||
@@ -129,6 +132,10 @@ Director::Director() {
|
||||
// Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a
|
||||
// data/config/ui.yaml cau al valor compilat a Defaults::UI.
|
||||
UiConfig::get().load(Defaults::Paths::UI_YAML);
|
||||
// Forma del gradient d'il·luminació (3 anells radials). El radi
|
||||
// per emissor i el `light_level` per sala viuen als YAMLs propis;
|
||||
// ací només la corba compartida per a tots els emissors.
|
||||
Lighting::loadConfig(Defaults::Paths::LIGHTING_YAML);
|
||||
// Configuració específica de les escenes TITLE i LOGO. Es carreguen
|
||||
// additivament sobre el mateix UiConfig: cada fitxer només conté la
|
||||
// seua subclau (`title:` o `logo:`), i les claus absents a la base
|
||||
@@ -162,6 +169,11 @@ Director::Director() {
|
||||
Resource::Cache::get().loadSurface(ROOM_TEXT_FONT_GIF_KEY, UiConfig::get().roomTextFont().gif);
|
||||
Resource::Cache::get().getSurface(ROOM_TEXT_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
|
||||
|
||||
// Sprite compartit de la sombra (tira horitzontal de fotogrames de
|
||||
// 16x16, de petit a gran). El consumeix `Effects::Shadow::render`,
|
||||
// que el reaprofiten Player i qualsevol entitat futura compatible.
|
||||
Resource::Cache::get().loadSurface(Effects::Shadow::CACHE_KEY, "data/entities/shadow/shadow.gif");
|
||||
|
||||
// Pistes de música del cicle de sala (intro/playing/outro). Es preloden
|
||||
// ací perquè formen part del preload d'assets, com la font i la paleta;
|
||||
// la lògica de quina sona en cada estat viu a `GameScene`.
|
||||
@@ -342,10 +354,10 @@ Director::~Director() {
|
||||
|
||||
void Director::createSystemFolder() {
|
||||
// Carpeta de configuració per plataforma:
|
||||
// - Windows: %APPDATA%\jailgames\aee_arcade\
|
||||
// - macOS: ~/Library/Application Support/jailgames/aee_arcade/
|
||||
// - Linux: $XDG_CONFIG_HOME/jailgames/aee_arcade/ (defecte
|
||||
// ~/.config/jailgames/aee_arcade/)
|
||||
// - Windows: %APPDATA%\jailgames\aee_arcade
|
||||
// - macOS: ~/Library/Application Support/jailgames/aee_arcade
|
||||
// - Linux: $XDG_CONFIG_HOME/jailgames/aee_arcade (defecte
|
||||
// ~/.config/jailgames/aee_arcade)
|
||||
// No fem servir SDL_GetPrefPath: a Linux apunta a XDG_DATA_HOME
|
||||
// (~/.local/share), que és per a dades, no per a configuració.
|
||||
std::string base;
|
||||
@@ -373,6 +385,12 @@ void Director::createSystemFolder() {
|
||||
if (error) {
|
||||
Log::error("Director", "no s'ha pogut crear {}: {}", system_folder_, error.message());
|
||||
}
|
||||
|
||||
// Les captures de pantalla viuen sota la mateixa carpeta per
|
||||
// plataforma. Sense aquesta crida, `Screenshot::save` cauria al
|
||||
// CWD del procés (raïz del projecte si llances `build/aee_arcade`
|
||||
// a mà, lloc imprevisible en una release de macOS/Windows).
|
||||
Screenshot::setBaseDir(system_folder_);
|
||||
}
|
||||
|
||||
void Director::processEvents() {
|
||||
|
||||
@@ -82,8 +82,15 @@ UiConfig::UiConfig()
|
||||
menu_cursor_(Defaults::UI::MENU_CURSOR),
|
||||
menu_disabled_(Defaults::UI::MENU_DISABLED),
|
||||
hud_bg_(Defaults::UI::HUD_BG),
|
||||
hud_label_(Defaults::UI::HUD_LABEL),
|
||||
hud_value_(Defaults::UI::HUD_VALUE),
|
||||
hud_p1_label_(Defaults::UI::HUD_P1_LABEL),
|
||||
hud_p1_value_(Defaults::UI::HUD_P1_VALUE),
|
||||
hud_p1_shadow_(Defaults::UI::HUD_P1_SHADOW),
|
||||
hud_center_label_(Defaults::UI::HUD_CENTER_LABEL),
|
||||
hud_center_value_(Defaults::UI::HUD_CENTER_VALUE),
|
||||
hud_center_shadow_(Defaults::UI::HUD_CENTER_SHADOW),
|
||||
hud_p2_label_(Defaults::UI::HUD_P2_LABEL),
|
||||
hud_p2_value_(Defaults::UI::HUD_P2_VALUE),
|
||||
hud_p2_shadow_(Defaults::UI::HUD_P2_SHADOW),
|
||||
hud_flash_(Defaults::UI::HUD_FLASH),
|
||||
room_text_(Defaults::UI::ROOM_TEXT),
|
||||
room_text_outline_(Defaults::UI::ROOM_TEXT_OUTLINE),
|
||||
@@ -92,10 +99,8 @@ UiConfig::UiConfig()
|
||||
hud_entry_ms_(Defaults::UI::HUD_ENTRY_MS),
|
||||
hud_flash_ms_(Defaults::UI::HUD_FLASH_MS),
|
||||
hud_shake_ms_(Defaults::UI::HUD_SHAKE_MS),
|
||||
pause_enter_ms_(Defaults::UI::PAUSE_ENTER_MS),
|
||||
pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS),
|
||||
pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)),
|
||||
pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)),
|
||||
hud_blink_cycle_ms_(Defaults::UI::HUD_BLINK_CYCLE_MS),
|
||||
hud_blink_visible_ratio_(Defaults::UI::HUD_BLINK_VISIBLE_RATIO),
|
||||
title_duration_ms_(Defaults::UI::Title::DURATION_MS),
|
||||
title_bg_color_(Defaults::UI::Title::BG_COLOR),
|
||||
title_text_color_(Defaults::UI::Title::TEXT_COLOR),
|
||||
@@ -218,13 +223,26 @@ void UiConfig::load(const std::string& file_path) {
|
||||
readColorIndex(c, "menu_cursor", menu_cursor_);
|
||||
readColorIndex(c, "menu_disabled", menu_disabled_);
|
||||
readColorIndex(c, "hud_bg", hud_bg_);
|
||||
readColorIndex(c, "hud_label", hud_label_);
|
||||
readColorIndex(c, "hud_value", hud_value_);
|
||||
readColorIndex(c, "hud_flash", hud_flash_);
|
||||
readColorIndex(c, "room_text", room_text_);
|
||||
readColorIndex(c, "room_text_outline", room_text_outline_);
|
||||
}
|
||||
|
||||
// Famílies de color del HUD: blocs `hud_p1`, `hud_center`,
|
||||
// `hud_p2` amb claus `label`, `value`, `shadow`. Cada bloc i
|
||||
// cada clau són opcionals — el que manqui cau al default
|
||||
// compilat a `Defaults::UI`.
|
||||
auto read_hud_family = [&](const std::string& key, Uint8& label, Uint8& value, Uint8& shadow) {
|
||||
if (!yaml.contains(key)) { return; }
|
||||
const auto& f = yaml[key];
|
||||
readColorIndex(f, "label", label);
|
||||
readColorIndex(f, "value", value);
|
||||
readColorIndex(f, "shadow", shadow);
|
||||
};
|
||||
read_hud_family("hud_p1", hud_p1_label_, hud_p1_value_, hud_p1_shadow_);
|
||||
read_hud_family("hud_center", hud_center_label_, hud_center_value_, hud_center_shadow_);
|
||||
read_hud_family("hud_p2", hud_p2_label_, hud_p2_value_, hud_p2_shadow_);
|
||||
|
||||
if (yaml.contains("timings")) {
|
||||
const auto& t = yaml["timings"];
|
||||
readField(t, "notification_ms", notification_ms_);
|
||||
@@ -247,14 +265,8 @@ void UiConfig::load(const std::string& file_path) {
|
||||
readField(h, "entry_ms", hud_entry_ms_);
|
||||
readField(h, "flash_ms", hud_flash_ms_);
|
||||
readField(h, "shake_ms", hud_shake_ms_);
|
||||
}
|
||||
|
||||
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_);
|
||||
readField(h, "blink_cycle_ms", hud_blink_cycle_ms_);
|
||||
readField(h, "blink_visible_ratio", hud_blink_visible_ratio_);
|
||||
}
|
||||
|
||||
if (yaml.contains("sounds")) {
|
||||
|
||||
@@ -66,8 +66,19 @@ class UiConfig {
|
||||
[[nodiscard]] auto menuCursor() const -> Uint8 { return menu_cursor_; }
|
||||
[[nodiscard]] auto menuDisabled() const -> Uint8 { return menu_disabled_; }
|
||||
[[nodiscard]] auto hudBg() const -> Uint8 { return hud_bg_; }
|
||||
[[nodiscard]] auto hudLabel() const -> Uint8 { return hud_label_; }
|
||||
[[nodiscard]] auto hudValue() const -> Uint8 { return hud_value_; }
|
||||
// Famílies de color del HUD per bloc (LEFT/CENTER/RIGHT). Cada
|
||||
// família té label (etiqueta apagada), value (valor destacat) i
|
||||
// shadow (to fosc per a la sombra-embossing). El flash és comú a
|
||||
// les tres famílies (groc neó al guanyar punts).
|
||||
[[nodiscard]] auto hudP1Label() const -> Uint8 { return hud_p1_label_; }
|
||||
[[nodiscard]] auto hudP1Value() const -> Uint8 { return hud_p1_value_; }
|
||||
[[nodiscard]] auto hudP1Shadow() const -> Uint8 { return hud_p1_shadow_; }
|
||||
[[nodiscard]] auto hudCenterLabel() const -> Uint8 { return hud_center_label_; }
|
||||
[[nodiscard]] auto hudCenterValue() const -> Uint8 { return hud_center_value_; }
|
||||
[[nodiscard]] auto hudCenterShadow() const -> Uint8 { return hud_center_shadow_; }
|
||||
[[nodiscard]] auto hudP2Label() const -> Uint8 { return hud_p2_label_; }
|
||||
[[nodiscard]] auto hudP2Value() const -> Uint8 { return hud_p2_value_; }
|
||||
[[nodiscard]] auto hudP2Shadow() const -> Uint8 { return hud_p2_shadow_; }
|
||||
[[nodiscard]] auto hudFlash() const -> Uint8 { return hud_flash_; }
|
||||
[[nodiscard]] auto roomText() const -> Uint8 { return room_text_; }
|
||||
[[nodiscard]] auto roomTextOutline() const -> Uint8 { return room_text_outline_; }
|
||||
@@ -78,8 +89,8 @@ class UiConfig {
|
||||
[[nodiscard]] auto hudEntryMs() const -> int { return hud_entry_ms_; }
|
||||
[[nodiscard]] auto hudFlashMs() const -> int { return hud_flash_ms_; }
|
||||
[[nodiscard]] auto hudShakeMs() const -> int { return hud_shake_ms_; }
|
||||
[[nodiscard]] auto pauseEnterMs() const -> int { return pause_enter_ms_; }
|
||||
[[nodiscard]] auto pauseExitMs() const -> int { return pause_exit_ms_; }
|
||||
[[nodiscard]] auto hudBlinkCycleMs() const -> int { return hud_blink_cycle_ms_; }
|
||||
[[nodiscard]] auto hudBlinkVisibleRatio() const -> float { return hud_blink_visible_ratio_; }
|
||||
|
||||
// Escena TITLE (placeholder visual fins que tinguem artwork).
|
||||
struct TitleHeatHaze {
|
||||
@@ -158,11 +169,6 @@ class UiConfig {
|
||||
|
||||
[[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
|
||||
// futurs camps d'UI triar corbes pel mateix mecanisme (ex.: HUD
|
||||
// entry, notifications slide...). Si el nom no està registrat,
|
||||
@@ -209,8 +215,15 @@ class UiConfig {
|
||||
Uint8 menu_cursor_;
|
||||
Uint8 menu_disabled_;
|
||||
Uint8 hud_bg_;
|
||||
Uint8 hud_label_;
|
||||
Uint8 hud_value_;
|
||||
Uint8 hud_p1_label_;
|
||||
Uint8 hud_p1_value_;
|
||||
Uint8 hud_p1_shadow_;
|
||||
Uint8 hud_center_label_;
|
||||
Uint8 hud_center_value_;
|
||||
Uint8 hud_center_shadow_;
|
||||
Uint8 hud_p2_label_;
|
||||
Uint8 hud_p2_value_;
|
||||
Uint8 hud_p2_shadow_;
|
||||
Uint8 hud_flash_;
|
||||
Uint8 room_text_;
|
||||
Uint8 room_text_outline_;
|
||||
@@ -220,10 +233,8 @@ class UiConfig {
|
||||
int hud_entry_ms_;
|
||||
int hud_flash_ms_;
|
||||
int hud_shake_ms_;
|
||||
int pause_enter_ms_;
|
||||
int pause_exit_ms_;
|
||||
CurveFn pause_enter_curve_;
|
||||
CurveFn pause_exit_curve_;
|
||||
int hud_blink_cycle_ms_;
|
||||
float hud_blink_visible_ratio_;
|
||||
|
||||
int title_duration_ms_;
|
||||
Uint8 title_bg_color_;
|
||||
|
||||
@@ -176,6 +176,23 @@ namespace {
|
||||
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
|
||||
|
||||
void Editor::enter() {
|
||||
@@ -190,6 +207,10 @@ void Editor::enter() {
|
||||
picker_ = Picker::NONE;
|
||||
brush_tile_ = -1;
|
||||
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_hover_idx_ = -1;
|
||||
ensureEditableEntities();
|
||||
@@ -256,7 +277,15 @@ void Editor::ensureEditableEntities() {
|
||||
}
|
||||
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.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));
|
||||
}
|
||||
@@ -374,6 +410,7 @@ auto Editor::applyHotkey(int scancode) -> bool {
|
||||
// tracte per a la seva acció de confirmar-eixida.
|
||||
if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) {
|
||||
picker_ = Picker::NONE;
|
||||
entity_frame_picker_source_ = nullptr;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -426,6 +463,8 @@ void Editor::update(int /*delta_ms*/, Tilemap& tilemap, EntityManager& entities)
|
||||
updateTilePicker(tilemap, CX, CY);
|
||||
} else if (picker_ == Picker::ENTITY) {
|
||||
updateEntityPicker(CX, CY);
|
||||
} else if (picker_ == Picker::ENTITY_FRAME) {
|
||||
updateEntityFramePicker(CX, CY);
|
||||
} else {
|
||||
switch (mode_) {
|
||||
case Mode::TILE:
|
||||
@@ -514,7 +553,9 @@ namespace {
|
||||
|
||||
// Composa el spawn YAML per al brush actual. Retorna empty si el snap
|
||||
// no és vàlid (fora de rejilla, solapament, o duplicat de porta).
|
||||
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing) -> std::string {
|
||||
// `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;
|
||||
y << "type: " << brush << '\n';
|
||||
if (brush == "sarcophagus") {
|
||||
@@ -530,6 +571,7 @@ namespace {
|
||||
const auto DEST = tileCellRect(cursor_col, cursor_row, Map::tile_size);
|
||||
if (cellOccupied(existing, DEST)) { return {}; }
|
||||
y << "position: [" << cursor_col << ", " << cursor_row << "]\n";
|
||||
if (frame >= 0) { y << "frame: " << frame << '\n'; }
|
||||
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
|
||||
// de tiles.
|
||||
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);
|
||||
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 (!entities.removeEntity(target)) { return false; }
|
||||
try {
|
||||
@@ -739,8 +789,10 @@ void Editor::renderEditorHud(const EntityManager& entities) const {
|
||||
? "(" + std::to_string(cursor_col_) + "," + std::to_string(cursor_row_) + ")"
|
||||
: "(--,--)";
|
||||
|
||||
const auto LBL = UiConfig::get().hudLabel();
|
||||
const auto VAL = UiConfig::get().hudValue();
|
||||
// El HUD ara té tres famílies (P1/CENTER/P2); l'editor n'agafa la
|
||||
// del CENTER (ambre/or) per ser neutra i contrastar amb el mapa.
|
||||
const auto LBL = UiConfig::get().hudCenterLabel();
|
||||
const auto VAL = UiConfig::get().hudCenterValue();
|
||||
const auto FLASH = UiConfig::get().hudFlash();
|
||||
const int KERN = UiConfig::get().hudFont().kerning;
|
||||
// Mateixos Y que el HUD del joc per simetria visual: línia 1 = 4,
|
||||
@@ -804,7 +856,7 @@ void Editor::renderEntityFrame(const Entity& e, std::uint32_t argb) {
|
||||
|
||||
auto Editor::tryPlaceEntity(EntityManager& entities) -> bool {
|
||||
if (brush_entity_.empty() || cursor_col_ < 0 || cursor_row_ < 0) { return false; }
|
||||
const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities());
|
||||
const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities(), brush_entity_frame_);
|
||||
if (STR.empty()) { return false; }
|
||||
try {
|
||||
auto node = fkyaml::node::deserialize(STR.begin(), STR.end());
|
||||
@@ -838,12 +890,24 @@ void Editor::updateEntityPicker(int canvas_x, int canvas_y) {
|
||||
entity_picker_scroll_ = std::clamp(entity_picker_scroll_, 0, LAY.max_scroll);
|
||||
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
|
||||
if (entity_picker_hover_ >= 0 && entity_picker_hover_ < static_cast<int>(editable_entities_.size())) {
|
||||
brush_entity_ = editable_entities_[entity_picker_hover_].name;
|
||||
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;
|
||||
picker_ = Picker::NONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const {
|
||||
@@ -894,6 +958,106 @@ void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const {
|
||||
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) {
|
||||
const auto LAY = layoutPicker(tilemap);
|
||||
tile_picker_hover_ = -1;
|
||||
@@ -928,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 {
|
||||
if (!active_) { return; }
|
||||
auto& screen = Screen::get();
|
||||
@@ -938,11 +1124,15 @@ void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const
|
||||
renderTilePicker(tilemap);
|
||||
} else if (picker_ == Picker::ENTITY) {
|
||||
renderEntityPicker(tilemap);
|
||||
} else if (picker_ == Picker::ENTITY_FRAME) {
|
||||
renderEntityFramePicker();
|
||||
} 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).
|
||||
const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize());
|
||||
const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize());
|
||||
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) {
|
||||
// Marcs d'entitat: hover (tenue) i selecció (brillant). El
|
||||
// pickerHoverArgb el reusem com a "selecció brillant"; el
|
||||
|
||||
@@ -78,9 +78,13 @@ class Editor {
|
||||
HAND };
|
||||
|
||||
// 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,
|
||||
TILE,
|
||||
ENTITY };
|
||||
ENTITY,
|
||||
ENTITY_FRAME };
|
||||
|
||||
// Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`.
|
||||
// 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 updateTilePicker(const Tilemap& tilemap, 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.
|
||||
// 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 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};
|
||||
Mode mode_{Mode::TILE};
|
||||
@@ -153,21 +165,22 @@ class Editor {
|
||||
int tile_picker_scroll_{0};
|
||||
int tile_picker_hover_{-1};
|
||||
|
||||
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
|
||||
std::string brush_entity_;
|
||||
// Estat de l'EntityPicker.
|
||||
int entity_picker_scroll_{0};
|
||||
int entity_picker_hover_{-1}; // Índex a la llista de noms.
|
||||
|
||||
// Entrada editable del picker. Cada item recorda el nom (clau de
|
||||
// factory + YAML), el surface del sprite (frame 0 com a icona) i la
|
||||
// mida de cada frame al GIF — necessària per clipar el primer frame
|
||||
// quan el GIF és un strip multi-frame.
|
||||
// - `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 {
|
||||
std::string name;
|
||||
std::shared_ptr<Surface> sprite;
|
||||
int frame_w{0};
|
||||
int frame_h{0};
|
||||
int frame_count{1};
|
||||
bool has_variants{false};
|
||||
};
|
||||
|
||||
// Construïda lazy al primer enter() i preservada entre sessions:
|
||||
@@ -178,6 +191,23 @@ class Editor {
|
||||
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -1,14 +1,87 @@
|
||||
#include "game/entities/animation_component.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool loop)
|
||||
: AnimationComponent(std::move(frames), fps, loop, false, false) {}
|
||||
namespace {
|
||||
|
||||
AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool loop, bool flip_h, bool flip_v)
|
||||
// Quan el caller no especifica temps, considerem un frame "estàtic" de
|
||||
// 1s. El valor és arbitrari: amb 1 frame el component mai no avança,
|
||||
// així que el speed concret és inert. Amb N frames és el fallback que
|
||||
// assumeix la factory (mateix valor que el default del format antic).
|
||||
constexpr float K_DEFAULT_FRAME_SECONDS = 1.0F;
|
||||
|
||||
// Si el YAML declara temps <= 0 per a algun frame, el saturem a aquest
|
||||
// mínim per a evitar bucles infinits a `tick` (que avancen frame
|
||||
// mentre `accumulator >= speeds[i]`). 1 ms és prou petit per a no
|
||||
// alterar mai una temporització real i prou gran per a no col·lapsar.
|
||||
constexpr float K_MIN_FRAME_SECONDS = 0.001F;
|
||||
|
||||
auto sanitizeSpeed(float seconds) -> float {
|
||||
return seconds > 0.0F ? seconds : K_MIN_FRAME_SECONDS;
|
||||
}
|
||||
|
||||
// Expandeix una llista de temps a la mida de `frame_count`:
|
||||
// - mida = N: 1:1.
|
||||
// - mida < N: emplena la resta amb l'últim valor (o el default si
|
||||
// la llista era buida).
|
||||
// - mida > N: talla a N.
|
||||
// Tots els valors es passen per `sanitizeSpeed`.
|
||||
auto fitSpeeds(std::vector<float> speeds, std::size_t frame_count) -> std::vector<float> {
|
||||
if (frame_count == 0) { return {}; }
|
||||
if (speeds.empty()) {
|
||||
return std::vector<float>(frame_count, K_DEFAULT_FRAME_SECONDS);
|
||||
}
|
||||
// cppcheck-suppress useStlAlgorithm
|
||||
for (auto& s : speeds) { s = sanitizeSpeed(s); }
|
||||
if (speeds.size() == frame_count) { return speeds; }
|
||||
if (speeds.size() > frame_count) {
|
||||
speeds.resize(frame_count);
|
||||
return speeds;
|
||||
}
|
||||
const float TAIL = speeds.back();
|
||||
speeds.resize(frame_count, TAIL);
|
||||
return speeds;
|
||||
}
|
||||
|
||||
// Llig `animations.<key>.frames` com a llista d'enters. Garanteix
|
||||
// almenys 1 frame (fallback a [0]) per a poder construir sempre un
|
||||
// component vàlid.
|
||||
auto parseFrames(const fkyaml::node& anim) -> std::vector<int> {
|
||||
std::vector<int> frames;
|
||||
for (const auto& f : anim["frames"]) {
|
||||
// cppcheck-suppress useStlAlgorithm
|
||||
frames.push_back(f.get_value<int>());
|
||||
}
|
||||
if (frames.empty()) { frames.push_back(0); }
|
||||
return frames;
|
||||
}
|
||||
|
||||
// Llig `animations.<key>.frame_ms` (escalar o llista) i retorna els
|
||||
// temps en segons. Si és escalar, retorna una llista uniforme de
|
||||
// `frame_count` elements; si és llista, copia tal qual (l'ajust de
|
||||
// mida el farà `fitSpeeds` al constructor).
|
||||
auto parseFrameMs(const fkyaml::node& anim, std::size_t frame_count) -> std::vector<float> {
|
||||
std::vector<float> speeds_secs;
|
||||
const auto& fm = anim["frame_ms"];
|
||||
if (fm.is_sequence()) {
|
||||
for (const auto& v : fm) {
|
||||
// cppcheck-suppress useStlAlgorithm
|
||||
speeds_secs.push_back(static_cast<float>(v.get_value<int>()) / 1000.0F);
|
||||
}
|
||||
} else {
|
||||
const float SECS = static_cast<float>(fm.get_value<int>()) / 1000.0F;
|
||||
speeds_secs.assign(frame_count, SECS);
|
||||
}
|
||||
return speeds_secs;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AnimationComponent::AnimationComponent(std::vector<int> frames, std::vector<float> speeds, bool loop, bool flip_h, bool flip_v)
|
||||
: frames_(std::move(frames)),
|
||||
fps_(fps > 0.0F ? fps : 1.0F),
|
||||
speeds_(fitSpeeds(std::move(speeds), frames_.size())),
|
||||
loop_(loop),
|
||||
flip_h_(flip_h),
|
||||
flip_v_(flip_v) {
|
||||
@@ -19,13 +92,52 @@ AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool
|
||||
if (frames_.size() <= 1 && !loop_) { completed_ = true; }
|
||||
}
|
||||
|
||||
auto AnimationComponent::uniform(std::vector<int> frames, float frame_ms, bool loop) -> AnimationComponent {
|
||||
return uniform(std::move(frames), frame_ms, loop, false, false);
|
||||
}
|
||||
|
||||
auto AnimationComponent::uniform(std::vector<int> frames, float frame_ms, bool loop, bool flip_h, bool flip_v) -> AnimationComponent {
|
||||
const float SECS = sanitizeSpeed(frame_ms / 1000.0F);
|
||||
std::vector<float> speeds(frames.size(), SECS);
|
||||
return {std::move(frames), std::move(speeds), loop, flip_h, flip_v};
|
||||
}
|
||||
|
||||
auto AnimationComponent::fromYaml(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
|
||||
// Defaults conservadors per a quan el bloc `animations.<key>` no
|
||||
// existeix: 1 frame loopant a 1s — visualment estàtic, semàntica neutra.
|
||||
std::vector<int> frames = {0};
|
||||
std::vector<float> speeds_secs; // buit ⇒ fitSpeeds emplenarà amb default
|
||||
bool loop = true;
|
||||
bool flip_h = false;
|
||||
bool flip_v = false;
|
||||
|
||||
if (!yaml.contains("animations") || !yaml["animations"].contains(key)) {
|
||||
return {std::move(frames), std::move(speeds_secs), loop, flip_h, flip_v};
|
||||
}
|
||||
const auto& anim = yaml["animations"][key];
|
||||
|
||||
// L'antic format `fps: N` ja no és suportat. Si apareix, peguem hard
|
||||
// perquè la migració sigui visible (no silenciosa). El missatge inclou
|
||||
// la clau d'animació per a facilitar localitzar el YAML problemàtic.
|
||||
if (anim.contains("fps")) {
|
||||
throw std::runtime_error("AnimationComponent: 'fps:' is no longer supported, use 'frame_ms:' instead (animation '" + key + "')");
|
||||
}
|
||||
|
||||
if (anim.contains("frames")) { frames = parseFrames(anim); }
|
||||
if (anim.contains("frame_ms")) { speeds_secs = parseFrameMs(anim, frames.size()); }
|
||||
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
|
||||
if (anim.contains("flip_h")) { flip_h = anim["flip_h"].get_value<bool>(); }
|
||||
if (anim.contains("flip_v")) { flip_v = anim["flip_v"].get_value<bool>(); }
|
||||
|
||||
return {std::move(frames), std::move(speeds_secs), loop, flip_h, flip_v};
|
||||
}
|
||||
|
||||
void AnimationComponent::tick(float delta_time) {
|
||||
if (frames_.size() <= 1 || completed_) { return; }
|
||||
|
||||
accumulator_ += delta_time;
|
||||
const float FRAME_TIME = 1.0F / fps_;
|
||||
while (accumulator_ >= FRAME_TIME) {
|
||||
accumulator_ -= FRAME_TIME;
|
||||
while (accumulator_ >= speeds_[static_cast<std::size_t>(current_)]) {
|
||||
accumulator_ -= speeds_[static_cast<std::size_t>(current_)];
|
||||
++current_;
|
||||
if (static_cast<std::size_t>(current_) >= frames_.size()) {
|
||||
if (loop_) {
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "external/fkyaml_node.hpp"
|
||||
|
||||
// Component reutilitzable d'animació: qualsevol entitat que necessiti
|
||||
// reproduir una seqüència de frames l'instancia. Es manté com a camp
|
||||
// opcional dins d'Entity (veure `entity.hpp`) i NO hereta de cap classe
|
||||
@@ -22,16 +25,41 @@
|
||||
// canvien d'animació, sense decidir-lo per direcció. Així una mòmia que va
|
||||
// cap a l'oest reprodueix `walk_west` (frames de l'est + `flip_h: true`)
|
||||
// sense que el codi de la mòmia toqui flip directament.
|
||||
//
|
||||
// El temps es modela com a `speeds_[i]` segons per frame (per-frame), no com
|
||||
// a `fps` uniforme. Això permet expressar holds al YAML via `frame_ms: [..]`
|
||||
// (llista) sense haver de repetir el mateix índex de frame. Per a casos
|
||||
// uniformes la factory omple `speeds_` amb el mateix valor a tot el vector.
|
||||
class AnimationComponent {
|
||||
public:
|
||||
AnimationComponent() = default;
|
||||
AnimationComponent(std::vector<int> frames, float fps, bool loop);
|
||||
AnimationComponent(std::vector<int> frames, float fps, bool loop, bool flip_h, bool flip_v);
|
||||
|
||||
// Factory canònica: llig el bloc `animations.<key>` del YAML.
|
||||
//
|
||||
// Camps llegits:
|
||||
// - `frames` : llista d'índexs sencers (obligatori per animar).
|
||||
// - `frame_ms` : escalar ms (uniforme) o llista de ms (per-frame).
|
||||
// Default 1000 ms (animació estàtica).
|
||||
// - `loop` : bool (default true).
|
||||
// - `flip_h` : bool (default false).
|
||||
// - `flip_v` : bool (default false).
|
||||
//
|
||||
// Si la clau no existeix retorna un component "estàtic" (1 frame, loop
|
||||
// true). Si el YAML conté `fps:`, llença `std::runtime_error`: el
|
||||
// format antic ja no és suportat — cal migrar a `frame_ms:`.
|
||||
static auto fromYaml(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent;
|
||||
|
||||
// Helper uniforme: construeix un component on tots els frames duren
|
||||
// `frame_ms` mil·lisegons. Útil per a usos programàtics (tests,
|
||||
// imposició d'un frame estàtic des del loader de nivell). Per al cas
|
||||
// típic guiat per YAML, usar `fromYaml`.
|
||||
static auto uniform(std::vector<int> frames, float frame_ms, bool loop) -> AnimationComponent;
|
||||
static auto uniform(std::vector<int> frames, float frame_ms, bool loop, bool flip_h, bool flip_v) -> AnimationComponent;
|
||||
|
||||
// Avanç del temps. Si `frames_` està buit o té un sol element, no fa
|
||||
// res. En mode loop, `current_frame_` rebota a 0 en passar del final;
|
||||
// en mode no-loop, es queda al darrer frame i `completed()` passa a
|
||||
// true.
|
||||
// res. En mode loop, `current_` rebota a 0 en passar del final; en
|
||||
// mode no-loop, es queda al darrer frame i `completed()` passa a true.
|
||||
// Cada frame consumeix `speeds_[current_]` segons.
|
||||
void tick(float delta_time);
|
||||
|
||||
// Torna al primer frame i reinicia l'acumulador. Útil en transicions
|
||||
@@ -39,8 +67,7 @@ class AnimationComponent {
|
||||
void reset();
|
||||
|
||||
// Índex actual dins `frames_`. Si `frames_` està buit retorna 0 com
|
||||
// a fallback tolerant (el loader ja ha emès warning si hi havia
|
||||
// frames fora de rang; aquí preferim no crashejar).
|
||||
// a fallback tolerant.
|
||||
[[nodiscard]] auto currentFrame() const -> int;
|
||||
|
||||
[[nodiscard]] auto frameCount() const -> std::size_t { return frames_.size(); }
|
||||
@@ -55,8 +82,10 @@ class AnimationComponent {
|
||||
[[nodiscard]] auto flipMode() const -> SDL_FlipMode;
|
||||
|
||||
private:
|
||||
AnimationComponent(std::vector<int> frames, std::vector<float> speeds, bool loop, bool flip_h, bool flip_v);
|
||||
|
||||
std::vector<int> frames_;
|
||||
float fps_{1.0F};
|
||||
std::vector<float> speeds_; // segons per frame; size == frames_.size()
|
||||
bool loop_{true};
|
||||
bool flip_h_{false};
|
||||
bool flip_v_{false};
|
||||
|
||||
@@ -1,45 +1,16 @@
|
||||
#include "game/entities/concrete/door.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/entities/entity_factory.hpp"
|
||||
#include "game/entities/event_bus.hpp"
|
||||
#include "game/entities/player.hpp"
|
||||
#include "game/map/map.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
// Carrega una animació d'un bloc concret (closed/opening/open) amb
|
||||
// fallbacks tolerants. Si la clau no existeix, retorna una animació
|
||||
// d'un sol frame loopant: no fa estat invàlid, simplement no hi ha res
|
||||
// per reproduir.
|
||||
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
|
||||
std::vector<int> frames = {0};
|
||||
float fps = 1.0F;
|
||||
bool loop = true;
|
||||
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
|
||||
const auto& anim = yaml["animations"][key];
|
||||
if (anim.contains("frames")) {
|
||||
frames.clear();
|
||||
for (const auto& f : anim["frames"]) {
|
||||
// cppcheck-suppress useStlAlgorithm
|
||||
frames.push_back(f.get_value<int>());
|
||||
}
|
||||
if (frames.empty()) { frames.push_back(0); }
|
||||
}
|
||||
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
|
||||
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
|
||||
}
|
||||
return {frames, fps, loop};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Door::Door(const fkyaml::node& yaml)
|
||||
: anim_closed_(buildAnimation(yaml, "closed")),
|
||||
anim_opening_(buildAnimation(yaml, "opening")),
|
||||
anim_open_(buildAnimation(yaml, "open")) {
|
||||
: anim_closed_(AnimationComponent::fromYaml(yaml, "closed")),
|
||||
anim_opening_(AnimationComponent::fromYaml(yaml, "opening")),
|
||||
anim_open_(AnimationComponent::fromYaml(yaml, "open")) {
|
||||
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("door");
|
||||
collides_ = true;
|
||||
interactable_ = true;
|
||||
@@ -99,7 +70,7 @@ void Door::open() {
|
||||
// placeholders defineixen `loop: false` explícitament al YAML, així
|
||||
// que aquest fallback només actua si algú dissenya malament.
|
||||
if (anim_.has_value() && anim_->frameCount() <= 1 && anim_->loops()) {
|
||||
anim_ = AnimationComponent({0}, 6.0F, false);
|
||||
anim_ = AnimationComponent::uniform({0}, 167.0F, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +1,12 @@
|
||||
#include "game/entities/concrete/dust.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/entities/entity_factory.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
// Llig una animació opcional del bloc `animations` del YAML, amb
|
||||
// valors per defecte segurs (1 frame, fps=1, sense loop). Si la
|
||||
// clau no existeix, retornem una animació "ja completada" — així,
|
||||
// un Dust spawnejat amb una variant inexistent al YAML mor al
|
||||
// pròxim frame sense petar.
|
||||
auto buildAnim(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
|
||||
std::vector<int> frames = {0};
|
||||
float fps = 1.0F;
|
||||
bool loop = false;
|
||||
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
|
||||
const auto& anim = yaml["animations"][key];
|
||||
if (anim.contains("frames")) {
|
||||
frames.clear();
|
||||
for (const auto& f : anim["frames"]) {
|
||||
// cppcheck-suppress useStlAlgorithm
|
||||
frames.push_back(f.get_value<int>());
|
||||
}
|
||||
if (frames.empty()) { frames.push_back(0); }
|
||||
}
|
||||
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
|
||||
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
|
||||
}
|
||||
return {frames, fps, loop};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Dust::Dust(const fkyaml::node& yaml)
|
||||
: anim_jump_(buildAnim(yaml, "jump")),
|
||||
anim_land_(buildAnim(yaml, "land")) {
|
||||
: anim_jump_(AnimationComponent::fromYaml(yaml, "jump")),
|
||||
anim_land_(AnimationComponent::fromYaml(yaml, "land")) {
|
||||
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("dust");
|
||||
collides_ = false;
|
||||
interactable_ = false;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include "core/audio/audio.hpp"
|
||||
#include "core/resources/resource_cache.hpp"
|
||||
#include "game/entities/concrete/whip_attack.hpp"
|
||||
#include "game/entities/entity_factory.hpp"
|
||||
#include "game/entities/entity_manager.hpp"
|
||||
#include "game/entities/event_bus.hpp"
|
||||
@@ -28,36 +29,6 @@ namespace {
|
||||
return k_rng_;
|
||||
}
|
||||
|
||||
// Llegeix un bloc d'animació del YAML amb fallbacks tolerants (mateix
|
||||
// patró que Door/Sarcophagus). Si la clau no apareix, retorna una
|
||||
// animació d'1 frame loopant, neutra — la momia es podrà moure però
|
||||
// visualment es quedarà estàtica. Llegeix `flip_h`/`flip_v` (default
|
||||
// false) i els passa al component perquè el motor sàpiga aplicar-los
|
||||
// al sprite quan aquesta animació estigui activa.
|
||||
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
|
||||
std::vector<int> frames = {0};
|
||||
float fps = 8.0F;
|
||||
bool loop = true;
|
||||
bool flip_h = false;
|
||||
bool flip_v = false;
|
||||
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
|
||||
const auto& anim = yaml["animations"][key];
|
||||
if (anim.contains("frames")) {
|
||||
frames.clear();
|
||||
for (const auto& f : anim["frames"]) {
|
||||
// cppcheck-suppress useStlAlgorithm
|
||||
frames.push_back(f.get_value<int>());
|
||||
}
|
||||
if (frames.empty()) { frames.push_back(0); }
|
||||
}
|
||||
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
|
||||
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
|
||||
if (anim.contains("flip_h")) { flip_h = anim["flip_h"].get_value<bool>(); }
|
||||
if (anim.contains("flip_v")) { flip_v = anim["flip_v"].get_value<bool>(); }
|
||||
}
|
||||
return {frames, fps, loop, flip_h, flip_v};
|
||||
}
|
||||
|
||||
// Busca si el segment [prev, prev+delta] travessa una lane del patró
|
||||
// `base + k·pitch` (k ∈ [0..max_idx]). Retorna el delta que cal aplicar
|
||||
// per arribar exactament a la primera línia travessada. Iterem en el
|
||||
@@ -65,6 +36,24 @@ namespace {
|
||||
// més d'una lane (cas marginal a velocitats típiques, però pot passar
|
||||
// amb dt grans). Comparació estricta a un costat: no dispara quan la
|
||||
// caixa ja es troba alineada a la lane (hi està després del snap).
|
||||
// Mapatge WhipAttack::Direction → Mummy::Direction. Viuen amb noms i
|
||||
// valors paral·lels però són dos enums distints (cada classe modela el
|
||||
// seu propi domini). Conversió 1-a-1; `NONE` no apareix com a possible
|
||||
// sortida del whip, però la deixem per coherència defensiva.
|
||||
auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction {
|
||||
switch (d) {
|
||||
case WhipAttack::Direction::NORTH:
|
||||
return Mummy::Direction::NORTH;
|
||||
case WhipAttack::Direction::SOUTH:
|
||||
return Mummy::Direction::SOUTH;
|
||||
case WhipAttack::Direction::EAST:
|
||||
return Mummy::Direction::EAST;
|
||||
case WhipAttack::Direction::WEST:
|
||||
return Mummy::Direction::WEST;
|
||||
}
|
||||
return Mummy::Direction::NONE;
|
||||
}
|
||||
|
||||
auto findLaneCross(float prev, float delta, float base, float pitch, int max_idx) -> std::optional<float> {
|
||||
const float NEXT = prev + delta;
|
||||
if (delta > 0.0F) {
|
||||
@@ -84,10 +73,11 @@ namespace {
|
||||
} // namespace
|
||||
|
||||
Mummy::Mummy(const fkyaml::node& yaml)
|
||||
: anim_walk_south_(buildAnimation(yaml, "walk_south")),
|
||||
anim_walk_north_(buildAnimation(yaml, "walk_north")),
|
||||
anim_walk_east_(buildAnimation(yaml, "walk_east")),
|
||||
anim_walk_west_(buildAnimation(yaml, "walk_west")) {
|
||||
: anim_walk_south_(AnimationComponent::fromYaml(yaml, "walk_south")),
|
||||
anim_walk_north_(AnimationComponent::fromYaml(yaml, "walk_north")),
|
||||
anim_walk_east_(AnimationComponent::fromYaml(yaml, "walk_east")),
|
||||
anim_walk_west_(AnimationComponent::fromYaml(yaml, "walk_west")),
|
||||
anim_raise_(AnimationComponent::fromYaml(yaml, "raise")) {
|
||||
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy");
|
||||
collides_ = true;
|
||||
interactable_ = false;
|
||||
@@ -122,16 +112,10 @@ Mummy::Mummy(const fkyaml::node& yaml)
|
||||
changeDirection(0.0F, 1.0F);
|
||||
}
|
||||
|
||||
// Estat SPAWN inicial: la mòmia naix visible i quieta, esperant que el
|
||||
// GameScene li armi el comptador via `triggerSpawnExit()` (cridat al
|
||||
// final de la subfase text-exit de ROOM_INTRO). Si el YAML no declara
|
||||
// `spawn.duration_s`, default 0 ⇒ al primer update post-trigger passa
|
||||
// directament a ACTIVE.
|
||||
float spawn_duration_s = 0.0F;
|
||||
if (yaml.contains("spawn") && yaml["spawn"].contains("duration_s")) {
|
||||
spawn_duration_s = yaml["spawn"]["duration_s"].get_value<float>();
|
||||
}
|
||||
enterSpawn(spawn_duration_s);
|
||||
// L'estat SPAWNING amb l'animació `raise` s'arma a `onJustSpawned`,
|
||||
// no al ctor. Així el manager pot instanciar la mòmia sense disparar
|
||||
// la cinemàtica (cas del mode editor) i veure-la ja en ROAMING amb
|
||||
// la `walk_*` triada per `changeDirection` més amunt.
|
||||
|
||||
// Configuració de l'estat HURT: opcional. Si el bloc no apareix, la
|
||||
// mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la
|
||||
@@ -158,6 +142,20 @@ Mummy::Mummy(const fkyaml::node& yaml)
|
||||
}
|
||||
}
|
||||
|
||||
void Mummy::onJustSpawned() {
|
||||
// Cinemàtica d'aparició: la mòmia "s'aixeca" reproduint `raise`
|
||||
// (16 frames no-loop). El countdown del DynamicHazard NO s'utilitza
|
||||
// (passem 0): `Mummy::update` controla la transició a ACTIVE per
|
||||
// `anim_->completed()`. Cap crida a la base perquè el `triggerSpawnExit`
|
||||
// del DynamicHazard arma aquest mateix countdown que aquí no toca.
|
||||
enterSpawn(0.0F);
|
||||
anim_ = anim_raise_;
|
||||
if (anim_.has_value()) {
|
||||
anim_->reset();
|
||||
flip_ = anim_->flipMode();
|
||||
}
|
||||
}
|
||||
|
||||
void Mummy::changeDirection(float dx, float dy) {
|
||||
setDirection(dx, dy);
|
||||
// Una animació per cada cardinal — el flip viu al YAML
|
||||
@@ -520,6 +518,15 @@ void Mummy::applyChoice(Direction dir) {
|
||||
}
|
||||
}
|
||||
|
||||
auto Mummy::onWhipHit(WhipAttack* attack) -> bool {
|
||||
// Defensiu: el filtre `shouldReceiveContact` ja salta DynamicHazards en
|
||||
// SPAWN, però blindem aquesta crida si arribara per un camí no central.
|
||||
if (isSpawning()) { return false; }
|
||||
if (!whippable_ || attack == nullptr) { return false; }
|
||||
enterHurt(whipDirToMummyDir(attack->getDirection()));
|
||||
return true;
|
||||
}
|
||||
|
||||
void Mummy::onPlayerContact(Player* player) {
|
||||
// Defensiu: el filtre central a EntityManager::checkPlayerCollisions ja
|
||||
// salta DynamicHazards en SPAWN, però blindem la crida directa.
|
||||
@@ -664,6 +671,21 @@ void Mummy::hurtStep(float delta_time) {
|
||||
}
|
||||
|
||||
void Mummy::update(float delta_time) {
|
||||
if (isSpawning()) {
|
||||
// SPAWN dirigit per animació: la `raise` és no-loop, així que
|
||||
// `completed()` ens marca el final. Mentre dura, NO cridem
|
||||
// `DynamicHazard::update`: la base hi té el countdown i ja
|
||||
// hauria saltat a ACTIVE (passem `enterSpawn(0)`), i el `step()`
|
||||
// d'IA mouria la mòmia abans de "aixecar-se". En completar,
|
||||
// tornem a la `walk_*` que toca segons la direcció ja triada
|
||||
// al ctor (initial_direction de l'IA).
|
||||
if (anim_.has_value()) { anim_->tick(delta_time); }
|
||||
if (anim_.has_value() && anim_->completed()) {
|
||||
endSpawn();
|
||||
changeDirection(getDirX(), getDirY());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state_ == State::HURT) {
|
||||
// Avancem l'animació inline (no podem cridar `Entity::update` ni
|
||||
// `DynamicHazard::update`: el primer salta una capa virtual i el
|
||||
|
||||
@@ -61,9 +61,21 @@ class Mummy : public DynamicHazard {
|
||||
// En estat ROAMING delega a `step()` com sempre via la base.
|
||||
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.
|
||||
void onPlayerContact(Player* player) override;
|
||||
|
||||
// Contacte amb el fueteig: si la mòmia és whippable, entra a HURT amb
|
||||
// la direcció derivada del whip. Retorna true si efectivament ha
|
||||
// rebut dany — el WhipAttack ho usa per al `already_hit_`. Substitueix
|
||||
// el `dynamic_cast<Mummy*>` que feia el whip abans per discriminar.
|
||||
auto onWhipHit(WhipAttack* attack) -> bool override;
|
||||
|
||||
// Activa l'estat HURT amb la direcció de fuga preferida `flee_dir`.
|
||||
// Si està bloquejada, prova perpendiculars; si totes estan bloquejades,
|
||||
// la mòmia queda quieta però en HURT durant tot el timer.
|
||||
@@ -171,6 +183,12 @@ class Mummy : public DynamicHazard {
|
||||
AnimationComponent anim_walk_east_;
|
||||
AnimationComponent anim_walk_west_;
|
||||
|
||||
// Animació d'aparició (no-loop). Mentre `isSpawning()`, `update()`
|
||||
// la tickeja sense moure la mòmia; quan `completed()`, la mòmia
|
||||
// transita a ACTIVE via `endSpawn()` i conmuta a la walk_* que
|
||||
// toca segons direcció.
|
||||
AnimationComponent anim_raise_;
|
||||
|
||||
// Estat HURT i la seua configuració. `hurt_speed_` és l'efectiva
|
||||
// (resultat d'aplicar el `speed` del bloc `hurt:` o, si val 0, la
|
||||
// velocitat normal de la mòmia).
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
#include "game/entities/concrete/sarcophagus.hpp"
|
||||
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/audio.hpp"
|
||||
#include "core/resources/resource_cache.hpp"
|
||||
#include "game/entities/entity_factory.hpp"
|
||||
#include "game/entities/entity_manager.hpp"
|
||||
#include "game/entities/event_bus.hpp"
|
||||
#include "utils/defaults.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
|
||||
std::vector<int> frames = {0};
|
||||
float fps = 1.0F;
|
||||
bool loop = true;
|
||||
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
|
||||
const auto& anim = yaml["animations"][key];
|
||||
if (anim.contains("frames")) {
|
||||
frames.clear();
|
||||
for (const auto& f : anim["frames"]) {
|
||||
// cppcheck-suppress useStlAlgorithm
|
||||
frames.push_back(f.get_value<int>());
|
||||
}
|
||||
if (frames.empty()) { frames.push_back(0); }
|
||||
}
|
||||
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
|
||||
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
|
||||
}
|
||||
return {frames, fps, loop};
|
||||
// RNG local al mòdul, mateix patró que `mummy.cpp`: llavor fixa per
|
||||
// facilitar la depuració. Si algun dia volem aleatoritat real entre
|
||||
// partides, prou amb llegir-la de std::random_device.
|
||||
auto rng() -> std::mt19937& {
|
||||
static std::mt19937 k_rng_{0xCAFEU};
|
||||
return k_rng_;
|
||||
}
|
||||
|
||||
// Llig `tile_size: [w, h]` del YAML. Fallback [1,1] si no existeix.
|
||||
@@ -48,14 +40,37 @@ Sarcophagus::Sarcophagus(const fkyaml::node& yaml, int anchor_col, int anchor_ro
|
||||
}
|
||||
|
||||
Sarcophagus::Sarcophagus(const fkyaml::node& yaml)
|
||||
: anim_closed_(buildAnimation(yaml, "closed")),
|
||||
anim_opening_(buildAnimation(yaml, "opening")),
|
||||
anim_open_(buildAnimation(yaml, "open")) {
|
||||
: anim_closed_(AnimationComponent::fromYaml(yaml, "closed")),
|
||||
anim_opening_(AnimationComponent::fromYaml(yaml, "opening")),
|
||||
anim_open_(AnimationComponent::fromYaml(yaml, "open")) {
|
||||
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("sarcophagus");
|
||||
collides_ = true;
|
||||
interactable_ = true;
|
||||
readTileSize(yaml, tile_size_w_, tile_size_h_);
|
||||
anim_ = anim_closed_;
|
||||
|
||||
// Sons opcionals (bloc `sound:` del YAML). El basename val tant de
|
||||
// clau al Resource::Cache com de path dins `Defaults::Paths::AUDIO_SOUNDS_DIR`.
|
||||
// Mateix patró que `mummy.hurt.sound` i `player.jump.sound`.
|
||||
if (yaml.contains("sound")) {
|
||||
const auto& s = yaml["sound"];
|
||||
if (s.contains("opening")) {
|
||||
opening_sound_key_ = s["opening"].get_value<std::string>();
|
||||
Resource::Cache::get().loadSound(opening_sound_key_, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + opening_sound_key_);
|
||||
}
|
||||
if (s.contains("opening_pitch_spread")) {
|
||||
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) {
|
||||
@@ -70,14 +85,16 @@ void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate&
|
||||
void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) {
|
||||
perimeter_tiles_.clear();
|
||||
|
||||
// El perímetre ortogonal d'un rectangle (w,h) de tiles són les files
|
||||
// de tiles immediatament N, S, E, O. No s'hi inclouen les cantonades
|
||||
// diagonals (tile NO, NE, SO, SE), perquè el jugador fa passes pels
|
||||
// quatre costats i no pot "tallar" per una diagonal: això garanteix
|
||||
// que el recorregut completa només si passa per cada costat.
|
||||
// El perímetre d'un rectangle (w,h) de tiles són les files de tiles
|
||||
// immediatament adjacents: N, S, E, O *més* les quatre cantonades
|
||||
// diagonals (NO, NE, SO, SE). En la pràctica el jugador trepitja
|
||||
// aquestes cantonades en doblar les cantonades del sarcòfag, així que
|
||||
// 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 Sud: (col, row+h) 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-1, col0+w] (inclou SO i SE)
|
||||
// - Columna O: (col0-1, row) per row ∈ [row0, row0+h-1]
|
||||
// - Columna E: (col0+w, row) per row ∈ [row0, row0+h-1]
|
||||
|
||||
@@ -86,12 +103,12 @@ void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) {
|
||||
perimeter_tiles_.insert({col, row});
|
||||
};
|
||||
|
||||
// Nord i Sud
|
||||
for (int dc = 0; dc < tile_size_w_; ++dc) {
|
||||
// Nord i Sud (incloent les quatre cantonades diagonals)
|
||||
for (int dc = -1; dc <= tile_size_w_; ++dc) {
|
||||
maybe_insert(anchor_col_ + dc, anchor_row_ - 1);
|
||||
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) {
|
||||
maybe_insert(anchor_col_ - 1, anchor_row_ + dr);
|
||||
maybe_insert(anchor_col_ + tile_size_w_, anchor_row_ + dr);
|
||||
@@ -106,21 +123,59 @@ void Sarcophagus::update(float delta_time) {
|
||||
anim_ = anim_open_;
|
||||
if (anim_.has_value()) { anim_->reset(); }
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Sarcophagus::notifyTilePainted(int col, int row) {
|
||||
void Sarcophagus::notifyTilePainted(int col, int row, int player_id) {
|
||||
if (state_ != State::CLOSED) { return; }
|
||||
const TileKey KEY{.col = col, .row = row};
|
||||
if (perimeter_tiles_.find(KEY) == perimeter_tiles_.end()) { return; }
|
||||
visited_.insert(KEY);
|
||||
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;
|
||||
anim_ = anim_opening_;
|
||||
if (anim_.has_value()) { anim_->reset(); }
|
||||
if (!opening_sound_key_.empty() && Audio::get() != nullptr) {
|
||||
// Variació de pitch/velocitat per evitar la fatiga d'escoltar
|
||||
// el mateix sample idèntic a cada obertura. Si l'spread és 0
|
||||
// (default), reproduïm amb ratio 1.0; en cas contrari sortegem
|
||||
// uniformement [1 - spread, 1 + spread].
|
||||
if (opening_pitch_spread_ > 0.0F) {
|
||||
std::uniform_real_distribution<float> dist(1.0F - opening_pitch_spread_, 1.0F + opening_pitch_spread_);
|
||||
Audio::get()->playSound(opening_sound_key_, Audio::Group::GAME, dist(rng()));
|
||||
} else {
|
||||
Audio::get()->playSound(opening_sound_key_, Audio::Group::GAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Sarcophagus::onRemoved() {
|
||||
// El manager comptabilitza els sarcòfags actius en `total_sarcs_` per
|
||||
// saber quan ha d'obrir la porta automàticament. Quan el editor
|
||||
// n'esborra un, cal informar-lo: sense aquesta crida, `total_sarcs_`
|
||||
// quedaria descompassat i la porta no s'obriria mai. Substitueix el
|
||||
// `getName() == "sarcophagus"` que el manager feia abans —
|
||||
// polimorfisme net, no comparació de strings.
|
||||
if (entity_manager_ != nullptr) {
|
||||
entity_manager_->onSarcophagusRemoved();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "external/fkyaml_node.hpp"
|
||||
@@ -17,7 +18,10 @@
|
||||
// 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
|
||||
// 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
|
||||
// s'aporta el predicat, s'inclouen tots.
|
||||
class Sarcophagus : public Interactable {
|
||||
@@ -42,7 +46,8 @@ class Sarcophagus : public Interactable {
|
||||
void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked);
|
||||
|
||||
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;
|
||||
|
||||
[[nodiscard]] auto getState() const -> State { return state_; }
|
||||
[[nodiscard]] auto getPerimeterSize() const -> std::size_t { return perimeter_tiles_.size(); }
|
||||
@@ -53,6 +58,17 @@ class Sarcophagus : public Interactable {
|
||||
[[nodiscard]] auto getTileWidth() const -> int { return tile_size_w_; }
|
||||
[[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:
|
||||
struct TileKey {
|
||||
int col;
|
||||
@@ -82,5 +98,23 @@ class Sarcophagus : public Interactable {
|
||||
AnimationComponent anim_opening_;
|
||||
AnimationComponent anim_open_;
|
||||
|
||||
// Clau (basename del fitxer) al Resource::Cache del so que es dispara
|
||||
// a la transició CLOSED → OPENING. Buit ⇒ no sona res.
|
||||
std::string opening_sound_key_;
|
||||
// Variació uniforme del ratio de reproducció al voltant de 1.0. Cada
|
||||
// obertura sorteja [1 - spread, 1 + spread]. 0 ⇒ ratio fix a 1.0.
|
||||
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};
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
#include <string>
|
||||
|
||||
#include "external/fkyaml_node.hpp"
|
||||
#include "game/entities/entity_factory.hpp"
|
||||
#include "game/entities/solid_decoration.hpp"
|
||||
|
||||
// Murs decoratius estàtics que comparteixen el mateix codi C++ però es
|
||||
// registren amb claus de factory diferents per a que el YAML els pugui
|
||||
// referenciar pel seu tipus i el editor els pugui distingir al picker.
|
||||
// La selecció del sub-sprite (frame del atlas) viatja al spawn YAML
|
||||
// (`frame: N`); l'aplica `placeGeneric` via `Entity::setAnimation`. Aquí no
|
||||
// cal cap lògica específica per a la variant.
|
||||
namespace {
|
||||
|
||||
class StaticSolid : public SolidDecoration {
|
||||
public:
|
||||
explicit StaticSolid(const fkyaml::node& yaml) {
|
||||
if (yaml.contains("name")) {
|
||||
name_ = yaml["name"].get_value<std::string>();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
REGISTER_ENTITY_AS("stone", StaticSolid, Stone)
|
||||
REGISTER_ENTITY_AS("column", StaticSolid, Column)
|
||||
@@ -1,41 +1,14 @@
|
||||
#include "game/entities/concrete/torch.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/entities/entity_factory.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
// Ajut: extreu l'animació `idle` del bloc `animations` del YAML si existeix,
|
||||
// amb valors per defecte que sempre produïxen una animació vàlida (1 frame).
|
||||
auto buildIdleAnimation(const fkyaml::node& yaml) -> AnimationComponent {
|
||||
std::vector<int> frames = {0};
|
||||
float fps = 1.0F;
|
||||
bool loop = true;
|
||||
if (yaml.contains("animations") && yaml["animations"].contains("idle")) {
|
||||
const auto& anim = yaml["animations"]["idle"];
|
||||
if (anim.contains("frames")) {
|
||||
frames.clear();
|
||||
for (const auto& f : anim["frames"]) {
|
||||
// cppcheck-suppress useStlAlgorithm
|
||||
frames.push_back(f.get_value<int>());
|
||||
}
|
||||
if (frames.empty()) { frames.push_back(0); }
|
||||
}
|
||||
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
|
||||
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
|
||||
}
|
||||
return {frames, fps, loop};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Torch::Torch(const fkyaml::node& yaml) {
|
||||
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("torch");
|
||||
collides_ = false;
|
||||
interactable_ = false;
|
||||
anim_ = buildIdleAnimation(yaml);
|
||||
anim_ = AnimationComponent::fromYaml(yaml, "idle");
|
||||
}
|
||||
|
||||
REGISTER_ENTITY("torch", Torch)
|
||||
@@ -33,43 +33,19 @@ namespace {
|
||||
return sprite;
|
||||
}
|
||||
|
||||
// Construeix l'`AnimationComponent` del swing a partir del bloc
|
||||
// `animations.swing`. Convenció igual que la resta d'entitats:
|
||||
// - `frames` : llista d'índexs dins l'spritesheet (obligatori).
|
||||
// - `frame_ms` : durada uniforme per frame (ms). Alternativament `fps`.
|
||||
// - `loop` : per defecte true; al látigo el volem false (mor en
|
||||
// completar).
|
||||
auto buildSwingAnim(const fkyaml::node& yaml, std::vector<int>& active_out)
|
||||
-> AnimationComponent {
|
||||
std::vector<int> frames = {0};
|
||||
float fps = 8.0F;
|
||||
bool loop = false;
|
||||
if (yaml.contains("animations") && yaml["animations"].contains("swing")) {
|
||||
// Llig el camp `active_frames` del bloc `animations.swing` — específic
|
||||
// del látigo: els índexs llistats són els frames on la hitbox aplica
|
||||
// dany (wind-up i recovery no danyen). La resta de l'animació la
|
||||
// construeix `AnimationComponent::fromYaml`.
|
||||
void readActiveFrames(const fkyaml::node& yaml, std::vector<int>& active_out) {
|
||||
if (!yaml.contains("animations") || !yaml["animations"].contains("swing")) { return; }
|
||||
const auto& sw = yaml["animations"]["swing"];
|
||||
if (sw.contains("frames")) {
|
||||
frames.clear();
|
||||
for (const auto& f : sw["frames"]) {
|
||||
// cppcheck-suppress useStlAlgorithm
|
||||
frames.push_back(f.get_value<int>());
|
||||
}
|
||||
if (frames.empty()) { frames.push_back(0); }
|
||||
}
|
||||
if (sw.contains("frame_ms")) {
|
||||
const auto MS = static_cast<float>(sw["frame_ms"].get_value<int>());
|
||||
if (MS > 0.0F) { fps = 1000.0F / MS; }
|
||||
} else if (sw.contains("fps")) {
|
||||
fps = sw["fps"].get_value<float>();
|
||||
}
|
||||
if (sw.contains("loop")) { loop = sw["loop"].get_value<bool>(); }
|
||||
if (sw.contains("active_frames")) {
|
||||
if (!sw.contains("active_frames")) { return; }
|
||||
for (const auto& f : sw["active_frames"]) {
|
||||
// cppcheck-suppress useStlAlgorithm
|
||||
active_out.push_back(f.get_value<int>());
|
||||
}
|
||||
}
|
||||
}
|
||||
return {frames, fps, loop};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -86,7 +62,8 @@ WhipAttack::WhipAttack(const fkyaml::node& yaml) {
|
||||
if (s.contains("west")) { sprite_west_ = readDirSprite(s["west"]); }
|
||||
}
|
||||
|
||||
anim_swing_ = buildSwingAnim(yaml, active_frames_);
|
||||
anim_swing_ = AnimationComponent::fromYaml(yaml, "swing");
|
||||
readActiveFrames(yaml, active_frames_);
|
||||
anim_ = anim_swing_;
|
||||
}
|
||||
|
||||
@@ -262,19 +239,10 @@ void WhipAttack::update(float delta_time) {
|
||||
|
||||
namespace {
|
||||
|
||||
auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction {
|
||||
switch (d) {
|
||||
case WhipAttack::Direction::NORTH:
|
||||
return Mummy::Direction::NORTH;
|
||||
case WhipAttack::Direction::SOUTH:
|
||||
return Mummy::Direction::SOUTH;
|
||||
case WhipAttack::Direction::EAST:
|
||||
return Mummy::Direction::EAST;
|
||||
case WhipAttack::Direction::WEST:
|
||||
return Mummy::Direction::WEST;
|
||||
}
|
||||
return Mummy::Direction::NONE;
|
||||
}
|
||||
// (El mapping WhipAttack::Direction → Mummy::Direction viu ara a
|
||||
// mummy.cpp, on s'usa des de `Mummy::onWhipHit`. Aquí només queda el
|
||||
// mapping a Player::Cardinal — usat per al friendly-fire entre
|
||||
// jugadors, que no és polimòrfic via Entity.)
|
||||
|
||||
auto whipDirToPlayerCardinal(WhipAttack::Direction d) -> Player::Cardinal {
|
||||
switch (d) {
|
||||
@@ -296,25 +264,24 @@ namespace {
|
||||
return STATE != Player::State::HURT && STATE != Player::State::EXITED && STATE != Player::State::DISABLED;
|
||||
}
|
||||
|
||||
// Tracta una mòmia candidata: si és whippable i la seua caja solapa
|
||||
// amb `damage_rect`, la posa a HURT i la marca al set. Extret per
|
||||
// acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar).
|
||||
// Rep un `DynamicHazard*` (l'iterador d'`applyDamage` ja filtra el
|
||||
// sac d'actors); només cal el dynamic_cast a Mummy per descartar
|
||||
// altres hermanes (RollingRock i futurs DynamicHazards no whippables).
|
||||
void hitMummyIfApplicable(
|
||||
DynamicHazard* actor,
|
||||
// Tracta una entitat candidata: si la seva caixa solapa amb
|
||||
// `damage_rect`, delega la decisió a `entity->onWhipHit(attack)`.
|
||||
// Cada entitat decideix què fer (Mummy entra HURT, Torch retorna
|
||||
// false sense efecte, RollingRock no override → no-op). El retorn
|
||||
// true marca el set `already_hit` per evitar segons impactes dins del
|
||||
// mateix swing. Polimorfisme net — sense dynamic_cast.
|
||||
void hitEntityIfApplicable(
|
||||
Entity* entity,
|
||||
const SDL_FRect& damage_rect,
|
||||
WhipAttack::Direction dir,
|
||||
WhipAttack* attack,
|
||||
std::unordered_set<void*>& already_hit) {
|
||||
if (actor == nullptr || actor->isDead()) { return; }
|
||||
if (already_hit.contains(static_cast<void*>(actor))) { return; }
|
||||
auto* mummy = dynamic_cast<Mummy*>(actor);
|
||||
if (mummy == nullptr || !mummy->isWhippable()) { return; }
|
||||
const auto BOX = mummy->getCollisionRect();
|
||||
if (entity == nullptr || entity->isDead()) { return; }
|
||||
if (already_hit.contains(static_cast<void*>(entity))) { return; }
|
||||
const auto BOX = entity->getCollisionRect();
|
||||
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
|
||||
mummy->enterHurt(whipDirToMummyDir(dir));
|
||||
already_hit.insert(static_cast<void*>(actor));
|
||||
if (entity->onWhipHit(attack)) {
|
||||
already_hit.insert(static_cast<void*>(entity));
|
||||
}
|
||||
}
|
||||
|
||||
void hitPlayerIfApplicable(
|
||||
@@ -339,12 +306,14 @@ namespace {
|
||||
|
||||
void WhipAttack::applyDamage() {
|
||||
if (entity_manager_ != nullptr) {
|
||||
// Només els actors poden rebre el fuetejo (decoracions, sarcs,
|
||||
// forats... no són hits del whip). Iterem el sac `actors_`
|
||||
// directament — sense recórrer tot el mapa per descartar-ne la
|
||||
// major part.
|
||||
for (auto* actor : entity_manager_->getActors()) {
|
||||
hitMummyIfApplicable(actor, damage_rect_, direction_, already_hit_);
|
||||
// Iterem totes les entitats. La discriminació la fa cada una via
|
||||
// `onWhipHit`: Mummy entra HURT si és whippable, la resta retorna
|
||||
// false (no-op). El cost d'iterar antorxes/sarcs/forats és una
|
||||
// crida virtual buida cada una — menyspreable comparat amb la
|
||||
// claredat del callsite. Si demà un sarc vol trencar-se al
|
||||
// fueteig, només cal override de `onWhipHit` allà.
|
||||
for (auto* entity : entity_manager_->getEntities()) {
|
||||
hitEntityIfApplicable(entity, damage_rect_, this, already_hit_);
|
||||
}
|
||||
}
|
||||
if (players_ != nullptr) {
|
||||
|
||||
@@ -24,10 +24,35 @@ void Entity::onPlayerContact(Player* /*player*/) {
|
||||
// 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.
|
||||
}
|
||||
|
||||
auto Entity::onWhipHit(WhipAttack* /*attack*/) -> bool {
|
||||
// No-op per defecte; les entitats no-whippables (Torch, Hole, Sarc,
|
||||
// Door, RollingRock...) retornen `false`. `Mummy` n'és l'override
|
||||
// efectiu. El caller usa el retorn per marcar `already_hit_` —
|
||||
// entitats que no han rebut dany NO bloquegen futurs frames del whip.
|
||||
return false;
|
||||
}
|
||||
|
||||
void Entity::onJustSpawned() {
|
||||
// No-op per defecte. `DynamicHazard` override per disparar el seu
|
||||
// `triggerSpawnExit` (anima la fase EMERGING just després de néixer).
|
||||
}
|
||||
|
||||
void Entity::onRemoved() {
|
||||
// No-op per defecte. `Sarcophagus` override per decrementar el
|
||||
// comptador `total_sarcs_` del manager.
|
||||
}
|
||||
|
||||
auto Entity::shouldReceiveContact() const -> bool {
|
||||
// Default: l'entitat rep contacte si és sòlida (`collides_`) o si és
|
||||
// un trigger d'àrea (`triggers_on_overlap_`). `DynamicHazard` afegeix
|
||||
// el filtre de SPAWN — mentre està emergint, ignora contactes.
|
||||
return collides_ || triggers_on_overlap_;
|
||||
}
|
||||
|
||||
auto Entity::getCollisionRect() const -> SDL_FRect {
|
||||
if (!collision_box_.has_value()) { return pixel_rect_; }
|
||||
return {
|
||||
@@ -38,6 +63,17 @@ auto Entity::getCollisionRect() const -> SDL_FRect {
|
||||
};
|
||||
}
|
||||
|
||||
auto Entity::getLightCenter() const -> SDL_FPoint {
|
||||
// Centre del rect de render + offset configurat al YAML. Per a
|
||||
// entitats que es desplacen visualment sobre la seva posició lògica
|
||||
// (p.ex. el Player saltant), la subclasse override aquest mètode
|
||||
// per restar el desplaçament — i ha d'incloure l'offset igualment.
|
||||
return {
|
||||
.x = pixel_rect_.x + (pixel_rect_.w * 0.5F) + static_cast<float>(light_offset_x_),
|
||||
.y = pixel_rect_.y + (pixel_rect_.h * 0.5F) + static_cast<float>(light_offset_y_),
|
||||
};
|
||||
}
|
||||
|
||||
auto Entity::getFeetY() const -> float {
|
||||
// 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
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "core/rendering/lighting.hpp"
|
||||
#include "game/entities/animation_component.hpp"
|
||||
|
||||
// Forward-declaracions: mantenim aquest header lleuger. `Player` viu a
|
||||
@@ -19,6 +21,7 @@ class Player;
|
||||
class EventBus;
|
||||
class Surface;
|
||||
class EntityManager;
|
||||
class WhipAttack;
|
||||
|
||||
// Entitat genèrica del món. Actua com a dada en comú per a tots els actors
|
||||
// del joc (decoratius, interactables, perills mòbils i estàtics): manté
|
||||
@@ -83,8 +86,47 @@ class Entity {
|
||||
// Hook que el `EntityManager` invoca per a cada entitat cada vegada
|
||||
// que un tile es pinta (via PaintLayer, com a conseqüència d'un
|
||||
// jugador trepitjant un sòl pintable). Només `Sarcophagus` ho consumeix
|
||||
// (per marcar perímetre); per defecte no-op.
|
||||
virtual void notifyTilePainted(int col, int row);
|
||||
// (per marcar perímetre); per defecte no-op. `player_id` és l'id del
|
||||
// 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
|
||||
// `WhipAttack` perquè la subclasse pugui llegir-ne direcció o atributs
|
||||
// (mateix patró que `onPlayerContact(Player*)`). Retorna `true` si
|
||||
// efectivament ha aplicat dany — el caller en marca el set
|
||||
// `already_hit_` per evitar segons impactes dins del mateix swing.
|
||||
// Default: no fa res, retorna `false`. Override a `Mummy` (whippable)
|
||||
// i a futurs enemics whippables. `RollingRock` no override → roca
|
||||
// immune al látigo per defecte.
|
||||
virtual auto onWhipHit(WhipAttack* attack) -> bool;
|
||||
|
||||
// Hook just després de néixer al món. El manager el crida quan
|
||||
// `addSpawn` / `spawnActor` / `spawnPendingActors` instancien una
|
||||
// entitat. La base no fa res; `DynamicHazard` override per disparar
|
||||
// el seu `triggerSpawnExit` (anima la fase EMERGING). Substitueix el
|
||||
// `dynamic_cast<DynamicHazard*>(observer)->triggerSpawnExit()` que el
|
||||
// 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();
|
||||
|
||||
// Hook just abans que el manager esborri l'entitat (via
|
||||
// `removeEntity`, típicament al editor). Permet a subclasses
|
||||
// notificar al manager o publicar events per mantenir invariants
|
||||
// (p.ex. `Sarcophagus` decrementa `total_sarcs_`). Default no-op.
|
||||
virtual void onRemoved();
|
||||
|
||||
// Decisió polimòrfica de si aquesta entitat ha de rebre el callback
|
||||
// `onPlayerContact` aquest frame. La base permet contacte si `collides_`
|
||||
// o `triggers_on_overlap_` estan posats. Subclasses poden afegir
|
||||
// condicions transitòries (p.ex. `DynamicHazard` retorna `false` mentre
|
||||
// està en SPAWN). Centralitza el filtre que abans vivia al manager
|
||||
// amb dynamic_casts/isSpawning explícits.
|
||||
[[nodiscard]] virtual auto shouldReceiveContact() const -> bool;
|
||||
|
||||
[[nodiscard]] auto getName() const -> const std::string& { return name_; }
|
||||
[[nodiscard]] auto getTilePos() const -> TilePos { return tile_; }
|
||||
@@ -92,6 +134,15 @@ class Entity {
|
||||
[[nodiscard]] auto collides() const -> bool { return collides_; }
|
||||
[[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`)
|
||||
// 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
|
||||
@@ -154,6 +205,14 @@ class Entity {
|
||||
// Entitats sense animació retornen `nullopt` → frame 0 per defecte.
|
||||
[[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
|
||||
// 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 —
|
||||
@@ -196,6 +255,74 @@ class Entity {
|
||||
[[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; }
|
||||
void setFlip(SDL_FlipMode flip) { flip_ = flip; }
|
||||
|
||||
// Radi de la llum emesa per aquesta entitat (en píxels del canvas).
|
||||
// 0 = no emet (cas habitual). El bloc opcional `light: { radius: N }`
|
||||
// del YAML de la plantilla el pobla via `EntityManager::buildEntity`.
|
||||
[[nodiscard]] auto getLightRadius() const -> int { return light_radius_; }
|
||||
void setLightRadius(int r) { light_radius_ = r; }
|
||||
|
||||
// Activa la modulació "tipus foc" del radi a la pasada d'il·luminació
|
||||
// (per a antorxes, fogueres, etc.). El bloc opcional
|
||||
// `light: { flicker: true }` del YAML el pobla. Per defecte false:
|
||||
// la llum queda perfectament estable (cas player, mòmia).
|
||||
[[nodiscard]] auto getLightFlicker() const -> bool { return light_flicker_; }
|
||||
void setLightFlicker(bool f) { light_flicker_ = f; }
|
||||
|
||||
// Color emès en mode additive (RGB 0..255 per canal). Default blanc:
|
||||
// en mode subtractive aquest valor s'ignora completament; en mode
|
||||
// additive és el tint que l'emissor afegeix al canvas. La cadena de
|
||||
// resolució (tipus → sala → spawn) el pobla via `setLightColor`.
|
||||
[[nodiscard]] auto getLightColorR() const -> std::uint8_t { return light_color_r_; }
|
||||
[[nodiscard]] auto getLightColorG() const -> std::uint8_t { return light_color_g_; }
|
||||
[[nodiscard]] auto getLightColorB() const -> std::uint8_t { return light_color_b_; }
|
||||
void setLightColor(std::uint8_t r, std::uint8_t g, std::uint8_t b) {
|
||||
light_color_r_ = r;
|
||||
light_color_g_ = g;
|
||||
light_color_b_ = b;
|
||||
}
|
||||
|
||||
// Desplaçament en píxels del centre del cercle de llum respecte el
|
||||
// centre visual del sprite (que és el que retornaria per defecte
|
||||
// `getLightCenter`). Útil quan el "punt de llum" iconogràfic no
|
||||
// coincideix amb el centre del bounding box — per exemple, la flama
|
||||
// d'una antorxa està a la part de dalt del sprite i interessa pujar
|
||||
// l'emissor 3-4 px per a que el cercle de llum surti de la flama,
|
||||
// no del peu. Y creix cap avall al canvas, així que `y: -3` puja
|
||||
// la llum 3 píxels. Cadena de resolució idèntica al color: tipus →
|
||||
// sala → spawn.
|
||||
[[nodiscard]] auto getLightOffsetX() const -> int { return light_offset_x_; }
|
||||
[[nodiscard]] auto getLightOffsetY() const -> int { return light_offset_y_; }
|
||||
void setLightOffset(int x, int y) {
|
||||
light_offset_x_ = x;
|
||||
light_offset_y_ = y;
|
||||
}
|
||||
|
||||
// Paràmetres del flicker resolts per a aquest emissor (combinació
|
||||
// del global de `data/config/lighting.yaml` + overrides del tipus,
|
||||
// sala i spawn). Només té efecte si `light_flicker_` és true. El
|
||||
// construct path li passa una còpia ja resolta — el raster (lighting.cpp)
|
||||
// només la llegeix. Es publica per camps perquè el bloc YAML
|
||||
// `flicker_params:` permet sobreescriure qualsevol subconjunt.
|
||||
[[nodiscard]] auto getFlickerParams() const -> const Lighting::FlickerConfig& { return flicker_params_; }
|
||||
void setFlickerParams(const Lighting::FlickerConfig& fp) { flicker_params_ = fp; }
|
||||
|
||||
// Identificador estable per al flicker. Cada Entity rep un valor
|
||||
// únic a la construcció (contador estàtic) i el conserva fins a la
|
||||
// mort — així el patró del flicker no "deriva" quan l'entitat es
|
||||
// mou. Si l'entitat respawneja, la nova instància tindrà una seed
|
||||
// nova; com que el cicle visual ja queda trencat per la mort, no és
|
||||
// un problema.
|
||||
[[nodiscard]] auto getLightSeed() const -> int { return light_seed_; }
|
||||
|
||||
// Centre de la llum en píxels del canvas. Per defecte és el centre
|
||||
// del `pixel_rect_` (caixa de render) més l'offset configurat al
|
||||
// YAML (`light.offset: [x, y]`). Virtual perquè entitats amb
|
||||
// desplaçament visual sobre la posició lògica (p.ex. el Player en
|
||||
// l'aire, que rebaixa visualment `pos_.y` amb `jump_z_`) puguin
|
||||
// retornar el centre que realment es veu — l'override n'és
|
||||
// responsable d'incloure l'offset (com fa `Player::getLightCenter`).
|
||||
[[nodiscard]] virtual auto getLightCenter() const -> SDL_FPoint;
|
||||
|
||||
protected:
|
||||
std::string name_;
|
||||
TilePos tile_{};
|
||||
@@ -207,7 +334,22 @@ class Entity {
|
||||
bool interactable_{false};
|
||||
bool triggers_on_overlap_{false};
|
||||
bool is_floor_decal_{false};
|
||||
bool blocks_movement_{false};
|
||||
int height_{0};
|
||||
int light_radius_{0};
|
||||
// Comptador global d'instàncies; cada Entity nova s'emporta el
|
||||
// valor incrementat com a seed propi. Estàtic perquè no calgui
|
||||
// estat extern (sense risc de threads: les entitats es construeixen
|
||||
// sempre des del fil del joc).
|
||||
static inline int s_next_light_seed{0};
|
||||
int light_seed_{++s_next_light_seed};
|
||||
bool light_flicker_{false};
|
||||
std::uint8_t light_color_r_{255};
|
||||
std::uint8_t light_color_g_{255};
|
||||
std::uint8_t light_color_b_{255};
|
||||
int light_offset_x_{0};
|
||||
int light_offset_y_{0};
|
||||
Lighting::FlickerConfig flicker_params_{};
|
||||
bool dead_{false};
|
||||
SDL_FlipMode flip_{SDL_FLIP_NONE};
|
||||
EventBus* bus_{nullptr};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "game/entities/concrete/door.hpp"
|
||||
#include "game/entities/concrete/sarcophagus.hpp"
|
||||
#include "game/entities/entity.hpp"
|
||||
#include "game/map/map.hpp"
|
||||
|
||||
// NOTA: `loadFromLevel` viu a `entity_manager_loadlevel.cpp` per evitar
|
||||
// arrossegar `Resource::Cache`, `Surface` i les seues dependències (GIF,
|
||||
@@ -30,7 +31,7 @@ EntityManager::EntityManager() {
|
||||
// els sarcòfags puguin actualitzar el seu `visited_`.
|
||||
tile_sub_id_ = bus_.subscribe("tile_painted", [this](const Event& ev) {
|
||||
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
|
||||
@@ -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() {
|
||||
bus_.unsubscribe(sarc_sub_id_);
|
||||
bus_.unsubscribe(tile_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) {
|
||||
@@ -74,19 +86,7 @@ auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
|
||||
entity->setEventBus(&bus_);
|
||||
entity->setEntityManager(this);
|
||||
Entity* raw = entity.get();
|
||||
// Enrutament per categoria semàntica: si l'entitat és (o deriva de)
|
||||
// DynamicHazard va al sac `actors_`, la resta al sac `map_entities_`.
|
||||
// Així els consumidors que només volen actors itera el sac petit i
|
||||
// ens estalviem dynamic_casts dispersos al codi (colisió, whip...).
|
||||
if (dynamic_cast<DynamicHazard*>(entity.get()) != nullptr) {
|
||||
// Transferim l'ownership al unique_ptr<DynamicHazard> tipat fort.
|
||||
// El `release()` consumeix el seu valor com a argument del ctor —
|
||||
// sense crida nua que el linter pugui marcar com a ignorada.
|
||||
std::unique_ptr<DynamicHazard> hazard{static_cast<DynamicHazard*>(entity.release())};
|
||||
actors_.push_back(std::move(hazard));
|
||||
} else {
|
||||
map_entities_.push_back(std::move(entity));
|
||||
}
|
||||
entities_.push_back(std::move(entity));
|
||||
// cppcheck-suppress returnDanglingLifetime
|
||||
return raw;
|
||||
}
|
||||
@@ -102,18 +102,15 @@ void EntityManager::setDoor(Door* door) {
|
||||
auto EntityManager::removeEntity(Entity* observer) -> bool {
|
||||
if (observer == nullptr) { return false; }
|
||||
const bool WAS_DOOR = (door_ == observer);
|
||||
const bool WAS_SARC = (observer->getName() == "sarcophagus");
|
||||
// Busquem en els dos sacs. El sarc i la door sempre viuen a
|
||||
// `map_entities_` per disseny; un actor (DynamicHazard) viu a `actors_`.
|
||||
if (auto it = std::ranges::find_if(map_entities_, [observer](const auto& up) { return up.get() == observer; }); it != map_entities_.end()) {
|
||||
map_entities_.erase(it);
|
||||
} else if (auto it_a = std::ranges::find_if(actors_, [observer](const auto& up) { return up.get() == observer; }); it_a != actors_.end()) {
|
||||
actors_.erase(it_a);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
// `onRemoved` és polimòrfic: cada subclasse decideix què cal
|
||||
// notificar quan se l'esborra. Avui només Sarcophagus l'override per
|
||||
// decrementar el seu comptador. La door es detecta per comparació de
|
||||
// punter (és única, no cal RTTI).
|
||||
observer->onRemoved();
|
||||
auto it = std::ranges::find_if(entities_, [observer](const auto& up) { return up.get() == observer; });
|
||||
if (it == entities_.end()) { return false; }
|
||||
entities_.erase(it);
|
||||
if (WAS_DOOR) { door_ = nullptr; }
|
||||
if (WAS_SARC && total_sarcs_ > 0) { --total_sarcs_; }
|
||||
// No tocar `opened_sarcs_` aquí: l'editor congela el joc i no pot
|
||||
// disparar-se cap esdeveniment derivat. Quan el GameScene faça el
|
||||
// reload posterior, els comptadors es re-deriven d'inici.
|
||||
@@ -125,8 +122,7 @@ auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcoph
|
||||
sarc->setEventBus(&bus_);
|
||||
sarc->setEntityManager(this);
|
||||
Sarcophagus* raw = sarc.get();
|
||||
// El sarc és Interactable, viu al sac `map_entities_`.
|
||||
map_entities_.push_back(std::move(sarc));
|
||||
entities_.push_back(std::move(sarc));
|
||||
++total_sarcs_;
|
||||
// cppcheck-suppress returnDanglingLifetime
|
||||
return raw;
|
||||
@@ -134,34 +130,33 @@ auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcoph
|
||||
|
||||
void EntityManager::update(float delta_time) {
|
||||
// ATENCIÓ: si en el futur una entitat publica al bus events que
|
||||
// resulten en `entity_manager_.add()` (push_back als sacs), açò
|
||||
// resulten en `entity_manager_.add()` (push_back a `entities_`), açò
|
||||
// invalidaria l'iterador de la ranged-for. Avui no passa: els spawns
|
||||
// runtime venen de fora del cicle d'`update` (p.ex. el polvet del
|
||||
// saltador es spawneja durant `Player::tick`, abans d'aquest mètode).
|
||||
for (auto& e : map_entities_) { e->update(delta_time); }
|
||||
for (auto& a : actors_) { a->update(delta_time); }
|
||||
for (auto& e : entities_) { e->update(delta_time); }
|
||||
// Purga d'entitats marcades mortes aquest frame (efectes acabats,
|
||||
// p.ex. polvet d'aterratge). Les persistents (sarcs, momies, etc.)
|
||||
// mai no es marquen.
|
||||
std::erase_if(map_entities_, [](const auto& e) { return e->isDead(); });
|
||||
std::erase_if(actors_, [](const auto& a) { return a->isDead(); });
|
||||
std::erase_if(entities_, [](const auto& e) { return e->isDead(); });
|
||||
}
|
||||
|
||||
void EntityManager::notifyPlayerTilePainted(int col, int row) {
|
||||
// Fan-out als dos sacs. Avui només Sarcophagus consumeix (viu a
|
||||
// map_entities_), però els actors també reben la notificació per
|
||||
// si en el futur volen reaccionar (cost: una crida virtual buida).
|
||||
for (auto& e : map_entities_) { e->notifyTilePainted(col, row); }
|
||||
for (auto& a : actors_) { a->notifyTilePainted(col, row); }
|
||||
void EntityManager::notifyPlayerTilePainted(int col, int row, int player_id) {
|
||||
// Fan-out polimòrfic a totes les entitats: cada una decideix què fer
|
||||
// (`Sarcophagus` actualitza el seu perímetre i recorda qui va tancar
|
||||
// el cercle, la resta queden en no-op). `player_id` ve del bus
|
||||
// (`ev.player_id`) — pot ser -1 al broadcast de càrrega inicial de
|
||||
// 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* {
|
||||
// Cerca lineal sobre `map_entities_`: els floor decals trigger són
|
||||
// exclusivament StaticHazards (Hole; en el futur pinchos plans,
|
||||
// plaques, charcos). Els actors no qualifiquen com a decals — saltem
|
||||
// directament aquest sac, sense `triggersOnOverlap`/`isFloorDecal` per
|
||||
// a cada un. La doble condició filtra dins de map_entities_.
|
||||
for (const auto& entity : map_entities_) {
|
||||
// Cerca lineal: només `Hole` (i futurs decals trigger) compleixen la
|
||||
// doble condició `triggersOnOverlap` + `isFloorDecal`. Costos típics
|
||||
// (poques desenes d'entitats) menyspreables, una crida per frame des
|
||||
// dels consumidors actius (Player a l'aterratge, Mòmia a la decisió
|
||||
// de direcció).
|
||||
for (const auto& entity : entities_) {
|
||||
if (!entity->triggersOnOverlap()) { continue; }
|
||||
if (!entity->isFloorDecal()) { continue; }
|
||||
const auto BOX = entity->getCollisionRect();
|
||||
@@ -171,28 +166,21 @@ auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Ent
|
||||
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*> {
|
||||
// Concatenació map + actors per retro-compat (editor, z-sort debug).
|
||||
// Els callers que ja saben quin sac volen poden cridar
|
||||
// `getMapEntities` o `getActors` i estalviar la construcció del temp.
|
||||
std::vector<Entity*> out;
|
||||
out.reserve(map_entities_.size() + actors_.size());
|
||||
std::ranges::transform(map_entities_, std::back_inserter(out), [](const auto& e) { return e.get(); });
|
||||
std::ranges::transform(actors_, std::back_inserter(out), [](const auto& a) -> Entity* { return a.get(); });
|
||||
return out;
|
||||
}
|
||||
|
||||
auto EntityManager::getMapEntities() const -> std::vector<Entity*> {
|
||||
std::vector<Entity*> out;
|
||||
out.reserve(map_entities_.size());
|
||||
std::ranges::transform(map_entities_, std::back_inserter(out), [](const auto& e) { return e.get(); });
|
||||
return out;
|
||||
}
|
||||
|
||||
auto EntityManager::getActors() const -> std::vector<DynamicHazard*> {
|
||||
std::vector<DynamicHazard*> out;
|
||||
out.reserve(actors_.size());
|
||||
std::ranges::transform(actors_, std::back_inserter(out), [](const auto& a) { return a.get(); });
|
||||
out.reserve(entities_.size());
|
||||
std::ranges::transform(entities_, std::back_inserter(out), [](const auto& e) { return e.get(); });
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,21 +9,22 @@
|
||||
|
||||
#include "external/fkyaml_node.hpp"
|
||||
#include "game/entities/event_bus.hpp"
|
||||
#include "game/entities/hazard.hpp"
|
||||
|
||||
class Entity;
|
||||
class Sarcophagus;
|
||||
class Door;
|
||||
class Player;
|
||||
class DynamicHazard;
|
||||
|
||||
// Contenidor i coordinador d'entitats d'un nivell. Responsabilitats:
|
||||
// 1. `add` registra una entitat, la connecta al bus i manté propietat.
|
||||
// 2. `update(dt)` propaga el tick a totes.
|
||||
// 3. Comptador de sarcòfags oberts — quan iguala el total registrat,
|
||||
// crida `door_->open()` automàticament.
|
||||
// 4. `notifyPlayerTilePainted(col, row)` — fan-out del hook per a tots
|
||||
// els sarcòfags (i qualsevol entitat futura que s'hi subscrigui via
|
||||
// `notifyTilePainted`).
|
||||
// 4. `notifyPlayerTilePainted(col, row, player_id)` — fan-out del hook
|
||||
// per a tots els sarcòfags (i qualsevol entitat futura que s'hi
|
||||
// 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
|
||||
// s'usa encara al GameScene (andamiatge pur).
|
||||
@@ -74,14 +75,12 @@ class EntityManager {
|
||||
static void renderEntity(const Entity& entity);
|
||||
|
||||
// Accés als punters observacionals a les entitats per a consumidors
|
||||
// externs que necessiten construir una llista ordenada (z-order).
|
||||
// `getEntities()` concatena els dos sacs interns (mapa + actors) per
|
||||
// a consumidors retro-compat (editor, z-sort debug). `getMapEntities`
|
||||
// i `getActors` exposen cada sac per separat — útil per a iteracions
|
||||
// que ja saben quin tipus volen i estalvien filtres O(n).
|
||||
// externs que necessiten iterar (z-order, aplicar dany, etc.). El
|
||||
// polimorfisme dins de cada entitat fa la discriminació: l'iterador
|
||||
// recorre tot i les entitats que no hagin de respondre tornen no-op
|
||||
// (p.ex. Torch::onWhipHit retorna false sense efecte). Sense sacs
|
||||
// separats — el `if (entity_is_X)` viu dins de l'entitat, no del caller.
|
||||
[[nodiscard]] auto getEntities() const -> std::vector<Entity*>;
|
||||
[[nodiscard]] auto getMapEntities() const -> std::vector<Entity*>;
|
||||
[[nodiscard]] auto getActors() const -> std::vector<DynamicHazard*>;
|
||||
|
||||
// Comprova contacte del(s) jugador(s) amb entitats `collides()=true`.
|
||||
// Si la caixa base del jugador solapa amb la caixa de col·lisió de
|
||||
@@ -105,7 +104,7 @@ class EntityManager {
|
||||
// notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte
|
||||
// (veure Entity::notifyTilePainted). Invocat com a reacció a
|
||||
// `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:
|
||||
// 1. Llegeix el bloc `entities:` via EntityLoader.
|
||||
@@ -136,6 +135,15 @@ class EntityManager {
|
||||
// `respawnPlayersInstant` (salt debug).
|
||||
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
|
||||
// `spawnPendingActors` però per a un sol). Pensat per a futurs
|
||||
// generadors (StaticHazards que produeixen DynamicHazards en runtime,
|
||||
@@ -160,7 +168,7 @@ class EntityManager {
|
||||
void setExpectedPlayerCount(int n);
|
||||
|
||||
[[nodiscard]] auto getBus() -> EventBus& { return bus_; }
|
||||
[[nodiscard]] auto entityCount() const -> std::size_t { return map_entities_.size() + actors_.size(); }
|
||||
[[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); }
|
||||
[[nodiscard]] auto pendingActorCount() const -> std::size_t { return pending_actors_.size(); }
|
||||
[[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; }
|
||||
[[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; }
|
||||
@@ -173,19 +181,29 @@ class EntityManager {
|
||||
// sarcs. No-op si la porta ja està oberta o si el nivell no en té.
|
||||
void forceOpenDoor();
|
||||
|
||||
// Hook que `Sarcophagus::onRemoved` crida per decrementar el
|
||||
// comptador `total_sarcs_`. Públic perquè el sarcòfag és la única
|
||||
// entitat que necessita aquest manteniment, però l'API es manté
|
||||
// genèrica: si en el futur Door volgués notificar quelcom semblant,
|
||||
// afegim el seu propi callback.
|
||||
void onSarcophagusRemoved() {
|
||||
if (total_sarcs_ > 0) { --total_sarcs_; }
|
||||
}
|
||||
|
||||
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_;
|
||||
// Dos sacs lògics:
|
||||
// `map_entities_` — decoració, static_hazards (Hole, FloorTrap,
|
||||
// WallTrap) i interactables (Sarcophagus, Door).
|
||||
// Cargades al loadFromLevel i persistents durant
|
||||
// tota la sala.
|
||||
// `actors_` — DynamicHazards (Mummy, RollingRock, futurs
|
||||
// enemics). Separats per eliminar dynamic_casts
|
||||
// i preparar el spawn diferit (commit posterior).
|
||||
// `add()` enruta automàticament segons sigui o no DynamicHazard.
|
||||
std::vector<std::unique_ptr<Entity>> map_entities_;
|
||||
std::vector<std::unique_ptr<DynamicHazard>> actors_;
|
||||
// Un sol contenidor lògic per a totes les entitats instanciades. La
|
||||
// discriminació de tipus la fan els virtuals (`onPlayerContact`,
|
||||
// `onWhipHit`, `shouldReceiveContact`, `notifyTilePainted`...) — el
|
||||
// manager itera i cada entitat decideix què fa. Hi conviuen
|
||||
// decoracions, static_hazards, interactables i dynamic_hazards
|
||||
// (actors).
|
||||
std::vector<std::unique_ptr<Entity>> entities_;
|
||||
// Cua d'actors declarats al YAML del mapa però encara no instanciats:
|
||||
// `loadFromLevel` hi empeny tots els DynamicHazards. `spawnPendingActors`
|
||||
// o `spawnActor` consumeixen aquesta cua (o creen un actor on-demand)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// consumidors que només volen la base del manager (p.ex. el smoke test
|
||||
// stand-alone) enllacen `entity_manager.cpp` sense arrossegar aquest fitxer.
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -57,11 +59,10 @@ void EntityManager::renderEntity(const Entity& e) {
|
||||
}
|
||||
|
||||
void EntityManager::renderAll() {
|
||||
// Sense ordenació per z: l'ordre és el d'inserció dins de cada sac.
|
||||
// El render del joc real (GameScene) construeix el seu propi vector
|
||||
// Sense ordenació per z: l'ordre és el d'inserció al vector. El
|
||||
// render del joc real (GameScene) construeix el seu propi vector
|
||||
// ordenat per `feet_y`; aquest helper queda per al editor i tests.
|
||||
for (const auto& e : map_entities_) { renderEntity(*e); }
|
||||
for (const auto& a : actors_) { renderEntity(*a); }
|
||||
for (const auto& e : entities_) { renderEntity(*e); }
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -100,22 +101,15 @@ void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
|
||||
}
|
||||
};
|
||||
|
||||
// Sac de mapa: filtre per col·lisió / trigger. Tanquem el gap latent
|
||||
// de FloorTrap/WallTrap (que tenien `collides_=false` i mai no es
|
||||
// disparaven) i obrim la porta a hazards no-físics com `Hole`.
|
||||
for (const auto& entity : map_entities_) {
|
||||
if (!entity->collides() && !entity->triggersOnOverlap()) { continue; }
|
||||
// Filtre polimòrfic unificat: cada entitat decideix si ha de rebre
|
||||
// contacte (`shouldReceiveContact`). Per defecte equival a `collides()
|
||||
// || triggersOnOverlap()`; `DynamicHazard` afegeix el filtre de SPAWN
|
||||
// (mentre està emergint torna false). El callsite no fa dynamic_cast
|
||||
// ni distingeix categories.
|
||||
for (const auto& entity : entities_) {
|
||||
if (!entity->shouldReceiveContact()) { continue; }
|
||||
apply_contacts(entity.get());
|
||||
}
|
||||
// Sac d'actors (DynamicHazards): tots fan dany quan són actius.
|
||||
// Saltem els que estan en SPAWN: visibles però sense interacció
|
||||
// durant la fase inicial de ROOM_INTRO i durant futurs spawns en
|
||||
// runtime. Sense dynamic_cast: el tipus ja és DynamicHazard.
|
||||
for (const auto& actor : actors_) {
|
||||
if (actor->isSpawning()) { continue; }
|
||||
if (!actor->collides() && !actor->triggersOnOverlap()) { continue; }
|
||||
apply_contacts(actor.get());
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -163,6 +157,147 @@ namespace {
|
||||
return c["height"].get_value<int>();
|
||||
}
|
||||
|
||||
// Bloc opcional `light: { radius: N, flicker: bool, color: [r,g,b], offset: [x,y] }`
|
||||
// de la plantilla. Si manca, el radi és 0 i l'entitat no entra a la
|
||||
// pasada d'il·luminació. `flicker` activa la modulació "tipus foc"
|
||||
// del radi (només antorxes l'haurien d'usar). `color` només té
|
||||
// efecte en mode additive — defecte blanc (255,255,255). `offset`
|
||||
// desplaça el centre del cercle de llum respecte el centre del
|
||||
// sprite (default (0, 0); Y creix cap avall). La forma del gradient
|
||||
// (3 anells) i els paràmetres del flicker viuen a
|
||||
// data/config/lighting.yaml — no cal declarar-los per entitat.
|
||||
struct LightTemplate {
|
||||
int radius{0};
|
||||
bool flicker{false};
|
||||
std::uint8_t color_r{255};
|
||||
std::uint8_t color_g{255};
|
||||
std::uint8_t color_b{255};
|
||||
int offset_x{0};
|
||||
int offset_y{0};
|
||||
// Inicialitzat al global a `readLight`; overrides parcials del
|
||||
// tipus/sala/spawn modifiquen camps concrets sense tocar la resta.
|
||||
Lighting::FlickerConfig flicker_params{};
|
||||
};
|
||||
|
||||
// Helper compartit entre les tres capes (tipus, sala via Map, spawn)
|
||||
// per llegir `color: [r, g, b]` amb clamp defensiu a 0..255.
|
||||
auto readColorTriplet(const fkyaml::node& seq, std::uint8_t& r, std::uint8_t& g, std::uint8_t& b) -> bool {
|
||||
if (!seq.is_sequence() || seq.size() != 3) { return false; }
|
||||
r = static_cast<std::uint8_t>(std::clamp(seq[0].get_value<int>(), 0, 255));
|
||||
g = static_cast<std::uint8_t>(std::clamp(seq[1].get_value<int>(), 0, 255));
|
||||
b = static_cast<std::uint8_t>(std::clamp(seq[2].get_value<int>(), 0, 255));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper paral·lel a `readColorTriplet` per a `offset: [x, y]` —
|
||||
// píxels enters, sense clamp (offsets negatius són legítims; Y
|
||||
// creix cap avall).
|
||||
auto readOffsetPair(const fkyaml::node& seq, int& x, int& y) -> bool {
|
||||
if (!seq.is_sequence() || seq.size() != 2) { return false; }
|
||||
x = seq[0].get_value<int>();
|
||||
y = seq[1].get_value<int>();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Aplica els camps presents al bloc `flicker_params: { ... }` sobre
|
||||
// una `FlickerConfig` ja inicialitzada (típicament amb el global o
|
||||
// amb el resultat d'una capa anterior). Camps absents es respecten
|
||||
// — per a poder sobreescriure'n només un.
|
||||
void applyFlickerOverrides(const fkyaml::node& fp, Lighting::FlickerConfig& out) {
|
||||
if (fp.contains("wave_amplitude_ratio")) { out.wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value<float>(); }
|
||||
if (fp.contains("wave_frequency_hz")) { out.wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
|
||||
if (fp.contains("noise_amplitude_ratio")) { out.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
|
||||
if (fp.contains("noise_frequency_hz")) { out.noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
|
||||
}
|
||||
|
||||
auto readLight(const fkyaml::node& tmpl) -> LightTemplate {
|
||||
LightTemplate out;
|
||||
// Punt de partida del flicker: el global de lighting.yaml.
|
||||
out.flicker_params = Lighting::flickerConfig();
|
||||
if (!tmpl.contains("light")) { return out; }
|
||||
const auto& l = tmpl["light"];
|
||||
if (l.contains("radius")) { out.radius = l["radius"].get_value<int>(); }
|
||||
if (l.contains("flicker")) { out.flicker = l["flicker"].get_value<bool>(); }
|
||||
if (l.contains("color")) { readColorTriplet(l["color"], out.color_r, out.color_g, out.color_b); }
|
||||
if (l.contains("offset")) { readOffsetPair(l["offset"], out.offset_x, out.offset_y); }
|
||||
if (l.contains("flicker_params")) { applyFlickerOverrides(l["flicker_params"], out.flicker_params); }
|
||||
return out;
|
||||
}
|
||||
|
||||
// Combina els defaults del tipus amb l'override de sala (si n'hi ha).
|
||||
// Forma 1: la sala no diu res del tipus → es retornen els defaults
|
||||
// del tipus tal qual (cas habitual / retrocompat).
|
||||
// Forma 2: la sala declara `<tipus>: false` → radi 0 (entitat sense
|
||||
// llum encara que el seu YAML en declari).
|
||||
// Forma 3: la sala declara `<tipus>: true` o `<tipus>: { ... }` → els
|
||||
// camps presents a l'override sobreescriuen el default; els absents
|
||||
// (`std::nullopt`) cauen al default del tipus.
|
||||
auto resolveLightWithRoom(const std::string& type, const LightTemplate& type_default) -> LightTemplate {
|
||||
const auto* room = Map::getRoomEmitterOverride(type);
|
||||
if (room == nullptr) { return type_default; }
|
||||
if (!room->enabled) {
|
||||
LightTemplate empty;
|
||||
empty.flicker_params = Lighting::flickerConfig();
|
||||
return empty;
|
||||
}
|
||||
LightTemplate out = type_default;
|
||||
if (room->radius.has_value()) { out.radius = *room->radius; }
|
||||
if (room->flicker.has_value()) { out.flicker = *room->flicker; }
|
||||
if (room->color.has_value()) {
|
||||
out.color_r = (*room->color)[0];
|
||||
out.color_g = (*room->color)[1];
|
||||
out.color_b = (*room->color)[2];
|
||||
}
|
||||
if (room->offset.has_value()) {
|
||||
out.offset_x = (*room->offset)[0];
|
||||
out.offset_y = (*room->offset)[1];
|
||||
}
|
||||
if (room->flicker_wave_amplitude_ratio.has_value()) { out.flicker_params.wave_amplitude_ratio = *room->flicker_wave_amplitude_ratio; }
|
||||
if (room->flicker_wave_frequency_hz.has_value()) { out.flicker_params.wave_frequency_hz = *room->flicker_wave_frequency_hz; }
|
||||
if (room->flicker_noise_amplitude_ratio.has_value()) { out.flicker_params.noise_amplitude_ratio = *room->flicker_noise_amplitude_ratio; }
|
||||
if (room->flicker_noise_frequency_hz.has_value()) { out.flicker_params.noise_frequency_hz = *room->flicker_noise_frequency_hz; }
|
||||
return out;
|
||||
}
|
||||
|
||||
// Aplica l'override per-spawn (si existeix) per damunt del que ja
|
||||
// viu a l'entitat (defaults del tipus + override de sala). Tres formes
|
||||
// a l'entry del YAML de nivell:
|
||||
// `light: false` → apaga aquesta instància
|
||||
// `light: true` → informatiu (cau a capes inferiors)
|
||||
// `light: { radius, flicker, color: [r,g,b], offset: [x,y] }` → camps presents sobreescriuen;
|
||||
// els absents respecten el que
|
||||
// ja duia l'entitat
|
||||
void applySpawnLightOverride(Entity& entity, const fkyaml::node& spawn) {
|
||||
if (!spawn.contains("light")) { return; }
|
||||
const auto& l = spawn["light"];
|
||||
if (l.is_boolean()) {
|
||||
if (!l.get_value<bool>()) {
|
||||
entity.setLightRadius(0);
|
||||
entity.setLightFlicker(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!l.is_mapping()) { return; }
|
||||
if (l.contains("radius")) { entity.setLightRadius(l["radius"].get_value<int>()); }
|
||||
if (l.contains("flicker")) { entity.setLightFlicker(l["flicker"].get_value<bool>()); }
|
||||
if (l.contains("color")) {
|
||||
std::uint8_t r{};
|
||||
std::uint8_t g{};
|
||||
std::uint8_t b{};
|
||||
if (readColorTriplet(l["color"], r, g, b)) { entity.setLightColor(r, g, b); }
|
||||
}
|
||||
if (l.contains("offset")) {
|
||||
int x{};
|
||||
int y{};
|
||||
if (readOffsetPair(l["offset"], x, y)) { entity.setLightOffset(x, y); }
|
||||
}
|
||||
if (l.contains("flicker_params")) {
|
||||
Lighting::FlickerConfig fp = entity.getFlickerParams();
|
||||
applyFlickerOverrides(l["flicker_params"], fp);
|
||||
entity.setFlickerParams(fp);
|
||||
}
|
||||
}
|
||||
|
||||
// Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }`
|
||||
// de la plantilla d'entitat.
|
||||
// - Sense `base_size` ni `offset`: la caja queda alineada a la vora
|
||||
@@ -285,6 +420,16 @@ namespace {
|
||||
entity->setCollisionBox(*BOX);
|
||||
}
|
||||
entity->setHeight(readHeight(tmpl));
|
||||
// Defaults del tipus → resolució amb override de sala. El layer
|
||||
// del spawn (per-instància) viu fora de `buildEntity`, perquè
|
||||
// aquí encara no tenim el node del spawn — el sobreposem a
|
||||
// `addSpawn` just després.
|
||||
const auto LIGHT = resolveLightWithRoom(type, readLight(tmpl));
|
||||
entity->setLightRadius(LIGHT.radius);
|
||||
entity->setLightFlicker(LIGHT.flicker);
|
||||
entity->setLightColor(LIGHT.color_r, LIGHT.color_g, LIGHT.color_b);
|
||||
entity->setLightOffset(LIGHT.offset_x, LIGHT.offset_y);
|
||||
entity->setFlickerParams(LIGHT.flicker_params);
|
||||
anchor_out = readAnchor(tmpl);
|
||||
return entity;
|
||||
}
|
||||
@@ -403,6 +548,17 @@ namespace {
|
||||
}
|
||||
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
|
||||
|
||||
// Variant del atlas: el spawn pot fixar un sub-sprite estàtic via
|
||||
// `frame: N`. El render del manager ja sap clipar (col, row) a
|
||||
// partir d'un índex lineal i la mida del frame; ací només cal
|
||||
// muntar un `AnimationComponent` d'un sol frame i imposar-lo a la
|
||||
// entitat. Cap entitat existent al joc emet aquest camp; només
|
||||
// els muros decoratius amb variants (stone, column, ...) l'usen.
|
||||
if (spawn.contains("frame")) {
|
||||
const int FRAME = spawn["frame"].get_value<int>();
|
||||
entity->setAnimation(AnimationComponent::uniform({FRAME}, 1000.0F, false));
|
||||
}
|
||||
|
||||
manager.add(std::move(entity));
|
||||
return true;
|
||||
}
|
||||
@@ -413,8 +569,7 @@ void EntityManager::reload(const std::string& level_yaml_path) {
|
||||
// subscripcions implícites desapareixen amb la seua vida (cap d'elles
|
||||
// se subscriu directament al bus — només publiquen). El bus i les
|
||||
// subscripcions externes (PaintLayer, HUD, GameScene) no es toquen.
|
||||
map_entities_.clear();
|
||||
actors_.clear();
|
||||
entities_.clear();
|
||||
pending_actors_.clear();
|
||||
door_ = nullptr;
|
||||
total_sarcs_ = 0;
|
||||
@@ -437,6 +592,10 @@ auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* {
|
||||
std::string anchor;
|
||||
auto entity = buildEntity(TYPE, sprite, anchor);
|
||||
if (entity == nullptr) { return nullptr; }
|
||||
// Tercera capa de resolució de llum: override per-instància declarat
|
||||
// al spawn del YAML de la sala. Capes prèvies (tipus, sala) ja viuen
|
||||
// dins de l'entitat. Aplica abans del std::move per no perdre l'accés.
|
||||
applySpawnLightOverride(*entity, spawn);
|
||||
Entity* observer = entity.get();
|
||||
|
||||
bool ok = false;
|
||||
@@ -468,53 +627,71 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
|
||||
if (addSpawn(spawn) != nullptr) { ++loaded; }
|
||||
}
|
||||
|
||||
// Broadcast inicial: marca com a "pintats" tots els tiles ocupats per
|
||||
// decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...).
|
||||
// Imprescindible perquè el jugador no pot trepitjar aquests tiles
|
||||
// (cauria al forat) i els sarcòfags vecins no podrien completar el
|
||||
// perímetre — la sala seria incompletable. Usa el canal semàntic
|
||||
// `tile_painted` (lògica de joc), no `on_tile_entered` (presència
|
||||
// física del jugador). Ho fem 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).
|
||||
// Broadcast inicial: marca com a "pintats" tots els tiles que el
|
||||
// jugador no pot trepitjar mai, de manera que el perímetre dels
|
||||
// sarcòfags vecins ja els consideri visitats i la sala sigui
|
||||
// completable. Inclou dues famílies:
|
||||
// - Decals de sòl (Hole, i futurs pinchos plans, plaques, charcos):
|
||||
// trigger-on-overlap que mataria/atraparia al jugador en trepitjar.
|
||||
// - Entitats sòlides genèriques (debris, columnes...): obstacles
|
||||
// físics que `Map::collides` bloqueja, així `isPaintable` retorna
|
||||
// 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;
|
||||
// Els decals de sòl (Hole, futurs pinchos plans...) són StaticHazards
|
||||
// i viuen exclusivament a `map_entities_`. Saltem `actors_` per estalviar.
|
||||
for (const auto& entity : map_entities_) {
|
||||
if (!entity->isFloorDecal()) { continue; }
|
||||
for (const auto& entity : entities_) {
|
||||
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 [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{
|
||||
.name = "tile_painted",
|
||||
.payload = TilePos{.col = COL, .row = ROW},
|
||||
.payload = TilePos{.col = COL0 + dc, .row = ROW0 + dr},
|
||||
.player_id = -1,
|
||||
});
|
||||
++decal_broadcast;
|
||||
}
|
||||
}
|
||||
}
|
||||
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", "{} map entities + {} 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);
|
||||
}
|
||||
|
||||
void EntityManager::spawnPendingActors() {
|
||||
// Itera els pending i els instancia via `addSpawn`. El dispatch d'`add()`
|
||||
// els enruta a `actors_`. El `triggerSpawnExit()` posterior arma el
|
||||
// comptador de SPAWN — la mòmia neix ja en EMERGING, sense esperes.
|
||||
// els enruta a `actors_`. `onJustSpawned()` és polimòrfic: la base
|
||||
// no fa res, DynamicHazard l'override per disparar `triggerSpawnExit()`
|
||||
// i entrar a EMERGING. Sense dynamic_cast: la decisió viu dins de
|
||||
// cada entitat.
|
||||
for (const auto& pa : pending_actors_) {
|
||||
Entity* observer = addSpawn(pa.spawn_node);
|
||||
if (observer == nullptr) { continue; }
|
||||
// Per categoria YAML (`type: dynamic_hazard`) sabem que és
|
||||
// DynamicHazard — el dynamic_cast aquí val zero i evita
|
||||
// assumptions implícites sobre la jerarquia.
|
||||
if (auto* dh = dynamic_cast<DynamicHazard*>(observer); dh != nullptr) {
|
||||
dh->triggerSpawnExit();
|
||||
}
|
||||
if (observer != nullptr) { observer->onJustSpawned(); }
|
||||
}
|
||||
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* {
|
||||
// Construïm un node de spawn sintètic amb el mínim que `placeGeneric`
|
||||
// necessita: `type` i `position: [col, row]`. Si el caller vol
|
||||
@@ -530,7 +707,9 @@ auto EntityManager::spawnActor(const std::string& type, int col, int row) -> Dyn
|
||||
}
|
||||
Entity* observer = addSpawn(spawn);
|
||||
if (observer == nullptr) { return nullptr; }
|
||||
auto* dh = dynamic_cast<DynamicHazard*>(observer);
|
||||
if (dh != nullptr) { dh->triggerSpawnExit(); }
|
||||
return dh;
|
||||
// `onJustSpawned` polimòrfic: per a DynamicHazards arma EMERGING; per
|
||||
// a un type erroni (no dynamic_hazard) queda no-op i retornem nullptr
|
||||
// mitjançant el cast final, deixant la signatura `DynamicHazard*` neta.
|
||||
observer->onJustSpawned();
|
||||
return dynamic_cast<DynamicHazard*>(observer);
|
||||
}
|
||||
@@ -60,6 +60,9 @@ namespace EntityWriter {
|
||||
} else if (s.col.has_value() && s.row.has_value()) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -35,12 +35,16 @@ namespace EntityWriter {
|
||||
|
||||
// Cada spawn duplica l'estructura mínima del que el lector espera:
|
||||
// type sempre, i un dels (position, sarc_position) o cap (cas door).
|
||||
// `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 {
|
||||
std::string type;
|
||||
std::optional<int> col;
|
||||
std::optional<int> row;
|
||||
std::optional<int> sarc_col;
|
||||
std::optional<int> sarc_row;
|
||||
std::optional<int> frame;
|
||||
};
|
||||
|
||||
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
|
||||
// de l'`EntityManager`); les entitats que el necessiten en reben un punter
|
||||
// via `Entity::setEventBus`. Aquest sistema cobreix les necessitats
|
||||
// actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered)
|
||||
// sense portar-hi senyalització més pesada.
|
||||
// actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered,
|
||||
// player_stats_changed) sense portar-hi senyalització més pesada.
|
||||
//
|
||||
// Decisió: `std::variant` per al payload — evita any/void* i facilita
|
||||
// consumir events tipats amb `std::get_if`. Si apareixen més tipus de
|
||||
// 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
|
||||
// metadada extra). No és un `pair<int,int>` per evitar alias ambigus al
|
||||
@@ -74,9 +83,20 @@ struct WhipSpawnPayload {
|
||||
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 {
|
||||
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;
|
||||
// Jugador que ha originat (o provocat) l'event. -1 = sense jugador
|
||||
// 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) {
|
||||
publish(Event{.name = name, .payload = whip});
|
||||
}
|
||||
void publish(const std::string& name, const PlayerStatsPayload& stats) {
|
||||
publish(Event{.name = name, .payload = stats});
|
||||
}
|
||||
|
||||
private:
|
||||
struct Subscription {
|
||||
|
||||
@@ -96,9 +96,28 @@ class DynamicHazard : public Hazard {
|
||||
// Arma el comptador del SPAWN. A partir d'aquí, cada `update(dt)`
|
||||
// descompta `dt` fins arribar a 0 i transitar a ACTIVE.
|
||||
void triggerSpawnExit() { spawn_exit_armed_ = true; }
|
||||
// Transició directa a ACTIVE, sense passar pel countdown. Pensat
|
||||
// per a subclasses amb SPAWN dirigit per animació: gestionen el
|
||||
// seu propi `update()` mentre `isSpawning()` i criden ací quan la
|
||||
// seua animació d'aparició ha completat.
|
||||
void endSpawn() { spawn_state_ = SpawnState::ACTIVE; }
|
||||
[[nodiscard]] auto isSpawning() const -> bool { return spawn_state_ == SpawnState::SPAWNING; }
|
||||
[[nodiscard]] auto getSpawnDurationS() const -> float { return spawn_duration_s_; }
|
||||
|
||||
// Just després d'instanciar-se al món (via spawnPendingActors /
|
||||
// spawnActor), l'actor entra automàticament en la fase EMERGING:
|
||||
// arma el comptador i comença l'animació de sortida. Substitueix el
|
||||
// dynamic_cast que el manager feia abans per cridar `triggerSpawnExit`.
|
||||
void onJustSpawned() override { triggerSpawnExit(); }
|
||||
|
||||
// Mentre està en SPAWN (HIDDEN/EMERGING), l'actor NO rep contacte
|
||||
// ni amb el jugador ni amb attacks: visible però "no juga". Quan
|
||||
// passa a ACTIVE, recupera el comportament estàndard d'Entity.
|
||||
[[nodiscard]] auto shouldReceiveContact() const -> bool override {
|
||||
if (isSpawning()) { return false; }
|
||||
return Entity::shouldReceiveContact();
|
||||
}
|
||||
|
||||
// Política de moviment per frame. La base desplaça el pixel_rect_
|
||||
// proporcionalment a velocitat i direcció; les subclasses amb IA la
|
||||
// sobreescriuen per triar nova direcció a les interseccions o rebotar.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "core/input/gamepad_manager.hpp"
|
||||
#include "core/input/input_state.hpp"
|
||||
#include "core/rendering/canvas.hpp"
|
||||
#include "core/rendering/effects/shadow.hpp"
|
||||
#include "core/rendering/screen.hpp"
|
||||
#include "core/rendering/surface.hpp"
|
||||
#include "core/resources/palette_loader.hpp"
|
||||
@@ -140,6 +141,55 @@ void Player::loadFrom(const std::string& yaml_path) {
|
||||
// `duration_ms` és 0 (o el bloc manca) la mecànica queda
|
||||
// desactivada i els hazards toquen al primer frame, com abans.
|
||||
readImmunityBlock(yaml);
|
||||
|
||||
// Bloc opcional `light:` — radi i flicker. Extret a un mètode per
|
||||
// mantenir `loadFrom` sota el llindar de complexitat de clang-tidy.
|
||||
readLightBlock(yaml);
|
||||
}
|
||||
|
||||
void Player::readLightBlock(const fkyaml::node& yaml) {
|
||||
light_radius = 0;
|
||||
light_flicker = false;
|
||||
light_color_r = 255;
|
||||
light_color_g = 255;
|
||||
light_color_b = 255;
|
||||
light_offset_x = 0;
|
||||
light_offset_y = 0;
|
||||
// Punt de partida del flicker: el global de lighting.yaml. El bloc
|
||||
// opcional `flicker_params:` del personatge sobreescriu camps concrets.
|
||||
flicker_params = Lighting::flickerConfig();
|
||||
if (!yaml.contains("light")) { return; }
|
||||
const auto& l = yaml["light"];
|
||||
if (l.contains("radius")) { light_radius = l["radius"].get_value<int>(); }
|
||||
if (l.contains("flicker")) { light_flicker = l["flicker"].get_value<bool>(); }
|
||||
// `color: [r, g, b]` opcional, bytes 0..255. Si manca, blanc (sense
|
||||
// tint) — comportament idèntic al mode subtractive de hui.
|
||||
if (l.contains("color")) {
|
||||
const auto& c = l["color"];
|
||||
if (c.is_sequence() && c.size() == 3) {
|
||||
light_color_r = static_cast<std::uint8_t>(std::clamp(c[0].get_value<int>(), 0, 255));
|
||||
light_color_g = static_cast<std::uint8_t>(std::clamp(c[1].get_value<int>(), 0, 255));
|
||||
light_color_b = static_cast<std::uint8_t>(std::clamp(c[2].get_value<int>(), 0, 255));
|
||||
}
|
||||
}
|
||||
// `offset: [x, y]` opcional, píxels enters. Default (0, 0) → centre
|
||||
// del sprite. Y creix cap avall, així que `y: -3` puja la llum 3 px.
|
||||
if (l.contains("offset")) {
|
||||
const auto& o = l["offset"];
|
||||
if (o.is_sequence() && o.size() == 2) {
|
||||
light_offset_x = o[0].get_value<int>();
|
||||
light_offset_y = o[1].get_value<int>();
|
||||
}
|
||||
}
|
||||
// `flicker_params: { ... }` opcional. Qualsevol dels 4 camps absent
|
||||
// conserva el valor heretat del global (vegeu inicialització a sobre).
|
||||
if (l.contains("flicker_params")) {
|
||||
const auto& fp = l["flicker_params"];
|
||||
if (fp.contains("wave_amplitude_ratio")) { flicker_params.wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value<float>(); }
|
||||
if (fp.contains("wave_frequency_hz")) { flicker_params.wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
|
||||
if (fp.contains("noise_amplitude_ratio")) { flicker_params.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
|
||||
if (fp.contains("noise_frequency_hz")) { flicker_params.noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
|
||||
}
|
||||
}
|
||||
|
||||
void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) {
|
||||
@@ -179,7 +229,6 @@ void Player::readJumpBlock(const fkyaml::node& yaml) {
|
||||
if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value<float>(); }
|
||||
if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
|
||||
if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
|
||||
if (j.contains("shadow_y_offset")) { jump_config.shadow_y_offset = j["shadow_y_offset"].get_value<int>(); }
|
||||
// Sons opcionals: el nom del fitxer (relatiu a Defaults::Paths::AUDIO_SOUNDS_DIR)
|
||||
// val tant de clau al Resource::Cache com de basename per a la càrrega.
|
||||
// Si el camp manca, la clau queda buida i `playSound` no es crida.
|
||||
@@ -1272,6 +1321,37 @@ void Player::applyDyingMovement(float delta_time) {
|
||||
|
||||
void Player::resetForJoin() {
|
||||
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() {
|
||||
@@ -1283,6 +1363,12 @@ void Player::respawnFromDying() {
|
||||
--lives_remaining_;
|
||||
death_animation_complete_ = false;
|
||||
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) {
|
||||
disable();
|
||||
if (bus_ != nullptr) {
|
||||
@@ -1425,6 +1511,17 @@ void Player::render() {
|
||||
if (CLIP_HOLE) { Rendering::popClip(); }
|
||||
}
|
||||
|
||||
auto Player::getLightCenter() const -> SDL_FPoint {
|
||||
// Sprite top-left = (pos_.x, pos_.y). El render desplaça verticalment
|
||||
// `jump_z_` píxels durant el salt; aplicar el mateix offset aquí fa
|
||||
// que la llum "salti" amb el jugador en lloc de quedar-se al sòl.
|
||||
// Sumem també l'offset estàtic configurat al YAML del personatge.
|
||||
return {
|
||||
.x = getPosX() + (static_cast<float>(sprite_w) * 0.5F) + static_cast<float>(light_offset_x),
|
||||
.y = getPosY() - jump_z_ + (static_cast<float>(sprite_h) * 0.5F) + static_cast<float>(light_offset_y),
|
||||
};
|
||||
}
|
||||
|
||||
void Player::renderShadow() const {
|
||||
// Sense salt actiu, no hi ha sombra: això simplifica els callsites,
|
||||
// que poden cridar `renderShadow` per a tots els jugadors sense
|
||||
@@ -1435,36 +1532,17 @@ void Player::renderShadow() const {
|
||||
// que `enterDying` netegi el flag.
|
||||
if (falling_through_hole_) { return; }
|
||||
|
||||
// Sombra centrada al sprite, recolzada a la línia dels peus reals
|
||||
// (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura
|
||||
// del salt — al pic queda més estreta, donant la sensació de
|
||||
// "elevació" sense necessitat de coordenades 3D reals. Si el
|
||||
// jump_config té un divisor zero (cas degenerat), només dibuixem
|
||||
// la sombra a mida plena per evitar nan.
|
||||
// `height_factor`: 0 al terra → fotograma més gran, 1 al pic → més
|
||||
// petit. Si el `jump_config` té un divisor zero (cas degenerat) caem
|
||||
// a 0 per no propagar nan al renderer.
|
||||
const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F)
|
||||
? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2)
|
||||
: 0.0F;
|
||||
const float NORM_Z = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F;
|
||||
constexpr float MAX_SHRINK = 0.4F; // Fins a un 40% més estreta al pic.
|
||||
const float SHRINK = 1.0F - (NORM_Z * MAX_SHRINK);
|
||||
const float HEIGHT_FACTOR = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F;
|
||||
|
||||
constexpr int SHADOW_H = 3;
|
||||
const auto SHADOW_W = static_cast<int>(static_cast<float>(base_size) * SHRINK);
|
||||
const float CX = getPosX() + (static_cast<float>(sprite_w) * 0.5F);
|
||||
const float GROUND_Y = getGroundFeetY();
|
||||
const auto SX = static_cast<int>(CX - (static_cast<float>(SHADOW_W) * 0.5F));
|
||||
// Pugem la sombra `shadow_y_offset` px per compensar la franja
|
||||
// transparent que el sprite del jugador té sota els peus reals
|
||||
// (perquè la silueta camine pel mig del passadís en falsa
|
||||
// perspectiva). Sense aquest ajust, la sombra apareix per sota
|
||||
// del passadís.
|
||||
const auto SY = static_cast<int>(GROUND_Y) - SHADOW_H - jump_config.shadow_y_offset;
|
||||
|
||||
// ARGB negre amb alpha ~40%. `blendRect` mescla sobre el canvas; així
|
||||
// la sombra fosqueja el sòl (pintat o no) sense tapar-lo del tot.
|
||||
constexpr std::uint32_t SHADOW_ARGB = 0x00000000U;
|
||||
constexpr std::uint8_t SHADOW_ALPHA = 100;
|
||||
Screen::get().blendRect(SX, SY, SHADOW_W, SHADOW_H, SHADOW_ARGB, SHADOW_ALPHA);
|
||||
const auto CENTER_X = static_cast<int>(getPosX() + (static_cast<float>(sprite_w) * 0.5F));
|
||||
const auto FEET_Y = static_cast<int>(getGroundFeetY());
|
||||
Effects::Shadow::render(CENTER_X, FEET_Y, HEIGHT_FACTOR);
|
||||
}
|
||||
|
||||
// Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/rendering/lighting.hpp"
|
||||
#include "core/rendering/sprite/animated_sprite.hpp"
|
||||
#include "core/rendering/surface.hpp"
|
||||
#include "external/fkyaml_node.hpp"
|
||||
@@ -124,6 +125,27 @@ class Player : public AnimatedSprite {
|
||||
// creua mentre `jump_z_` és superior a aquest valor, no col·lisionem.
|
||||
inline static int body_height = 0;
|
||||
|
||||
// Radi de la llum emesa pel jugador (en píxels del canvas), llegit
|
||||
// del bloc opcional `light: { radius: N, flicker: bool, color: [r,g,b], offset: [x,y] }`
|
||||
// del YAML. 0 = no emet. Es declara estàtic com la resta de configs
|
||||
// del jugador (immunity, jump, ...): tots els jugadors del mateix YAML
|
||||
// comparteixen el valor. `light_flicker` activa la modulació "tipus
|
||||
// foc" del radi; `light_color_*` són el tint quan la sala usa mode
|
||||
// additive (en subtractive s'ignora); `light_offset_*` desplaça el
|
||||
// centre del cercle respecte el centre del sprite (Y creix avall:
|
||||
// `y: -3` puja la llum 3 px).
|
||||
inline static int light_radius = 0;
|
||||
inline static bool light_flicker = false;
|
||||
inline static std::uint8_t light_color_r = 255;
|
||||
inline static std::uint8_t light_color_g = 255;
|
||||
inline static std::uint8_t light_color_b = 255;
|
||||
inline static int light_offset_x = 0;
|
||||
inline static int light_offset_y = 0;
|
||||
// Paràmetres del flicker resolts per al jugador (combinació del
|
||||
// global + override del YAML del personatge + override de sala).
|
||||
// Només té efecte si `light_flicker` és true.
|
||||
inline static Lighting::FlickerConfig flicker_params{};
|
||||
|
||||
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
|
||||
static void loadFrom(const std::string& yaml_path);
|
||||
|
||||
@@ -132,6 +154,25 @@ class Player : public AnimatedSprite {
|
||||
// marcador i `Player::resetForJoin` per a re-entrades mid-partida.
|
||||
[[nodiscard]] static auto getInitialLives() -> int { return death_config.initial_lives; }
|
||||
|
||||
// Emissió de llum (mateix paper que `Entity::getLightRadius` per a
|
||||
// les entitats). El centre puja amb el salt: durant `jump_z_>0` el
|
||||
// sprite es dibuixa amb `pos_.y - jump_z_`, i la llum ha de seguir-lo
|
||||
// per a que el cercle "salti" amb el jugador, no es quedi al sòl.
|
||||
[[nodiscard]] static auto getLightRadius() -> int { return light_radius; }
|
||||
[[nodiscard]] static auto getLightFlicker() -> bool { return light_flicker; }
|
||||
[[nodiscard]] static auto getLightColorR() -> std::uint8_t { return light_color_r; }
|
||||
[[nodiscard]] static auto getLightColorG() -> std::uint8_t { return light_color_g; }
|
||||
[[nodiscard]] static auto getLightColorB() -> std::uint8_t { return light_color_b; }
|
||||
[[nodiscard]] static auto getLightOffsetX() -> int { return light_offset_x; }
|
||||
[[nodiscard]] static auto getLightOffsetY() -> int { return light_offset_y; }
|
||||
[[nodiscard]] static auto getFlickerParams() -> const Lighting::FlickerConfig& { return flicker_params; }
|
||||
// Seed estable per al flicker: reusem `id_` (0 o 1 en multijugador
|
||||
// local) amb un offset gran per evitar col·lisions amb el contador
|
||||
// d'Entity (que arrenca des de 1). Així P1 i P2 oscil·len en fases
|
||||
// pròpies que no s'encavalquen amb cap entitat.
|
||||
[[nodiscard]] auto getLightSeed() const -> int { return id_ + 1000; }
|
||||
[[nodiscard]] auto getLightCenter() const -> SDL_FPoint;
|
||||
|
||||
Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1);
|
||||
|
||||
// Update en una sola crida: manté l'API anterior per als tests i
|
||||
@@ -158,6 +199,21 @@ class Player : public AnimatedSprite {
|
||||
// xarxa — és únic per dins del procés.
|
||||
[[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
|
||||
// GameScene els toca entre `tick` i `applyMovement` per implementar
|
||||
// col·lisions jugador↔jugador (escalar a push_factor, anul·lar en
|
||||
@@ -633,7 +689,6 @@ class Player : public AnimatedSprite {
|
||||
float impulse_px_per_s{0.0F}; // Velocitat vertical inicial.
|
||||
float gravity_px_per_s2{0.0F}; // Acceleració descendent constant.
|
||||
int landing_stun_ms{0}; // Stun en aterrar (no es mou ni salta).
|
||||
int shadow_y_offset{0}; // Px que puja la sombra (compensa transparències al peu del sprite).
|
||||
std::string sound_key; // Clau al Resource::Cache del so de despegue (buit = silenci).
|
||||
std::string land_sound_key; // Clau del so d'aterratge (buit = silenci).
|
||||
};
|
||||
@@ -728,6 +783,11 @@ class Player : public AnimatedSprite {
|
||||
// readers — `loadFrom` el crida al final.
|
||||
static void readImmunityBlock(const fkyaml::node& yaml);
|
||||
|
||||
// Llig el bloc opcional `light: { radius: N, flicker: bool }` del
|
||||
// YAML i pobla `light_radius` / `light_flicker`. Si manca, els
|
||||
// valors queden a 0 / false (jugador sense llum).
|
||||
static void readLightBlock(const fkyaml::node& yaml);
|
||||
|
||||
// Activa l'estat ATTACKING en la direcció cardinal indicada (la
|
||||
// direcció cap on mira el jugador) i publica `whip_spawn` al bus
|
||||
// perquè el GameScene cree el `WhipAttack` corresponent. Posa el
|
||||
@@ -853,6 +913,17 @@ class Player : public AnimatedSprite {
|
||||
// inicial quan el jugador re-entra mid-partida.
|
||||
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
|
||||
// sprite). Les assigna el GameScene via `setSpawnPoint`. 0/0 fins que
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
#include "game/map/map.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "core/resources/resource_helper.hpp"
|
||||
#include "external/fkyaml_node.hpp"
|
||||
#include "utils/defaults.hpp"
|
||||
#include "utils/log.hpp"
|
||||
|
||||
namespace {
|
||||
@@ -21,6 +24,138 @@ namespace {
|
||||
|
||||
namespace Map {
|
||||
|
||||
namespace {
|
||||
// Predicat extra que el `collides` consulta com a sobre-capa,
|
||||
// injectat des de `EntityManager` per a entitats sòlides genèriques
|
||||
// (debris, columnes). El TU local manté l'estat sense exposar-lo
|
||||
// al header.
|
||||
std::function<bool(const SDL_FRect&)> g_entity_collider;
|
||||
|
||||
// Overrides d'emissor declarats al bloc `lighting.emitters` del YAML
|
||||
// de la sala. Clau = tipus d'entitat en singular (per concordar amb
|
||||
// el camp `type:` de cada plantilla). Buit per defecte: una sala
|
||||
// sense bloc `lighting.emitters` deixa que cada entitat utilitze els
|
||||
// seus propis defaults del seu YAML de tipus.
|
||||
std::unordered_map<std::string, RoomEmitterOverride> g_room_emitters;
|
||||
|
||||
// Plural → singular per a tipus coneguts. El YAML de sala fa servir
|
||||
// el plural perquè es llegeix natural ("mummies: false"); el codi
|
||||
// treballa amb la forma singular que coincideix amb el camp `type:`
|
||||
// del spawn ("type: mummy"). Si la clau no és reconeguda, passa tal
|
||||
// qual (suposa que l'autor ja l'ha escrit en singular).
|
||||
auto normalizeEmitterKey(const std::string& yaml_key) -> std::string {
|
||||
if (yaml_key == "players") { return "player"; }
|
||||
if (yaml_key == "mummies") { return "mummy"; }
|
||||
if (yaml_key == "torches") { return "torch"; }
|
||||
return yaml_key;
|
||||
}
|
||||
|
||||
// Parser comú d'un color YAML 3-tupla a bytes. Retorna nullopt
|
||||
// si el node no és una seqüència de 3 elements. Usat pels
|
||||
// overrides de sala i, en `entity_manager_loadlevel.cpp`, també
|
||||
// pels defaults de tipus i overrides de spawn (passa pel mateix
|
||||
// helper per coherència). `clamp` defensiu per a YAMLs que
|
||||
// entreguen valors fora de 0..255.
|
||||
auto parseColorTriplet(const fkyaml::node& node) -> std::optional<std::array<std::uint8_t, 3>> {
|
||||
if (!node.is_sequence() || node.size() != 3) { return std::nullopt; }
|
||||
auto clamp = [](int v) { return static_cast<std::uint8_t>(std::clamp(v, 0, 255)); };
|
||||
return std::array<std::uint8_t, 3>{
|
||||
clamp(node[0].get_value<int>()),
|
||||
clamp(node[1].get_value<int>()),
|
||||
clamp(node[2].get_value<int>()),
|
||||
};
|
||||
}
|
||||
|
||||
// Parser comú d'una 2-tupla d'enters (offset de llum). Retorna
|
||||
// nullopt si el node no és una seqüència de 2 elements. Sense
|
||||
// clamp: els offsets negatius són legítims (Y creix cap avall,
|
||||
// així que `y: -3` puja la llum 3 px).
|
||||
auto parseIntPair(const fkyaml::node& node) -> std::optional<std::array<int, 2>> {
|
||||
if (!node.is_sequence() || node.size() != 2) { return std::nullopt; }
|
||||
return std::array<int, 2>{
|
||||
node[0].get_value<int>(),
|
||||
node[1].get_value<int>(),
|
||||
};
|
||||
}
|
||||
|
||||
// Llegeix el sub-bloc opcional `flicker_params:` d'un emissor de
|
||||
// sala. Camps individuals — qualsevol absent conserva el valor
|
||||
// del global (`Lighting::flickerConfig()`) o de la capa que ho
|
||||
// precedeixi (tipus / sala).
|
||||
void readFlickerOverrides(const fkyaml::node& fp, RoomEmitterOverride& ov) {
|
||||
if (fp.contains("wave_amplitude_ratio")) { ov.flicker_wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value<float>(); }
|
||||
if (fp.contains("wave_frequency_hz")) { ov.flicker_wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
|
||||
if (fp.contains("noise_amplitude_ratio")) { ov.flicker_noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
|
||||
if (fp.contains("noise_frequency_hz")) { ov.flicker_noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
|
||||
}
|
||||
|
||||
// Llegeix una entrada del mapping `lighting.emitters.<tipus>` en
|
||||
// qualsevol de les seves 3 formes (bool curt o mapping detallat).
|
||||
// Extret per mantenir `loadLightingBlock` sota el llindar de
|
||||
// complexitat cognitiva de clang-tidy.
|
||||
auto readEmitterEntry(const fkyaml::node& val) -> RoomEmitterOverride {
|
||||
RoomEmitterOverride ov;
|
||||
if (val.is_boolean()) {
|
||||
ov.enabled = val.get_value<bool>();
|
||||
return ov;
|
||||
}
|
||||
if (!val.is_mapping()) { return ov; }
|
||||
ov.enabled = true;
|
||||
if (val.contains("radius")) { ov.radius = val["radius"].get_value<int>(); }
|
||||
if (val.contains("flicker")) { ov.flicker = val["flicker"].get_value<bool>(); }
|
||||
if (val.contains("color")) { ov.color = parseColorTriplet(val["color"]); }
|
||||
if (val.contains("offset")) { ov.offset = parseIntPair(val["offset"]); }
|
||||
if (val.contains("flicker_params")) { readFlickerOverrides(val["flicker_params"], ov); }
|
||||
return ov;
|
||||
}
|
||||
|
||||
// Llegeix la subclau `mode:` del bloc `lighting:` i actualitza
|
||||
// `lighting_mode`. Avisa via Log si el valor no és reconegut.
|
||||
// Extret de `loadLightingBlock` per la mateixa raó.
|
||||
void readLightingMode(const fkyaml::node& lighting) {
|
||||
if (!lighting.contains("mode")) { return; }
|
||||
const auto MODE_STR = lighting["mode"].get_value<std::string>();
|
||||
if (MODE_STR == "additive") {
|
||||
lighting_mode = Lighting::Mode::ADDITIVE;
|
||||
} else if (MODE_STR == "subtractive") {
|
||||
lighting_mode = Lighting::Mode::SUBTRACTIVE;
|
||||
} else {
|
||||
Log::warn("Map", "lighting.mode '{}' desconegut — s'usa subtractive", MODE_STR);
|
||||
}
|
||||
}
|
||||
|
||||
// Llegeix el bloc opcional `lighting:` del YAML d'una sala i
|
||||
// actualitza `light_level`, `lighting_mode` i el mapping
|
||||
// `g_room_emitters`. Idempotent: cada `Map::loadFrom` reseteja
|
||||
// tot l'estat abans de poblar-lo, així no s'arrosseguen valors
|
||||
// d'una sala anterior.
|
||||
void loadLightingBlock(const fkyaml::node& yaml) {
|
||||
g_room_emitters.clear();
|
||||
lighting_mode = Lighting::Mode::SUBTRACTIVE;
|
||||
if (!yaml.contains("lighting")) { return; }
|
||||
const auto& lighting = yaml["lighting"];
|
||||
if (lighting.contains("level")) {
|
||||
light_level = lighting["level"].get_value<float>();
|
||||
}
|
||||
readLightingMode(lighting);
|
||||
if (!lighting.contains("emitters") || !lighting["emitters"].is_mapping()) { return; }
|
||||
const auto& em = lighting["emitters"];
|
||||
for (auto it = em.begin(); it != em.end(); ++it) {
|
||||
const auto KEY = normalizeEmitterKey(it.key().get_value<std::string>());
|
||||
g_room_emitters[KEY] = readEmitterEntry(it.value());
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void setEntityCollider(std::function<bool(const SDL_FRect&)> fn) {
|
||||
g_entity_collider = std::move(fn);
|
||||
}
|
||||
|
||||
auto getRoomEmitterOverride(const std::string& type) -> const RoomEmitterOverride* {
|
||||
const auto IT = g_room_emitters.find(type);
|
||||
return IT == g_room_emitters.end() ? nullptr : &IT->second;
|
||||
}
|
||||
|
||||
void loadFrom(const std::string& yaml_path) {
|
||||
auto bytes = Resource::Helper::loadFile(yaml_path);
|
||||
if (bytes.empty()) {
|
||||
@@ -90,6 +225,17 @@ namespace Map {
|
||||
// canviarà el flag a true quan transiti a OPEN.
|
||||
door_open = false;
|
||||
|
||||
// Llum base de la sala. Dues formes acceptades:
|
||||
// - `light_level: <float>` a l'arrel (forma antiga, encara
|
||||
// suportada per retrocompat).
|
||||
// - `lighting.level: <float>` dins del bloc nou `lighting:`,
|
||||
// que també hostatja `lighting.emitters` (overrides per tipus).
|
||||
// Si manca tot, queda al default 1.0 (sala totalment il·luminada).
|
||||
// `loadLightingBlock` aplica el `lighting.level` per damunt del
|
||||
// `light_level:` antic, així el nou bloc guanya si conviuen.
|
||||
light_level = yaml.contains("light_level") ? yaml["light_level"].get_value<float>() : Defaults::Lighting::ROOM_LIGHT_DEFAULT;
|
||||
loadLightingBlock(yaml);
|
||||
|
||||
Log::info("Map", "geometria {}x{} sarcs {}x{} pitch {}x{} door_col {} des de {}", grid_cols, grid_rows, sarc_w, sarc_h, pitch_x, pitch_y, door_col, yaml_path);
|
||||
}
|
||||
|
||||
@@ -123,6 +269,11 @@ namespace Map {
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "core/rendering/lighting.hpp"
|
||||
|
||||
// Mapa d'una piràmide: sala rectangular amb marcador superior, muros pels
|
||||
// quatre costats i graella de sarcòfags envoltada per un passadís a cada
|
||||
// cantó. La geometria (graella, pitch, amplada de murs) es carrega des del
|
||||
@@ -49,6 +55,68 @@ namespace Map {
|
||||
inline int back_wall_h = 0;
|
||||
inline int side_wall_w = 0;
|
||||
|
||||
// Nivell d'il·luminació base de la sala. 1.0 = totalment il·luminada
|
||||
// (com hui, sense efecte visual); 0.0 = completament negra fora del
|
||||
// radi d'un emissor. La clau `light_level:` al YAML de la sala el
|
||||
// sobreescriu — si manca, queda al default 1.0 i la pasada
|
||||
// d'il·luminació del frame es salta com a no-op. La forma nova és
|
||||
// declarar-ho dins del bloc `lighting:` (vegeu més avall); `light_level:`
|
||||
// al root es manté per compatibilitat enrere.
|
||||
inline float light_level = 1.0F;
|
||||
|
||||
// Mode de composició de la pasada d'il·luminació (subtractive per
|
||||
// defecte; additive si la sala declara `lighting.mode: additive`).
|
||||
// Veure el comentari de `core/rendering/lighting.hpp` per a la
|
||||
// semàntica de cada mode. En subtractive, els colors dels emissors
|
||||
// s'ignoren — el camí és el de sempre.
|
||||
inline Lighting::Mode lighting_mode = Lighting::Mode::SUBTRACTIVE;
|
||||
|
||||
// Override d'emissor declarat al bloc opcional `lighting.emitters.<tipus>`
|
||||
// de la sala. Tres formes acceptades al YAML:
|
||||
// <tipus>: false → desactiva la llum per a tot el tipus
|
||||
// <tipus>: true → activa amb els defaults del tipus
|
||||
// <tipus>: { radius: N, flicker: B } → activa i sobreescriu camps
|
||||
// Els camps absents (`std::nullopt`) cauen al default del tipus
|
||||
// d'entitat (radi/flicker del seu YAML). Els tipus no presents al
|
||||
// mapping conserven el seu comportament per defecte (com avui).
|
||||
//
|
||||
// Conversió de clau YAML → tipus d'entitat: el YAML usa plural
|
||||
// ("players", "mummies", "torches") perquè es llegeix natural per a
|
||||
// l'autor de la sala; `loadFrom` normalitza a la clau singular
|
||||
// ("player", "mummy", "torch") que coincideix amb el camp `type:` de
|
||||
// cada plantilla d'entitat. Tipus no reconeguts s'accepten "tal qual"
|
||||
// (singular pass-through) per si en el futur s'hi afegeixen emissors
|
||||
// nous sense tocar el mapatge.
|
||||
struct RoomEmitterOverride {
|
||||
bool enabled{true};
|
||||
std::optional<int> radius;
|
||||
std::optional<bool> flicker;
|
||||
// Color emès en mode additive. `nullopt` → conservar el color
|
||||
// del tipus (default blanc 255,255,255 si el tipus no en declara).
|
||||
// La forma YAML és `color: [r, g, b]` (bytes 0..255).
|
||||
std::optional<std::array<std::uint8_t, 3>> color;
|
||||
// Offset del centre del cercle de llum respecte el centre del
|
||||
// sprite (píxels enters; Y creix avall). `nullopt` → conservar
|
||||
// l'offset del tipus (default [0, 0]). Forma YAML: `offset: [x, y]`.
|
||||
std::optional<std::array<int, 2>> offset;
|
||||
// Overrides parcials per cada camp del `FlickerConfig` (ràtio
|
||||
// d'amplitud i freqüència del wave + noise). `nullopt` → no
|
||||
// toca aquell camp (conserva el del tipus, que al seu torn pot
|
||||
// ser el global). Forma YAML: `flicker_params: { wave_frequency_hz: 1.0, ... }`.
|
||||
std::optional<float> flicker_wave_amplitude_ratio;
|
||||
std::optional<float> flicker_wave_frequency_hz;
|
||||
std::optional<float> flicker_noise_amplitude_ratio;
|
||||
std::optional<float> flicker_noise_frequency_hz;
|
||||
};
|
||||
|
||||
// Consulta l'override de sala per a un tipus d'entitat (clau singular,
|
||||
// p.ex. "player" / "mummy" / "torch"). Retorna `nullptr` si la sala no
|
||||
// declara res per aquest tipus — el caller usa els defaults del tipus
|
||||
// sense modificació. El punter retornat és vàlid fins a la propera
|
||||
// crida a `Map::loadFrom` (cada càrrega de nivell substitueix el
|
||||
// mapping intern).
|
||||
[[nodiscard]] auto getRoomEmitterOverride(const std::string& type) -> const RoomEmitterOverride*;
|
||||
|
||||
// Derivats de la geometria base: el pas entre sarcòfags ha de cabre
|
||||
// exactament el jugador, i el mateix passadís s'usa a l'exterior.
|
||||
inline int corridor_w = 0;
|
||||
@@ -91,9 +159,17 @@ namespace Map {
|
||||
|
||||
// Mapa de colisions (separat del mapa de dibuix): retorna true si el
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
// interior, sense tocar els murs exteriors). El Player ja té clamp
|
||||
// propi que aplica el mateix constrenyiment; això serveix a les
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "core/input/global_inputs.hpp"
|
||||
#include "core/input/key_config.hpp"
|
||||
#include "core/locale/locale.hpp"
|
||||
#include "core/rendering/lighting.hpp"
|
||||
#include "core/rendering/notifier.hpp"
|
||||
#include "core/rendering/render_info.hpp"
|
||||
#include "core/rendering/screen.hpp"
|
||||
@@ -26,6 +27,7 @@
|
||||
#include "core/ui/ui_config.hpp"
|
||||
#include "external/fkyaml_node.hpp"
|
||||
#include "game/entities/concrete/dust.hpp"
|
||||
#include "game/entities/concrete/sarcophagus.hpp"
|
||||
#include "game/entities/concrete/whip_attack.hpp"
|
||||
#include "game/entities/entity.hpp"
|
||||
#include "game/entities/entity_factory.hpp"
|
||||
@@ -230,6 +232,39 @@ namespace {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Override de sala per a la llum del jugador. Si el YAML de la sala
|
||||
// declara `lighting.emitters.players`, substitueix els valors carregats
|
||||
// per `Player::loadFrom`. Extret aquí per no inflar la complexitat
|
||||
// cognitiva del ctor de GameScene (clang-tidy: cognitive-complexity).
|
||||
// No s'aplica una "tercera capa" per-instància al jugador perquè no
|
||||
// neix com a spawn del nivell; el dia d'un ítem "linterna" aquell
|
||||
// sistema mutarà els mateixos camps en runtime — ortogonal a aquest
|
||||
// override.
|
||||
void applyRoomLightOverrideToPlayer() {
|
||||
const auto* room = Map::getRoomEmitterOverride("player");
|
||||
if (room == nullptr) { return; }
|
||||
if (!room->enabled) {
|
||||
Player::light_radius = 0;
|
||||
Player::light_flicker = false;
|
||||
return;
|
||||
}
|
||||
if (room->radius.has_value()) { Player::light_radius = *room->radius; }
|
||||
if (room->flicker.has_value()) { Player::light_flicker = *room->flicker; }
|
||||
if (room->color.has_value()) {
|
||||
Player::light_color_r = (*room->color)[0];
|
||||
Player::light_color_g = (*room->color)[1];
|
||||
Player::light_color_b = (*room->color)[2];
|
||||
}
|
||||
if (room->offset.has_value()) {
|
||||
Player::light_offset_x = (*room->offset)[0];
|
||||
Player::light_offset_y = (*room->offset)[1];
|
||||
}
|
||||
if (room->flicker_wave_amplitude_ratio.has_value()) { Player::flicker_params.wave_amplitude_ratio = *room->flicker_wave_amplitude_ratio; }
|
||||
if (room->flicker_wave_frequency_hz.has_value()) { Player::flicker_params.wave_frequency_hz = *room->flicker_wave_frequency_hz; }
|
||||
if (room->flicker_noise_amplitude_ratio.has_value()) { Player::flicker_params.noise_amplitude_ratio = *room->flicker_noise_amplitude_ratio; }
|
||||
if (room->flicker_noise_frequency_hz.has_value()) { Player::flicker_params.noise_frequency_hz = *room->flicker_noise_frequency_hz; }
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto GameScene::defaultConfig() -> Config {
|
||||
@@ -287,6 +322,7 @@ GameScene::GameScene(Config config)
|
||||
// del YAML de P1 és suficient (els dos personatges comparteixen
|
||||
// geometria — morcus és un repaletat de sam).
|
||||
Player::loadFrom(config_.player_yaml);
|
||||
applyRoomLightOverrideToPlayer();
|
||||
|
||||
// Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia,
|
||||
// etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa
|
||||
@@ -321,6 +357,12 @@ GameScene::GameScene(Config config)
|
||||
entity_manager_.getBus().subscribe("player_disabled", [this](const Event& ev) {
|
||||
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è
|
||||
// els spawns runtime de l'entitat no facin disc al primer salt.
|
||||
@@ -484,16 +526,8 @@ GameScene::GameScene(Config config)
|
||||
// al Resource::Cache (preload al Director), per tant la primera
|
||||
// crida a playMusic és immediata.
|
||||
|
||||
// Font de l'overlay de pausa: reutilitzem la Surface ja cachejada al
|
||||
// Director (PLACEHOLDER_NAME + ".gif"). Text només parseja el .fnt a
|
||||
// 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í).
|
||||
// Font del rètol de transició de sala. Reutilitzem la Surface ja
|
||||
// cachejada al Director; només construïm el `Text` ací.
|
||||
const std::string ROOM_TEXT_FONT_KEY = UiConfig::get().roomTextFont().name + ".gif";
|
||||
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);
|
||||
@@ -560,6 +594,14 @@ void GameScene::loadRoom(const std::string& yaml_path) {
|
||||
// CLOSED amb visited buit, momies i altres actors a la posició inicial.
|
||||
config_.map_yaml = yaml_path;
|
||||
Map::loadFrom(yaml_path);
|
||||
// Refresquem els statics del Player a partir del YAML i tornem a
|
||||
// aplicar l'override d'aquesta sala. Sense això, una sala amb
|
||||
// `players: false` deixa `light_radius=0` i, en transicionar a una
|
||||
// altra sala que no toqui el camp (o digui `players: true`), els
|
||||
// jugadors no tornen a emetre — l'override actual és "additiu"
|
||||
// sobre els statics existents, no un reset.
|
||||
Player::loadFrom(config_.player_yaml);
|
||||
applyRoomLightOverrideToPlayer();
|
||||
// Tilemap pre-blita els tiles a un buffer ARGB intern al ctor — sense
|
||||
// reconstruir-lo, la imatge de la sala anterior es queda fixada per a
|
||||
// sempre. PaintLayer captura `tileset_path_`/`tileset_cols_` per valor
|
||||
@@ -740,6 +782,21 @@ void GameScene::onPlayerDisabled(int player_id) {
|
||||
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 {
|
||||
// Pre-load del so del countdown: el dispara `updateGameOverFlow`
|
||||
// cada vegada que canvia el número visible de CONTINUE? (incloent
|
||||
@@ -991,6 +1048,45 @@ void GameScene::updateRoomTransition(int delta_ms) {
|
||||
}
|
||||
}
|
||||
|
||||
void GameScene::applyLightingPass() {
|
||||
// Atajo: si la sala està totalment il·luminada i ningú emet,
|
||||
// `Lighting::apply` ja s'autosalta amb les mateixes condicions.
|
||||
// Recollim igualment perquè el cas comú durant el desenvolupament
|
||||
// serà tocar `Map::light_level` per sales concretes.
|
||||
std::vector<Lighting::Emitter> emitters;
|
||||
emitters.reserve(players_.size() + entity_manager_.entityCount());
|
||||
const int PLAYER_R = Player::getLightRadius();
|
||||
// Quan el YAML del jugador no declara `light:`, `PLAYER_R` és 0 i
|
||||
// `Lighting::apply` salta el cercle al filtre intern. No fem un
|
||||
// gating extern aquí (els jugadors són només 1-2 actors i el cost
|
||||
// d'afegir-los al vector és menyspreable).
|
||||
const bool PLAYER_FLICKER = Player::getLightFlicker();
|
||||
const Lighting::Color PLAYER_COLOR{
|
||||
.r = Player::getLightColorR(),
|
||||
.g = Player::getLightColorG(),
|
||||
.b = Player::getLightColorB(),
|
||||
};
|
||||
const Lighting::FlickerConfig PLAYER_FP = Player::getFlickerParams();
|
||||
for (const auto& p : players_) {
|
||||
if (p->isOutOfPlay()) { continue; }
|
||||
const auto C = p->getLightCenter();
|
||||
emitters.push_back({.cx = C.x, .cy = C.y, .radius = PLAYER_R, .seed = p->getLightSeed(), .flicker = PLAYER_FLICKER, .color = PLAYER_COLOR, .flicker_params = PLAYER_FP});
|
||||
}
|
||||
for (const auto* e : entity_manager_.getEntities()) {
|
||||
const int R = e->getLightRadius();
|
||||
if (R <= 0) { continue; }
|
||||
const auto C = e->getLightCenter();
|
||||
const Lighting::Color COL{.r = e->getLightColorR(), .g = e->getLightColorG(), .b = e->getLightColorB()};
|
||||
emitters.push_back({.cx = C.x, .cy = C.y, .radius = R, .seed = e->getLightSeed(), .flicker = e->getLightFlicker(), .color = COL, .flicker_params = e->getFlickerParams()});
|
||||
}
|
||||
Lighting::apply(Screen::get().getCanvasArgb(),
|
||||
Screen::get().getCanvasWidth(),
|
||||
Screen::get().getCanvasHeight(),
|
||||
Map::lighting_mode,
|
||||
Map::light_level,
|
||||
emitters);
|
||||
}
|
||||
|
||||
void GameScene::renderRoomFade() {
|
||||
// Delegació als FadeToBlack: les durades, la direcció i el progrés
|
||||
// viuen dins de cada instància. Comportament:
|
||||
@@ -1011,11 +1107,6 @@ void GameScene::renderRoomFade() {
|
||||
}
|
||||
|
||||
void GameScene::renderRoomText() {
|
||||
// Orquestrador del rètol de transició de sala. La feina pesada
|
||||
// (animació de X, animació d'altura del panel i pintat) la fan
|
||||
// quatre helpers privats; aquesta funció només decideix quin
|
||||
// text dibuixar i amb quines fases segons l'estat.
|
||||
//
|
||||
// Mapatge per estat (només els estats amb text actiu hi entren):
|
||||
// ROOM_INTRO: phases 2..4 → enter / hold / exit; dues línies
|
||||
// (piràmide damunt, sala davall) que travessen la
|
||||
@@ -1024,82 +1115,35 @@ void GameScene::renderRoomText() {
|
||||
// oposades ("fase" / "completada"). Phase 3 és el
|
||||
// fade-out i 4 la pausa en negre — cap text en cap
|
||||
// de les dues.
|
||||
if (!room_text_font_) { return; }
|
||||
|
||||
constexpr int KERNING = 0;
|
||||
// Centre vertical de la zona de joc (canvas sense la HUD). Reemplaça
|
||||
// l'antic GameCanvas::CENTER_Y per descomptar la franja del marcador
|
||||
// i tenir el rètol equilibrat sobre el playfield.
|
||||
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
|
||||
// Colors del rètol via UiConfig (`data/config/ui.yaml > colors.room_text` i
|
||||
// `room_text_outline`). Independents dels colors de notificació.
|
||||
const Uint8 FILL = UiConfig::get().roomText();
|
||||
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
|
||||
const int LINE_HEIGHT = room_text_font_->getLineHeight();
|
||||
|
||||
const int PHASE = transition_.phase;
|
||||
const int ELAPSED = transition_.elapsed_ms;
|
||||
if (state_ == RoomFsm::State::ROOM_INTRO && PHASE >= 2 && PHASE <= 4) {
|
||||
const int DUR_ENTER = transition_config_.intro_text_enter_ms;
|
||||
const int DUR_EXIT = transition_config_.intro_text_exit_ms;
|
||||
// Panel primer (queda sota el text gràcies a l'ordre de blits).
|
||||
drawRoomTextPanel(PLAY_CENTER_Y,
|
||||
computeRoomTextPanelHeight(PHASE, ELAPSED, 2, 3, 4, DUR_ENTER, DUR_EXIT));
|
||||
|
||||
const auto& mgr = Levels::LevelManager::get();
|
||||
const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid());
|
||||
const std::string LINE_ROOM = RoomFsm::formatIntroRoomLine(mgr.currentRoom());
|
||||
// Direccions oposades: piràmide entra per l'esquerra i surt per
|
||||
// la dreta; sala fa el contrari.
|
||||
const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING),
|
||||
renderTextScreen(TextScreenSpec{
|
||||
.line1 = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid()),
|
||||
.line2 = RoomFsm::formatIntroRoomLine(mgr.currentRoom()),
|
||||
.phase_enter = 2,
|
||||
.phase_hold = 3,
|
||||
.phase_exit = 4,
|
||||
.dur_enter_ms = transition_config_.intro_text_enter_ms,
|
||||
.dur_exit_ms = transition_config_.intro_text_exit_ms,
|
||||
.line_gap_px = transition_config_.intro_text_line_gap_px,
|
||||
},
|
||||
PHASE,
|
||||
ELAPSED,
|
||||
2,
|
||||
4,
|
||||
DUR_ENTER,
|
||||
DUR_EXIT,
|
||||
/*from_right=*/false);
|
||||
const int X_ROOM = computeRoomTextX(room_text_font_->length(LINE_ROOM, KERNING),
|
||||
PHASE,
|
||||
ELAPSED,
|
||||
2,
|
||||
4,
|
||||
DUR_ENTER,
|
||||
DUR_EXIT,
|
||||
/*from_right=*/true);
|
||||
// Bloc de dues línies centrat al voltant del PLAY_CENTER_Y. El
|
||||
// gap (`intro_text_line_gap_px`) es reparteix simètricament:
|
||||
// mig amunt a la piràmide, mig avall a la sala.
|
||||
const int GAP = transition_config_.intro_text_line_gap_px;
|
||||
drawRoomTextLine(LINE_PYRAMID, X_PYRAMID, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
|
||||
drawRoomTextLine(LINE_ROOM, X_ROOM, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
|
||||
ELAPSED);
|
||||
} else if (state_ == RoomFsm::State::ROOM_OUTRO && PHASE >= 0 && PHASE <= 2) {
|
||||
const int DUR_ENTER = transition_config_.outro_text_enter_ms;
|
||||
const int DUR_EXIT = transition_config_.outro_text_exit_ms;
|
||||
drawRoomTextPanel(PLAY_CENTER_Y,
|
||||
computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT));
|
||||
|
||||
const std::string LINE_1 = Locale::get().text("game.room_complete_line1");
|
||||
const std::string LINE_2 = Locale::get().text("game.room_complete_line2");
|
||||
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
|
||||
renderTextScreen(TextScreenSpec{
|
||||
.line1 = Locale::get().text("game.room_complete_line1"),
|
||||
.line2 = Locale::get().text("game.room_complete_line2"),
|
||||
.phase_enter = 0,
|
||||
.phase_hold = 1,
|
||||
.phase_exit = 2,
|
||||
.dur_enter_ms = transition_config_.outro_text_enter_ms,
|
||||
.dur_exit_ms = transition_config_.outro_text_exit_ms,
|
||||
.line_gap_px = transition_config_.outro_text_line_gap_px,
|
||||
},
|
||||
PHASE,
|
||||
ELAPSED,
|
||||
0,
|
||||
2,
|
||||
DUR_ENTER,
|
||||
DUR_EXIT,
|
||||
/*from_right=*/false);
|
||||
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
|
||||
PHASE,
|
||||
ELAPSED,
|
||||
0,
|
||||
2,
|
||||
DUR_ENTER,
|
||||
DUR_EXIT,
|
||||
/*from_right=*/true);
|
||||
const int GAP = transition_config_.outro_text_line_gap_px;
|
||||
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
|
||||
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
|
||||
ELAPSED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1179,6 +1223,51 @@ void GameScene::drawRoomTextLine(const std::string& text, int x, int y, std::uin
|
||||
outline_color);
|
||||
}
|
||||
|
||||
void GameScene::renderTextScreen(const TextScreenSpec& spec, int phase, int elapsed_ms) const {
|
||||
// Coreografia comuna als quatre rètols (ROOM_INTRO, ROOM_OUTRO,
|
||||
// CONTINUE?, GAME OVER): panel translúcid de fons + dues línies
|
||||
// que entren/surten per costats oposats. Els quatre helpers
|
||||
// existents (`drawRoomTextPanel`, `computeRoomTextPanelHeight`,
|
||||
// `computeRoomTextX`, `drawRoomTextLine`) fan la feina pesada;
|
||||
// aquesta funció només els encadena en el mateix ordre per als
|
||||
// quatre casos. Una `lineN` buida es salta sense pintar — així
|
||||
// CONTINUE? amaga la línia del countdown fora del hold sense
|
||||
// que els callers hagin de duplicar la branca.
|
||||
if (!room_text_font_) { return; }
|
||||
|
||||
constexpr int KERNING = 0;
|
||||
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
|
||||
const Uint8 FILL = UiConfig::get().roomText();
|
||||
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
|
||||
const int LINE_HEIGHT = room_text_font_->getLineHeight();
|
||||
|
||||
drawRoomTextPanel(PLAY_CENTER_Y,
|
||||
computeRoomTextPanelHeight(phase, elapsed_ms, spec.phase_enter, spec.phase_hold, spec.phase_exit, spec.dur_enter_ms, spec.dur_exit_ms));
|
||||
|
||||
if (!spec.line1.empty()) {
|
||||
const int X_1 = computeRoomTextX(room_text_font_->length(spec.line1, KERNING),
|
||||
phase,
|
||||
elapsed_ms,
|
||||
spec.phase_enter,
|
||||
spec.phase_exit,
|
||||
spec.dur_enter_ms,
|
||||
spec.dur_exit_ms,
|
||||
/*from_right=*/false);
|
||||
drawRoomTextLine(spec.line1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (spec.line_gap_px / 2), FILL, OUTLINE);
|
||||
}
|
||||
if (!spec.line2.empty()) {
|
||||
const int X_2 = computeRoomTextX(room_text_font_->length(spec.line2, KERNING),
|
||||
phase,
|
||||
elapsed_ms,
|
||||
spec.phase_enter,
|
||||
spec.phase_exit,
|
||||
spec.dur_enter_ms,
|
||||
spec.dur_exit_ms,
|
||||
/*from_right=*/true);
|
||||
drawRoomTextLine(spec.line2, X_2, PLAY_CENTER_Y + (spec.line_gap_px / 2), FILL, OUTLINE);
|
||||
}
|
||||
}
|
||||
|
||||
void GameScene::setGameOverState(GameOverFsm::State new_state) {
|
||||
if (new_state == game_over_state_) { return; }
|
||||
const auto PREV = game_over_state_;
|
||||
@@ -1315,104 +1404,57 @@ void GameScene::emitCountdownTickIfChanged() {
|
||||
}
|
||||
|
||||
void GameScene::renderGameOverText() {
|
||||
if (!room_text_font_) { return; }
|
||||
// Mapatge per estat:
|
||||
// CONTINUE_PROMPT: phases 0=enter, 1=hold (countdown visible),
|
||||
// 2=exit. La línia 2 (número) només es pinta
|
||||
// durant la fase 1 perquè els números no
|
||||
// travessin la pantalla.
|
||||
// GAME_OVER: phases 0=hold, 1=exit (sense enter anim — el
|
||||
// panel ja és amunt del CONTINUE_PROMPT). Phase 2
|
||||
// (wait) sense text ni panel.
|
||||
if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT && game_over_state_ != GameOverFsm::State::GAME_OVER) {
|
||||
return;
|
||||
}
|
||||
|
||||
constexpr int KERNING = 0;
|
||||
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
|
||||
const Uint8 FILL = UiConfig::get().roomText();
|
||||
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
|
||||
const int LINE_HEIGHT = room_text_font_->getLineHeight();
|
||||
const int PHASE = game_over_transition_.phase;
|
||||
const int ELAPSED = game_over_transition_.elapsed_ms;
|
||||
|
||||
if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) {
|
||||
const int DUR_ENTER = game_over_config_.continue_text_enter_ms;
|
||||
const int DUR_EXIT = game_over_config_.continue_text_exit_ms;
|
||||
// Phases del CONTINUE_PROMPT: 0=enter, 1=hold, 2=exit. El panel
|
||||
// segueix la mateixa corba enter/hold/exit que el room text.
|
||||
drawRoomTextPanel(PLAY_CENTER_Y,
|
||||
computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT));
|
||||
|
||||
const std::string LINE_1 = Locale::get().text("game.continue_prompt");
|
||||
const int SECONDS = GameOverFsm::countdownSecondsRemaining(
|
||||
game_over_state_,
|
||||
game_over_transition_,
|
||||
game_over_config_);
|
||||
// Línia 2 només té text durant la fase de hold (countdown). En
|
||||
// les fases d'enter i exit deixem només el "CONTINUE?" perquè
|
||||
// l'animació dels números no quedi rara.
|
||||
const std::string LINE_2 = (PHASE == 1) ? std::to_string(SECONDS) : std::string{};
|
||||
|
||||
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
|
||||
renderTextScreen(TextScreenSpec{
|
||||
.line1 = Locale::get().text("game.continue_prompt"),
|
||||
.line2 = (PHASE == 1) ? std::to_string(SECONDS) : std::string{},
|
||||
.phase_enter = 0,
|
||||
.phase_hold = 1,
|
||||
.phase_exit = 2,
|
||||
.dur_enter_ms = game_over_config_.continue_text_enter_ms,
|
||||
.dur_exit_ms = game_over_config_.continue_text_exit_ms,
|
||||
.line_gap_px = transition_config_.intro_text_line_gap_px,
|
||||
},
|
||||
PHASE,
|
||||
ELAPSED,
|
||||
0,
|
||||
2,
|
||||
DUR_ENTER,
|
||||
DUR_EXIT,
|
||||
/*from_right=*/false);
|
||||
const int X_2 = computeRoomTextX(LINE_2.empty() ? 0 : room_text_font_->length(LINE_2, KERNING),
|
||||
PHASE,
|
||||
ELAPSED,
|
||||
0,
|
||||
2,
|
||||
DUR_ENTER,
|
||||
DUR_EXIT,
|
||||
/*from_right=*/true);
|
||||
const int GAP = transition_config_.intro_text_line_gap_px;
|
||||
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
|
||||
if (!LINE_2.empty()) {
|
||||
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
|
||||
}
|
||||
ELAPSED);
|
||||
return;
|
||||
}
|
||||
|
||||
// GAME_OVER: panel sempre amunt durant phase 0/1, decreix en phase 1
|
||||
// (exit). Phase 2 (wait) sense panel ni text — només temps mort
|
||||
// abans del fade.
|
||||
const int DUR_HOLD = game_over_config_.game_over_text_hold_ms;
|
||||
const int DUR_EXIT = game_over_config_.game_over_text_exit_ms;
|
||||
// GAME_OVER: phase 2 (wait) sense panel ni text — només temps mort
|
||||
// abans del fade. phase_enter=-1 desactiva l'animació d'entrada
|
||||
// (el panel ja és amunt heretat del CONTINUE_PROMPT).
|
||||
if (PHASE > 1) { return; }
|
||||
|
||||
// Panel: phase 0 = ple, phase 1 = decreix. Reaprofitem
|
||||
// computeRoomTextPanelHeight passant un phase_enter "fora de rang"
|
||||
// (-1) perquè la branca d'enter no s'activi mai.
|
||||
drawRoomTextPanel(PLAY_CENTER_Y,
|
||||
computeRoomTextPanelHeight(PHASE, ELAPSED, /*phase_enter=*/-1,
|
||||
/*phase_hold=*/0,
|
||||
/*phase_exit=*/1,
|
||||
/*dur_enter=*/0,
|
||||
DUR_EXIT));
|
||||
|
||||
const std::string LINE_1 = Locale::get().text("game.game_over_line1");
|
||||
const std::string LINE_2 = Locale::get().text("game.game_over_line2");
|
||||
// Phase 0 (hold): text quiet al CENTER_X. Phase 1 (exit): el text
|
||||
// marxa pels costats com el room outro. Reaprofitem computeRoomTextX
|
||||
// amb phase_enter=-1 (no enter anim) i phase_exit=1.
|
||||
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
|
||||
renderTextScreen(TextScreenSpec{
|
||||
.line1 = Locale::get().text("game.game_over_line1"),
|
||||
.line2 = Locale::get().text("game.game_over_line2"),
|
||||
.phase_enter = -1,
|
||||
.phase_hold = 0,
|
||||
.phase_exit = 1,
|
||||
.dur_enter_ms = 0,
|
||||
.dur_exit_ms = game_over_config_.game_over_text_exit_ms,
|
||||
.line_gap_px = transition_config_.outro_text_line_gap_px,
|
||||
},
|
||||
PHASE,
|
||||
ELAPSED,
|
||||
/*phase_enter=*/-1,
|
||||
/*phase_exit=*/1,
|
||||
/*dur_enter=*/0,
|
||||
DUR_EXIT,
|
||||
/*from_right=*/false);
|
||||
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
|
||||
PHASE,
|
||||
ELAPSED,
|
||||
/*phase_enter=*/-1,
|
||||
/*phase_exit=*/1,
|
||||
/*dur_enter=*/0,
|
||||
DUR_EXIT,
|
||||
/*from_right=*/true);
|
||||
const int GAP = transition_config_.outro_text_line_gap_px;
|
||||
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
|
||||
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
|
||||
|
||||
(void)DUR_HOLD; // Reservat per a futurs efectes de hold (pulse, etc.).
|
||||
ELAPSED);
|
||||
}
|
||||
|
||||
void GameScene::renderGameOverFade() {
|
||||
@@ -1467,81 +1509,6 @@ void GameScene::spawnWhip(const WhipSpawnPayload& payload) {
|
||||
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
|
||||
namespace {
|
||||
auto detectEditorToggle() -> bool {
|
||||
@@ -1577,33 +1544,33 @@ namespace {
|
||||
#endif
|
||||
|
||||
void GameScene::update(int delta_ms) {
|
||||
handlePauseInput();
|
||||
|
||||
// Congelació instantània mentre el menú de servei està obert. A
|
||||
// diferència de la pausa de gameplay (que és un dial amb easing i
|
||||
// fade de música), aquesta és binària: el món no avança gens i la
|
||||
// música no es toca. També posem en pausa el dial de la pausa
|
||||
// regular si estava a mig camí, perquè no continui mentre l'usuari
|
||||
// navega el menú.
|
||||
// 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
|
||||
// entre altres coses per calibrar les opcions d'àudio.
|
||||
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)
|
||||
// perquè és UI. Avança avans del bloc de món perquè quan PLAYING
|
||||
// Transició de sala i sub-FSM de game over: viuen en temps real
|
||||
// perquè són UI. Avancen abans del bloc de món perquè quan PLAYING
|
||||
// arrenque ja sigui efectiu en el mateix frame.
|
||||
updateRoomTransition(delta_ms);
|
||||
|
||||
// Sub-FSM de game over (CONTINUE? + GAME OVER + fade-to-LOGO).
|
||||
// També en temps real: el countdown de CONTINUE? no es congela
|
||||
// amb la pausa de gameplay. La detecció de "tots fora de partida"
|
||||
// s'arma dins quan estem a PLAYING.
|
||||
// Game-over flow pausat mentre el menú de servei o l'editor estan
|
||||
// oberts. L'editor fa `loadRoom` complet i deixa els jugadors DISABLED
|
||||
// (`isOutOfPlay()==true`) esperant respawn instantani a la sortida;
|
||||
// si el flux corre durant aquest interval, `activePlayerCount()==0`
|
||||
// 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);
|
||||
}
|
||||
|
||||
#ifdef _DEBUG
|
||||
// Toggle de l'editor: només si no hi ha cap transició de pausa ni
|
||||
// pausa consolidada. L'editor mateix no consumeix la tecla — la
|
||||
// Toggle de l'editor. L'editor mateix no consumeix la tecla — 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
|
||||
// disc: les entitats tornen a la posició inicial (momies, sarcs),
|
||||
@@ -1611,7 +1578,7 @@ void GameScene::update(int delta_ms) {
|
||||
// l'editor sempre veu l'estat-base de la sala (no el d'una partida
|
||||
// a meitat) i el joc, en sortir, arrenca fresc amb les edicions ja
|
||||
// persistides al YAML.
|
||||
if (pause_.state == PauseState::IDLE && detectEditorToggle()) {
|
||||
if (!MENU_FROZEN && detectEditorToggle()) {
|
||||
const bool WAS_ACTIVE = editor_.active();
|
||||
editor_.toggle(tilemap_, entity_manager_);
|
||||
const bool NOW_ACTIVE = editor_.active();
|
||||
@@ -1621,6 +1588,14 @@ void GameScene::update(int delta_ms) {
|
||||
// totes maneres) i es reactivaran amb el spawn instantani
|
||||
// a la sortida.
|
||||
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) {
|
||||
// Acabem de sortir: el writer ja ha persistit el YAML.
|
||||
// Carreguem fresh i fem spawn instantani sense cinemàtica.
|
||||
@@ -1629,10 +1604,10 @@ void GameScene::update(int delta_ms) {
|
||||
}
|
||||
}
|
||||
|
||||
// Salts debug entre sales (`[` / `]`). Mútuament excloents amb la
|
||||
// pausa i amb l'editor: si qualsevol està actiu, ignorem la tecla
|
||||
// perquè el salt recarrega entitats i invalidaria l'estat editat.
|
||||
if (pause_.state == PauseState::IDLE && !editor_.active()) {
|
||||
// Salts debug entre sales (`[` / `]`). Mútuament excloents amb el
|
||||
// menú de servei i amb l'editor: si qualsevol està actiu, ignorem la
|
||||
// tecla perquè el salt recarrega entitats i invalidaria l'estat editat.
|
||||
if (!MENU_FROZEN && !editor_.active()) {
|
||||
const int DELTA = detectRoomJump();
|
||||
if (DELTA != 0) {
|
||||
auto& mgr = Levels::LevelManager::get();
|
||||
@@ -1646,7 +1621,7 @@ void GameScene::update(int delta_ms) {
|
||||
// pintat/sarcs durant proves de progressió. La resta del flux
|
||||
// (creuar → player_exited → level_complete → OUTRO) es manté.
|
||||
// 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");
|
||||
entity_manager_.forceOpenDoor();
|
||||
}
|
||||
@@ -1655,33 +1630,31 @@ void GameScene::update(int delta_ms) {
|
||||
// Join in progress (regla 1): un slot DISABLED pot entrar a partida
|
||||
// pulsant START durant PLAYING. Es processa abans del bloc de món
|
||||
// per que el spawn aeri ja sigui efectiu en aquest mateix frame.
|
||||
// Gateat per pausa/menú/editor: no es vol que un START accidental
|
||||
// mentre l'usuari navega per un menú trigueri un join.
|
||||
// Gateat per menú/editor: no es vol que un START accidental mentre
|
||||
// l'usuari navega per un menú trigueri un join.
|
||||
#ifdef _DEBUG
|
||||
const bool EDITOR_OPEN = editor_.active();
|
||||
#else
|
||||
constexpr bool EDITOR_OPEN = false;
|
||||
#endif
|
||||
if (pause_.state == PauseState::IDLE && !MENU_FROZEN && !EDITOR_OPEN) {
|
||||
if (!MENU_FROZEN && !EDITOR_OPEN) {
|
||||
handleJoinInput();
|
||||
}
|
||||
|
||||
GlobalInputs::update(delta_ms);
|
||||
if (GlobalInputs::wantsQuit()) { wants_quit_ = true; }
|
||||
|
||||
// El món avança amb `delta_ms * pause_.scale`: scale=1 corre a velocitat
|
||||
// real, scale=0 el congela. Quan l'editor de debug està actiu també
|
||||
// es salta completament (no l'escalem, el saltem). Notifier,
|
||||
// RenderInfo i HUD reben el delta real, per això viuen fora d'aquest
|
||||
// bloc i no es ralenteixen amb el dial de pausa.
|
||||
// El bloc de món no s'executa si el menú de servei o l'editor de
|
||||
// debug estan oberts: tots dos congelen el joc instantàniament.
|
||||
// Notifier, RenderInfo i HUD viuen fora d'aquest bloc perquè s'han
|
||||
// de seguir actualitzant en aquests casos.
|
||||
#ifdef _DEBUG
|
||||
const bool EDITOR_FROZEN = editor_.active();
|
||||
#else
|
||||
constexpr bool EDITOR_FROZEN = false;
|
||||
#endif
|
||||
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
|
||||
if (!EDITOR_FROZEN && !MENU_FROZEN && SCALED_DELTA_MS > 0) {
|
||||
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
|
||||
if (!EDITOR_FROZEN && !MENU_FROZEN && delta_ms > 0) {
|
||||
const float DT = static_cast<float>(delta_ms) / 1000.0F;
|
||||
|
||||
// Pipeline de moviment en tres passes per poder intercalar la
|
||||
// resolució de contactes jugador↔jugador entre input i posició:
|
||||
@@ -1793,18 +1766,12 @@ void GameScene::render() {
|
||||
};
|
||||
std::vector<Renderable> order;
|
||||
order.reserve(entity_manager_.entityCount() + players_.size());
|
||||
// Mergem els dos sacs (map + actors) i els jugadors en un sol
|
||||
// vector temporal — el `stable_sort` per `feet_y` posterior fa el
|
||||
// z-order interleaving entre els tres orígens sense importar el
|
||||
// sac d'on venen.
|
||||
const auto MAP = entity_manager_.getMapEntities();
|
||||
std::ranges::transform(MAP, std::back_inserter(order), [](Entity* e) {
|
||||
// Mergem entitats i jugadors en un sol vector temporal — el
|
||||
// `stable_sort` per `feet_y` posterior fa el z-order interleaving.
|
||||
const auto ENTITIES = entity_manager_.getEntities();
|
||||
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
|
||||
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
|
||||
});
|
||||
const auto ACTORS = entity_manager_.getActors();
|
||||
std::ranges::transform(ACTORS, std::back_inserter(order), [](DynamicHazard* a) -> Renderable {
|
||||
return Renderable{.feet_y = a->getFeetY(), .entity = a, .player = nullptr};
|
||||
});
|
||||
// Tots els jugadors entren al z-sort. Els fora de joc fan no-op a
|
||||
// `render()`; els actius (inclosos AIRBORNE durant l'spawn aeri)
|
||||
// es pinten amb el seu feet_y normal. La sombra que afecta la
|
||||
@@ -1823,6 +1790,12 @@ void GameScene::render() {
|
||||
}
|
||||
}
|
||||
|
||||
// Pasada d'il·luminació: oscureix el canvas segons `Map::light_level`
|
||||
// i hi obre cercles brillants al voltant dels emissors (jugadors,
|
||||
// antorxes, mòmies, ...). Va abans del fade i del HUD perquè el
|
||||
// fade pinta negre encima i la UI ha de quedar llegible.
|
||||
applyLightingPass();
|
||||
|
||||
// Fade del món: pintat ENTRE actors i HUD perquè el marcador, les
|
||||
// notificacions i el service menu queden per damunt sense afectar-se.
|
||||
// Inactiu si l'estat és PLAYING.
|
||||
@@ -1851,36 +1824,6 @@ void GameScene::render() {
|
||||
editor_.render(tilemap_, entity_manager_);
|
||||
#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();
|
||||
RenderInfo::get().render();
|
||||
Screen::get().present();
|
||||
|
||||
@@ -55,24 +55,11 @@ class GameScene : public Scene {
|
||||
void setState(RoomFsm::State new_state);
|
||||
|
||||
// Avança la transició activa via `RoomFsm::advance`. S'executa cada
|
||||
// frame amb delta real (no escalat per la pausa) perquè la transició
|
||||
// viu en temps de UI. Si l'estat retornat és diferent de l'actual,
|
||||
// crida `setState` (que reseteja la `Transition`).
|
||||
// frame amb delta real perquè la transició viu en temps de UI. Si
|
||||
// l'estat retornat és diferent de l'actual, crida `setState` (que
|
||||
// reseteja la `Transition`).
|
||||
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`).
|
||||
// Cridat per la subscripció del bus a `player_jumped` /
|
||||
// `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.
|
||||
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
|
||||
// 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
|
||||
@@ -239,41 +234,11 @@ class GameScene : public Scene {
|
||||
// la destrucció.
|
||||
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).
|
||||
// Distinta de `pause_font_` perquè el rètol de fase té estètica
|
||||
// pròpia (CaesarDressing per defecte, configurable via
|
||||
// `data/config/ui.yaml > fonts.room_text`). La pausa segueix amb la
|
||||
// placeholder.
|
||||
// Té estètica pròpia (CaesarDressing per defecte, configurable via
|
||||
// `data/config/ui.yaml > fonts.room_text`).
|
||||
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
|
||||
// de `data/scenes/game/game.yaml`; valors per defecte si manca el YAML.
|
||||
RoomFsm::Config transition_config_{};
|
||||
@@ -306,6 +271,13 @@ class GameScene : public Scene {
|
||||
void renderRoomFade();
|
||||
void renderRoomText();
|
||||
|
||||
// Pasada d'il·luminació CPU sobre el canvas, entre els actors i el
|
||||
// HUD: recull els emissors actius (jugadors + entitats amb
|
||||
// `light_radius>0`) i aplica el gradient amb `Map::light_level`
|
||||
// com a llum de sala. El HUD/notificacions queden fora de la
|
||||
// multiplicació perquè es pinten després.
|
||||
void applyLightingPass();
|
||||
|
||||
// Configuració, estat i transició de la sub-FSM de game over
|
||||
// (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la
|
||||
// RoomFsm via `GameOverFsm::loadConfig`.
|
||||
@@ -372,6 +344,33 @@ class GameScene : public Scene {
|
||||
void drawRoomTextPanel(int center_y, int panel_height) const;
|
||||
void drawRoomTextLine(const std::string& text, int x, int y, std::uint8_t fill_color, std::uint8_t outline_color) const;
|
||||
|
||||
// Especificació declarativa d'un rètol de transició de dues línies
|
||||
// amb panel translúcid. ROOM_INTRO, ROOM_OUTRO, CONTINUE? i GAME OVER
|
||||
// comparteixen la mateixa coreografia (panel + dues línies en
|
||||
// direccions oposades) i només divergeixen en el text, els índexs
|
||||
// de fase i les durades. `phase_enter = -1` desactiva l'animació
|
||||
// d'entrada (cas GAME_OVER, que apareix amb el panel ja amunt).
|
||||
// Una `lineN` buida indica que la línia no s'ha de pintar (cas
|
||||
// CONTINUE? fora del hold, on la línia 2 mostra el countdown).
|
||||
struct TextScreenSpec {
|
||||
std::string line1;
|
||||
std::string line2;
|
||||
int phase_enter{0};
|
||||
int phase_hold{0};
|
||||
int phase_exit{0};
|
||||
int dur_enter_ms{0};
|
||||
int dur_exit_ms{0};
|
||||
int line_gap_px{0};
|
||||
};
|
||||
|
||||
// Orquestrador unificat dels quatre rètols de transició. Pinta el
|
||||
// panel + dues línies a partir de `spec`, deixant la decisió de
|
||||
// QUÈ pintar (text, fases, durades) als callers `renderRoomText`
|
||||
// i `renderGameOverText`. Sense estat propi: la fase i temps
|
||||
// arriben pels paràmetres perquè la mateixa funció serveix les
|
||||
// dues sub-FSMs (RoomFsm i GameOverFsm).
|
||||
void renderTextScreen(const TextScreenSpec& spec, int phase, int elapsed_ms) const;
|
||||
|
||||
// Posició inicial dels jugadors derivada de `Map::door_col`. Tots
|
||||
// els jugadors comparteixen Y (centre del corredor exterior superior);
|
||||
// X depèn del nombre actiu: amb un jugador queda centrat sota la
|
||||
@@ -418,11 +417,10 @@ class GameScene : public Scene {
|
||||
#endif
|
||||
|
||||
#ifdef _DEBUG
|
||||
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la
|
||||
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i
|
||||
// viceversa. Quan `editor_.active()` és true, saltem entitats i
|
||||
// jugador (update i render) i deixem el tilemap visible perquè es
|
||||
// pugui editar.
|
||||
// Editor modal del tilemap. Mútuament excloent amb el menú de servei:
|
||||
// si el menú està obert, la tecla de toggle de l'editor s'ignora.
|
||||
// Quan `editor_.active()` és true, saltem entitats i jugador (update
|
||||
// i render) i deixem el tilemap visible perquè es pugui editar.
|
||||
Editor editor_;
|
||||
#endif
|
||||
};
|
||||
@@ -40,6 +40,25 @@ namespace {
|
||||
// Separació extra entre camps dins d'una mateixa línia.
|
||||
constexpr int FIELD_GAP = 6; // px entre "Vides 3" i "Tres. 00"
|
||||
|
||||
// Desplaçament de l'ombra del text en píxels (sota-dreta). Petit
|
||||
// perquè la font del HUD és 8 px d'alçada — qualsevol valor més
|
||||
// gran tapa el següent caràcter.
|
||||
constexpr int SHADOW_DX = 1;
|
||||
constexpr int SHADOW_DY = 1;
|
||||
|
||||
// Helpers per pintar text amb ombra. Primer la sombra desplaçada,
|
||||
// després el text principal al damunt — l'ordre garanteix que la
|
||||
// sombra mai cobreix el text. El kerning queda al default del
|
||||
// Text (1 px), igual que la resta del HUD.
|
||||
void drawShadowed(Text& font, int x, int y, const std::string& s, Uint8 color, Uint8 shadow) {
|
||||
font.writeColored(x + SHADOW_DX, y + SHADOW_DY, s, shadow);
|
||||
font.writeColored(x, y, s, color);
|
||||
}
|
||||
void drawMonoShadowed(Text& font, int x, int y, const std::string& s, Uint8 color, Uint8 shadow, int cell_w) {
|
||||
font.writeMonoDigits(x + SHADOW_DX, y + SHADOW_DY, s, shadow, cell_w);
|
||||
font.writeMonoDigits(x, y, s, color, cell_w);
|
||||
}
|
||||
|
||||
// Format zero-padded a 6 dígits per a la puntuació.
|
||||
auto formatScore(int v) -> std::string {
|
||||
const int CLAMPED = std::clamp(v, 0, 999999);
|
||||
@@ -116,10 +135,14 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font, int initial_lives)
|
||||
players_[1].active = false;
|
||||
players_[1].lives = initial_lives_;
|
||||
|
||||
// Subscripcions al bus de l'EntityManager. De moment només `player_hit`
|
||||
// afecta el HUD visualment: decrementa vides, dispara flash i shake al
|
||||
// bloc del jugador. Quan els events evolucionin per portar `player_id`
|
||||
// i puntuacions, ampliarem aquí sense tocar callers.
|
||||
// Subscripcions al bus de l'EntityManager.
|
||||
//
|
||||
// `player_hit` només fa feedback visual (flash + shake) al bloc del
|
||||
// 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) {
|
||||
const auto* dmg = std::get_if<int>(&ev.payload);
|
||||
// Dispatch pel `player_id` de l'event. 0 = P1 (bloc LEFT), 1 = P2
|
||||
@@ -128,14 +151,28 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font, int initial_lives)
|
||||
// publishers de `player_hit` actuals sí que porten id.
|
||||
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
|
||||
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)
|
||||
: static_cast<std::size_t>(BlockId::RIGHT);
|
||||
auto& blk = blocks_[BLK_IDX];
|
||||
blk.flash_ms_left = UiConfig::get().hudFlashMs();
|
||||
blk.shake_ms_left = UiConfig::get().hudShakeMs();
|
||||
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*/) {
|
||||
@@ -169,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.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) {
|
||||
@@ -184,10 +225,11 @@ void Hud::activatePlayer(int player_one_based) {
|
||||
if (player_one_based < 1 || player_one_based > 2) { return; }
|
||||
auto& p = players_[static_cast<std::size_t>(player_one_based - 1)];
|
||||
p.active = true;
|
||||
p.lives = initial_lives_;
|
||||
p.score = 0;
|
||||
p.treasures = 0;
|
||||
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) {
|
||||
@@ -219,6 +261,16 @@ auto Hud::computeOffset(const Block& b) -> SDL_FPoint {
|
||||
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 {
|
||||
// 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
|
||||
@@ -261,11 +313,22 @@ void Hud::renderBlock(BlockId id) const {
|
||||
void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index, const std::string& label) const {
|
||||
const auto& p = players_[static_cast<std::size_t>(player_index)];
|
||||
|
||||
// Família de color del bloc: P1 (vermell-crema-vermell-fosc) per al
|
||||
// jugador 0, P2 (cian-blau-blau-marí) per al jugador 1. Mantenir el
|
||||
// mateix matís a label/value/shadow dóna jerarquia visual coherent
|
||||
// entre línies sense empastar (la sombra és un to fosc del mateix
|
||||
// matís → efecte embossing).
|
||||
const auto& ui = UiConfig::get();
|
||||
const Uint8 LBL_C = (player_index == 0) ? ui.hudP1Label() : ui.hudP2Label();
|
||||
const Uint8 VAL_C = (player_index == 0) ? ui.hudP1Value() : ui.hudP2Value();
|
||||
const Uint8 SHD_C = (player_index == 0) ? ui.hudP1Shadow() : ui.hudP2Shadow();
|
||||
|
||||
// Slot inactiu amb prompt actiu: substituïm el render de zeros pel
|
||||
// missatge "PULSA START / PARA JUGAR" o "ESPERA / PARA JUGAR" segons
|
||||
// el mode fixat per la GameScene cada frame. El render de zeros
|
||||
// queda com a fallback (mode NONE o slot actiu).
|
||||
if (!p.active && p.join_prompt != JoinPromptMode::NONE) {
|
||||
if (!blinkVisible()) { return; }
|
||||
const bool CAN_JOIN = (p.join_prompt == JoinPromptMode::CAN_JOIN);
|
||||
const std::string L1 = Locale::get().text(
|
||||
CAN_JOIN ? "hud.join_prompt_can_join_l1" : "hud.join_prompt_wait_l1");
|
||||
@@ -275,12 +338,12 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
|
||||
const int L2_W = font_->length(L2);
|
||||
const int X1 = x + ((w - L1_W) / 2);
|
||||
const int X2 = x + ((w - L2_W) / 2);
|
||||
font_->writeColored(X1, y + LINE1_Y, L1, UiConfig::get().hudLabel());
|
||||
font_->writeColored(X2, y + LINE2_Y, L2, UiConfig::get().hudValue());
|
||||
drawShadowed(*font_, X1, y + LINE1_Y, L1, LBL_C, SHD_C);
|
||||
drawShadowed(*font_, X2, y + LINE2_Y, L2, VAL_C, SHD_C);
|
||||
return;
|
||||
}
|
||||
|
||||
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? UiConfig::get().hudFlash() : UiConfig::get().hudValue();
|
||||
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? ui.hudFlash() : VAL_C;
|
||||
|
||||
// --- Línia 1: "<label>-000000" centrada ---
|
||||
const std::string LBL1 = label + SEPARATOR;
|
||||
@@ -289,9 +352,9 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
|
||||
const int SC_W = font_->lengthMonoDigits(SCORE, MONO_DIGIT_W);
|
||||
const int LINE1_W = LBL_W + SC_W;
|
||||
int xx = x + ((w - LINE1_W) / 2);
|
||||
font_->writeColored(xx, y + LINE1_Y, LBL1, UiConfig::get().hudLabel());
|
||||
drawShadowed(*font_, xx, y + LINE1_Y, LBL1, LBL_C, SHD_C);
|
||||
xx += LBL_W;
|
||||
font_->writeMonoDigits(xx, y + LINE1_Y, SCORE, SCORE_C, MONO_DIGIT_W);
|
||||
drawMonoShadowed(*font_, xx, y + LINE1_Y, SCORE, SCORE_C, SHD_C, MONO_DIGIT_W);
|
||||
|
||||
// --- Línia 2: "<vides>-<n> <tresors>-<n>" centrada ---
|
||||
const std::string V_LBL = Locale::get().text("hud.lives") + SEPARATOR;
|
||||
@@ -304,16 +367,21 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
|
||||
const int T_VW = font_->lengthMonoDigits(T_VAL, MONO_DIGIT_W);
|
||||
const int LINE2_W = V_LW + V_VW + FIELD_GAP + T_LW + T_VW;
|
||||
xx = x + ((w - LINE2_W) / 2);
|
||||
font_->writeColored(xx, y + LINE2_Y, V_LBL, UiConfig::get().hudLabel());
|
||||
drawShadowed(*font_, xx, y + LINE2_Y, V_LBL, LBL_C, SHD_C);
|
||||
xx += V_LW;
|
||||
font_->writeMonoDigits(xx, y + LINE2_Y, V_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
|
||||
drawMonoShadowed(*font_, xx, y + LINE2_Y, V_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
|
||||
xx += V_VW + FIELD_GAP;
|
||||
font_->writeColored(xx, y + LINE2_Y, T_LBL, UiConfig::get().hudLabel());
|
||||
drawShadowed(*font_, xx, y + LINE2_Y, T_LBL, LBL_C, SHD_C);
|
||||
xx += T_LW;
|
||||
font_->writeMonoDigits(xx, y + LINE2_Y, T_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
|
||||
drawMonoShadowed(*font_, xx, y + LINE2_Y, T_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
|
||||
}
|
||||
|
||||
void Hud::renderIdleCenter(int x, int y, int w) const {
|
||||
const auto& ui = UiConfig::get();
|
||||
const Uint8 LBL_C = ui.hudCenterLabel();
|
||||
const Uint8 VAL_C = ui.hudCenterValue();
|
||||
const Uint8 SHD_C = ui.hudCenterShadow();
|
||||
|
||||
// --- Línia 1: "<piramide>-<n>" centrada ---
|
||||
const std::string PIR_LBL = Locale::get().text("hud.pyramid") + SEPARATOR;
|
||||
const std::string PIR_VAL = std::to_string(pyramid_);
|
||||
@@ -321,9 +389,9 @@ void Hud::renderIdleCenter(int x, int y, int w) const {
|
||||
const int PV_W = font_->lengthMonoDigits(PIR_VAL, MONO_DIGIT_W);
|
||||
const int LINE1_W = PL_W + PV_W;
|
||||
int xx = x + ((w - LINE1_W) / 2);
|
||||
font_->writeColored(xx, y + LINE1_Y, PIR_LBL, UiConfig::get().hudLabel());
|
||||
drawShadowed(*font_, xx, y + LINE1_Y, PIR_LBL, LBL_C, SHD_C);
|
||||
xx += PL_W;
|
||||
font_->writeMonoDigits(xx, y + LINE1_Y, PIR_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
|
||||
drawMonoShadowed(*font_, xx, y + LINE1_Y, PIR_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
|
||||
|
||||
// --- Línia 2: "<sala>-<r>" centrada ---
|
||||
const std::string SALA_LBL = Locale::get().text("hud.room") + SEPARATOR;
|
||||
@@ -332,7 +400,7 @@ void Hud::renderIdleCenter(int x, int y, int w) const {
|
||||
const int SV_W = font_->lengthMonoDigits(SALA_VAL, MONO_DIGIT_W);
|
||||
const int LINE2_W = SL_W + SV_W;
|
||||
xx = x + ((w - LINE2_W) / 2);
|
||||
font_->writeColored(xx, y + LINE2_Y, SALA_LBL, UiConfig::get().hudLabel());
|
||||
drawShadowed(*font_, xx, y + LINE2_Y, SALA_LBL, LBL_C, SHD_C);
|
||||
xx += SL_W;
|
||||
font_->writeMonoDigits(xx, y + LINE2_Y, SALA_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
|
||||
drawMonoShadowed(*font_, xx, y + LINE2_Y, SALA_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
|
||||
}
|
||||
@@ -170,6 +170,10 @@ class Hud {
|
||||
// les constants de classe (no toca cap altre membre).
|
||||
[[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_;
|
||||
std::vector<EventBus::SubId> subs_;
|
||||
|
||||
@@ -179,6 +183,10 @@ class Hud {
|
||||
std::array<PlayerStats, 2> players_;
|
||||
int pyramid_{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
|
||||
// ctor des de `Player::death_config.initial_lives` per no llegir el
|
||||
// singleton estàtic en el camí calent del bus de events.
|
||||
|
||||
@@ -106,6 +106,44 @@ namespace Defaults {
|
||||
constexpr float PUSH_FACTOR = 0.30F;
|
||||
} // namespace Player
|
||||
|
||||
// Fallbacks del sistema d'il·luminació. El gradient en viu viu a
|
||||
// data/config/lighting.yaml; aquests valors només s'apliquen si el
|
||||
// fitxer falta o li manca alguna clau. La forma final (3 anells) es
|
||||
// descriu al YAML; veure `Lighting::GradientConfig` per a la semàntica.
|
||||
namespace Lighting {
|
||||
// Defaults del gradient subtractive (mode històric). Les
|
||||
// intensitats són multiplicadors 0..1 que es multipliquen sobre
|
||||
// el píxel: 1.0 = no toca, 0.0 = negre.
|
||||
constexpr float INNER_RATIO = 0.8F;
|
||||
constexpr float MID_RATIO = 0.9F;
|
||||
constexpr float INNER_INTENSITY = 1.0F;
|
||||
constexpr float MID_INTENSITY = 0.9F;
|
||||
constexpr float OUTER_INTENSITY = 0.8F;
|
||||
// Defaults del gradient additive. Les intensitats són "alfa"
|
||||
// del color afegit (0..1): 1.0 = afegeix el color sencer i
|
||||
// satura, 0.0 = no afegeix res. Valors baixos a propòsit perquè
|
||||
// l'additive es nota fort molt fàcilment — només un toc de tint
|
||||
// cromàtic al voltant de l'emissor.
|
||||
constexpr float INNER_RATIO_ADDITIVE = 0.5F;
|
||||
constexpr float MID_RATIO_ADDITIVE = 0.85F;
|
||||
constexpr float INNER_INTENSITY_ADDITIVE = 0.15F;
|
||||
constexpr float MID_INTENSITY_ADDITIVE = 0.10F;
|
||||
constexpr float OUTER_INTENSITY_ADDITIVE = 0.05F;
|
||||
// Nivell d'il·luminació de sala per defecte. Una sala sense la
|
||||
// clau `light_level` queda totalment il·luminada (com hui).
|
||||
constexpr float ROOM_LIGHT_DEFAULT = 1.0F;
|
||||
// Defaults del flicker. Els reemplaça el bloc `flicker:` de
|
||||
// data/config/lighting.yaml. Veure `Lighting::FlickerConfig`
|
||||
// per a la semàntica de cada camp. Les amplituds són **ràtios**
|
||||
// sobre el radi base de l'emissor: així el feel del flicker és
|
||||
// independent del radi. Els valors per defecte conserven el feel
|
||||
// anterior per a una torxa de radi 40 (2/40 = 0.05, 1/40 = 0.025).
|
||||
constexpr float FLICKER_WAVE_AMPLITUDE_RATIO = 0.05F;
|
||||
constexpr float FLICKER_WAVE_FREQUENCY_HZ = 3.0F;
|
||||
constexpr float FLICKER_NOISE_AMPLITUDE_RATIO = 0.025F;
|
||||
constexpr float FLICKER_NOISE_FREQUENCY_HZ = 7.0F;
|
||||
} // namespace Lighting
|
||||
|
||||
// Fallbacks del tema d'UI: índexs de paleta, timings (ms) i fonts.
|
||||
// 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
|
||||
@@ -217,20 +255,28 @@ namespace Defaults {
|
||||
constexpr Uint8 MENU_DISABLED = 16; // Label en gris quan no aplica.
|
||||
|
||||
// Índexs de paleta per al marcador de joc (HUD de la GameScene).
|
||||
// Arranca reciclant els colors del menú per coherència visual;
|
||||
// separant els noms a constants pròpies, tocar-los a posteriori
|
||||
// no afecta el menú.
|
||||
// Tres "famílies" de color — un parell (label, value) + sombra per
|
||||
// cada bloc — donen jerarquia visual: P1 càlid (vermell+crema),
|
||||
// CENTER ambiental (ambre+or), P2 fred (cian+blau pàl·lid). La
|
||||
// sombra és un to més fosc del mateix matís per donar volum.
|
||||
constexpr Uint8 HUD_BG = 0; // Fons de cada bloc.
|
||||
constexpr Uint8 HUD_LABEL = 58; // Etiquetes ("P1", "V", "T").
|
||||
constexpr Uint8 HUD_VALUE = 187; // Valors (score, vides, tresors).
|
||||
constexpr Uint8 HUD_FLASH = 237; // Color temporal del valor en flash.
|
||||
constexpr Uint8 HUD_P1_LABEL = 7; // P1 label: vermell pur (#ff0000).
|
||||
constexpr Uint8 HUD_P1_VALUE = 183; // P1 value: crema-préssec (#ffdaaa).
|
||||
constexpr Uint8 HUD_P1_SHADOW = 2; // P1 ombra: vermell fosc (#480000).
|
||||
constexpr Uint8 HUD_CENTER_LABEL = 29; // CENTER label: ambre cremat (#b66d00).
|
||||
constexpr Uint8 HUD_CENTER_VALUE = 55; // CENTER value: or brillant (#ffda00).
|
||||
constexpr Uint8 HUD_CENTER_SHADOW = 10; // CENTER ombra: marró cremat (#482400).
|
||||
constexpr Uint8 HUD_P2_LABEL = 248; // P2 label: cian pur (#00ffff).
|
||||
constexpr Uint8 HUD_P2_VALUE = 253; // P2 value: blau pàl·lid (#b6ffff).
|
||||
constexpr Uint8 HUD_P2_SHADOW = 64; // P2 ombra: blau marí (#000055).
|
||||
constexpr Uint8 HUD_FLASH = 63; // Flash (groc neó, comú a totes les famílies).
|
||||
|
||||
// Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO).
|
||||
// Independent dels colors de notificació perquè el rètol de
|
||||
// fase és un canal visual diferent. `ROOM_TEXT_OUTLINE` és el
|
||||
// contorn que abans estava hardcodejat al `renderRoomText`.
|
||||
constexpr Uint8 ROOM_TEXT = 232;
|
||||
constexpr Uint8 ROOM_TEXT_OUTLINE = 255;
|
||||
// Comparteix la família CENTER del HUD: or sobre marró cremat,
|
||||
// per coherència visual ("Piràmide N / Sala N" és sempre
|
||||
// daurat arreu del joc).
|
||||
constexpr Uint8 ROOM_TEXT = 55; // Or brillant (= HUD_CENTER_VALUE).
|
||||
constexpr Uint8 ROOM_TEXT_OUTLINE = 10; // Marró cremat (= HUD_CENTER_SHADOW).
|
||||
|
||||
// Timings d'animació del HUD. L'entrada són els tres blocs lliscant
|
||||
// a la seua posició; flash i shake acompanyen un `player_hit`.
|
||||
@@ -238,16 +284,11 @@ namespace Defaults {
|
||||
constexpr int HUD_FLASH_MS = 250;
|
||||
constexpr int HUD_SHAKE_MS = 300;
|
||||
|
||||
// Pausa graduada de la GameScene: el món no para d'un colp, es
|
||||
// frena amb una corba i torna a arrancar amb una altra. Els noms
|
||||
// de corba han de coincidir amb els registrats a
|
||||
// UiConfig::curveByName (linear / outQuad / inQuad / inOutQuad /
|
||||
// outCubic / inCubic). Si un nom no es reconeix, es cau a
|
||||
// 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";
|
||||
// Parpadeig dels prompts inactius del HUD ("Polsa start..." /
|
||||
// "Espera..."). Un únic temporitzador global al Hud sincronitza
|
||||
// P1 i P2. cycle<=0 o ratio>=1 → sempre visible (disable trivial).
|
||||
constexpr int HUD_BLINK_CYCLE_MS = 1000;
|
||||
constexpr float HUD_BLINK_VISIBLE_RATIO = 0.8F;
|
||||
|
||||
// Sons del menú de servei. SELECT acompanya navegació (amunt/avall)
|
||||
// i back; ACCEPT acompanya modificació d'opció (esq/dret/retorn).
|
||||
@@ -495,6 +536,7 @@ namespace Defaults {
|
||||
constexpr const char* SOUNDS_YAML = "data/config/sounds.yaml";
|
||||
constexpr const char* INPUT_YAML = "data/config/input.yaml";
|
||||
constexpr const char* EDITOR_YAML = "data/config/editor.yaml";
|
||||
constexpr const char* LIGHTING_YAML = "data/config/lighting.yaml";
|
||||
constexpr const char* UI_PALETTE = "data/palettes/uzebox.pal";
|
||||
|
||||
// Configuració d'escenes (yaml). El de la GameScene viu a
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace Texts {
|
||||
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner";
|
||||
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
|
||||
constexpr const char* VERSION = "0.41.0"; // Single source of truth — Makefile el grep
|
||||
constexpr const char* VERSION = "0.45.0"; // Single source of truth — Makefile el grep
|
||||
} // namespace Texts
|
||||
|
||||
namespace GameCanvas {
|
||||
|
||||
@@ -18,6 +18,9 @@ auto Audio::get() -> Audio* {
|
||||
void Audio::playSound(const std::string& /*name*/, Audio::Group /*group*/) {
|
||||
}
|
||||
|
||||
void Audio::playSound(const std::string& /*name*/, Audio::Group /*group*/, float /*speed*/) {
|
||||
}
|
||||
|
||||
namespace Resource {
|
||||
|
||||
auto Cache::get() -> Cache& {
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
|
||||
// T8 decoration — Torch no col·lisiona ni és interactable.
|
||||
// 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.
|
||||
// T12 collision — `Entity::CollisionBox` encastada al sprite: la caixa
|
||||
// retornada coincideix amb les dimensions del bloc
|
||||
@@ -69,13 +70,14 @@ namespace {
|
||||
|
||||
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
|
||||
|
||||
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra).
|
||||
// Equival al que fa GameScene al final del text-exit de ROOM_INTRO: arma
|
||||
// el comptador i consumeix la durada amb un update. Tests que operin
|
||||
// directament sobre HURT/ROAMING l'han de cridar abans.
|
||||
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra
|
||||
// amb l'animació `raise` activa). Tickem amb dt gran fins sortir de
|
||||
// SPAWNING — mateix path que `Mummy::update` executa al final de
|
||||
// l'animació en partida real. El for limita el bucle defensivament.
|
||||
void exitMummySpawn(Mummy& mummy) {
|
||||
mummy.triggerSpawnExit();
|
||||
mummy.update(mummy.getSpawnDurationS() + 0.001F);
|
||||
for (int i = 0; i < 32 && mummy.isSpawning(); ++i) {
|
||||
mummy.update(0.5F);
|
||||
}
|
||||
}
|
||||
|
||||
void testFactory() {
|
||||
@@ -85,6 +87,7 @@ namespace {
|
||||
const std::vector<std::string> TYPES = {
|
||||
"torch", "sarcophagus", "door", "mummy",
|
||||
"rolling_rock", "wall_trap", "floor_trap", "hole",
|
||||
"stone", "column",
|
||||
};
|
||||
for (const auto& type : TYPES) {
|
||||
fkyaml::node tmpl = EntityLoader::loadTemplate(type);
|
||||
@@ -142,32 +145,36 @@ namespace {
|
||||
fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus");
|
||||
auto sarc = std::make_unique<Sarcophagus>(tmpl);
|
||||
// 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
|
||||
// BlockedPredicate):
|
||||
// Nord: (1,0), (2,0), (3,0)
|
||||
// Sud: (1,2), (2,2), (3,2)
|
||||
// cobreix (1,1), (2,1), (3,1). Perímetre esperat (sense
|
||||
// BlockedPredicate), incloent les quatre cantonades diagonals:
|
||||
// Nord (amb NO i NE): (0,0), (1,0), (2,0), (3,0), (4,0)
|
||||
// Sud (amb SO i SE): (0,2), (1,2), (2,2), (3,2), (4,2)
|
||||
// Oest: (0,1)
|
||||
// Est: (4,1)
|
||||
// Total: 8 tiles.
|
||||
// Total: 12 tiles.
|
||||
sarc->place(1, 1, {});
|
||||
REQUIRE(sarc->getPerimeterSize() == 8);
|
||||
REQUIRE(sarc->getPerimeterSize() == 12);
|
||||
REQUIRE(sarc->getState() == Sarcophagus::State::CLOSED);
|
||||
|
||||
// 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);
|
||||
|
||||
// Visitem tots els del perímetre.
|
||||
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); }
|
||||
REQUIRE(sarc->getVisitedCount() == 8);
|
||||
for (const auto& [c, r] : PERIM) { sarc->notifyTilePainted(c, r, /*player_id=*/0); }
|
||||
REQUIRE(sarc->getVisitedCount() == 12);
|
||||
REQUIRE(sarc->getState() == Sarcophagus::State::OPENING);
|
||||
|
||||
// Un update amb l'animació `opening` d'1 frame loop=false completa
|
||||
// la transició a OPEN.
|
||||
sarc->update(0.0F);
|
||||
// Tickem prou per consumir l'animació `opening` (4 frames @ 6fps =
|
||||
// ~0.67s). Quan completa, la transició OPENING → OPEN es dispara.
|
||||
for (int i = 0; i < 16 && sarc->getState() == Sarcophagus::State::OPENING; ++i) {
|
||||
sarc->update(0.1F);
|
||||
}
|
||||
REQUIRE(sarc->getState() == Sarcophagus::State::OPEN);
|
||||
}
|
||||
|
||||
@@ -189,30 +196,33 @@ namespace {
|
||||
manager.add(std::move(door));
|
||||
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) {
|
||||
const int COL0 = s->getTilePos().col;
|
||||
const int ROW0 = s->getTilePos().row;
|
||||
const int W = s->getTileWidth();
|
||||
const int H = s->getTileHeight();
|
||||
for (int dc = 0; dc < W; ++dc) {
|
||||
s->notifyTilePainted(COL0 + dc, ROW0 - 1);
|
||||
s->notifyTilePainted(COL0 + dc, ROW0 + H);
|
||||
for (int dc = -1; dc <= W; ++dc) {
|
||||
s->notifyTilePainted(COL0 + dc, ROW0 - 1, /*player_id=*/0);
|
||||
s->notifyTilePainted(COL0 + dc, ROW0 + H, /*player_id=*/0);
|
||||
}
|
||||
for (int dr = 0; dr < H; ++dr) {
|
||||
s->notifyTilePainted(COL0 - 1, ROW0 + dr);
|
||||
s->notifyTilePainted(COL0 + W, ROW0 + dr);
|
||||
s->notifyTilePainted(COL0 - 1, ROW0 + dr, /*player_id=*/0);
|
||||
s->notifyTilePainted(COL0 + W, ROW0 + dr, /*player_id=*/0);
|
||||
}
|
||||
};
|
||||
forceOpen(s1);
|
||||
forceOpen(s2);
|
||||
|
||||
// Un primer tick: cada sarc transiciona OPENING → OPEN i publica;
|
||||
// l'handler del manager compta i, en arribar al total, crida
|
||||
// door->open() — que en aquest mateix tick (la iteració sobre
|
||||
// entities continua) farà transitar la porta OPENING → OPEN perquè
|
||||
// l'animació és d'1 frame loop=false.
|
||||
manager.update(0.0F);
|
||||
// Tickem prou per consumir l'animació `opening` dels sarcs (4
|
||||
// frames @ 6fps) i la de la porta. Cada sarc transiciona
|
||||
// OPENING → OPEN i publica; l'handler del manager compta i, en
|
||||
// arribar al total, crida `door->open()`, que també necessita
|
||||
// tickar fins acabar la seua animació d'obertura.
|
||||
for (int i = 0; i < 20 && door_ptr->getState() != Door::State::OPEN; ++i) {
|
||||
manager.update(0.1F);
|
||||
}
|
||||
REQUIRE(manager.openedSarcophagusCount() == 2);
|
||||
REQUIRE(door_ptr->getState() == Door::State::OPEN);
|
||||
|
||||
@@ -232,11 +242,12 @@ namespace {
|
||||
|
||||
void testAnimationComponent() {
|
||||
std::cout << "[T6] animation component\n";
|
||||
AnimationComponent anim({10, 20, 30}, 10.0F, true);
|
||||
// 100 ms per frame: a tick(0.15) avança 1 frame; tick(0.10) un altre.
|
||||
AnimationComponent anim = AnimationComponent::uniform({10, 20, 30}, 100.0F, true);
|
||||
REQUIRE(anim.frameCount() == 3);
|
||||
REQUIRE(anim.currentFrame() == 10);
|
||||
|
||||
anim.tick(0.15F); // 1.5 frames de 10 fps → hauria d'avançar a l'índex 1
|
||||
anim.tick(0.15F); // 1.5 frames de 100 ms → hauria d'avançar a l'índex 1
|
||||
REQUIRE(anim.currentFrame() == 20);
|
||||
|
||||
anim.tick(0.10F);
|
||||
@@ -247,21 +258,107 @@ namespace {
|
||||
REQUIRE(anim.currentFrame() == 10);
|
||||
|
||||
// Variant no-loop: es para al darrer i queda completed.
|
||||
AnimationComponent once({1, 2}, 10.0F, false);
|
||||
AnimationComponent once = AnimationComponent::uniform({1, 2}, 100.0F, false);
|
||||
once.tick(1.0F); // molts frames; ha d'arribar al final i quedar-se.
|
||||
REQUIRE(once.currentFrame() == 2);
|
||||
REQUIRE(once.completed());
|
||||
|
||||
// Variant d'1 frame no-loop: completed des del principi.
|
||||
AnimationComponent single({5}, 1.0F, false);
|
||||
AnimationComponent single = AnimationComponent::uniform({5}, 1000.0F, false);
|
||||
REQUIRE(single.completed());
|
||||
}
|
||||
|
||||
// Cobertura específica de `AnimationComponent::fromYaml`: format
|
||||
// `frame_ms` escalar i llista per-frame, gestió de `fps:` (rebutjat),
|
||||
// i defaults quan la clau no existeix.
|
||||
void testAnimationFromYaml() {
|
||||
std::cout << "[T6b] animation fromYaml\n";
|
||||
|
||||
// 1) Format escalar `frame_ms`: tots els frames duren el mateix.
|
||||
{
|
||||
const std::string YAML = R"(
|
||||
animations:
|
||||
walk: { frames: [0, 1, 2, 3], frame_ms: 100, loop: true }
|
||||
)";
|
||||
auto node = fkyaml::node::deserialize(YAML);
|
||||
auto anim = AnimationComponent::fromYaml(node, "walk");
|
||||
REQUIRE(anim.frameCount() == 4);
|
||||
REQUIRE(anim.currentFrame() == 0);
|
||||
anim.tick(0.10F);
|
||||
REQUIRE(anim.currentFrame() == 1);
|
||||
}
|
||||
|
||||
// 2) Format llista `frame_ms`: el darrer frame aguanta més.
|
||||
{
|
||||
const std::string YAML = R"(
|
||||
animations:
|
||||
raise:
|
||||
frames: [10, 11, 12]
|
||||
frame_ms: [100, 100, 500]
|
||||
loop: false
|
||||
)";
|
||||
auto node = fkyaml::node::deserialize(YAML);
|
||||
auto anim = AnimationComponent::fromYaml(node, "raise");
|
||||
REQUIRE(anim.frameCount() == 3);
|
||||
anim.tick(0.10F);
|
||||
REQUIRE(anim.currentFrame() == 11);
|
||||
anim.tick(0.10F);
|
||||
REQUIRE(anim.currentFrame() == 12);
|
||||
// El darrer frame dura 500 ms: tick de 400 ms no completa l'anim.
|
||||
anim.tick(0.40F);
|
||||
REQUIRE(!anim.completed());
|
||||
// 100 ms més (total 500 ms al frame 12) sí la completa.
|
||||
anim.tick(0.10F);
|
||||
REQUIRE(anim.completed());
|
||||
}
|
||||
|
||||
// 3) Clau no existent: defaults segurs (1 frame loop, no peta).
|
||||
{
|
||||
const std::string YAML = R"(
|
||||
animations:
|
||||
walk: { frames: [0], frame_ms: 100, loop: true }
|
||||
)";
|
||||
auto node = fkyaml::node::deserialize(YAML);
|
||||
auto anim = AnimationComponent::fromYaml(node, "missing");
|
||||
REQUIRE(anim.frameCount() == 1);
|
||||
REQUIRE(anim.loops());
|
||||
}
|
||||
|
||||
// 4) `fps:` no és suportat: ha de llençar excepció.
|
||||
{
|
||||
const std::string YAML = R"(
|
||||
animations:
|
||||
walk: { frames: [0, 1], fps: 8, loop: true }
|
||||
)";
|
||||
auto node = fkyaml::node::deserialize(YAML);
|
||||
bool threw = false;
|
||||
try {
|
||||
(void) AnimationComponent::fromYaml(node, "walk");
|
||||
} catch (const std::runtime_error&) {
|
||||
threw = true;
|
||||
}
|
||||
REQUIRE(threw);
|
||||
}
|
||||
|
||||
// 5) Flip_h al YAML es preserva al component.
|
||||
{
|
||||
const std::string YAML = R"(
|
||||
animations:
|
||||
west: { frames: [0, 1], frame_ms: 100, loop: true, flip_h: true }
|
||||
)";
|
||||
auto node = fkyaml::node::deserialize(YAML);
|
||||
auto anim = AnimationComponent::fromYaml(node, "west");
|
||||
REQUIRE(anim.flipH());
|
||||
REQUIRE(anim.flipMode() == SDL_FLIP_HORIZONTAL);
|
||||
}
|
||||
}
|
||||
|
||||
void testAllTemplatesLoad() {
|
||||
std::cout << "[T7] plantilles YAML\n";
|
||||
const std::vector<std::string> TYPES = {
|
||||
"torch", "sarcophagus", "door", "mummy",
|
||||
"rolling_rock", "wall_trap", "floor_trap", "hole",
|
||||
"stone", "column",
|
||||
};
|
||||
for (const auto& type : TYPES) {
|
||||
try {
|
||||
@@ -331,11 +428,14 @@ namespace {
|
||||
auto sarc = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
|
||||
sarc->place(3, 4, {});
|
||||
Sarcophagus* sarc_raw = manager.addSarcophagus(std::move(sarc));
|
||||
REQUIRE(sarc_raw->getPerimeterSize() == 8);
|
||||
// Les 8 tiles del perímetre: N (3..5,3), S (3..5,5), O (2,4), E (6,4).
|
||||
REQUIRE(sarc_raw->getPerimeterSize() == 12);
|
||||
// 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[] = {
|
||||
{3, 3}, {4, 3}, {5, 3},
|
||||
{3, 5}, {4, 5}, {5, 5},
|
||||
{2, 3}, {3, 3}, {4, 3}, {5, 3}, {6, 3},
|
||||
{2, 5}, {3, 5}, {4, 5}, {5, 5}, {6, 5},
|
||||
{2, 4}, {6, 4},
|
||||
};
|
||||
for (const auto& [c, r] : PERIM) {
|
||||
@@ -356,12 +456,13 @@ namespace {
|
||||
REQUIRE(tmpl["sprite"]["size"][0].get_value<int>() == 48);
|
||||
REQUIRE(tmpl["sprite"]["size"][1].get_value<int>() == 32);
|
||||
|
||||
// Amb tile_size [3,1], un sarc a ancoratge (3,4) conserva els 8
|
||||
// tiles de perímetre (no es converteix en 10 per culpa del [3,2]
|
||||
// antic). Això blinda la geometria contra regressions al YAML.
|
||||
// Amb tile_size [3,1], un sarc a ancoratge (3,4) té 12 tiles de
|
||||
// perímetre: 5 al nord (amb NO/NE), 5 al sud (amb SO/SE), 1 a
|
||||
// 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);
|
||||
sarc->place(3, 4, {});
|
||||
REQUIRE(sarc->getPerimeterSize() == 8);
|
||||
REQUIRE(sarc->getPerimeterSize() == 12);
|
||||
}
|
||||
|
||||
void testCollisionBox() {
|
||||
@@ -682,6 +783,7 @@ auto main() -> int {
|
||||
testSarcophagusPerimeter();
|
||||
testEntityManagerEndToEnd();
|
||||
testAnimationComponent();
|
||||
testAnimationFromYaml();
|
||||
testAllTemplatesLoad();
|
||||
testDecorationFlags();
|
||||
testLoadFromLevel();
|
||||
|
||||
@@ -32,12 +32,15 @@ namespace {
|
||||
|
||||
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
|
||||
|
||||
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra). Els
|
||||
// tests del whip necessiten que la mòmia estigui ACTIVE per reaccionar
|
||||
// al fueteig — sense això, `enterHurt` torna early per `isSpawning()`.
|
||||
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra
|
||||
// amb l'animació `raise` activa). Els tests del whip necessiten que la
|
||||
// mòmia estigui ACTIVE per reaccionar al fueteig — sense això,
|
||||
// `enterHurt` torna early per `isSpawning()`. Tickem amb dt gran fins
|
||||
// sortir de SPAWNING (mateix path real, només accelerat).
|
||||
void exitMummySpawn(Mummy& mummy) {
|
||||
mummy.triggerSpawnExit();
|
||||
mummy.update(mummy.getSpawnDurationS() + 0.001F);
|
||||
for (int i = 0; i < 32 && mummy.isSpawning(); ++i) {
|
||||
mummy.update(0.5F);
|
||||
}
|
||||
}
|
||||
|
||||
// Caja de jugador "estàndard" AEE: 16×32 amb cantonada (x, y).
|
||||
|
||||