Compare commits

..
45 Commits
Author SHA1 Message Date
JailDesigner 8eba52353b chore(version): bump 0.44.0 -> 0.45.0 2026-05-13 19:44:47 +02:00
JailDesigner 15a4016407 Merge branch 'feature/lighting-additive' 2026-05-13 19:44:31 +02:00
JailDesigner 2272d3a37a feat(lighting): flicker proporcional al radi amb overrides per emissor 2026-05-13 19:38:34 +02:00
JailDesigner 3a42c7958a feat(lighting): offset del centre de la llum per emissor 2026-05-13 18:26:19 +02:00
JailDesigner dacc4a9909 feat(lighting): corba additive separada al config 2026-05-13 17:53:32 +02:00
JailDesigner 771ff1f6cd feat(lighting): mode additive amb color per emissor 2026-05-13 14:11:51 +02:00
JailDesigner 24609d6c5c Merge branch 'feature/animation-loader-unification' 2026-05-13 13:27:51 +02:00
JailDesigner 9d0dde38f9 feat(lighting): override d'emissors per sala i per spawn 2026-05-13 12:52:08 +02:00
JailDesigner b76a3eb1f3 refactor(entities): unifica loader d'animacio via AnimationComponent::fromYaml i elimina fps: 2026-05-13 10:06:56 +02:00
JailDesigner a779533d61 fix(release): mou GIFs a la carpeta de l'entitat i silencia -Wcomment a director 2026-05-13 08:18:53 +02:00
JailDesigner bc6ad6f718 chore(version): bump 0.43.0 -> 0.44.0 2026-05-12 22:41:43 +02:00
JailDesigner 9bd5e7db33 Merge branch 'feature/lighting' 2026-05-12 22:40:55 +02:00
JailDesigner 5cc8e349a5 feat(lighting): seed estable per emissor (no deriva pel moviment) 2026-05-12 22:37:27 +02:00
JailDesigner 0046d526b0 fix(lighting): llig flicker tambe del YAML del jugador 2026-05-12 22:26:17 +02:00
JailDesigner fc9947b87f feat(data): flicker tambe a jugadors i momies 2026-05-12 22:17:36 +02:00
JailDesigner 35aaa6c519 feat(lighting): flicker tipus foc per antorxes 2026-05-12 22:07:25 +02:00
JailDesigner 3195746189 feat(data): activa la llum del jugador, antorxes i mòmies + sala test 2026-05-12 21:14:16 +02:00
JailDesigner d1d5679d14 feat(game_scene): wire lighting pass entre actors i HUD 2026-05-12 21:12:06 +02:00
JailDesigner ebb161f1f5 feat(entities): light_radius + getLightCenter a Entity i Player 2026-05-12 21:06:01 +02:00
JailDesigner 68be8e55ec feat(map): Map::light_level per sala des del YAML 2026-05-12 20:58:30 +02:00
JailDesigner 5ed963b53d feat(rendering): lighting module CPU + gradient config YAML 2026-05-12 20:56:27 +02:00
JailDesigner 5e5ff835a9 Merge branch 'feature/player-stats-bus' 2026-05-12 19:55:50 +02:00
JailDesigner f5d251c33b feat(stats): Player com a font de veritat de vides/score/tresors i sarcs amb recompensa 2026-05-12 19:51:42 +02:00
JailDesigner d186f66c2c art(player): paletes d'immunitat per-personatge (wash, ice, gold) 2026-05-12 19:10:00 +02:00
JailDesigner da361a1452 feat(editor): mostra mommies i altres dynamic hazards al mode editor 2026-05-12 13:58:45 +02:00
JailDesigner a43ae21014 feat(editor): preview del brush d'entitat i fons del picker mes clar 2026-05-12 13:35:01 +02:00
JailDesigner 6749780356 feat(entities): afegeix stone i column com a muros decoratius 2026-05-12 13:03:35 +02:00
JailDesigner 1c49248b72 art(debris): afegeix column.gif i stone.gif 2026-05-12 11:06:42 +02:00
JailDesigner 63a03ae632 refactor(pause): elimina pausa F11 (cobreix-la el menú de servei) 2026-05-12 10:52:46 +02:00
JailDesigner a8bff31a7c chore(fonts): regenera ari-w9500 sense accents 2026-05-12 10:39:10 +02:00
JailDesigner 793e87a203 feat(hud): parpadeig configurable als prompts inactius 2026-05-12 10:27:23 +02:00
JailDesigner 642e0653b2 fix(sarcophagus): inclou les cantonades diagonals al perímetre 2026-05-12 09:00:56 +02:00
JailDesigner d26abcb9d5 chore(version): bump 0.42.0 -> 0.43.0 2026-05-11 22:56:14 +02:00
JailDesigner a358d8ddbd Merge branch 'feat/assets-pipeline' 2026-05-11 22:55:50 +02:00
JailDesigner f7c4d62234 feat(screenshot): desa-les a la carpeta de sistema per plataforma 2026-05-11 22:50:45 +02:00
JailDesigner 06a49e09ba style(hud): rètol de sala daurat sobre banda violeta 2026-05-11 22:31:12 +02:00
JailDesigner 629baaaec3 feat(hud): famílies de color per bloc + ombra del text 2026-05-11 22:18:56 +02:00
JailDesigner 932d195eb0 feat(audio): so del sarcòfag amb pitch aleatori per obertura 2026-05-11 20:46:18 +02:00
JailDesigner 3e31a88f78 fix(anim): walk_* arrenca en frame distint del stand 2026-05-11 19:56:16 +02:00
JailDesigner cba46642c6 feat(mummy): SPAWN dirigit per l'animació raise 2026-05-11 19:47:28 +02:00
JailDesigner 8c84892250 art(dust): retocs al sprite 2026-05-11 17:44:10 +02:00
JailDesigner d14da0e61f fix(make): el target pack no canvia el build_type 2026-05-11 17:30:15 +02:00
JailDesigner 8bdc994b75 feat(render): sombra com a sprite 16x16 compartit 2026-05-11 17:29:05 +02:00
JailDesigner 6a70d6de9f style(hud): sentence case als prompts del slot inactiu 2026-05-11 16:32:57 +02:00
JailDesigner 2523661c8f refactor(game): unifica renderitzat dels rètols de transició via TextScreenSpec 2026-05-11 16:32:57 +02:00
95 changed files with 3386 additions and 993 deletions

No files matched your search

+3
View File
@@ -52,6 +52,7 @@ set(ENTITY_SOURCES_BASE
source/game/entities/concrete/floor_trap.cpp source/game/entities/concrete/floor_trap.cpp
source/game/entities/concrete/hole.cpp source/game/entities/concrete/hole.cpp
source/game/entities/concrete/dust.cpp source/game/entities/concrete/dust.cpp
source/game/entities/concrete/solid_props.cpp
) )
# Entitats que criden directament símbols de Player (com WhipAttack via # Entitats que criden directament símbols de Player (com WhipAttack via
@@ -80,6 +81,7 @@ set(APP_SOURCES
source/core/input/mouse.cpp source/core/input/mouse.cpp
source/core/rendering/screen.cpp source/core/rendering/screen.cpp
source/core/rendering/canvas.cpp source/core/rendering/canvas.cpp
source/core/rendering/lighting.cpp
source/core/rendering/notifier.cpp source/core/rendering/notifier.cpp
source/core/rendering/render_info.cpp source/core/rendering/render_info.cpp
source/core/rendering/gif.cpp source/core/rendering/gif.cpp
@@ -94,6 +96,7 @@ set(APP_SOURCES
source/core/rendering/sprite/animated_sprite_parser.cpp source/core/rendering/sprite/animated_sprite_parser.cpp
source/core/rendering/effects/heat_haze.cpp source/core/rendering/effects/heat_haze.cpp
source/core/rendering/effects/scan_reveal.cpp source/core/rendering/effects/scan_reveal.cpp
source/core/rendering/effects/shadow.cpp
source/core/rendering/transitions/mosaic.cpp source/core/rendering/transitions/mosaic.cpp
source/core/rendering/transitions/fade_to_black.cpp source/core/rendering/transitions/fade_to_black.cpp
source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp
+7 -1
View File
@@ -293,8 +293,14 @@ rebuild: clean all
# Empaqueta data/ a $(BUILDDIR)/resources.pack (format AE26). Força un rebuild # 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 # 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`. # 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: 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 @cmake --build $(BUILDDIR) --target pack_resources
@./$(BUILDDIR)/pack_resources data $(BUILDDIR)/resources.pack @./$(BUILDDIR)/pack_resources data $(BUILDDIR)/resources.pack
Binary file not shown.
+2 -2
View File
@@ -8,6 +8,6 @@ picker:
# Colors ARGB en hex. Accepta enter (0xFF2244FF) o cadena ("#FF2244FF"). # Colors ARGB en hex. Accepta enter (0xFF2244FF) o cadena ("#FF2244FF").
colors: colors:
border: "0xFF2244FF" # vora 1 px que envolta canvas i picker border: "0xFF6A86C8" # vora 1 px que envolta canvas i picker (blau suau)
picker_bg: "0xFF101824" # fons del picker picker_bg: "0xFFC8C0D8" # fons del picker (lavanda clara)
picker_hover: "0xFFFFFFFF" # vora 1 px del tile sota el cursor picker_hover: "0xFFFFFFFF" # vora 1 px del tile sota el cursor
-3
View File
@@ -35,9 +35,6 @@ keys:
- id: toggle_render_info - id: toggle_render_info
code: "F10" code: "F10"
desc: "Cicle de l'overlay de debug: apagat / dalt / baix" desc: "Cicle de l'overlay de debug: apagat / dalt / baix"
- id: pause
code: "F11"
desc: "Pausa el joc (o botó START del mando)"
- id: menu_toggle - id: menu_toggle
code: "F12" code: "F12"
desc: "Obri / tanca el menú de servei" desc: "Obri / tanca el menú de servei"
+56
View File
@@ -0,0 +1,56 @@
# Configuració global del sistema d'il·luminació.
#
# Cada llum emissora (player, torxa, mòmia, ...) té un únic paràmetre
# propi: el radi en píxels. La forma de la llum dins d'aquest radi
# (els tres anells del gradient) viu en aquest fitxer perquè és una
# decisió de "feel" comuna a totes les entitats — així pots tocar la
# corba aquí sense haver de modificar 10 YAMLs.
#
# Forma del gradient (centre → vora):
# 0..inner_ratio del radi → inner_intensity (zona totalment il·luminada)
# inner_ratio..mid_ratio → mid_intensity (anell mig)
# mid_ratio..1.0 → outer_intensity (anell exterior)
# fora del radi → light_level de la sala
#
# Hi ha dues corbes paral·leles, una per mode, perquè la mateixa escala
# 0..1 vol dir coses molt diferents:
# `gradient` → subtractive: 1.0 = "no fosqueix" el píxel
# (l'emissor "trenca" una sala fosca).
# `gradient_additive` → additive: 1.0 = "afegeix el color sencer"
# (satura). Valors molt més baixos donen un
# halo cromàtic subtil sobre una sala normal.
gradient:
inner_ratio: 0.5
mid_ratio: 0.85
inner_intensity: 1.0
mid_intensity: 0.8
outer_intensity: 0.6
# Corba del mode additive. Valors petits a propòsit: només volem un
# toc de tint cromàtic prop de l'emissor — fora del nucli, la sala
# original ha de quedar pràcticament intacta. Si pugeu massa l'inner,
# els colors de l'emissor "inunden" la sala (additiu satura ràpid).
gradient_additive:
inner_ratio: 0.5
mid_ratio: 0.85
inner_intensity: 0.15
mid_intensity: 0.10
outer_intensity: 0.05
# Modulació del radi quan una entitat declara `flicker: true` al seu
# bloc `light:`. El radi efectiu per frame és:
# r_eff = r_base + (r_amp1·r_base)·sin(2π·f1·t + φ1) + (r_amp2·r_base)·sin(2π·f2·t + φ2)
# on (φ1, φ2) es deriven de la seed de l'emissor per a que cada
# antorxa balancegi en una fase diferent (sense estat per emissor).
# Les amplituds són **ràtios** sobre el radi base: així el feel del
# flicker és independent del radi (una torxa de 24 px oscil·la el
# mateix % que una de 40 px). Combinant dues sinusoides amb freqüències
# incommensurables s'obté un patró "fluid" que el ull llegeix com a
# foc; pujar `noise_*` aporta més inquietud, baixar-ho fa la flama
# més serena. Aquests són els defaults globals; cada tipus d'entitat
# (o cada spawn / sala) pot sobreescriure'ls al seu `light.flicker_params:`.
flicker:
wave_amplitude_ratio: 0.05 # 5% del radi (per a radi 40 → ±2 px, feel actual)
wave_frequency_hz: 3.0
noise_amplitude_ratio: 0.025 # 2.5% (per a radi 40 → ±1 px, feel actual)
noise_frequency_hz: 7.0
+30 -17
View File
@@ -17,15 +17,15 @@ colors:
menu_cursor: 71 menu_cursor: 71
menu_disabled: 16 menu_disabled: 16
hud_bg: 9 hud_bg: 9
hud_label: 58 hud_flash: 63
hud_value: 187
hud_flash: 237
# Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). Independents # Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). Independents
# de notification_* perquè la fase i les notificacions són dos canals # de notification_* perquè la fase i les notificacions són dos canals
# visuals distints: ací pots canviar el color del rètol sense tocar # visuals distints: ací pots canviar el color del rètol sense tocar
# els missatges del HUD. # els missatges del HUD. Comparteix la família del CENTER del HUD
room_text: 160 # (or sobre marró) perquè "Piràmide N / Sala N" sigui sempre daurat
room_text_outline: 253 # arreu del joc.
room_text: 55 # or brillant (#FFDA00, = hud_center.value)
room_text_outline: 10 # marró cremat (#482400, = hud_center.shadow)
timings: timings:
notification_ms: 4000 notification_ms: 4000
@@ -73,6 +73,30 @@ hud:
entry_ms: 800 # durada de l'animació d'entrada dels tres blocs entry_ms: 800 # durada de l'animació d'entrada dels tres blocs
flash_ms: 250 # durada del flash quan un jugador rep un hit flash_ms: 250 # durada del flash quan un jugador rep un hit
shake_ms: 300 # durada del shake que acompanya el flash shake_ms: 300 # durada del shake que acompanya el flash
# Parpadeig dels prompts inactius ("Polsa start..." / "Espera..."). Un
# únic temporitzador global al Hud sincronitza P1 i P2 i no es reinicia
# quan el text canvia entre CAN_JOIN i WAIT. cycle<=0 o ratio>=1 →
# sempre visible (disable trivial).
blink_cycle_ms: 1000 # durada del cicle complet
blink_visible_ratio: 0.8 # proporció del cicle visible (0..1)
# Famílies de color del HUD per bloc. Cada bloc té `label` (to apagat
# 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 # 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 # slide_ms (durada en ms de l'animació d'entrada i d'eixida; constant
@@ -90,17 +114,6 @@ notifier:
slide_in_curve: outCubic slide_in_curve: outCubic
slide_out_curve: inCubic slide_out_curve: inCubic
# Pausa graduada de la GameScene: el món no para d'un colp, s'atura amb
# easing i torna a arrancar amb una altra corba. Els timings es poden
# afinar independentment, i les corbes permeten estils diferents entrant
# i sortint (p.ex. frenar suau i arrancar seca). Corbes disponibles:
# linear, outQuad, inQuad, inOutQuad, outCubic, inCubic.
pause:
enter_ms: 400
exit_ms: 250
enter_curve: outCubic
exit_curve: inCubic
# Sons del menú de servei. Els fitxers viuen a data/audio/sounds/. SELECT # Sons del menú de servei. Els fitxers viuen a data/audio/sounds/. SELECT
# acompanya navegació (amunt/avall) i back; ACCEPT acompanya modificació # acompanya navegació (amunt/avall) i back; ACCEPT acompanya modificació
# d'opció (esquerra/dreta/retorn) o entrada a un submenú. # d'opció (esquerra/dreta/retorn) o entrada a un submenú.
Binary file not shown.

After

Width:  |  Height:  |  Size: 805 B

+22
View File
@@ -0,0 +1,22 @@
name: column
type: decoration
editable: true
# Sub-picker de frames a l'editor: 6 variants (atlas 2×3 de sub-sprites 16×32).
editable_variants: true
sprite:
path: column.gif
size: [16, 32]
# Ancoratge "feet": el tile [col, row] del spawn és la base de la columna.
# El sprite de 16×32 sobresurt 16 px cap amunt sense afectar col·lisió ni
# z-order (la caixa lògica és el tile inferior).
anchor: feet
tile_size: [1, 1]
collision:
size: [16, 16]
# Mur infranquejable al salt: el jugador no pot passar pel damunt.
height: 999
flags:
collides: true
interactable: false
animations:
default: { frames: [0], frame_ms: 1000, loop: false }
+3 -3
View File
@@ -21,9 +21,9 @@ flags:
collides: true collides: true
interactable: true interactable: true
animations: animations:
closed: { frames: [1], fps: 1, loop: true } closed: { frames: [1], frame_ms: 1000, loop: true }
opening: { frames: [0], fps: 6, loop: false } opening: { frames: [0], frame_ms: 167, loop: false }
open: { frames: [0], fps: 1, loop: true } open: { frames: [0], frame_ms: 1000, loop: true }
behaviour: behaviour:
states: [CLOSED, OPENING, OPEN] states: [CLOSED, OPENING, OPEN]
initial_state: CLOSED initial_state: CLOSED
Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 303 B

+3 -3
View File
@@ -16,7 +16,7 @@ flags:
# apropiada via `setVariant` abans del primer tick. # apropiada via `setVariant` abans del primer tick.
# jump: frames 0..6 (7 frames) → reproduït al despegue. # jump: frames 0..6 (7 frames) → reproduït al despegue.
# land: frames 7..12 (6 frames) → reproduït a l'aterratge. # land: frames 7..12 (6 frames) → reproduït a l'aterratge.
# fps: 10 = 100 ms per frame. # frame_ms: 67 ≈ 15 fps (1000 / 67 ≈ 14.9 fps).
animations: animations:
jump: { frames: [0, 1, 2, 3, 4, 5, 6], fps: 15, loop: false } jump: { frames: [0, 1, 2, 3, 4, 5, 6], frame_ms: 67, loop: false }
land: { frames: [7, 8, 9, 10, 11, 12], fps: 15, loop: false } land: { frames: [7, 8, 9, 10, 11, 12], frame_ms: 67, loop: false }
+2 -2
View File
@@ -13,8 +13,8 @@ flags:
collides: false collides: false
interactable: false interactable: false
animations: animations:
inactive: { frames: [0], fps: 1, loop: true } inactive: { frames: [0], frame_ms: 1000, loop: true }
active: { frames: [0], fps: 6, loop: true } active: { frames: [0], frame_ms: 167, loop: true }
cycle: cycle:
active_duration: 0.3 active_duration: 0.3
inactive_duration: 2.0 inactive_duration: 2.0
+1 -1
View File
@@ -22,6 +22,6 @@ flags:
# lectura genèrica de flags. # lectura genèrica de flags.
triggers_on_overlap: true triggers_on_overlap: true
animations: animations:
idle: { frames: [0], fps: 1, loop: true } idle: { frames: [0], frame_ms: 1000, loop: true }
stats: stats:
damage: 0 damage: 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+24 -11
View File
@@ -32,22 +32,28 @@ flags:
# no decideix flip per direcció (només selecciona quina animació mostrar). # no decideix flip per direcció (només selecciona quina animació mostrar).
# La momia no té variants `stand_*` perquè mai no para. # La momia no té variants `stand_*` perquè mai no para.
animations: animations:
walk_south: { frames: [0, 1, 2, 3], fps: 8, loop: true } walk_south: { frames: [0, 1, 2, 3], frame_ms: 125, loop: true }
walk_north: { frames: [4, 5, 6, 7], fps: 8, loop: true } walk_north: { frames: [4, 5, 6, 7], frame_ms: 125, loop: true }
walk_east: { frames: [8, 9, 10, 11], fps: 8, loop: true } walk_east: { frames: [8, 9, 10, 11], frame_ms: 125, loop: true }
walk_west: { frames: [8, 9, 10, 11], fps: 8, loop: true, flip_h: true } walk_west: { frames: [8, 9, 10, 11], frame_ms: 125, loop: true, flip_h: true }
# Alçament: els 15 primers frames passen ràpid (125 ms cadascun) i el
# darrer aguanta 1 s perquè el jugador pugui veure la mòmia ja alçada
# abans que comenci a deambular. `frame_ms` accepta una llista per-frame
# que mapeja 1:1 contra `frames`.
raise:
frames: [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27]
frame_ms: [125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 125, 1000]
loop: false
ai: ai:
type: corridor_wanderer type: corridor_wanderer
direction_choice: random direction_choice: random
initial_direction: east initial_direction: east
# Estat SPAWN: la mòmia s'instancia visible al pixel_rect_ inicial just # Estat SPAWN: la mòmia s'instancia al pixel_rect_ inicial quan
# quan GameScene crida `spawnPendingActors()` (a la subfase text-exit # GameScene crida `spawnPendingActors()` (subfase text-exit de
# de ROOM_INTRO, fase 4). El `triggerSpawnExit` es dispara llavors # ROOM_INTRO, fase 4) amb l'animació `raise` activa. La duració del
# mateix; passats `duration_s` segons transita a ROAMING i empieza a # SPAWN la marca la pròpia animació (no-loop): al `completed()`,
# deambular. Sense aquest bloc, default 0 ⇒ ACTIVE immediat. # transita a ROAMING i comença a deambular.
spawn:
duration_s: 1.0
stats: stats:
speed: 30 speed: 30
damage: 1 damage: 1
@@ -69,3 +75,10 @@ hurt:
# So opcional: basename del fitxer dins `data/audio/sounds/`. Es dispara # So opcional: basename del fitxer dins `data/audio/sounds/`. Es dispara
# un sol cop per fuetada quan la mòmia entra a HURT. Si manca, no sona. # un sol cop per fuetada quan la mòmia entra a HURT. Si manca, no sona.
sound: mummy_hurt.wav sound: mummy_hurt.wav
# Halo tènue al voltant de la mòmia. Radi més curt que el del jugador
# perquè el rastre lumínic delati la seva posició sense "il·luminar
# la sala" — vol ser una pista visual a la foscor.
light:
radius: 24
flicker: true
-35
View File
@@ -1,35 +0,0 @@
JASC-PAL
0100
32
0 0 0
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
217 217 217
+13 -8
View File
@@ -30,10 +30,10 @@ animations:
stun_east: { frames: [18] } stun_east: { frames: [18] }
stun_west: { frames: [18], flip_h: true } stun_west: { frames: [18], flip_h: true }
walk_south: { frames: [0, 1, 2, 3], frame_ms: 100 } walk_south: { frames: [1, 2, 3, 0], frame_ms: 100 }
walk_north: { frames: [4, 5, 6, 7], frame_ms: 100 } walk_north: { frames: [5, 6, 7, 4], frame_ms: 100 }
walk_east: { frames: [8, 9, 10, 11], frame_ms: 100 } walk_east: { frames: [9, 10, 11, 8], frame_ms: 100 }
walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true } walk_west: { frames: [9, 10, 11, 8], frame_ms: 100, flip_h: true }
hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 } hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 } hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
@@ -97,13 +97,10 @@ turn:
south_to_north: west south_to_north: west
# Salt vertical (px de z, no toca la posició al món). # 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: jump:
impulse: 200 impulse: 200
gravity: 600 gravity: 600
landing_stun_ms: 400 landing_stun_ms: 400
shadow_y_offset: 3
sound: jump.wav sound: jump.wav
land_sound: land.wav land_sound: land.wav
@@ -142,4 +139,12 @@ death:
immunity: immunity:
duration_ms: 3000 duration_ms: 3000
blink_period_ms: 100 blink_period_ms: 100
palette: immune palette: morcus_wash
# Llum emesa pel jugador. `radius` és el radi del cercle de llum
# en píxels del canvas; el gradient (3 anells: 1.0 / 0.9 / 0.8) viu
# a data/config/lighting.yaml i és compartit per tots els emissors.
# Omet el bloc per desactivar la llum d'aquest personatge.
light:
radius: 64
flicker: true
+19
View File
@@ -0,0 +1,19 @@
JASC-PAL
0100
16
0 0 0 255
150 133 88 255
253 233 185 255
237 215 165 255
214 193 145 255
255 252 208 255
252 234 189 255
239 222 176 255
175 190 128 255
173 175 115 255
159 159 104 255
246 211 157 255
229 192 139 255
213 177 127 255
243 226 184 255
0 0 0 255
+19
View File
@@ -0,0 +1,19 @@
JASC-PAL
0100
16
0 0 0 255
102 118 132 255
226 238 247 255
206 216 222 255
179 190 198 255
245 255 255 255
223 239 253 255
208 224 238 255
124 190 178 255
125 170 162 255
109 151 150 255
221 210 210 255
200 187 189 255
181 169 175 255
213 229 247 255
0 0 0 255
+19
View File
@@ -0,0 +1,19 @@
JASC-PAL
0100
16
0 0 0 255
190 190 190 255
255 255 255 255
255 248 239 255
239 233 226 255
255 255 255 255
255 255 255 255
254 254 254 255
184 244 213 255
193 229 202 255
184 216 198 255
255 242 225 255
255 228 213 255
251 216 207 255
255 255 255 255
0 0 0 255
+13 -8
View File
@@ -30,10 +30,10 @@ animations:
stun_east: { frames: [18] } stun_east: { frames: [18] }
stun_west: { frames: [18], flip_h: true } stun_west: { frames: [18], flip_h: true }
walk_south: { frames: [0, 1, 2, 3], frame_ms: 100 } walk_south: { frames: [1, 2, 3, 0], frame_ms: 100 }
walk_north: { frames: [4, 5, 6, 7], frame_ms: 100 } walk_north: { frames: [5, 6, 7, 4], frame_ms: 100 }
walk_east: { frames: [8, 9, 10, 11], frame_ms: 100 } walk_east: { frames: [9, 10, 11, 8], frame_ms: 100 }
walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true } walk_west: { frames: [9, 10, 11, 8], frame_ms: 100, flip_h: true }
hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 } hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 } hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
@@ -97,13 +97,10 @@ turn:
south_to_north: west south_to_north: west
# Salt vertical (px de z, no toca la posició al món). # 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: jump:
impulse: 200 impulse: 200
gravity: 600 gravity: 600
landing_stun_ms: 400 landing_stun_ms: 400
shadow_y_offset: 3
sound: jump.wav sound: jump.wav
land_sound: land.wav land_sound: land.wav
@@ -142,4 +139,12 @@ death:
immunity: immunity:
duration_ms: 3000 duration_ms: 3000
blink_period_ms: 100 blink_period_ms: 100
palette: immune palette: sam_wash
# Llum emesa pel jugador. `radius` és el radi del cercle de llum
# en píxels del canvas; el gradient (3 anells: 1.0 / 0.9 / 0.8) viu
# a data/config/lighting.yaml i és compartit per tots els emissors.
# Omet el bloc per desactivar la llum d'aquest personatge.
light:
radius: 64
flicker: true
+19
View File
@@ -0,0 +1,19 @@
JASC-PAL
0100
16
0 0 0 255
150 133 88 255
202 176 123 255
191 165 112 255
181 156 103 255
217 158 115 255
194 142 101 255
188 143 98 255
214 193 145 255
196 176 128 255
181 161 115 255
246 211 157 255
229 192 139 255
213 177 127 255
243 226 184 255
0 0 0 255
+19
View File
@@ -0,0 +1,19 @@
JASC-PAL
0100
16
0 0 0 255
102 118 132 255
166 169 171 255
152 156 158 255
140 144 147 255
191 144 160 255
161 125 146 255
152 127 142 255
179 190 198 255
157 168 179 255
139 151 164 255
221 210 210 255
200 187 189 255
181 169 175 255
213 229 247 255
0 0 0 255
+19
View File
@@ -0,0 +1,19 @@
JASC-PAL
0100
16
0 0 0 255
190 190 190 255
235 220 205 255
226 212 197 255
219 205 191 255
255 194 197 255
248 184 193 255
238 188 189 255
239 233 226 255
226 220 216 255
214 210 208 255
255 242 225 255
255 228 213 255
251 216 207 255
255 255 255 255
0 0 0 255
+1 -1
View File
@@ -13,7 +13,7 @@ flags:
collides: true collides: true
interactable: false interactable: false
animations: animations:
roll: { frames: [0], fps: 10, loop: true } roll: { frames: [0], frame_ms: 100, loop: true }
ai: ai:
type: straight_line type: straight_line
initial_direction: right initial_direction: right
Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.6 KiB

+21 -3
View File
@@ -19,12 +19,30 @@ flags:
collides: true collides: true
interactable: true interactable: true
animations: animations:
closed: { frames: [0], fps: 1, loop: true } closed: { frames: [0], frame_ms: 1000, loop: true }
opening: { frames: [0], fps: 6, loop: false } opening: { frames: [1, 2], frame_ms: 167, loop: false }
open: { frames: [0], fps: 1, loop: true } open: { frames: [3], frame_ms: 1000, loop: true }
behaviour: behaviour:
states: [CLOSED, OPENING, OPEN] states: [CLOSED, OPENING, OPEN]
initial_state: CLOSED initial_state: CLOSED
on_perimeter_complete: OPENING on_perimeter_complete: OPENING
on_opening_end: OPEN on_opening_end: OPEN
emits_event_on_open: sarcophagus_opened 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+22
View File
@@ -0,0 +1,22 @@
name: stone
type: decoration
editable: true
# Sub-picker de frames a l'editor: ofereix una graella amb les 24 variants
# del atlas (6×4 sub-sprites 16×16) per escollir quina representa cada
# instància col·locada.
editable_variants: true
sprite:
path: stone.gif
size: [16, 16]
tile_size: [1, 1]
collision:
size: [16, 16]
# Mur infranquejable al salt: el jugador no la pot saltar per damunt.
height: 999
flags:
collides: true
interactable: false
# Animació "default" amb un sol frame: el spawn YAML pot sobreescriure-la
# via `frame: N` per fixar la variant escollida al editor.
animations:
default: { frames: [0], frame_ms: 1000, loop: false }
+16 -1
View File
@@ -15,4 +15,19 @@ flags:
# El torch.gif és un strip vertical 16x64 amb 4 frames apilats. # El torch.gif és un strip vertical 16x64 amb 4 frames apilats.
# El render clipa cada frame per `anim.currentFrame()` × 16 píxels en Y. # El render clipa cada frame per `anim.currentFrame()` × 16 píxels en Y.
animations: animations:
idle: { frames: [0, 1, 2, 3], fps: 8, loop: true } idle: { frames: [0, 1, 2, 3], frame_ms: 125, loop: true }
# Cercle de llum centrat a l'antorxa. El radi és en píxels del canvas;
# el gradient global (data/config/lighting.yaml) hi posa la forma (3
# anells). Amb `flicker: true` el radi efectiu oscil·la per frame
# (suma de dues sinusoides amb fase derivada de la posició) per a
# que el cercle "respiri" com una flama; els paràmetres del flicker
# també viuen al config global. `offset: [x, y]` desplaça el centre
# del cercle: la flama del sprite no està al centre del bounding box
# sinó cap a dalt, així que pujem la llum 4 px (Y creix cap avall)
# per a que el halo "neixi" de la flama, no del peu. Omet el bloc
# per fer una antorxa "apagada".
light:
radius: 40
flicker: true
offset: [0, -4]
+2 -2
View File
@@ -13,8 +13,8 @@ flags:
collides: false collides: false
interactable: false interactable: false
animations: animations:
inactive: { frames: [0], fps: 1, loop: true } inactive: { frames: [0], frame_ms: 1000, loop: true }
active: { frames: [0], fps: 6, loop: true } active: { frames: [0], frame_ms: 167, loop: true }
cycle: cycle:
active_duration: 0.5 active_duration: 0.5
inactive_duration: 1.5 inactive_duration: 1.5
+7 -7
View File
@@ -2,7 +2,7 @@
# Generado con font_gen.py # Generado con font_gen.py
box_width 12 box_width 12
box_height 14 box_height 13
columns 15 columns 15
# codepoint_decimal ancho_visual # codepoint_decimal ancho_visual
@@ -105,8 +105,8 @@ columns 15
193 8 # Á 193 8 # Á
200 7 # È 200 7 # È
201 7 # É 201 7 # É
204 4 # Ì 204 3 # Ì
205 4 # Í 205 3 # Í
210 9 # Ò 210 9 # Ò
211 9 # Ó 211 9 # Ó
217 7 # Ù 217 7 # Ù
@@ -117,8 +117,8 @@ columns 15
225 7 # á 225 7 # á
232 7 # è 232 7 # è
233 7 # é 233 7 # é
236 4 # ì 236 3 # ì
237 4 # í 237 3 # í
242 7 # ò 242 7 # ò
243 7 # ó 243 7 # ó
249 7 # ù 249 7 # ù
@@ -127,7 +127,7 @@ columns 15
231 7 # ç 231 7 # ç
161 3 # ¡ 161 3 # ¡
191 7 # ¿ 191 7 # ¿
171 10 # « 171 7 # «
187 10 # » 187 7 # »
183 3 # · 183 3 # ·
169 10 # © 169 10 # ©
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

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

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+5 -6
View File
@@ -14,7 +14,7 @@ input:
shader_off: "Shaders desactivats" shader_off: "Shaders desactivats"
vsync_on: "VSync activat" vsync_on: "VSync activat"
vsync_off: "VSync desactivat" vsync_off: "VSync desactivat"
screenshot: "Captura" screenshot: "Imatge {} guardada a {}"
shader: "Shader" shader: "Shader"
preset: "Preset" preset: "Preset"
scaling_disabled: "Escalat sense" scaling_disabled: "Escalat sense"
@@ -23,7 +23,6 @@ input:
scaling_overscan: "Escalat overscan" scaling_overscan: "Escalat overscan"
scaling_integer: "Escalat enter" scaling_integer: "Escalat enter"
press_again_exit: "Prem una altra volta per eixir" press_again_exit: "Prem una altra volta per eixir"
paused: "Pausa"
editor_on: "Editor ON" editor_on: "Editor ON"
editor_off: "Editor OFF" editor_off: "Editor OFF"
editor_save_error: "Editor: error guardant el mapa" editor_save_error: "Editor: error guardant el mapa"
@@ -41,10 +40,10 @@ hud:
# Prompts mostrats al bloc d'un slot inactiu (en lloc dels zeros) # Prompts mostrats al bloc d'un slot inactiu (en lloc dels zeros)
# quan un jugador encara no ha pulsat START o ha esgotat vides. # quan un jugador encara no ha pulsat START o ha esgotat vides.
# Dues línies per fer-los caber al bloc lateral (~106 px). # Dues línies per fer-los caber al bloc lateral (~106 px).
join_prompt_can_join_l1: "POLSA START" join_prompt_can_join_l1: "Polsa start"
join_prompt_can_join_l2: "PER JUGAR" join_prompt_can_join_l2: "per jugar"
join_prompt_wait_l1: "ESPERA" join_prompt_wait_l1: "Espera"
join_prompt_wait_l2: "PER JUGAR" join_prompt_wait_l2: "per jugar"
# Textos de l'escena TITLE. # Textos de l'escena TITLE.
title: title:
+5 -6
View File
@@ -14,7 +14,7 @@ input:
shader_off: "Shaders off" shader_off: "Shaders off"
vsync_on: "VSync on" vsync_on: "VSync on"
vsync_off: "VSync off" vsync_off: "VSync off"
screenshot: "Screenshot" screenshot: "Image {} saved at {}"
shader: "Shader" shader: "Shader"
preset: "Preset" preset: "Preset"
scaling_disabled: "Scaling off" scaling_disabled: "Scaling off"
@@ -23,7 +23,6 @@ input:
scaling_overscan: "Scaling overscan" scaling_overscan: "Scaling overscan"
scaling_integer: "Scaling integer" scaling_integer: "Scaling integer"
press_again_exit: "Press again to exit" press_again_exit: "Press again to exit"
paused: "Paused"
editor_on: "Editor ON" editor_on: "Editor ON"
editor_off: "Editor OFF" editor_off: "Editor OFF"
editor_save_error: "Editor: map save failed" editor_save_error: "Editor: map save failed"
@@ -41,10 +40,10 @@ hud:
# Prompts shown in an inactive slot's block (instead of zeros) while # 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 # 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). # short lines so they fit in the side block (~106 px wide).
join_prompt_can_join_l1: "PRESS START" join_prompt_can_join_l1: "Press start"
join_prompt_can_join_l2: "TO PLAY" join_prompt_can_join_l2: "to play"
join_prompt_wait_l1: "PLEASE" join_prompt_wait_l1: "Please"
join_prompt_wait_l2: "WAIT" join_prompt_wait_l2: "wait"
# Texts of the TITLE scene. # Texts of the TITLE scene.
title: title:
+33 -4
View File
@@ -10,11 +10,38 @@
# la cel·la buida (transparent), útil per caselles que ocuparà un actor. # la cel·la buida (transparent), útil per caselles que ocuparà un actor.
pyramid: 1 pyramid: 1
room: 1 room: 2
tileset: data/tilesets/pyramid_1.gif tileset: data/tilesets/pyramid_1.gif
tile_size: 16 tile_size: 16
origin: [-8, 0] origin: [-8, 0]
# Banc de proves del mode `additive` d'il·luminació. La sala es veu
# "normal" (level 1.0, el GIF intacte fora del cercle de les torxes)
# i només les antorxes hi sumen un halo càlid suau. Player i mummies
# desactivats explícitament perquè aquí no volem cap halo al voltant
# d'actors — el toc cromàtic és només l'antorxa. La corba additiva
# (intensitats baixes a propòsit perquè additive satura ràpid) viu a
# `gradient_additive:` de data/config/lighting.yaml.
lighting:
mode: additive
level: 1.0
emitters:
torches:
radius: 20
color: [255, 170, 60]
# Valors default del flicker explicitats per anar-los ajustant
# in-situ. Coincideixen amb els del global a data/config/lighting.yaml;
# baixar wave_frequency_hz fa el "palpitar" més lent, baixar les
# ratios fa l'oscil·lació més subtil. Qualsevol camp es pot
# eliminar i caurà al valor del global.
flicker_params:
wave_amplitude_ratio: 0.04
wave_frequency_hz: 3.0
noise_amplitude_ratio: 0.025
noise_frequency_hz: 3.0
players: false
mummies: false
# Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl # Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl
# pintable. Un índex per jugador perquè a multijugador local el recorregut # pintable. Un índex per jugador perquè a multijugador local el recorregut
# es distingeix a simple vista. Provisional fins que l'art del pintat # es distingeix a simple vista. Provisional fins que l'art del pintat
@@ -82,6 +109,8 @@ data:
# d'obertura (262/263). La momia al corredor central. L'antorxa al costat # d'obertura (262/263). La momia al corredor central. L'antorxa al costat
# superior esquerre per verificar render d'una entitat decorativa. # superior esquerre per verificar render d'una entitat decorativa.
entities: entities:
- type: torch
position: [4, 2]
- type: torch - type: torch
position: [8, 2] position: [8, 2]
- type: torch - type: torch
@@ -128,8 +157,8 @@ entities:
sarc_position: [2, 4] sarc_position: [2, 4]
- type: sarcophagus - type: sarcophagus
sarc_position: [3, 4] sarc_position: [3, 4]
- type: torch
position: [4, 2]
- type: door - type: door
- type: mummy - type: mummy
position: [10, 11] position: [10, 7]
- type: mummy
position: [7, 9]
+56 -13
View File
@@ -10,11 +10,38 @@
# la cel·la buida (transparent), útil per caselles que ocuparà un actor. # la cel·la buida (transparent), útil per caselles que ocuparà un actor.
pyramid: 1 pyramid: 1
room: 2 room: 1
tileset: data/tilesets/pyramid_1.gif tileset: data/tilesets/pyramid_1.gif
tile_size: 16 tile_size: 16
origin: [-8, 0] origin: [-8, 0]
# Il·luminació de la sala. Bloc opcional; si manca, la sala queda
# totalment il·luminada (1.0) i tots els tipus d'entitat emeten amb els
# valors declarats al seu YAML de tipus (comportament històric).
#
# level → atenuació base de la sala (0..1). 1.0 = res
# d'efecte, 0.0 = negre fora del radi dels emissors.
# El gradient dels emissors viu a
# data/config/lighting.yaml.
# emitters.<tipus> → override per tipus d'entitat. Tres formes:
# false → apaga el tipus
# true → defaults del tipus
# { radius: N, flicker: B } → camps presents
# sobreescriuen el
# default; els
# absents cauen al
# default del tipus.
# Plural per llegir-se natural ("totes les momies de la sala"). Per a
# casos particulars, un spawn pot dur el seu propi `light:` (mateixes
# formes) que té prioritat sobre l'override de sala. La forma antiga
# (`light_level:` plà a l'arrel) també segueix sent vàlida per
# retrocompatibilitat.
lighting:
level: 0.15
emitters:
players: true
mummies: false
# Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl # Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl
# pintable. Un índex per jugador perquè a multijugador local el recorregut # pintable. Un índex per jugador perquè a multijugador local el recorregut
# es distingeix a simple vista. Provisional fins que l'art del pintat # es distingeix a simple vista. Provisional fins que l'art del pintat
@@ -82,16 +109,12 @@ data:
# d'obertura (262/263). La momia al corredor central. L'antorxa al costat # d'obertura (262/263). La momia al corredor central. L'antorxa al costat
# superior esquerre per verificar render d'una entitat decorativa. # superior esquerre per verificar render d'una entitat decorativa.
entities: entities:
- type: torch
position: [4, 2]
- type: torch - type: torch
position: [8, 2] position: [8, 2]
- type: torch - type: torch
position: [12, 2] position: [12, 2]
- type: torch - type: torch
position: [16, 2] position: [16, 2]
- type: sarcophagus
sarc_position: [0, 0]
- type: sarcophagus - type: sarcophagus
sarc_position: [1, 0] sarc_position: [1, 0]
- type: sarcophagus - type: sarcophagus
@@ -118,8 +141,6 @@ entities:
sarc_position: [0, 3] sarc_position: [0, 3]
- type: sarcophagus - type: sarcophagus
sarc_position: [1, 3] sarc_position: [1, 3]
- type: sarcophagus
sarc_position: [2, 3]
- type: sarcophagus - type: sarcophagus
sarc_position: [3, 3] sarc_position: [3, 3]
- type: sarcophagus - type: sarcophagus
@@ -130,11 +151,33 @@ entities:
sarc_position: [2, 4] sarc_position: [2, 4]
- type: sarcophagus - type: sarcophagus
sarc_position: [3, 4] sarc_position: [3, 4]
- type: torch
position: [4, 2]
- type: door - type: door
# Sense `position`: la porta sempre viu al mur trasser. La columna - type: column
# transitable la dicta `geometry.door_col`; el render i la caixa position: [3, 4]
# de col·lisió es deriven d'aquesta dada al loadFromLevel. frame: 2
- type: stone
position: [5, 4]
frame: 16
- type: stone
position: [4, 4]
frame: 19
- type: column
position: [13, 10]
frame: 4
- type: stone
position: [12, 10]
frame: 13
- type: stone
position: [11, 10]
frame: 23
- type: hole
position: [5, 9]
- type: hole
position: [9, 11]
- type: stone
position: [10, 10]
frame: 18
- type: mummy - type: mummy
position: [10, 7] position: [10, 13]
- type: mummy
position: [7, 9]
+6 -3
View File
@@ -1,6 +1,6 @@
# Configuració de la GameScene: timings i corbes de la FSM de sala # Configuració de la GameScene: timings i corbes de la FSM de sala
# (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria # (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria
# i font del text es reutilitzen del tema UI (data/config/ui.yaml > pause + # i font del text es reutilitzen del tema UI (data/config/ui.yaml >
# notification). Si manca el fitxer o alguna clau, GameScene cau als # notification). Si manca el fitxer o alguna clau, GameScene cau als
# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp. # fallbacks compilats; veure `readSceneConfig` a game_scene.cpp.
@@ -43,8 +43,11 @@ text:
# text i decreix amb l'exit_curve. `color` són tres canals RGB (0..255); # 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). # `alpha` és la transparència del blend (0 = invisible, 255 = opac).
text_panel: text_panel:
color: [36, 182, 170] # Violeta cerimonial: contrasta amb l'or del text (room_text), no
alpha: 128 # 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 height_px: 32
# Sub-FSM de game over: dispara CONTINUE? quan tots els jugadors # Sub-FSM de game over: dispara CONTINUE? quan tots els jugadors
+16
View File
@@ -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 // 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 // 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. // sec — l'usuari sent el so encara que la cua no s'apliqui.
+6
View File
@@ -85,6 +85,12 @@ class Audio {
// --- Control de sons --- // --- Control de sons ---
void playSound(const std::string& name, Group group = Group::GAME); // Reproduir so puntual per nom (muta globals de Ja) 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) 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 // Reprodueix un so processat per un efecte definit a data/config/sounds.yaml
// (preset_name busca a SoundEffectsConfig). Si el preset no existeix // (preset_name busca a SoundEffectsConfig). Si el preset no existeix
// o el motor està al cap de canals amb efecte, fa fallback a playSound // o el motor està al cap de canals amb efecte, fa fallback a playSound
+6
View File
@@ -430,6 +430,12 @@ namespace Ja {
return channel; 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) { void Engine::pauseChannel(const int channel) {
forEachTargetChannel(channel, [](Channel& ch) { forEachTargetChannel(channel, [](Channel& ch) {
if (ch.state == ChannelState::PLAYING) { if (ch.state == ChannelState::PLAYING) {
+8
View File
@@ -192,6 +192,14 @@ namespace Ja {
// --- So --- // --- So ---
auto playSound(Sound* sound, int loop = 0, int group = 0) -> int; auto playSound(Sound* sound, int loop = 0, int group = 0) -> int;
auto playSoundOnChannel(Sound* sound, int channel, 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 // Reproducció amb so processat per un efecte. Retorna el canal
// assignat o -1 si no queden slots d'efecte (MAX_EFFECT_CHANNELS). // 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 // El sound original només s'usa per consultar el spec/buffer; el
+17 -5
View File
@@ -3,6 +3,7 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <format>
#include <string> #include <string>
#include "core/input/key_config.hpp" #include "core/input/key_config.hpp"
@@ -69,15 +70,26 @@ namespace GlobalInputs {
notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off"); notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off");
} }
// Volca el canvas ARGB actual a PNG dins de `screenshots/`. Si ha // Volca el canvas ARGB actual a PNG dins del directori de
// fallat l'escriptura, el handler no mostra notificació (retorn buit). // 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() { void onScreenshot() {
const auto* buffer = Screen::get().getCanvasArgb(); const auto* buffer = Screen::get().getCanvasArgb();
const int W = Screen::get().getCanvasWidth(); const int W = Screen::get().getCanvasWidth();
const int H = Screen::get().getCanvasHeight(); const int H = Screen::get().getCanvasHeight();
const std::string FILENAME = Screenshot::save(buffer, W, H); const auto R = Screenshot::save(buffer, W, H);
if (!FILENAME.empty()) { if (!R.filename.empty()) {
Notification::notify(Locale::get().text("input.screenshot") + ' ' + FILENAME); // `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));
} }
} }
+43
View File
@@ -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
+25
View File
@@ -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
+376
View File
@@ -0,0 +1,376 @@
#include "core/rendering/lighting.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <numbers>
#include <vector>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp"
namespace Lighting {
namespace {
// Buffer de luminància compartit entre frames. Es redimensiona
// només si canvia la mida del canvas (mai en un run normal: el
// canvas del joc és 320×240 fix). El reusem perquè una `apply`
// allotjant cada frame seria un cost evitable.
auto luminanceBuffer() -> std::vector<std::uint8_t>& {
static std::vector<std::uint8_t> buf_;
return buf_;
}
// Corba activa del mode subtractive. loadConfig() la reemplaça
// si troba el bloc `gradient:` al YAML.
auto activeSubtractiveConfig() -> GradientConfig& {
static GradientConfig cfg_{
.inner_ratio = Defaults::Lighting::INNER_RATIO,
.mid_ratio = Defaults::Lighting::MID_RATIO,
.inner_intensity = Defaults::Lighting::INNER_INTENSITY,
.mid_intensity = Defaults::Lighting::MID_INTENSITY,
.outer_intensity = Defaults::Lighting::OUTER_INTENSITY,
};
return cfg_;
}
// Corba activa del mode additive. Defaults baixos a propòsit:
// en additive la intensitat és l'alfa del color afegit, així
// que valors petits (0.05..0.15) donen el "halo subtil" que
// sol voler-se. loadConfig() la reemplaça si troba el bloc
// `gradient_additive:` al YAML.
auto activeAdditiveConfig() -> GradientConfig& {
static GradientConfig cfg_{
.inner_ratio = Defaults::Lighting::INNER_RATIO_ADDITIVE,
.mid_ratio = Defaults::Lighting::MID_RATIO_ADDITIVE,
.inner_intensity = Defaults::Lighting::INNER_INTENSITY_ADDITIVE,
.mid_intensity = Defaults::Lighting::MID_INTENSITY_ADDITIVE,
.outer_intensity = Defaults::Lighting::OUTER_INTENSITY_ADDITIVE,
};
return cfg_;
}
auto activeFlicker() -> FlickerConfig& {
static FlickerConfig fc_{
.wave_amplitude_ratio = Defaults::Lighting::FLICKER_WAVE_AMPLITUDE_RATIO,
.wave_frequency_hz = Defaults::Lighting::FLICKER_WAVE_FREQUENCY_HZ,
.noise_amplitude_ratio = Defaults::Lighting::FLICKER_NOISE_AMPLITUDE_RATIO,
.noise_frequency_hz = Defaults::Lighting::FLICKER_NOISE_FREQUENCY_HZ,
};
return fc_;
}
// Modula el radi base d'un emissor amb la suma de dues sinusoides
// (`wave` + `noise`) i una fase derivada de `seed`. La seed és un
// identificador estable de l'emissor (no canvia amb la posició) —
// així el patró no "deriva" quan l'emissor es mou. El factor
// ~φ (golden ratio) dispersa les fases per a seeds consecutives.
// Les amplituds són ràtios sobre el radi base, multiplicades aquí
// → mateix percentatge d'oscil·lació per a qualsevol mida.
auto computeFlickerRadius(int base_radius, int seed, float t_s, const FlickerConfig& fc) -> int {
constexpr float TWO_PI = 2.0F * std::numbers::pi_v<float>;
constexpr float GOLDEN_FRACTION = 0.6180339F;
const float PHASE = static_cast<float>(seed) * GOLDEN_FRACTION;
const float W1 = std::sin((TWO_PI * fc.wave_frequency_hz * t_s) + PHASE);
const float W2 = std::sin((TWO_PI * fc.noise_frequency_hz * t_s) + (PHASE * 2.7F));
const auto R_F = static_cast<float>(base_radius);
const float DELTA = (fc.wave_amplitude_ratio * R_F * W1) + (fc.noise_amplitude_ratio * R_F * W2);
const int R = base_radius + static_cast<int>(std::lround(DELTA));
return std::max(1, R);
}
// Quantitza un factor 0..1 a 0..255. `clamp` per defensar-nos de
// YAMLs que entreguen valors fora de rang sense forçar la mà.
auto toByte(float v) -> std::uint8_t {
const float CLAMPED = std::clamp(v, 0.0F, 1.0F);
return static_cast<std::uint8_t>(std::lround(CLAMPED * 255.0F));
}
void readField(const fkyaml::node& node, const char* key, float& dst) {
if (node.contains(key)) { dst = node[key].get_value<float>(); }
}
// Pre-calcula els 3 quadrats de radi i les intensitats que farem
// servir dins del bucle per píxel. Es resol una sola vegada per
// emissor i s'evita arrastrar 6 paràmetres a `rasterizeEmitter`.
struct EmitterRings {
int radius_sq{0};
int mid_sq{0};
int inner_sq{0};
std::uint8_t inner{0};
std::uint8_t mid{0};
std::uint8_t outer{0};
};
auto computeRings(int radius, const GradientConfig& cfg) -> EmitterRings {
const auto R_F = static_cast<float>(radius);
const float R_INNER = R_F * cfg.inner_ratio;
const float R_MID = R_F * cfg.mid_ratio;
return EmitterRings{
.radius_sq = radius * radius,
.mid_sq = static_cast<int>(R_MID * R_MID),
.inner_sq = static_cast<int>(R_INNER * R_INNER),
.inner = toByte(cfg.inner_intensity),
.mid = toByte(cfg.mid_intensity),
.outer = toByte(cfg.outer_intensity),
};
}
// Itera la bounding box de l'emissor clipada al canvas i actualitza
// la fila de luminància corresponent amb `max(actual, ring)`. Es
// crida un cop per emissor, així extreure-la deixa `apply` simple.
void rasterizeEmitter(const Emitter& e, const EmitterRings& rings, std::uint8_t* lum, int width, int height) {
const int R = e.radius;
const int CX = static_cast<int>(e.cx);
const int CY = static_cast<int>(e.cy);
const int X0 = std::max(0, CX - R);
const int Y0 = std::max(0, CY - R);
const int X1 = std::min(width - 1, CX + R);
const int Y1 = std::min(height - 1, CY + R);
for (int y = Y0; y <= Y1; ++y) {
const int DY = y - CY;
const int DY_SQ = DY * DY;
if (DY_SQ > rings.radius_sq) { continue; }
std::uint8_t* row = lum + (static_cast<std::ptrdiff_t>(y) * width);
for (int x = X0; x <= X1; ++x) {
const int DX = x - CX;
const int DIST_SQ = (DX * DX) + DY_SQ;
if (DIST_SQ > rings.radius_sq) { continue; }
std::uint8_t intensity = rings.outer;
if (DIST_SQ <= rings.inner_sq) {
intensity = rings.inner;
} else if (DIST_SQ <= rings.mid_sq) {
intensity = rings.mid;
}
row[x] = std::max(intensity, row[x]);
}
}
}
// Pasada final del mode subtractive: pixel = pixel * lum / 255
// per canal. El canal alpha es preserva (la resta del pipeline
// assumeix opacitat total al canvas; veure Rendering::OPAQUE_ALPHA).
void modulateCanvas(std::uint32_t* canvas, const std::uint8_t* lum, int width, int height) {
for (int y = 0; y < height; ++y) {
std::uint32_t* px = canvas + (static_cast<std::ptrdiff_t>(y) * width);
const std::uint8_t* lrow = lum + (static_cast<std::ptrdiff_t>(y) * width);
for (int x = 0; x < width; ++x) {
const std::uint8_t L = lrow[x];
if (L == 255) { continue; } // píxel intacte.
const std::uint32_t SRC = px[x];
const std::uint8_t SR = (SRC >> 16) & 0xFF;
const std::uint8_t SG = (SRC >> 8) & 0xFF;
const std::uint8_t SB = SRC & 0xFF;
const auto R = static_cast<std::uint8_t>((SR * L) / 255);
const auto G = static_cast<std::uint8_t>((SG * L) / 255);
const auto B = static_cast<std::uint8_t>((SB * L) / 255);
px[x] = (SRC & 0xFF000000U) |
(static_cast<std::uint32_t>(R) << 16) |
(static_cast<std::uint32_t>(G) << 8) |
static_cast<std::uint32_t>(B);
}
}
}
// Atenuació ambient del mode additive: aplica `pixel × level` per
// canvas abans del bucle d'emissors. Si `level == 255` (sala a
// 1.0) salta com a no-op — el cas comú quan només volem el tint
// dels emissors sense oscurir la sala.
void darkenCanvas(std::uint32_t* canvas, int width, int height, std::uint8_t level) {
if (level == 255) { return; }
for (int y = 0; y < height; ++y) {
std::uint32_t* px = canvas + (static_cast<std::ptrdiff_t>(y) * width);
for (int x = 0; x < width; ++x) {
const std::uint32_t SRC = px[x];
const std::uint8_t SR = (SRC >> 16) & 0xFF;
const std::uint8_t SG = (SRC >> 8) & 0xFF;
const std::uint8_t SB = SRC & 0xFF;
const auto R = static_cast<std::uint8_t>((SR * level) / 255);
const auto G = static_cast<std::uint8_t>((SG * level) / 255);
const auto B = static_cast<std::uint8_t>((SB * level) / 255);
px[x] = (SRC & 0xFF000000U) |
(static_cast<std::uint32_t>(R) << 16) |
(static_cast<std::uint32_t>(G) << 8) |
static_cast<std::uint32_t>(B);
}
}
}
// Pasada del mode additive per a un emissor: suma `color × intensity`
// a cada píxel cobert pel cercle, saturant a 255 per canal. A
// diferència del subtractive, NO usa buffer intermig (no cal max())
// perquè la suma és commutativa; emissors solapats acumulen
// contribucions de manera natural. Si dues llums de colors diferents
// es solapen, el resultat tendeix al blanc en la zona de coincidència
// (suma física): el solape s'aclareix més que un sol emissor.
void rasterizeEmitterAdditive(const Emitter& e, const EmitterRings& rings, std::uint32_t* canvas, int width, int height) {
const int R = e.radius;
const int CX = static_cast<int>(e.cx);
const int CY = static_cast<int>(e.cy);
const int X0 = std::max(0, CX - R);
const int Y0 = std::max(0, CY - R);
const int X1 = std::min(width - 1, CX + R);
const int Y1 = std::min(height - 1, CY + R);
for (int y = Y0; y <= Y1; ++y) {
const int DY = y - CY;
const int DY_SQ = DY * DY;
if (DY_SQ > rings.radius_sq) { continue; }
std::uint32_t* row = canvas + (static_cast<std::ptrdiff_t>(y) * width);
for (int x = X0; x <= X1; ++x) {
const int DX = x - CX;
const int DIST_SQ = (DX * DX) + DY_SQ;
if (DIST_SQ > rings.radius_sq) { continue; }
std::uint8_t intensity = rings.outer;
if (DIST_SQ <= rings.inner_sq) {
intensity = rings.inner;
} else if (DIST_SQ <= rings.mid_sq) {
intensity = rings.mid;
}
// contrib_channel = emitter_color_channel × intensity / 255
// (intensity ja és 0..255 quantitzat a `toByte`).
const int CR = (e.color.r * intensity) / 255;
const int CG = (e.color.g * intensity) / 255;
const int CB = (e.color.b * intensity) / 255;
const std::uint32_t SRC = row[x];
const int SR = static_cast<int>((SRC >> 16) & 0xFF);
const int SG = static_cast<int>((SRC >> 8) & 0xFF);
const int SB = static_cast<int>(SRC & 0xFF);
const auto NR = static_cast<std::uint32_t>(std::min(255, SR + CR));
const auto NG = static_cast<std::uint32_t>(std::min(255, SG + CG));
const auto NB = static_cast<std::uint32_t>(std::min(255, SB + CB));
row[x] = (SRC & 0xFF000000U) | (NR << 16) | (NG << 8) | NB;
}
}
}
} // namespace
auto config() -> const GradientConfig& {
return activeSubtractiveConfig();
}
auto flickerConfig() -> const FlickerConfig& {
return activeFlicker();
}
namespace {
// Llegeix els 5 camps de `GradientConfig` d'un node YAML — qualsevol
// camp absent conserva el valor compilat dels defaults. Compartit
// entre els blocs `gradient:` i `gradient_additive:`.
void readGradient(const fkyaml::node& g, GradientConfig& cfg) {
readField(g, "inner_ratio", cfg.inner_ratio);
readField(g, "mid_ratio", cfg.mid_ratio);
readField(g, "inner_intensity", cfg.inner_intensity);
readField(g, "mid_intensity", cfg.mid_intensity);
readField(g, "outer_intensity", cfg.outer_intensity);
}
} // namespace
void loadConfig(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
Log::warn("Lighting", "no s'ha pogut obrir {} — s'usaran els defaults compilats", yaml_path);
return;
}
try {
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
if (yaml.contains("gradient")) {
readGradient(yaml["gradient"], activeSubtractiveConfig());
}
if (yaml.contains("gradient_additive")) {
readGradient(yaml["gradient_additive"], activeAdditiveConfig());
}
if (yaml.contains("flicker")) {
const auto& f = yaml["flicker"];
auto& fc = activeFlicker();
readField(f, "wave_amplitude_ratio", fc.wave_amplitude_ratio);
readField(f, "wave_frequency_hz", fc.wave_frequency_hz);
readField(f, "noise_amplitude_ratio", fc.noise_amplitude_ratio);
readField(f, "noise_frequency_hz", fc.noise_frequency_hz);
}
} catch (const std::exception& ex) {
Log::warn("Lighting", "error parsejant {}: {} — s'usaran els defaults compilats", yaml_path, ex.what());
}
}
namespace {
// Helper: emissor "patxat" amb el radi efectiu (post-flicker) per
// a aquest frame. Mantenim l'`Emitter` del caller intacte i la
// resta de camps (color, seed, flicker_params, ...) propagats.
auto withEffectiveRadius(const Emitter& e, int r_eff) -> Emitter {
return Emitter{
.cx = e.cx,
.cy = e.cy,
.radius = r_eff,
.seed = e.seed,
.flicker = e.flicker,
.color = e.color,
.flicker_params = e.flicker_params,
};
}
// Camí del mode subtractive (històric): omple un buffer de
// luminància amb `max(room, emitter_intensity)` i finalment
// multiplica el canvas per ell. Color de l'emissor ignorat.
void applySubtractive(std::uint32_t* canvas, int width, int height, std::uint8_t room, std::span<const Emitter> emitters) {
auto& lum = luminanceBuffer();
const std::size_t TOTAL = static_cast<std::size_t>(width) * static_cast<std::size_t>(height);
if (lum.size() != TOTAL) { lum.resize(TOTAL); }
std::ranges::fill(lum, room);
const auto& cfg = activeSubtractiveConfig();
const float T_S = static_cast<float>(SDL_GetTicks()) / 1000.0F;
for (const auto& e : emitters) {
if (e.radius <= 0) { continue; }
const int R_EFF = e.flicker ? computeFlickerRadius(e.radius, e.seed, T_S, e.flicker_params) : e.radius;
const auto RINGS = computeRings(R_EFF, cfg);
const Emitter EFF = withEffectiveRadius(e, R_EFF);
rasterizeEmitter(EFF, RINGS, lum.data(), width, height);
}
modulateCanvas(canvas, lum.data(), width, height);
}
// Camí del mode additive: atenua el canvas per `room` (si <255)
// i després suma `color × intensity` per emissor directament al
// canvas, sense buffer intermig.
void applyAdditive(std::uint32_t* canvas, int width, int height, std::uint8_t room, std::span<const Emitter> emitters) {
darkenCanvas(canvas, width, height, room);
const auto& cfg = activeAdditiveConfig();
const float T_S = static_cast<float>(SDL_GetTicks()) / 1000.0F;
for (const auto& e : emitters) {
if (e.radius <= 0) { continue; }
const int R_EFF = e.flicker ? computeFlickerRadius(e.radius, e.seed, T_S, e.flicker_params) : e.radius;
const auto RINGS = computeRings(R_EFF, cfg);
const Emitter EFF = withEffectiveRadius(e, R_EFF);
rasterizeEmitterAdditive(EFF, RINGS, canvas, width, height);
}
}
} // namespace
void apply(std::uint32_t* canvas, int width, int height, Mode mode, float room_light, std::span<const Emitter> emitters) {
if (canvas == nullptr || width <= 0 || height <= 0) { return; }
// Atajo per a tots dos modes: sala totalment "neutra" + cap
// emissor → la pasada no canviaria res al canvas.
// subtractive: room=1.0 i sense emissors → pixel * 1.0 global.
// additive: room=1.0 i sense emissors → cap aportació.
const std::uint8_t ROOM = toByte(room_light);
if (ROOM == 255 && emitters.empty()) { return; }
if (mode == Mode::ADDITIVE) {
applyAdditive(canvas, width, height, ROOM, emitters);
} else {
applySubtractive(canvas, width, height, ROOM, emitters);
}
}
} // namespace Lighting
+150
View File
@@ -0,0 +1,150 @@
#pragma once
#include <cstdint>
#include <span>
#include <string>
// Sistema d'il·luminació CPU. Treballa sobre el mateix canvas ARGB del
// joc (320×240) que tot el pipeline de render: després que GameScene
// haja pintat tilemap, paint layer, sombres i actors, una pasada final
// modifica cada píxel del canvas en funció de la "llum de la sala" i
// dels cercles emissors al voltant de certes entitats.
//
// Dos modes seleccionables per la sala (`lighting.mode` al YAML):
//
// SUBTRACTIVE (mode històric, default si no es declara mode):
// out = src × max(room_light, max_emitter_intensity_at_pixel)
// Els emissors "trenquen" una capa fosca multiplicativa. Tot el
// trip cromàtic dels emissors s'ignora — només la intensitat
// compta. Pensat per a sales fosques (cripta, pirámide).
//
// ADDITIVE (mode nou):
// out = saturate( (src × room_light) + Σ (emitter_color × intensity) )
// El canvas s'enfosqueix opcionalment per `room_light` (atenuació
// ambient) i els emissors hi sumen color cap a sobre. Les intensitats
// dels anells es llegeixen com a "alfa" del color afegit (0..1).
// Pensat per a sales clares on les antorxes aporten halo cromàtic
// sense restar contrast — o per a sales lleument fosques (0.85, p.ex.)
// on el halo de l'antorxa es veu càlid en lloc de "blanquinós".
//
// Forma del cercle de cada emissor (radial, 3 anells configurables al
// fitxer data/config/lighting.yaml; veure `GradientConfig`):
// 0..inner_ratio del radi → inner_intensity
// inner_ratio..mid_ratio → mid_intensity
// mid_ratio..1.0 → outer_intensity
//
// La corba viu en dos blocs separats al YAML perquè la mateixa escala
// 0..1 té significats molt diferents per mode:
// `gradient:` → corba del mode subtractive (1.0 = "no fosqueix")
// `gradient_additive:` → corba del mode additive (1.0 = "afegeix
// el color sencer i satura"). Es vol molt més
// baixa per a no inundar la sala de color.
// Cada mode llegeix la seva pròpia corba — no es comparteixen valors.
//
// Combinació entre emissors:
// SUBTRACTIVE → per a cada píxel es pren el màxim de la intensitat
// aportada per cada emissor que el solapa (no se sumen intensitats).
// ADDITIVE → per a cada píxel se sumen totes les contribucions
// (`color × intensity`), saturant a 255 per canal. Dues llums de
// colors diferents que solapen produeixen la suma natural (orange +
// blue → near-white) — comportament físic.
namespace Lighting {
// Mode de composició. La sala el selecciona via `lighting.mode:
// additive | subtractive` (default subtractive si manca, retrocompat).
enum class Mode : std::uint8_t {
SUBTRACTIVE,
ADDITIVE,
};
// Color emès. Default blanc (sense modificació quan s'usa en mode
// additive). En mode subtractive s'ignora — la pasada multiplicativa
// només té un canal (intensitat). El YAML accepta `color: [r, g, b]`
// a qualsevol nivell de la cadena de resolució (tipus, sala, spawn).
struct Color {
std::uint8_t r{255};
std::uint8_t g{255};
std::uint8_t b{255};
};
// Forma del gradient. Compartida per tots els emissors per evitar
// duplicar la corba a cada YAML d'entitat. Càrrega via loadConfig.
struct GradientConfig {
float inner_ratio{0.8F};
float mid_ratio{0.9F};
float inner_intensity{1.0F};
float mid_intensity{0.9F};
float outer_intensity{0.8F};
};
// Paràmetres del flicker per a emissors amb `flicker=true`. La
// forma final del radi és la suma d'una sinusoide "respiració"
// (wave_*) i una sinusoide secundària de freqüència més alta
// (noise_*) que aporta la sensació d'inquietud. Les fases es
// deriven de la seed estable de cada emissor — sense estat per
// emissor. Les amplituds són **ràtios** sobre el radi base (p.ex.
// 0.05 = 5% del radi): així una torxa de radi 40 i una de radi 24
// tenen el mateix percentatge d'oscil·lació visual, no els mateixos
// píxels (que es notarien més en la petita).
struct FlickerConfig {
float wave_amplitude_ratio{0.05F};
float wave_frequency_hz{3.0F};
float noise_amplitude_ratio{0.025F};
float noise_frequency_hz{7.0F};
};
// Posició al canvas (en píxels, mateixa unitat que la resta del
// motor) i radi efectiu de l'emissor. El centre és el del sprite
// visible: és l'entitat qui sap on cau (veure `Entity::getLightCenter`).
// `flicker` activa la modulació del radi per frame; `seed` és un
// identificador estable per emissor (no canvia amb la posició) del
// qual es deriva la fase del flicker — així els emissors mòbils
// mantenen un patró propi que no "deriva" pel moviment. `color`
// només té efecte en mode additive.
struct Emitter {
float cx{0.0F};
float cy{0.0F};
int radius{0};
int seed{0};
bool flicker{false};
Color color{};
// Paràmetres del flicker resolts per a aquest emissor (combinació
// global + override del tipus/sala/spawn). Només té sentit si
// `flicker == true`; en cas contrari s'ignoren. Per defecte
// s'inicialitza amb els defaults compilats — el caller (game_scene)
// hi posa el resolt correcte.
FlickerConfig flicker_params{};
};
// Llegeix `yaml_path` (data/config/lighting.yaml) i pobla la
// configuració global. Qualsevol clau absent cau als Defaults::Lighting.
// No es crida més d'una vegada en un run normal; tornar-la a cridar
// simplement reemplaça la configuració activa.
void loadConfig(const std::string& yaml_path);
// Configuració activa (per a debug/inspectors). Retorna sempre la
// corba subtractive — la corba additive viu en paral·lel al cpp i,
// si algun dia es necessita inspeccionar des de fora, caldrà afegir
// una variant `configAdditive()`. Inicialitzada amb els
// Defaults::Lighting fins que loadConfig() es crida.
[[nodiscard]] auto config() -> const GradientConfig&;
// Defaults globals del flicker (data/config/lighting.yaml, bloc
// `flicker:`). Els callers del construct path d'entitats el llegeixen
// per inicialitzar la `FlickerConfig` de cada emissor, i aplicar a
// sobre els overrides per tipus/sala/spawn.
[[nodiscard]] auto flickerConfig() -> const FlickerConfig&;
// Aplica la pasada d'il·luminació al canvas in-place. `mode`
// selecciona la composició (vegeu el comentari de la capçalera).
// `room_light` és l'atenuació ambient de la sala (0..1, 1 = sense
// atenuació): en subtractive és el "terra" de la intensitat fora
// dels emissors; en additive és la multiplicació prèvia del canvas
// abans de sumar els emissors (es pot deixar a 1.0 per "només tint",
// o baixar per combinar oscuriment ambient + halo cromàtic). El
// span d'emissors pot ser buit. La funció no fa cap allocació si
// es crida amb la mateixa mida de canvas múltiples cops: manté un
// buffer intern reutilitzable (només l'usa el mode subtractive).
void apply(std::uint32_t* canvas, int width, int height, Mode mode, float room_light, std::span<const Emitter> emitters);
} // namespace Lighting
+48 -21
View File
@@ -12,11 +12,52 @@
namespace { namespace {
// Parteix `text` en una llista de línies que caben cadascuna dins // Parteix `text` en una llista de línies que caben cadascuna dins
// `max_width`. Algoritme de projecte_2026: tall per espais, la paraula // Trenca una "paraula" llarga (sense espais) en trossos contigus
// que no cap passa a la línia següent. Les paraules aïllades més // que càpiguen a `max_width`. Si un sol caràcter ja excedeix
// llargues que `max_width` es queden a la seva pròpia línia sense // (font enorme), el deixa sol per garantir progrés. La part final
// trencar-les (cas improbable amb els pretty_names de gamepad retallats // que encara cap queda a `current` per a fusionar-se amb paraules
// a ~25 caràcters). // 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> { auto wrapToWidth(const std::string& text, int max_width, const Text& font, int kerning) -> std::vector<std::string> {
std::vector<std::string> lines; std::vector<std::string> lines;
if (text.empty()) { if (text.empty()) {
@@ -30,26 +71,12 @@ namespace {
const std::size_t SPACE_POS = text.find(' ', i); const std::size_t SPACE_POS = text.find(' ', i);
const bool LAST = (SPACE_POS == std::string::npos); const bool LAST = (SPACE_POS == std::string::npos);
const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i); const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i);
appendWord(WORD, max_width, font, kerning, lines, current);
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;
}
}
if (LAST) { break; } if (LAST) { break; }
i = SPACE_POS + 1; i = SPACE_POS + 1;
} }
lines.push_back(current); if (!current.empty()) { lines.push_back(current); }
return lines; return lines;
} }
+6
View File
@@ -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 Y0 = std::max(y, CLIP.y);
const int X1 = std::min(x + w, CLIP.x + CLIP.w); const int X1 = std::min(x + w, CLIP.x + CLIP.w);
const int Y1 = std::min(y + h, CLIP.y + CLIP.h); 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) { for (int iy = Y0; iy < Y1; ++iy) {
std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_); std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_);
std::fill(row + X0, row + X1, argb); std::fill(row + X0, row + X1, argb);
+1 -1
View File
@@ -72,7 +72,7 @@ class Screen {
// Alpha-blend sobre el canvas: `alpha=0` no fa res, `alpha=255` equival // Alpha-blend sobre el canvas: `alpha=0` no fa res, `alpha=255` equival
// a fillRect. El color entra com a ARGB little-endian; `alpha` és el // a fillRect. El color entra com a ARGB little-endian; `alpha` és el
// factor de mescla amb el que ja hi ha al canvas. Pensat per a overlays // factor de mescla amb el que ja hi ha al canvas. Pensat per a overlays
// (menú, pauses) que volen veure's a través el joc atenuat. // (menú de servei) que volen veure's a través el joc atenuat.
void blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha); void blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha);
// Hook invocat al principi de `present()`, abans de pujar els píxels al // Hook invocat al principi de `present()`, abans de pujar els píxels al
// backend. Serveix per a pintar overlays que han de quedar per damunt // backend. Serveix per a pintar overlays que han de quedar per damunt
+32 -20
View File
@@ -5,6 +5,7 @@
#include <ctime> #include <ctime>
#include <filesystem> #include <filesystem>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
#include "external/stb_image_write.h" #include "external/stb_image_write.h"
@@ -13,21 +14,18 @@ namespace Screenshot {
namespace { namespace {
// Crea la carpeta `screenshots/` si no existeix i torna la ruta // Estat de mòdul: ruta base on s'escriu la subcarpeta
// completa al fitxer. El paràmetre `filename` rep el nom nu (sense // `screenshots/`. Buida per defecte ⇒ relativa al CWD (fallback
// directori) per poder mostrar-lo a la notificació. // si el Director no ha pogut establir la ruta per plataforma).
auto generateFilePath(std::string& filename) -> std::string { std::string g_base_dir; // NOLINT(*-avoid-non-const-global-variables)
std::filesystem::create_directories("screenshots");
const auto NOW = std::chrono::system_clock::now(); // Construeix la ruta del directori on van les captures:
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW); // `<base>/screenshots/`. Si la base és buida, retorna
const std::tm* tm = std::localtime(&TIME); // "screenshots/" relativa al CWD.
auto screenshotsDir() -> std::string {
char timestamp[20]; if (g_base_dir.empty()) { return "screenshots/"; }
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm); const bool ENDS_WITH_SEP = (g_base_dir.back() == '/' || g_base_dir.back() == '\\');
return g_base_dir + (ENDS_WITH_SEP ? "" : "/") + "screenshots/";
filename = std::string("scr_") + timestamp + ".png";
return "screenshots/" + filename;
} }
// Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write // Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write
@@ -47,14 +45,28 @@ namespace Screenshot {
} // namespace } // 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ó. // 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::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height));
if (!writePng(copy.data(), width, height, FILEPATH)) { return {}; }
std::string filename; return {.filename = FILENAME, .folder = FOLDER};
const std::string FILEPATH = generateFilePath(filename);
return writePng(copy.data(), width, height, FILEPATH) ? filename : "";
} }
} // namespace Screenshot } // namespace Screenshot
+23 -4
View File
@@ -3,11 +3,30 @@
#include <cstdint> #include <cstdint>
#include <string> #include <string>
// Volca un buffer ARGB8888 a un fitxer PNG dins de la carpeta `screenshots/` // Volca un buffer ARGB8888 a un fitxer PNG dins de la subcarpeta
// (relativa al CWD). El nom es genera amb un timestamp i la funció retorna // `screenshots/` del directori configurat amb `setBaseDir`. El nom es
// el nom del fitxer creat, o una cadena buida si l'escriptura ha fallat. // 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 { 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 } // namespace Screenshot
+22 -4
View File
@@ -18,9 +18,12 @@
#include "core/input/key_config.hpp" #include "core/input/key_config.hpp"
#include "core/input/mouse.hpp" #include "core/input/mouse.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/effects/shadow.hpp"
#include "core/rendering/lighting.hpp"
#include "core/rendering/notifier.hpp" #include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp" #include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/screenshot.hpp"
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "core/rendering/text.hpp" #include "core/rendering/text.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
@@ -129,6 +132,10 @@ Director::Director() {
// Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a // Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a
// data/config/ui.yaml cau al valor compilat a Defaults::UI. // data/config/ui.yaml cau al valor compilat a Defaults::UI.
UiConfig::get().load(Defaults::Paths::UI_YAML); UiConfig::get().load(Defaults::Paths::UI_YAML);
// Forma del gradient d'il·luminació (3 anells radials). El radi
// per emissor i el `light_level` per sala viuen als YAMLs propis;
// ací només la corba compartida per a tots els emissors.
Lighting::loadConfig(Defaults::Paths::LIGHTING_YAML);
// Configuració específica de les escenes TITLE i LOGO. Es carreguen // Configuració específica de les escenes TITLE i LOGO. Es carreguen
// additivament sobre el mateix UiConfig: cada fitxer només conté la // additivament sobre el mateix UiConfig: cada fitxer només conté la
// seua subclau (`title:` o `logo:`), i les claus absents a la base // seua subclau (`title:` o `logo:`), i les claus absents a la base
@@ -162,6 +169,11 @@ Director::Director() {
Resource::Cache::get().loadSurface(ROOM_TEXT_FONT_GIF_KEY, UiConfig::get().roomTextFont().gif); 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")); 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 // 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; // 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`. // la lògica de quina sona en cada estat viu a `GameScene`.
@@ -342,10 +354,10 @@ Director::~Director() {
void Director::createSystemFolder() { void Director::createSystemFolder() {
// Carpeta de configuració per plataforma: // Carpeta de configuració per plataforma:
// - Windows: %APPDATA%\jailgames\aee_arcade\ // - Windows: %APPDATA%\jailgames\aee_arcade
// - macOS: ~/Library/Application Support/jailgames/aee_arcade/ // - macOS: ~/Library/Application Support/jailgames/aee_arcade
// - Linux: $XDG_CONFIG_HOME/jailgames/aee_arcade/ (defecte // - Linux: $XDG_CONFIG_HOME/jailgames/aee_arcade (defecte
// ~/.config/jailgames/aee_arcade/) // ~/.config/jailgames/aee_arcade)
// No fem servir SDL_GetPrefPath: a Linux apunta a XDG_DATA_HOME // No fem servir SDL_GetPrefPath: a Linux apunta a XDG_DATA_HOME
// (~/.local/share), que és per a dades, no per a configuració. // (~/.local/share), que és per a dades, no per a configuració.
std::string base; std::string base;
@@ -373,6 +385,12 @@ void Director::createSystemFolder() {
if (error) { if (error) {
Log::error("Director", "no s'ha pogut crear {}: {}", system_folder_, error.message()); 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() { void Director::processEvents() {
+28 -16
View File
@@ -82,8 +82,15 @@ UiConfig::UiConfig()
menu_cursor_(Defaults::UI::MENU_CURSOR), menu_cursor_(Defaults::UI::MENU_CURSOR),
menu_disabled_(Defaults::UI::MENU_DISABLED), menu_disabled_(Defaults::UI::MENU_DISABLED),
hud_bg_(Defaults::UI::HUD_BG), hud_bg_(Defaults::UI::HUD_BG),
hud_label_(Defaults::UI::HUD_LABEL), hud_p1_label_(Defaults::UI::HUD_P1_LABEL),
hud_value_(Defaults::UI::HUD_VALUE), 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), hud_flash_(Defaults::UI::HUD_FLASH),
room_text_(Defaults::UI::ROOM_TEXT), room_text_(Defaults::UI::ROOM_TEXT),
room_text_outline_(Defaults::UI::ROOM_TEXT_OUTLINE), room_text_outline_(Defaults::UI::ROOM_TEXT_OUTLINE),
@@ -92,10 +99,8 @@ UiConfig::UiConfig()
hud_entry_ms_(Defaults::UI::HUD_ENTRY_MS), hud_entry_ms_(Defaults::UI::HUD_ENTRY_MS),
hud_flash_ms_(Defaults::UI::HUD_FLASH_MS), hud_flash_ms_(Defaults::UI::HUD_FLASH_MS),
hud_shake_ms_(Defaults::UI::HUD_SHAKE_MS), hud_shake_ms_(Defaults::UI::HUD_SHAKE_MS),
pause_enter_ms_(Defaults::UI::PAUSE_ENTER_MS), hud_blink_cycle_ms_(Defaults::UI::HUD_BLINK_CYCLE_MS),
pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS), hud_blink_visible_ratio_(Defaults::UI::HUD_BLINK_VISIBLE_RATIO),
pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)),
pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)),
title_duration_ms_(Defaults::UI::Title::DURATION_MS), title_duration_ms_(Defaults::UI::Title::DURATION_MS),
title_bg_color_(Defaults::UI::Title::BG_COLOR), title_bg_color_(Defaults::UI::Title::BG_COLOR),
title_text_color_(Defaults::UI::Title::TEXT_COLOR), title_text_color_(Defaults::UI::Title::TEXT_COLOR),
@@ -218,13 +223,26 @@ void UiConfig::load(const std::string& file_path) {
readColorIndex(c, "menu_cursor", menu_cursor_); readColorIndex(c, "menu_cursor", menu_cursor_);
readColorIndex(c, "menu_disabled", menu_disabled_); readColorIndex(c, "menu_disabled", menu_disabled_);
readColorIndex(c, "hud_bg", hud_bg_); 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, "hud_flash", hud_flash_);
readColorIndex(c, "room_text", room_text_); readColorIndex(c, "room_text", room_text_);
readColorIndex(c, "room_text_outline", room_text_outline_); 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")) { if (yaml.contains("timings")) {
const auto& t = yaml["timings"]; const auto& t = yaml["timings"];
readField(t, "notification_ms", notification_ms_); 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, "entry_ms", hud_entry_ms_);
readField(h, "flash_ms", hud_flash_ms_); readField(h, "flash_ms", hud_flash_ms_);
readField(h, "shake_ms", hud_shake_ms_); readField(h, "shake_ms", hud_shake_ms_);
} readField(h, "blink_cycle_ms", hud_blink_cycle_ms_);
readField(h, "blink_visible_ratio", hud_blink_visible_ratio_);
if (yaml.contains("pause")) {
const auto& p = yaml["pause"];
readField(p, "enter_ms", pause_enter_ms_);
readField(p, "exit_ms", pause_exit_ms_);
readCurve(p, "enter_curve", pause_enter_curve_);
readCurve(p, "exit_curve", pause_exit_curve_);
} }
if (yaml.contains("sounds")) { if (yaml.contains("sounds")) {
+26 -15
View File
@@ -66,8 +66,19 @@ class UiConfig {
[[nodiscard]] auto menuCursor() const -> Uint8 { return menu_cursor_; } [[nodiscard]] auto menuCursor() const -> Uint8 { return menu_cursor_; }
[[nodiscard]] auto menuDisabled() const -> Uint8 { return menu_disabled_; } [[nodiscard]] auto menuDisabled() const -> Uint8 { return menu_disabled_; }
[[nodiscard]] auto hudBg() const -> Uint8 { return hud_bg_; } [[nodiscard]] auto hudBg() const -> Uint8 { return hud_bg_; }
[[nodiscard]] auto hudLabel() const -> Uint8 { return hud_label_; } // Famílies de color del HUD per bloc (LEFT/CENTER/RIGHT). Cada
[[nodiscard]] auto hudValue() const -> Uint8 { return hud_value_; } // 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 hudFlash() const -> Uint8 { return hud_flash_; }
[[nodiscard]] auto roomText() const -> Uint8 { return room_text_; } [[nodiscard]] auto roomText() const -> Uint8 { return room_text_; }
[[nodiscard]] auto roomTextOutline() const -> Uint8 { return room_text_outline_; } [[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 hudEntryMs() const -> int { return hud_entry_ms_; }
[[nodiscard]] auto hudFlashMs() const -> int { return hud_flash_ms_; } [[nodiscard]] auto hudFlashMs() const -> int { return hud_flash_ms_; }
[[nodiscard]] auto hudShakeMs() const -> int { return hud_shake_ms_; } [[nodiscard]] auto hudShakeMs() const -> int { return hud_shake_ms_; }
[[nodiscard]] auto pauseEnterMs() const -> int { return pause_enter_ms_; } [[nodiscard]] auto hudBlinkCycleMs() const -> int { return hud_blink_cycle_ms_; }
[[nodiscard]] auto pauseExitMs() const -> int { return pause_exit_ms_; } [[nodiscard]] auto hudBlinkVisibleRatio() const -> float { return hud_blink_visible_ratio_; }
// Escena TITLE (placeholder visual fins que tinguem artwork). // Escena TITLE (placeholder visual fins que tinguem artwork).
struct TitleHeatHaze { struct TitleHeatHaze {
@@ -158,11 +169,6 @@ class UiConfig {
[[nodiscard]] auto logoTimeline() const -> const LogoTimeline& { return logo_timeline_; } [[nodiscard]] auto logoTimeline() const -> const LogoTimeline& { return logo_timeline_; }
// Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa
// Easing::linear com a fallback.
[[nodiscard]] auto pauseEnterCurve() const -> CurveFn { return pause_enter_curve_; }
[[nodiscard]] auto pauseExitCurve() const -> CurveFn { return pause_exit_curve_; }
// Resol un nom de corba a la seua funció. Pública per permetre a // Resol un nom de corba a la seua funció. Pública per permetre a
// futurs camps d'UI triar corbes pel mateix mecanisme (ex.: HUD // futurs camps d'UI triar corbes pel mateix mecanisme (ex.: HUD
// entry, notifications slide...). Si el nom no està registrat, // entry, notifications slide...). Si el nom no està registrat,
@@ -209,8 +215,15 @@ class UiConfig {
Uint8 menu_cursor_; Uint8 menu_cursor_;
Uint8 menu_disabled_; Uint8 menu_disabled_;
Uint8 hud_bg_; Uint8 hud_bg_;
Uint8 hud_label_; Uint8 hud_p1_label_;
Uint8 hud_value_; 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 hud_flash_;
Uint8 room_text_; Uint8 room_text_;
Uint8 room_text_outline_; Uint8 room_text_outline_;
@@ -220,10 +233,8 @@ class UiConfig {
int hud_entry_ms_; int hud_entry_ms_;
int hud_flash_ms_; int hud_flash_ms_;
int hud_shake_ms_; int hud_shake_ms_;
int pause_enter_ms_; int hud_blink_cycle_ms_;
int pause_exit_ms_; float hud_blink_visible_ratio_;
CurveFn pause_enter_curve_;
CurveFn pause_exit_curve_;
int title_duration_ms_; int title_duration_ms_;
Uint8 title_bg_color_; Uint8 title_bg_color_;
+199 -9
View File
@@ -176,6 +176,23 @@ namespace {
return {PX, PY}; return {PX, PY};
} }
// Compta sub-sprites del atlas i decideix si el template demana
// sub-picker de frames. Extret de `ensureEditableEntities` per acotar
// la complexitat cognitiva del callsite. Per a strips d'1 frame
// (la majoria del catàleg) retorna (1, false).
struct AtlasVariants {
int frame_count;
bool has_variants;
};
auto computeAtlasVariants(const Surface& surface, int frame_w, int frame_h, const fkyaml::node& tmpl) -> AtlasVariants {
const int COLS = (frame_w > 0) ? (surface.getWidth() / frame_w) : 1;
const int ROWS = (frame_h > 0) ? (surface.getHeight() / frame_h) : 1;
const int FRAME_COUNT = std::max(1, COLS * ROWS);
const bool FLAG = tmpl.contains("editable_variants") && tmpl["editable_variants"].get_value<bool>();
return {.frame_count = FRAME_COUNT, .has_variants = FLAG && FRAME_COUNT > 1};
}
} // namespace } // namespace
void Editor::enter() { void Editor::enter() {
@@ -190,6 +207,10 @@ void Editor::enter() {
picker_ = Picker::NONE; picker_ = Picker::NONE;
brush_tile_ = -1; brush_tile_ = -1;
brush_entity_.clear(); brush_entity_.clear();
brush_entity_frame_ = -1;
entity_frame_picker_source_ = nullptr;
entity_frame_picker_hover_ = -1;
entity_frame_picker_scroll_ = 0;
hand_selected_idx_ = -1; hand_selected_idx_ = -1;
hand_hover_idx_ = -1; hand_hover_idx_ = -1;
ensureEditableEntities(); ensureEditableEntities();
@@ -256,7 +277,15 @@ void Editor::ensureEditableEntities() {
} }
if (surface == nullptr) { continue; } if (surface == nullptr) { continue; }
editable_entities_.push_back({.name = name, .sprite = surface, .frame_w = FRAME_W, .frame_h = FRAME_H}); const auto [FRAME_COUNT, HAS_VARIANTS] = computeAtlasVariants(*surface, FRAME_W, FRAME_H, tmpl);
editable_entities_.push_back({
.name = name,
.sprite = surface,
.frame_w = FRAME_W,
.frame_h = FRAME_H,
.frame_count = FRAME_COUNT,
.has_variants = HAS_VARIANTS,
});
} }
} }
@@ -301,6 +330,13 @@ namespace {
s.col = (static_cast<int>(R.x) - Map::tilemap_origin_x) / Map::tile_size; s.col = (static_cast<int>(R.x) - Map::tilemap_origin_x) / Map::tile_size;
s.row = (static_cast<int>(R.y) - Map::tilemap_origin_y) / Map::tile_size; s.row = (static_cast<int>(R.y) - Map::tilemap_origin_y) / Map::tile_size;
} }
// Variant del atlas: si l'entitat té un AnimationComponent
// d'un sol frame, és una entitat amb variant escollida
// (debris, columna). Persistim l'índex perquè la pròxima
// càrrega restauri la mateixa cara del sub-sprite.
if (const auto& anim = e->getAnimation(); anim.has_value() && anim->frameCount() == 1) {
s.frame = anim->currentFrame();
}
} }
out.push_back(std::move(s)); out.push_back(std::move(s));
} }
@@ -374,6 +410,7 @@ auto Editor::applyHotkey(int scancode) -> bool {
// tracte per a la seva acció de confirmar-eixida. // tracte per a la seva acció de confirmar-eixida.
if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) { if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) {
picker_ = Picker::NONE; picker_ = Picker::NONE;
entity_frame_picker_source_ = nullptr;
return true; return true;
} }
return false; return false;
@@ -426,6 +463,8 @@ void Editor::update(int /*delta_ms*/, Tilemap& tilemap, EntityManager& entities)
updateTilePicker(tilemap, CX, CY); updateTilePicker(tilemap, CX, CY);
} else if (picker_ == Picker::ENTITY) { } else if (picker_ == Picker::ENTITY) {
updateEntityPicker(CX, CY); updateEntityPicker(CX, CY);
} else if (picker_ == Picker::ENTITY_FRAME) {
updateEntityFramePicker(CX, CY);
} else { } else {
switch (mode_) { switch (mode_) {
case Mode::TILE: case Mode::TILE:
@@ -514,7 +553,9 @@ namespace {
// Composa el spawn YAML per al brush actual. Retorna empty si el snap // Composa el spawn YAML per al brush actual. Retorna empty si el snap
// no és vàlid (fora de rejilla, solapament, o duplicat de porta). // no és vàlid (fora de rejilla, solapament, o duplicat de porta).
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing) -> std::string { // `frame` (-1 = no emetre) afegeix el camp `frame: N` per a entitats
// amb variants estàtiques (debris, columna).
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing, int frame) -> std::string {
std::ostringstream y; std::ostringstream y;
y << "type: " << brush << '\n'; y << "type: " << brush << '\n';
if (brush == "sarcophagus") { if (brush == "sarcophagus") {
@@ -530,6 +571,7 @@ namespace {
const auto DEST = tileCellRect(cursor_col, cursor_row, Map::tile_size); const auto DEST = tileCellRect(cursor_col, cursor_row, Map::tile_size);
if (cellOccupied(existing, DEST)) { return {}; } if (cellOccupied(existing, DEST)) { return {}; }
y << "position: [" << cursor_col << ", " << cursor_row << "]\n"; y << "position: [" << cursor_col << ", " << cursor_row << "]\n";
if (frame >= 0) { y << "frame: " << frame << '\n'; }
return y.str(); return y.str();
} }
@@ -546,6 +588,7 @@ void Editor::updateEntityBrush(EntityManager& entities, int /*canvas_x*/, int /*
// H (mà): manté la simetria amb el comportament del brush // H (mà): manté la simetria amb el comportament del brush
// de tiles. // de tiles.
brush_entity_.clear(); brush_entity_.clear();
brush_entity_frame_ = -1;
} }
} }
} }
@@ -665,7 +708,14 @@ auto Editor::tryMoveSelected(const Tilemap& tilemap, EntityManager& entities) co
filtered.reserve(list.size() - 1); filtered.reserve(list.size() - 1);
std::ranges::copy_if(list, std::back_inserter(filtered), [target](const Entity* e) { return e != target; }); std::ranges::copy_if(list, std::back_inserter(filtered), [target](const Entity* e) { return e != target; });
const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered); // Si l'entitat moguda té una variant fixada (AnimationComponent d'un
// sol frame, convenció per a debris/columnes), la preservem al spawn
// reconstruït — així moure no perd la cara escollida.
int frame = -1;
if (const auto& anim = target->getAnimation(); anim.has_value() && anim->frameCount() == 1) {
frame = anim->currentFrame();
}
const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered, frame);
if (STR.empty()) { return false; } if (STR.empty()) { return false; }
if (!entities.removeEntity(target)) { return false; } if (!entities.removeEntity(target)) { return false; }
try { try {
@@ -739,8 +789,10 @@ void Editor::renderEditorHud(const EntityManager& entities) const {
? "(" + std::to_string(cursor_col_) + "," + std::to_string(cursor_row_) + ")" ? "(" + std::to_string(cursor_col_) + "," + std::to_string(cursor_row_) + ")"
: "(--,--)"; : "(--,--)";
const auto LBL = UiConfig::get().hudLabel(); // El HUD ara té tres famílies (P1/CENTER/P2); l'editor n'agafa la
const auto VAL = UiConfig::get().hudValue(); // 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 auto FLASH = UiConfig::get().hudFlash();
const int KERN = UiConfig::get().hudFont().kerning; const int KERN = UiConfig::get().hudFont().kerning;
// Mateixos Y que el HUD del joc per simetria visual: línia 1 = 4, // 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 { auto Editor::tryPlaceEntity(EntityManager& entities) -> bool {
if (brush_entity_.empty() || cursor_col_ < 0 || cursor_row_ < 0) { return false; } if (brush_entity_.empty() || cursor_col_ < 0 || cursor_row_ < 0) { return false; }
const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities()); const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities(), brush_entity_frame_);
if (STR.empty()) { return false; } if (STR.empty()) { return false; }
try { try {
auto node = fkyaml::node::deserialize(STR.begin(), STR.end()); auto node = fkyaml::node::deserialize(STR.begin(), STR.end());
@@ -838,9 +890,21 @@ void Editor::updateEntityPicker(int canvas_x, int canvas_y) {
entity_picker_scroll_ = std::clamp(entity_picker_scroll_, 0, LAY.max_scroll); entity_picker_scroll_ = std::clamp(entity_picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) { } else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (entity_picker_hover_ >= 0 && entity_picker_hover_ < static_cast<int>(editable_entities_.size())) { if (entity_picker_hover_ >= 0 && entity_picker_hover_ < static_cast<int>(editable_entities_.size())) {
brush_entity_ = editable_entities_[entity_picker_hover_].name; const auto& sel = editable_entities_[entity_picker_hover_];
mode_ = Mode::ENTITY; if (sel.has_variants) {
picker_ = Picker::NONE; // 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;
}
} }
} }
} }
@@ -894,6 +958,106 @@ void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const {
screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER); screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER);
} }
void Editor::updateEntityFramePicker(int canvas_x, int canvas_y) {
// Si la font ha desaparegut (no hauria de passar mentre el picker
// estigui obert, però defensem el cas), tanquem el sub-picker en lloc
// de fer crash.
if (entity_frame_picker_source_ == nullptr) {
picker_ = Picker::ENTITY;
return;
}
const auto LAY = layoutEntityPicker(entity_frame_picker_source_->frame_count);
entity_frame_picker_hover_ = -1;
const int INNER_X = LAY.x + PICKER_BORDER;
const int INNER_Y = LAY.y + PICKER_BORDER;
const int RX = canvas_x - INNER_X;
const int RY = canvas_y - INNER_Y;
if (RX >= 0 && RY >= 0 && LAY.pitch > 0 && LAY.cols > 0) {
const int VC = RX / LAY.pitch;
const int VR = RY / LAY.pitch;
const bool IN_SPACING_X = (RX % LAY.pitch) >= ENTITY_CELL;
const bool IN_SPACING_Y = (RY % LAY.pitch) >= ENTITY_CELL;
if (!IN_SPACING_X && !IN_SPACING_Y && VC < LAY.cols && VR < LAY.rows_visible) {
const int IDX = ((entity_frame_picker_scroll_ + VR) * LAY.cols) + VC;
if (IDX < LAY.total_items) { entity_frame_picker_hover_ = IDX; }
}
}
for (const auto& ev : EventBuffer::events) {
if (ev.type == SDL_EVENT_MOUSE_WHEEL) {
entity_frame_picker_scroll_ -= static_cast<int>(ev.wheel.y);
entity_frame_picker_scroll_ = std::clamp(entity_frame_picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (entity_frame_picker_hover_ >= 0) {
brush_entity_ = entity_frame_picker_source_->name;
brush_entity_frame_ = entity_frame_picker_hover_;
mode_ = Mode::ENTITY;
picker_ = Picker::NONE;
}
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_RIGHT) {
// Botó dret: tanca el sub-picker i torna al picker d'entitats
// sense fixar brush. Coherent amb la convenció de "right =
// cancel·la l'acció actual" dels altres modes.
picker_ = Picker::ENTITY;
entity_frame_picker_source_ = nullptr;
}
}
}
void Editor::renderEntityFramePicker() const {
if (entity_frame_picker_source_ == nullptr) { return; }
const auto& src = *entity_frame_picker_source_;
const auto LAY = layoutEntityPicker(src.frame_count);
if (LAY.cols <= 0 || LAY.rows_visible <= 0) { return; }
auto& screen = Screen::get();
const auto& cfg = EditorConfig::get();
screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, cfg.pickerBgArgb());
// Cols del atlas (sub-sprites per fila): es dedueix de la mida del
// surface i del frame, igual que fa `EntityManager::renderEntity`.
const int ATLAS_COLS = (src.frame_w > 0) ? (src.sprite->getWidth() / src.frame_w) : 1;
const int ATLAS_COLS_SAFE = (ATLAS_COLS > 0) ? ATLAS_COLS : 1;
for (int vr = 0; vr < LAY.rows_visible; ++vr) {
const int LOGICAL_ROW = entity_frame_picker_scroll_ + vr;
for (int vc = 0; vc < LAY.cols; ++vc) {
const int IDX = (LOGICAL_ROW * LAY.cols) + vc;
if (IDX >= LAY.total_items) { break; }
const auto [PX, PY] = entityCellOrigin(LAY, vc, vr);
const int OX = PX + ((ENTITY_CELL - src.frame_w) / 2);
const int OY = PY + ((ENTITY_CELL - src.frame_h) / 2);
const int A_COL = IDX % ATLAS_COLS_SAFE;
const int A_ROW = IDX / ATLAS_COLS_SAFE;
const SDL_FRect SRC{
.x = static_cast<float>(A_COL * src.frame_w),
.y = static_cast<float>(A_ROW * src.frame_h),
.w = static_cast<float>(src.frame_w),
.h = static_cast<float>(src.frame_h),
};
src.sprite->render(OX, OY, &SRC, SDL_FLIP_NONE);
}
}
if (entity_frame_picker_hover_ >= 0) {
const int VR = (entity_frame_picker_hover_ / LAY.cols) - entity_frame_picker_scroll_;
const int VC = entity_frame_picker_hover_ % LAY.cols;
if (VR >= 0 && VR < LAY.rows_visible) {
const auto [PX, PY] = entityCellOrigin(LAY, VC, VR);
const auto C = cfg.pickerHoverArgb();
screen.fillRect(PX, PY, ENTITY_CELL, 1, C);
screen.fillRect(PX, PY + ENTITY_CELL - 1, ENTITY_CELL, 1, C);
screen.fillRect(PX, PY, 1, ENTITY_CELL, C);
screen.fillRect(PX + ENTITY_CELL - 1, PY, 1, ENTITY_CELL, C);
}
}
const auto BORDER = cfg.borderArgb();
screen.fillRect(LAY.x, LAY.y, LAY.w, 1, BORDER);
screen.fillRect(LAY.x, LAY.y + LAY.h - 1, LAY.w, 1, BORDER);
screen.fillRect(LAY.x, LAY.y, 1, LAY.h, BORDER);
screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER);
}
void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) { void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
const auto LAY = layoutPicker(tilemap); const auto LAY = layoutPicker(tilemap);
tile_picker_hover_ = -1; tile_picker_hover_ = -1;
@@ -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 { void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const {
if (!active_) { return; } if (!active_) { return; }
auto& screen = Screen::get(); auto& screen = Screen::get();
@@ -938,11 +1124,15 @@ void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const
renderTilePicker(tilemap); renderTilePicker(tilemap);
} else if (picker_ == Picker::ENTITY) { } else if (picker_ == Picker::ENTITY) {
renderEntityPicker(tilemap); renderEntityPicker(tilemap);
} else if (picker_ == Picker::ENTITY_FRAME) {
renderEntityFramePicker();
} else if (mode_ == Mode::TILE && brush_tile_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) { } else if (mode_ == Mode::TILE && brush_tile_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
// Preview del brush sota el cursor (només quan el picker és tancat). // Preview del brush sota el cursor (només quan el picker és tancat).
const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize()); const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize());
const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize()); const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize());
tilemap.renderTilePreview(PX, PY, brush_tile_); tilemap.renderTilePreview(PX, PY, brush_tile_);
} else if (mode_ == Mode::ENTITY && !brush_entity_.empty() && cursor_col_ >= 0 && cursor_row_ >= 0) {
renderEntityBrushPreview(tilemap);
} else if (mode_ == Mode::HAND && picker_ == Picker::NONE) { } else if (mode_ == Mode::HAND && picker_ == Picker::NONE) {
// Marcs d'entitat: hover (tenue) i selecció (brillant). El // Marcs d'entitat: hover (tenue) i selecció (brillant). El
// pickerHoverArgb el reusem com a "selecció brillant"; el // pickerHoverArgb el reusem com a "selecció brillant"; el
+37 -7
View File
@@ -78,9 +78,13 @@ class Editor {
HAND }; HAND };
// Quin picker està obert (si n'hi ha). Mútuament excloents. // Quin picker està obert (si n'hi ha). Mútuament excloents.
// - ENTITY_FRAME: sub-picker que apareix després de clicar una entitat
// amb variants al picker principal. Mostra tots els sub-sprites del
// atlas (índex frame) per a triar quina cara representa la instància.
enum class Picker : std::uint8_t { NONE, enum class Picker : std::uint8_t { NONE,
TILE, TILE,
ENTITY }; ENTITY,
ENTITY_FRAME };
// Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`. // Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`.
// Es crida al principi d'`update` perquè altres handlers no vegin // Es crida al principi d'`update` perquè altres handlers no vegin
@@ -100,6 +104,7 @@ class Editor {
void updateHand(const Tilemap& tilemap, EntityManager& entities, int canvas_x, int canvas_y); void updateHand(const Tilemap& tilemap, EntityManager& entities, int canvas_x, int canvas_y);
void updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y); void updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y);
void updateEntityPicker(int canvas_x, int canvas_y); void updateEntityPicker(int canvas_x, int canvas_y);
void updateEntityFramePicker(int canvas_x, int canvas_y);
// Intent de col·locació segons brush_entity_ a la cel·la del cursor. // Intent de col·locació segons brush_entity_ a la cel·la del cursor.
// Construeix un node spawn sintètic, fa el snap concret per tipus // Construeix un node spawn sintètic, fa el snap concret per tipus
@@ -138,6 +143,13 @@ class Editor {
void renderTilePicker(const Tilemap& tilemap) const; void renderTilePicker(const Tilemap& tilemap) const;
void renderEntityPicker(const Tilemap& tilemap) const; void renderEntityPicker(const Tilemap& tilemap) const;
void renderEntityFramePicker() const;
// Preview del sprite de l'entitat seleccionada com a brush, snapped al
// tile sota el cursor. Anchor "feet": base del sprite a la base inferior
// del tile, centrat horitzontalment — així stone cau exactament i column
// sobresurt cap amunt, coincidint amb el resultat de col·locar.
void renderEntityBrushPreview(const Tilemap& tilemap) const;
bool active_{false}; bool active_{false};
Mode mode_{Mode::TILE}; Mode mode_{Mode::TILE};
@@ -153,21 +165,22 @@ class Editor {
int tile_picker_scroll_{0}; int tile_picker_scroll_{0};
int tile_picker_hover_{-1}; int tile_picker_hover_{-1};
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
std::string brush_entity_;
// Estat de l'EntityPicker.
int entity_picker_scroll_{0};
int entity_picker_hover_{-1}; // Índex a la llista de noms.
// Entrada editable del picker. Cada item recorda el nom (clau de // Entrada editable del picker. Cada item recorda el nom (clau de
// factory + YAML), el surface del sprite (frame 0 com a icona) i la // factory + YAML), el surface del sprite (frame 0 com a icona) i la
// mida de cada frame al GIF — necessària per clipar el primer frame // mida de cada frame al GIF — necessària per clipar el primer frame
// quan el GIF és un strip multi-frame. // quan el GIF és un strip multi-frame.
// - `frame_count` és el nombre total de sub-sprites del atlas
// (cols × rows segons les dimensions del surface i del frame).
// - `has_variants` és `true` quan el template declara `editable_variants:
// true` i hi ha més d'un frame: aleshores clicar al picker obre el
// sub-picker de frames en lloc de fixar el brush directament.
struct EditableEntity { struct EditableEntity {
std::string name; std::string name;
std::shared_ptr<Surface> sprite; std::shared_ptr<Surface> sprite;
int frame_w{0}; int frame_w{0};
int frame_h{0}; int frame_h{0};
int frame_count{1};
bool has_variants{false};
}; };
// Construïda lazy al primer enter() i preservada entre sessions: // Construïda lazy al primer enter() i preservada entre sessions:
@@ -178,6 +191,23 @@ class Editor {
std::vector<EditableEntity> editable_entities_; std::vector<EditableEntity> editable_entities_;
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
std::string brush_entity_;
// Frame escollit per al brush actiu (-1 = no aplicable: entitat sense
// variants o brush encara per fixar). El emet el sub-picker en clicar
// un frame; `tryPlaceEntity` el passa al spawn YAML com a `frame: N`.
int brush_entity_frame_{-1};
// Estat de l'EntityPicker.
int entity_picker_scroll_{0};
int entity_picker_hover_{-1}; // Índex a la llista de noms.
// Estat del sub-picker de frames. `entity_frame_picker_source_` apunta
// a la entitat activa (vista al EntityPicker) per a la qual el
// sub-picker mostra les variants. Quan el sub-picker es tanca sense
// selecció, ho deixem a nullptr i `brush_entity_frame_ = -1`.
int entity_frame_picker_scroll_{0};
int entity_frame_picker_hover_{-1};
const EditableEntity* entity_frame_picker_source_{nullptr};
// Font compartida amb el Notifier/HUD: el surface el carrega Director // Font compartida amb el Notifier/HUD: el surface el carrega Director
// a l'arrencada, ací només prenem la referència del cache. shared_ptr // a l'arrencada, ací només prenem la referència del cache. shared_ptr
// perquè `Text` accepta el surface per shared_ptr i així no copiem. // perquè `Text` accepta el surface per shared_ptr i així no copiem.
+119 -7
View File
@@ -1,14 +1,87 @@
#include "game/entities/animation_component.hpp" #include "game/entities/animation_component.hpp"
#include <cmath> #include <cmath>
#include <stdexcept>
#include <utility> #include <utility>
AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool loop) namespace {
: AnimationComponent(std::move(frames), fps, loop, false, false) {}
AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool loop, bool flip_h, bool flip_v) // Quan el caller no especifica temps, considerem un frame "estàtic" de
// 1s. El valor és arbitrari: amb 1 frame el component mai no avança,
// així que el speed concret és inert. Amb N frames és el fallback que
// assumeix la factory (mateix valor que el default del format antic).
constexpr float K_DEFAULT_FRAME_SECONDS = 1.0F;
// Si el YAML declara temps <= 0 per a algun frame, el saturem a aquest
// mínim per a evitar bucles infinits a `tick` (que avancen frame
// mentre `accumulator >= speeds[i]`). 1 ms és prou petit per a no
// alterar mai una temporització real i prou gran per a no col·lapsar.
constexpr float K_MIN_FRAME_SECONDS = 0.001F;
auto sanitizeSpeed(float seconds) -> float {
return seconds > 0.0F ? seconds : K_MIN_FRAME_SECONDS;
}
// Expandeix una llista de temps a la mida de `frame_count`:
// - mida = N: 1:1.
// - mida < N: emplena la resta amb l'últim valor (o el default si
// la llista era buida).
// - mida > N: talla a N.
// Tots els valors es passen per `sanitizeSpeed`.
auto fitSpeeds(std::vector<float> speeds, std::size_t frame_count) -> std::vector<float> {
if (frame_count == 0) { return {}; }
if (speeds.empty()) {
return std::vector<float>(frame_count, K_DEFAULT_FRAME_SECONDS);
}
// cppcheck-suppress useStlAlgorithm
for (auto& s : speeds) { s = sanitizeSpeed(s); }
if (speeds.size() == frame_count) { return speeds; }
if (speeds.size() > frame_count) {
speeds.resize(frame_count);
return speeds;
}
const float TAIL = speeds.back();
speeds.resize(frame_count, TAIL);
return speeds;
}
// Llig `animations.<key>.frames` com a llista d'enters. Garanteix
// almenys 1 frame (fallback a [0]) per a poder construir sempre un
// component vàlid.
auto parseFrames(const fkyaml::node& anim) -> std::vector<int> {
std::vector<int> frames;
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
return frames;
}
// Llig `animations.<key>.frame_ms` (escalar o llista) i retorna els
// temps en segons. Si és escalar, retorna una llista uniforme de
// `frame_count` elements; si és llista, copia tal qual (l'ajust de
// mida el farà `fitSpeeds` al constructor).
auto parseFrameMs(const fkyaml::node& anim, std::size_t frame_count) -> std::vector<float> {
std::vector<float> speeds_secs;
const auto& fm = anim["frame_ms"];
if (fm.is_sequence()) {
for (const auto& v : fm) {
// cppcheck-suppress useStlAlgorithm
speeds_secs.push_back(static_cast<float>(v.get_value<int>()) / 1000.0F);
}
} else {
const float SECS = static_cast<float>(fm.get_value<int>()) / 1000.0F;
speeds_secs.assign(frame_count, SECS);
}
return speeds_secs;
}
} // namespace
AnimationComponent::AnimationComponent(std::vector<int> frames, std::vector<float> speeds, bool loop, bool flip_h, bool flip_v)
: frames_(std::move(frames)), : frames_(std::move(frames)),
fps_(fps > 0.0F ? fps : 1.0F), speeds_(fitSpeeds(std::move(speeds), frames_.size())),
loop_(loop), loop_(loop),
flip_h_(flip_h), flip_h_(flip_h),
flip_v_(flip_v) { flip_v_(flip_v) {
@@ -19,13 +92,52 @@ AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool
if (frames_.size() <= 1 && !loop_) { completed_ = true; } if (frames_.size() <= 1 && !loop_) { completed_ = true; }
} }
auto AnimationComponent::uniform(std::vector<int> frames, float frame_ms, bool loop) -> AnimationComponent {
return uniform(std::move(frames), frame_ms, loop, false, false);
}
auto AnimationComponent::uniform(std::vector<int> frames, float frame_ms, bool loop, bool flip_h, bool flip_v) -> AnimationComponent {
const float SECS = sanitizeSpeed(frame_ms / 1000.0F);
std::vector<float> speeds(frames.size(), SECS);
return {std::move(frames), std::move(speeds), loop, flip_h, flip_v};
}
auto AnimationComponent::fromYaml(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
// Defaults conservadors per a quan el bloc `animations.<key>` no
// existeix: 1 frame loopant a 1s — visualment estàtic, semàntica neutra.
std::vector<int> frames = {0};
std::vector<float> speeds_secs; // buit ⇒ fitSpeeds emplenarà amb default
bool loop = true;
bool flip_h = false;
bool flip_v = false;
if (!yaml.contains("animations") || !yaml["animations"].contains(key)) {
return {std::move(frames), std::move(speeds_secs), loop, flip_h, flip_v};
}
const auto& anim = yaml["animations"][key];
// L'antic format `fps: N` ja no és suportat. Si apareix, peguem hard
// perquè la migració sigui visible (no silenciosa). El missatge inclou
// la clau d'animació per a facilitar localitzar el YAML problemàtic.
if (anim.contains("fps")) {
throw std::runtime_error("AnimationComponent: 'fps:' is no longer supported, use 'frame_ms:' instead (animation '" + key + "')");
}
if (anim.contains("frames")) { frames = parseFrames(anim); }
if (anim.contains("frame_ms")) { speeds_secs = parseFrameMs(anim, frames.size()); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
if (anim.contains("flip_h")) { flip_h = anim["flip_h"].get_value<bool>(); }
if (anim.contains("flip_v")) { flip_v = anim["flip_v"].get_value<bool>(); }
return {std::move(frames), std::move(speeds_secs), loop, flip_h, flip_v};
}
void AnimationComponent::tick(float delta_time) { void AnimationComponent::tick(float delta_time) {
if (frames_.size() <= 1 || completed_) { return; } if (frames_.size() <= 1 || completed_) { return; }
accumulator_ += delta_time; accumulator_ += delta_time;
const float FRAME_TIME = 1.0F / fps_; while (accumulator_ >= speeds_[static_cast<std::size_t>(current_)]) {
while (accumulator_ >= FRAME_TIME) { accumulator_ -= speeds_[static_cast<std::size_t>(current_)];
accumulator_ -= FRAME_TIME;
++current_; ++current_;
if (static_cast<std::size_t>(current_) >= frames_.size()) { if (static_cast<std::size_t>(current_) >= frames_.size()) {
if (loop_) { if (loop_) {
+37 -8
View File
@@ -3,8 +3,11 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <cstddef> #include <cstddef>
#include <string>
#include <vector> #include <vector>
#include "external/fkyaml_node.hpp"
// Component reutilitzable d'animació: qualsevol entitat que necessiti // Component reutilitzable d'animació: qualsevol entitat que necessiti
// reproduir una seqüència de frames l'instancia. Es manté com a camp // reproduir una seqüència de frames l'instancia. Es manté com a camp
// opcional dins d'Entity (veure `entity.hpp`) i NO hereta de cap classe // opcional dins d'Entity (veure `entity.hpp`) i NO hereta de cap classe
@@ -22,16 +25,41 @@
// canvien d'animació, sense decidir-lo per direcció. Així una mòmia que va // canvien d'animació, sense decidir-lo per direcció. Així una mòmia que va
// cap a l'oest reprodueix `walk_west` (frames de l'est + `flip_h: true`) // cap a l'oest reprodueix `walk_west` (frames de l'est + `flip_h: true`)
// sense que el codi de la mòmia toqui flip directament. // sense que el codi de la mòmia toqui flip directament.
//
// El temps es modela com a `speeds_[i]` segons per frame (per-frame), no com
// a `fps` uniforme. Això permet expressar holds al YAML via `frame_ms: [..]`
// (llista) sense haver de repetir el mateix índex de frame. Per a casos
// uniformes la factory omple `speeds_` amb el mateix valor a tot el vector.
class AnimationComponent { class AnimationComponent {
public: public:
AnimationComponent() = default; AnimationComponent() = default;
AnimationComponent(std::vector<int> frames, float fps, bool loop);
AnimationComponent(std::vector<int> frames, float fps, bool loop, bool flip_h, bool flip_v); // Factory canònica: llig el bloc `animations.<key>` del YAML.
//
// Camps llegits:
// - `frames` : llista d'índexs sencers (obligatori per animar).
// - `frame_ms` : escalar ms (uniforme) o llista de ms (per-frame).
// Default 1000 ms (animació estàtica).
// - `loop` : bool (default true).
// - `flip_h` : bool (default false).
// - `flip_v` : bool (default false).
//
// Si la clau no existeix retorna un component "estàtic" (1 frame, loop
// true). Si el YAML conté `fps:`, llença `std::runtime_error`: el
// format antic ja no és suportat — cal migrar a `frame_ms:`.
static auto fromYaml(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent;
// Helper uniforme: construeix un component on tots els frames duren
// `frame_ms` mil·lisegons. Útil per a usos programàtics (tests,
// imposició d'un frame estàtic des del loader de nivell). Per al cas
// típic guiat per YAML, usar `fromYaml`.
static auto uniform(std::vector<int> frames, float frame_ms, bool loop) -> AnimationComponent;
static auto uniform(std::vector<int> frames, float frame_ms, bool loop, bool flip_h, bool flip_v) -> AnimationComponent;
// Avanç del temps. Si `frames_` està buit o té un sol element, no fa // Avanç del temps. Si `frames_` està buit o té un sol element, no fa
// res. En mode loop, `current_frame_` rebota a 0 en passar del final; // res. En mode loop, `current_` rebota a 0 en passar del final; en
// en mode no-loop, es queda al darrer frame i `completed()` passa a // mode no-loop, es queda al darrer frame i `completed()` passa a true.
// true. // Cada frame consumeix `speeds_[current_]` segons.
void tick(float delta_time); void tick(float delta_time);
// Torna al primer frame i reinicia l'acumulador. Útil en transicions // Torna al primer frame i reinicia l'acumulador. Útil en transicions
@@ -39,8 +67,7 @@ class AnimationComponent {
void reset(); void reset();
// Índex actual dins `frames_`. Si `frames_` està buit retorna 0 com // Índex actual dins `frames_`. Si `frames_` està buit retorna 0 com
// a fallback tolerant (el loader ja ha emès warning si hi havia // a fallback tolerant.
// frames fora de rang; aquí preferim no crashejar).
[[nodiscard]] auto currentFrame() const -> int; [[nodiscard]] auto currentFrame() const -> int;
[[nodiscard]] auto frameCount() const -> std::size_t { return frames_.size(); } [[nodiscard]] auto frameCount() const -> std::size_t { return frames_.size(); }
@@ -55,8 +82,10 @@ class AnimationComponent {
[[nodiscard]] auto flipMode() const -> SDL_FlipMode; [[nodiscard]] auto flipMode() const -> SDL_FlipMode;
private: private:
AnimationComponent(std::vector<int> frames, std::vector<float> speeds, bool loop, bool flip_h, bool flip_v);
std::vector<int> frames_; std::vector<int> frames_;
float fps_{1.0F}; std::vector<float> speeds_; // segons per frame; size == frames_.size()
bool loop_{true}; bool loop_{true};
bool flip_h_{false}; bool flip_h_{false};
bool flip_v_{false}; bool flip_v_{false};
+4 -33
View File
@@ -1,45 +1,16 @@
#include "game/entities/concrete/door.hpp" #include "game/entities/concrete/door.hpp"
#include <string> #include <string>
#include <vector>
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp" #include "game/entities/player.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
namespace {
// Carrega una animació d'un bloc concret (closed/opening/open) amb
// fallbacks tolerants. Si la clau no existeix, retorna una animació
// d'un sol frame loopant: no fa estat invàlid, simplement no hi ha res
// per reproduir.
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = true;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace
Door::Door(const fkyaml::node& yaml) Door::Door(const fkyaml::node& yaml)
: anim_closed_(buildAnimation(yaml, "closed")), : anim_closed_(AnimationComponent::fromYaml(yaml, "closed")),
anim_opening_(buildAnimation(yaml, "opening")), anim_opening_(AnimationComponent::fromYaml(yaml, "opening")),
anim_open_(buildAnimation(yaml, "open")) { anim_open_(AnimationComponent::fromYaml(yaml, "open")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("door"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("door");
collides_ = true; collides_ = true;
interactable_ = true; interactable_ = true;
@@ -99,7 +70,7 @@ void Door::open() {
// placeholders defineixen `loop: false` explícitament al YAML, així // placeholders defineixen `loop: false` explícitament al YAML, així
// que aquest fallback només actua si algú dissenya malament. // que aquest fallback només actua si algú dissenya malament.
if (anim_.has_value() && anim_->frameCount() <= 1 && anim_->loops()) { if (anim_.has_value() && anim_->frameCount() <= 1 && anim_->loops()) {
anim_ = AnimationComponent({0}, 6.0F, false); anim_ = AnimationComponent::uniform({0}, 167.0F, false);
} }
} }
+2 -32
View File
@@ -1,42 +1,12 @@
#include "game/entities/concrete/dust.hpp" #include "game/entities/concrete/dust.hpp"
#include <string> #include <string>
#include <vector>
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
namespace {
// Llig una animació opcional del bloc `animations` del YAML, amb
// valors per defecte segurs (1 frame, fps=1, sense loop). Si la
// clau no existeix, retornem una animació "ja completada" — així,
// un Dust spawnejat amb una variant inexistent al YAML mor al
// pròxim frame sense petar.
auto buildAnim(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = false;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace
Dust::Dust(const fkyaml::node& yaml) Dust::Dust(const fkyaml::node& yaml)
: anim_jump_(buildAnim(yaml, "jump")), : anim_jump_(AnimationComponent::fromYaml(yaml, "jump")),
anim_land_(buildAnim(yaml, "land")) { anim_land_(AnimationComponent::fromYaml(yaml, "land")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("dust"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("dust");
collides_ = false; collides_ = false;
interactable_ = false; interactable_ = false;
+38 -44
View File
@@ -29,36 +29,6 @@ namespace {
return k_rng_; return k_rng_;
} }
// Llegeix un bloc d'animació del YAML amb fallbacks tolerants (mateix
// patró que Door/Sarcophagus). Si la clau no apareix, retorna una
// animació d'1 frame loopant, neutra — la momia es podrà moure però
// visualment es quedarà estàtica. Llegeix `flip_h`/`flip_v` (default
// false) i els passa al component perquè el motor sàpiga aplicar-los
// al sprite quan aquesta animació estigui activa.
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 8.0F;
bool loop = true;
bool flip_h = false;
bool flip_v = false;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
if (anim.contains("flip_h")) { flip_h = anim["flip_h"].get_value<bool>(); }
if (anim.contains("flip_v")) { flip_v = anim["flip_v"].get_value<bool>(); }
}
return {frames, fps, loop, flip_h, flip_v};
}
// Busca si el segment [prev, prev+delta] travessa una lane del patró // Busca si el segment [prev, prev+delta] travessa una lane del patró
// `base + k·pitch` (k ∈ [0..max_idx]). Retorna el delta que cal aplicar // `base + k·pitch` (k ∈ [0..max_idx]). Retorna el delta que cal aplicar
// per arribar exactament a la primera línia travessada. Iterem en el // per arribar exactament a la primera línia travessada. Iterem en el
@@ -103,10 +73,11 @@ namespace {
} // namespace } // namespace
Mummy::Mummy(const fkyaml::node& yaml) Mummy::Mummy(const fkyaml::node& yaml)
: anim_walk_south_(buildAnimation(yaml, "walk_south")), : anim_walk_south_(AnimationComponent::fromYaml(yaml, "walk_south")),
anim_walk_north_(buildAnimation(yaml, "walk_north")), anim_walk_north_(AnimationComponent::fromYaml(yaml, "walk_north")),
anim_walk_east_(buildAnimation(yaml, "walk_east")), anim_walk_east_(AnimationComponent::fromYaml(yaml, "walk_east")),
anim_walk_west_(buildAnimation(yaml, "walk_west")) { anim_walk_west_(AnimationComponent::fromYaml(yaml, "walk_west")),
anim_raise_(AnimationComponent::fromYaml(yaml, "raise")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy");
collides_ = true; collides_ = true;
interactable_ = false; interactable_ = false;
@@ -141,16 +112,10 @@ Mummy::Mummy(const fkyaml::node& yaml)
changeDirection(0.0F, 1.0F); changeDirection(0.0F, 1.0F);
} }
// Estat SPAWN inicial: la mòmia naix visible i quieta, esperant que el // L'estat SPAWNING amb l'animació `raise` s'arma a `onJustSpawned`,
// GameScene li armi el comptador via `triggerSpawnExit()` (cridat al // no al ctor. Així el manager pot instanciar la mòmia sense disparar
// final de la subfase text-exit de ROOM_INTRO). Si el YAML no declara // la cinemàtica (cas del mode editor) i veure-la ja en ROAMING amb
// `spawn.duration_s`, default 0 ⇒ al primer update post-trigger passa // la `walk_*` triada per `changeDirection` més amunt.
// 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);
// Configuració de l'estat HURT: opcional. Si el bloc no apareix, la // Configuració de l'estat HURT: opcional. Si el bloc no apareix, la
// mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la // mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la
@@ -177,6 +142,20 @@ Mummy::Mummy(const fkyaml::node& yaml)
} }
} }
void Mummy::onJustSpawned() {
// Cinemàtica d'aparició: la mòmia "s'aixeca" reproduint `raise`
// (16 frames no-loop). El countdown del DynamicHazard NO s'utilitza
// (passem 0): `Mummy::update` controla la transició a ACTIVE per
// `anim_->completed()`. Cap crida a la base perquè el `triggerSpawnExit`
// del DynamicHazard arma aquest mateix countdown que aquí no toca.
enterSpawn(0.0F);
anim_ = anim_raise_;
if (anim_.has_value()) {
anim_->reset();
flip_ = anim_->flipMode();
}
}
void Mummy::changeDirection(float dx, float dy) { void Mummy::changeDirection(float dx, float dy) {
setDirection(dx, dy); setDirection(dx, dy);
// Una animació per cada cardinal — el flip viu al YAML // Una animació per cada cardinal — el flip viu al YAML
@@ -692,6 +671,21 @@ void Mummy::hurtStep(float delta_time) {
} }
void Mummy::update(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) { if (state_ == State::HURT) {
// Avancem l'animació inline (no podem cridar `Entity::update` ni // Avancem l'animació inline (no podem cridar `Entity::update` ni
// `DynamicHazard::update`: el primer salta una capa virtual i el // `DynamicHazard::update`: el primer salta una capa virtual i el
+12
View File
@@ -61,6 +61,12 @@ class Mummy : public DynamicHazard {
// En estat ROAMING delega a `step()` com sempre via la base. // En estat ROAMING delega a `step()` com sempre via la base.
void update(float delta_time) override; void update(float delta_time) override;
// Inicia la fase SPAWNING amb l'animació `raise`. Es crida al
// spawnPendingActors() del joc, no al ctor: així l'editor (que
// materialitza les entitats sense aquest hook) veu la mòmia ja en
// ROAMING visible, sense quedar-se "soterrada".
void onJustSpawned() override;
// Contacte amb el jugador: publica `player_hit` amb el dany. // Contacte amb el jugador: publica `player_hit` amb el dany.
void onPlayerContact(Player* player) override; void onPlayerContact(Player* player) override;
@@ -177,6 +183,12 @@ class Mummy : public DynamicHazard {
AnimationComponent anim_walk_east_; AnimationComponent anim_walk_east_;
AnimationComponent anim_walk_west_; 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 // Estat HURT i la seua configuració. `hurt_speed_` és l'efectiva
// (resultat d'aplicar el `speed` del bloc `hurt:` o, si val 0, la // (resultat d'aplicar el `speed` del bloc `hurt:` o, si val 0, la
// velocitat normal de la mòmia). // velocitat normal de la mòmia).
+76 -34
View File
@@ -1,32 +1,23 @@
#include "game/entities/concrete/sarcophagus.hpp" #include "game/entities/concrete/sarcophagus.hpp"
#include <random>
#include <string> #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_factory.hpp"
#include "game/entities/entity_manager.hpp" #include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "utils/defaults.hpp"
namespace { namespace {
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent { // RNG local al mòdul, mateix patró que `mummy.cpp`: llavor fixa per
std::vector<int> frames = {0}; // facilitar la depuració. Si algun dia volem aleatoritat real entre
float fps = 1.0F; // partides, prou amb llegir-la de std::random_device.
bool loop = true; auto rng() -> std::mt19937& {
if (yaml.contains("animations") && yaml["animations"].contains(key)) { static std::mt19937 k_rng_{0xCAFEU};
const auto& anim = yaml["animations"][key]; return k_rng_;
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
} }
// Llig `tile_size: [w, h]` del YAML. Fallback [1,1] si no existeix. // Llig `tile_size: [w, h]` del YAML. Fallback [1,1] si no existeix.
@@ -49,14 +40,37 @@ Sarcophagus::Sarcophagus(const fkyaml::node& yaml, int anchor_col, int anchor_ro
} }
Sarcophagus::Sarcophagus(const fkyaml::node& yaml) Sarcophagus::Sarcophagus(const fkyaml::node& yaml)
: anim_closed_(buildAnimation(yaml, "closed")), : anim_closed_(AnimationComponent::fromYaml(yaml, "closed")),
anim_opening_(buildAnimation(yaml, "opening")), anim_opening_(AnimationComponent::fromYaml(yaml, "opening")),
anim_open_(buildAnimation(yaml, "open")) { anim_open_(AnimationComponent::fromYaml(yaml, "open")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("sarcophagus"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("sarcophagus");
collides_ = true; collides_ = true;
interactable_ = true; interactable_ = true;
readTileSize(yaml, tile_size_w_, tile_size_h_); readTileSize(yaml, tile_size_w_, tile_size_h_);
anim_ = anim_closed_; 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) { void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) {
@@ -71,14 +85,16 @@ void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate&
void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) { void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) {
perimeter_tiles_.clear(); perimeter_tiles_.clear();
// El perímetre ortogonal d'un rectangle (w,h) de tiles són les files // El perímetre d'un rectangle (w,h) de tiles són les files de tiles
// de tiles immediatament N, S, E, O. No s'hi inclouen les cantonades // immediatament adjacents: N, S, E, O *més* les quatre cantonades
// diagonals (tile NO, NE, SO, SE), perquè el jugador fa passes pels // diagonals (NO, NE, SO, SE). En la pràctica el jugador trepitja
// quatre costats i no pot "tallar" per una diagonal: això garanteix // aquestes cantonades en doblar les cantonades del sarcòfag, així que
// que el recorregut completa només si passa per cada costat. // si no s'hi compten, el cerco no es pot tancar visualment encara
// que es completi a nivell de codi (o, pitjor, s'obre sense que el
// jugador hagi clos realment el contorn).
// //
// - Fila Nord: (col, row-1) per col ∈ [col0, col0+w-1] // - Fila Nord: (col, row-1) per col ∈ [col0-1, col0+w] (inclou NO i NE)
// - Fila Sud: (col, row+h) per col ∈ [col0, col0+w-1] // - Fila Sud: (col, row+h) per col ∈ [col0-1, col0+w] (inclou SO i SE)
// - Columna O: (col0-1, row) per row ∈ [row0, row0+h-1] // - Columna O: (col0-1, row) per row ∈ [row0, row0+h-1]
// - Columna E: (col0+w, row) per row ∈ [row0, row0+h-1] // - Columna E: (col0+w, row) per row ∈ [row0, row0+h-1]
@@ -87,12 +103,12 @@ void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) {
perimeter_tiles_.insert({col, row}); perimeter_tiles_.insert({col, row});
}; };
// Nord i Sud // Nord i Sud (incloent les quatre cantonades diagonals)
for (int dc = 0; dc < tile_size_w_; ++dc) { for (int dc = -1; dc <= tile_size_w_; ++dc) {
maybe_insert(anchor_col_ + dc, anchor_row_ - 1); maybe_insert(anchor_col_ + dc, anchor_row_ - 1);
maybe_insert(anchor_col_ + dc, anchor_row_ + tile_size_h_); maybe_insert(anchor_col_ + dc, anchor_row_ + tile_size_h_);
} }
// Oest i Est // Oest i Est (les cantonades ja les ha posat el bucle N/S)
for (int dr = 0; dr < tile_size_h_; ++dr) { for (int dr = 0; dr < tile_size_h_; ++dr) {
maybe_insert(anchor_col_ - 1, anchor_row_ + dr); maybe_insert(anchor_col_ - 1, anchor_row_ + dr);
maybe_insert(anchor_col_ + tile_size_w_, anchor_row_ + dr); maybe_insert(anchor_col_ + tile_size_w_, anchor_row_ + dr);
@@ -107,21 +123,47 @@ void Sarcophagus::update(float delta_time) {
anim_ = anim_open_; anim_ = anim_open_;
if (anim_.has_value()) { anim_->reset(); } if (anim_.has_value()) { anim_->reset(); }
if (!opened_event_emitted_ && bus_ != nullptr) { if (!opened_event_emitted_ && bus_ != nullptr) {
bus_->publish("sarcophagus_opened", static_cast<Entity*>(this)); // Publica amb `player_id = last_closer_player_id_` perquè el
// GameScene pugui atribuir la recompensa al jugador correcte.
// El payload manté el punter al sarc perquè el consumidor
// pugui llegir `getPointsReward()` i `getTreasuresReward()`.
bus_->publish(Event{
.name = "sarcophagus_opened",
.payload = static_cast<Entity*>(this),
.player_id = last_closer_player_id_,
});
opened_event_emitted_ = true; opened_event_emitted_ = true;
} }
} }
} }
void Sarcophagus::notifyTilePainted(int col, int row) { void Sarcophagus::notifyTilePainted(int col, int row, int player_id) {
if (state_ != State::CLOSED) { return; } if (state_ != State::CLOSED) { return; }
const TileKey KEY{.col = col, .row = row}; const TileKey KEY{.col = col, .row = row};
if (perimeter_tiles_.find(KEY) == perimeter_tiles_.end()) { return; } if (perimeter_tiles_.find(KEY) == perimeter_tiles_.end()) { return; }
visited_.insert(KEY); visited_.insert(KEY);
if (visited_.size() >= perimeter_tiles_.size()) { if (visited_.size() >= perimeter_tiles_.size()) {
// Atribució: el jugador que pinta el tile que tanca el cercle es
// queda amb la recompensa. Si el broadcast inicial de càrrega
// arribara a tancar un sarc (no hauria de passar perquè els tiles
// d'aquest broadcast són decals/murs, no de corredor), `player_id`
// seria -1 i el GameScene saltarà el repartiment.
last_closer_player_id_ = player_id;
state_ = State::OPENING; state_ = State::OPENING;
anim_ = anim_opening_; anim_ = anim_opening_;
if (anim_.has_value()) { anim_->reset(); } if (anim_.has_value()) { anim_->reset(); }
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);
}
}
} }
} }
+35 -2
View File
@@ -2,6 +2,7 @@
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <string>
#include <unordered_set> #include <unordered_set>
#include "external/fkyaml_node.hpp" #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 // El càlcul de `perimeter_tiles_` es fa al ctor a partir de la posició del
// sarcòfag a la graella del Map (`anchor_col`, `anchor_row`) i de les // sarcòfag a la graella del Map (`anchor_col`, `anchor_row`) i de les
// dimensions en tiles (`tile_size_w_`, `tile_size_h_`, llegits del YAML // dimensions en tiles (`tile_size_w_`, `tile_size_h_`, llegits del YAML
// plantilla). Es recorren els tiles adjacents per N/S/E/O; els que // plantilla). S'inclouen els tiles adjacents per N/S/E/O i també les
// quatre cantonades diagonals (NO/NE/SO/SE), perquè el jugador les
// trepitja en doblar les cantonades del sarcòfag i, sense comptar-les,
// el cerco s'obriria abans d'haver-se tancat visualment. Els tiles que
// cauen dins d'un altre sarcòfag es descarten (predicat opcional). Si no // cauen dins d'un altre sarcòfag es descarten (predicat opcional). Si no
// s'aporta el predicat, s'inclouen tots. // s'aporta el predicat, s'inclouen tots.
class Sarcophagus : public Interactable { class Sarcophagus : public Interactable {
@@ -42,7 +46,7 @@ class Sarcophagus : public Interactable {
void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked); void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked);
void update(float delta_time) override; void update(float delta_time) override;
void notifyTilePainted(int col, int row) override; void notifyTilePainted(int col, int row, int player_id) override;
void onRemoved() override; void onRemoved() override;
[[nodiscard]] auto getState() const -> State { return state_; } [[nodiscard]] auto getState() const -> State { return state_; }
@@ -54,6 +58,17 @@ class Sarcophagus : public Interactable {
[[nodiscard]] auto getTileWidth() const -> int { return tile_size_w_; } [[nodiscard]] auto getTileWidth() const -> int { return tile_size_w_; }
[[nodiscard]] auto getTileHeight() const -> int { return tile_size_h_; } [[nodiscard]] auto getTileHeight() const -> int { return tile_size_h_; }
// Recompensa que es donarà al jugador que pinta el tile que tanca el
// perímetre (el `last_closer_player_id_`). Llegits del YAML del sarc;
// 0/0 si el bloc `reward:` no existeix. El GameScene escolta
// `sarcophagus_opened` i aplica aquests valors al Player corresponent.
[[nodiscard]] auto getPointsReward() const -> int { return points_reward_; }
[[nodiscard]] auto getTreasuresReward() const -> int { return treasures_reward_; }
// Jugador atribuit (qui pinta el tile que dispara CLOSED→OPENING). -1
// si el sarcòfag s'ha tancat per pintades sense atribució (p.ex. el
// broadcast inicial de càrrega) — cas que el GameScene ignora.
[[nodiscard]] auto getLastCloserPlayerId() const -> int { return last_closer_player_id_; }
private: private:
struct TileKey { struct TileKey {
int col; int col;
@@ -83,5 +98,23 @@ class Sarcophagus : public Interactable {
AnimationComponent anim_opening_; AnimationComponent anim_opening_;
AnimationComponent anim_open_; 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}; bool opened_event_emitted_{false};
}; };
@@ -0,0 +1,27 @@
#include <string>
#include "external/fkyaml_node.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/solid_decoration.hpp"
// Murs decoratius estàtics que comparteixen el mateix codi C++ però es
// registren amb claus de factory diferents per a que el YAML els pugui
// referenciar pel seu tipus i el editor els pugui distingir al picker.
// La selecció del sub-sprite (frame del atlas) viatja al spawn YAML
// (`frame: N`); l'aplica `placeGeneric` via `Entity::setAnimation`. Aquí no
// cal cap lògica específica per a la variant.
namespace {
class StaticSolid : public SolidDecoration {
public:
explicit StaticSolid(const fkyaml::node& yaml) {
if (yaml.contains("name")) {
name_ = yaml["name"].get_value<std::string>();
}
}
};
} // namespace
REGISTER_ENTITY_AS("stone", StaticSolid, Stone)
REGISTER_ENTITY_AS("column", StaticSolid, Column)
+1 -28
View File
@@ -1,41 +1,14 @@
#include "game/entities/concrete/torch.hpp" #include "game/entities/concrete/torch.hpp"
#include <string> #include <string>
#include <vector>
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
namespace {
// Ajut: extreu l'animació `idle` del bloc `animations` del YAML si existeix,
// amb valors per defecte que sempre produïxen una animació vàlida (1 frame).
auto buildIdleAnimation(const fkyaml::node& yaml) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = true;
if (yaml.contains("animations") && yaml["animations"].contains("idle")) {
const auto& anim = yaml["animations"]["idle"];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace
Torch::Torch(const fkyaml::node& yaml) { Torch::Torch(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("torch"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("torch");
collides_ = false; collides_ = false;
interactable_ = false; interactable_ = false;
anim_ = buildIdleAnimation(yaml); anim_ = AnimationComponent::fromYaml(yaml, "idle");
} }
REGISTER_ENTITY("torch", Torch) REGISTER_ENTITY("torch", Torch)
+13 -36
View File
@@ -33,42 +33,18 @@ namespace {
return sprite; return sprite;
} }
// Construeix l'`AnimationComponent` del swing a partir del bloc // Llig el camp `active_frames` del bloc `animations.swing` — específic
// `animations.swing`. Convenció igual que la resta d'entitats: // del látigo: els índexs llistats són els frames on la hitbox aplica
// - `frames` : llista d'índexs dins l'spritesheet (obligatori). // dany (wind-up i recovery no danyen). La resta de l'animació la
// - `frame_ms` : durada uniforme per frame (ms). Alternativament `fps`. // construeix `AnimationComponent::fromYaml`.
// - `loop` : per defecte true; al látigo el volem false (mor en void readActiveFrames(const fkyaml::node& yaml, std::vector<int>& active_out) {
// completar). if (!yaml.contains("animations") || !yaml["animations"].contains("swing")) { return; }
auto buildSwingAnim(const fkyaml::node& yaml, std::vector<int>& active_out) const auto& sw = yaml["animations"]["swing"];
-> AnimationComponent { if (!sw.contains("active_frames")) { return; }
std::vector<int> frames = {0}; for (const auto& f : sw["active_frames"]) {
float fps = 8.0F; // cppcheck-suppress useStlAlgorithm
bool loop = false; active_out.push_back(f.get_value<int>());
if (yaml.contains("animations") && yaml["animations"].contains("swing")) {
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")) {
for (const auto& f : sw["active_frames"]) {
// cppcheck-suppress useStlAlgorithm
active_out.push_back(f.get_value<int>());
}
}
} }
return {frames, fps, loop};
} }
} // namespace } // namespace
@@ -86,7 +62,8 @@ WhipAttack::WhipAttack(const fkyaml::node& yaml) {
if (s.contains("west")) { sprite_west_ = readDirSprite(s["west"]); } if (s.contains("west")) { sprite_west_ = readDirSprite(s["west"]); }
} }
anim_swing_ = buildSwingAnim(yaml, active_frames_); anim_swing_ = AnimationComponent::fromYaml(yaml, "swing");
readActiveFrames(yaml, active_frames_);
anim_ = anim_swing_; anim_ = anim_swing_;
} }
+12 -1
View File
@@ -24,7 +24,7 @@ void Entity::onPlayerContact(Player* /*player*/) {
// No-op per defecte. // No-op per defecte.
} }
void Entity::notifyTilePainted(int /*col*/, int /*row*/) { void Entity::notifyTilePainted(int /*col*/, int /*row*/, int /*player_id*/) {
// No-op per defecte; només `Sarcophagus` l'usa. // No-op per defecte; només `Sarcophagus` l'usa.
} }
@@ -63,6 +63,17 @@ auto Entity::getCollisionRect() const -> SDL_FRect {
}; };
} }
auto Entity::getLightCenter() const -> SDL_FPoint {
// Centre del rect de render + offset configurat al YAML. Per a
// entitats que es desplacen visualment sobre la seva posició lògica
// (p.ex. el Player saltant), la subclasse override aquest mètode
// per restar el desplaçament — i ha d'incloure l'offset igualment.
return {
.x = pixel_rect_.x + (pixel_rect_.w * 0.5F) + static_cast<float>(light_offset_x_),
.y = pixel_rect_.y + (pixel_rect_.h * 0.5F) + static_cast<float>(light_offset_y_),
};
}
auto Entity::getFeetY() const -> float { auto Entity::getFeetY() const -> float {
// Decal de sòl: sentinel mínim per quedar al principi del Y-sort // Decal de sòl: sentinel mínim per quedar al principi del Y-sort
// ascendent de `GameScene` (stable_sort) i així pintar-se ABANS de // ascendent de `GameScene` (stable_sort) i així pintar-se ABANS de
+112 -3
View File
@@ -2,10 +2,12 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <cstdint>
#include <memory> #include <memory>
#include <optional> #include <optional>
#include <string> #include <string>
#include "core/rendering/lighting.hpp"
#include "game/entities/animation_component.hpp" #include "game/entities/animation_component.hpp"
// Forward-declaracions: mantenim aquest header lleuger. `Player` viu a // Forward-declaracions: mantenim aquest header lleuger. `Player` viu a
@@ -84,8 +86,12 @@ class Entity {
// Hook que el `EntityManager` invoca per a cada entitat cada vegada // Hook que el `EntityManager` invoca per a cada entitat cada vegada
// que un tile es pinta (via PaintLayer, com a conseqüència d'un // que un tile es pinta (via PaintLayer, com a conseqüència d'un
// jugador trepitjant un sòl pintable). Només `Sarcophagus` ho consumeix // jugador trepitjant un sòl pintable). Només `Sarcophagus` ho consumeix
// (per marcar perímetre); per defecte no-op. // (per marcar perímetre); per defecte no-op. `player_id` és l'id del
virtual void notifyTilePainted(int col, int row); // jugador que ha pintat el tile, o -1 si la pintada ve del broadcast
// inicial de càrrega de nivell (decals + murs, sense atribució). Els
// consumidors han de filtrar per `player_id >= 0` si necessiten
// atribució.
virtual void notifyTilePainted(int col, int row, int player_id);
// L'entitat ha rebut un fueteig amb solapament. Es passa el punter al // L'entitat ha rebut un fueteig amb solapament. Es passa el punter al
// `WhipAttack` perquè la subclasse pugui llegir-ne direcció o atributs // `WhipAttack` perquè la subclasse pugui llegir-ne direcció o atributs
@@ -102,7 +108,10 @@ class Entity {
// entitat. La base no fa res; `DynamicHazard` override per disparar // entitat. La base no fa res; `DynamicHazard` override per disparar
// el seu `triggerSpawnExit` (anima la fase EMERGING). Substitueix el // el seu `triggerSpawnExit` (anima la fase EMERGING). Substitueix el
// `dynamic_cast<DynamicHazard*>(observer)->triggerSpawnExit()` que el // `dynamic_cast<DynamicHazard*>(observer)->triggerSpawnExit()` que el
// manager feia abans — ara el dispatch és polimòrfic. // manager feia abans — ara el dispatch és polimòrfic. Entitats que
// requereixen una fase d'aparició (mòmia amb animació `raise`)
// l'inicien aquí, NO al ctor: així `materializePendingActorsStatic`
// (editor) les salta i queden visibles directament.
virtual void onJustSpawned(); virtual void onJustSpawned();
// Hook just abans que el manager esborri l'entitat (via // Hook just abans que el manager esborri l'entitat (via
@@ -125,6 +134,15 @@ class Entity {
[[nodiscard]] auto collides() const -> bool { return collides_; } [[nodiscard]] auto collides() const -> bool { return collides_; }
[[nodiscard]] auto interactable() const -> bool { return interactable_; } [[nodiscard]] auto interactable() const -> bool { return interactable_; }
// Marca d'entitat que ha de bloquejar el moviment del jugador (com un
// mur). `collides_=true` només garanteix que l'entitat reb el callback
// `onPlayerContact` quan el jugador la toca, no que el seu rect sigui
// sòlid: dynamic hazards (mòmies, roques) tenen `collides_=true` però
// no han de bloquejar el pas — el contacte resol el dany i la roca es
// mou independentment. Per a "murs passius" (sarcs, columnes, debris)
// posem aquest flag a true i `Map::collides` els tracta com a obstacle.
[[nodiscard]] auto blocksMovement() const -> bool { return blocks_movement_; }
// Trigger d'àrea: l'entitat NO bloqueja el moviment (`collides_=false`) // Trigger d'àrea: l'entitat NO bloqueja el moviment (`collides_=false`)
// però sí vol rebre `onPlayerContact` quan el jugador la solapa. Pensat // però sí vol rebre `onPlayerContact` quan el jugador la solapa. Pensat
// per a hazards "no físics" (forats al sòl, picos retràctils, plaques // per a hazards "no físics" (forats al sòl, picos retràctils, plaques
@@ -187,6 +205,14 @@ class Entity {
// Entitats sense animació retornen `nullopt` → frame 0 per defecte. // Entitats sense animació retornen `nullopt` → frame 0 per defecte.
[[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; } [[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; }
// Sobreescriu l'`AnimationComponent` actual. L'usa el loader del nivell
// per aplicar el camp opcional `frame: N` del spawn a entitats amb
// variants estàtiques (debris, columnes, etc.): el spawn fixa una
// animació de 1 frame al índex escollit i el render del manager clipa
// el sub-sprite corresponent. També és útil per a tests que volen
// controlar el frame mostrat sense passar pel YAML.
void setAnimation(AnimationComponent anim) { anim_ = std::move(anim); }
// Alçada del cos de l'entitat en píxels. La consulten els filtres // Alçada del cos de l'entitat en píxels. La consulten els filtres
// de col·lisió per altura: si un jugador en l'aire passa per damunt // de col·lisió per altura: si un jugador en l'aire passa per damunt
// d'aquest valor (jump_z_ > height), no col·lisiona amb l'entitat — // d'aquest valor (jump_z_ > height), no col·lisiona amb l'entitat —
@@ -229,6 +255,74 @@ class Entity {
[[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; } [[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; }
void setFlip(SDL_FlipMode flip) { flip_ = flip; } void setFlip(SDL_FlipMode flip) { flip_ = flip; }
// Radi de la llum emesa per aquesta entitat (en píxels del canvas).
// 0 = no emet (cas habitual). El bloc opcional `light: { radius: N }`
// del YAML de la plantilla el pobla via `EntityManager::buildEntity`.
[[nodiscard]] auto getLightRadius() const -> int { return light_radius_; }
void setLightRadius(int r) { light_radius_ = r; }
// Activa la modulació "tipus foc" del radi a la pasada d'il·luminació
// (per a antorxes, fogueres, etc.). El bloc opcional
// `light: { flicker: true }` del YAML el pobla. Per defecte false:
// la llum queda perfectament estable (cas player, mòmia).
[[nodiscard]] auto getLightFlicker() const -> bool { return light_flicker_; }
void setLightFlicker(bool f) { light_flicker_ = f; }
// Color emès en mode additive (RGB 0..255 per canal). Default blanc:
// en mode subtractive aquest valor s'ignora completament; en mode
// additive és el tint que l'emissor afegeix al canvas. La cadena de
// resolució (tipus → sala → spawn) el pobla via `setLightColor`.
[[nodiscard]] auto getLightColorR() const -> std::uint8_t { return light_color_r_; }
[[nodiscard]] auto getLightColorG() const -> std::uint8_t { return light_color_g_; }
[[nodiscard]] auto getLightColorB() const -> std::uint8_t { return light_color_b_; }
void setLightColor(std::uint8_t r, std::uint8_t g, std::uint8_t b) {
light_color_r_ = r;
light_color_g_ = g;
light_color_b_ = b;
}
// Desplaçament en píxels del centre del cercle de llum respecte el
// centre visual del sprite (que és el que retornaria per defecte
// `getLightCenter`). Útil quan el "punt de llum" iconogràfic no
// coincideix amb el centre del bounding box — per exemple, la flama
// d'una antorxa està a la part de dalt del sprite i interessa pujar
// l'emissor 3-4 px per a que el cercle de llum surti de la flama,
// no del peu. Y creix cap avall al canvas, així que `y: -3` puja
// la llum 3 píxels. Cadena de resolució idèntica al color: tipus →
// sala → spawn.
[[nodiscard]] auto getLightOffsetX() const -> int { return light_offset_x_; }
[[nodiscard]] auto getLightOffsetY() const -> int { return light_offset_y_; }
void setLightOffset(int x, int y) {
light_offset_x_ = x;
light_offset_y_ = y;
}
// Paràmetres del flicker resolts per a aquest emissor (combinació
// del global de `data/config/lighting.yaml` + overrides del tipus,
// sala i spawn). Només té efecte si `light_flicker_` és true. El
// construct path li passa una còpia ja resolta — el raster (lighting.cpp)
// només la llegeix. Es publica per camps perquè el bloc YAML
// `flicker_params:` permet sobreescriure qualsevol subconjunt.
[[nodiscard]] auto getFlickerParams() const -> const Lighting::FlickerConfig& { return flicker_params_; }
void setFlickerParams(const Lighting::FlickerConfig& fp) { flicker_params_ = fp; }
// Identificador estable per al flicker. Cada Entity rep un valor
// únic a la construcció (contador estàtic) i el conserva fins a la
// mort — així el patró del flicker no "deriva" quan l'entitat es
// mou. Si l'entitat respawneja, la nova instància tindrà una seed
// nova; com que el cicle visual ja queda trencat per la mort, no és
// un problema.
[[nodiscard]] auto getLightSeed() const -> int { return light_seed_; }
// Centre de la llum en píxels del canvas. Per defecte és el centre
// del `pixel_rect_` (caixa de render) més l'offset configurat al
// YAML (`light.offset: [x, y]`). Virtual perquè entitats amb
// desplaçament visual sobre la posició lògica (p.ex. el Player en
// l'aire, que rebaixa visualment `pos_.y` amb `jump_z_`) puguin
// retornar el centre que realment es veu — l'override n'és
// responsable d'incloure l'offset (com fa `Player::getLightCenter`).
[[nodiscard]] virtual auto getLightCenter() const -> SDL_FPoint;
protected: protected:
std::string name_; std::string name_;
TilePos tile_{}; TilePos tile_{};
@@ -240,7 +334,22 @@ class Entity {
bool interactable_{false}; bool interactable_{false};
bool triggers_on_overlap_{false}; bool triggers_on_overlap_{false};
bool is_floor_decal_{false}; bool is_floor_decal_{false};
bool blocks_movement_{false};
int height_{0}; int height_{0};
int light_radius_{0};
// Comptador global d'instàncies; cada Entity nova s'emporta el
// valor incrementat com a seed propi. Estàtic perquè no calgui
// estat extern (sense risc de threads: les entitats es construeixen
// sempre des del fil del joc).
static inline int s_next_light_seed{0};
int light_seed_{++s_next_light_seed};
bool light_flicker_{false};
std::uint8_t light_color_r_{255};
std::uint8_t light_color_g_{255};
std::uint8_t light_color_b_{255};
int light_offset_x_{0};
int light_offset_y_{0};
Lighting::FlickerConfig flicker_params_{};
bool dead_{false}; bool dead_{false};
SDL_FlipMode flip_{SDL_FLIP_NONE}; SDL_FlipMode flip_{SDL_FLIP_NONE};
EventBus* bus_{nullptr}; EventBus* bus_{nullptr};
+30 -6
View File
@@ -7,6 +7,7 @@
#include "game/entities/concrete/door.hpp" #include "game/entities/concrete/door.hpp"
#include "game/entities/concrete/sarcophagus.hpp" #include "game/entities/concrete/sarcophagus.hpp"
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
#include "game/map/map.hpp"
// NOTA: `loadFromLevel` viu a `entity_manager_loadlevel.cpp` per evitar // NOTA: `loadFromLevel` viu a `entity_manager_loadlevel.cpp` per evitar
// arrossegar `Resource::Cache`, `Surface` i les seues dependències (GIF, // arrossegar `Resource::Cache`, `Surface` i les seues dependències (GIF,
@@ -30,7 +31,7 @@ EntityManager::EntityManager() {
// els sarcòfags puguin actualitzar el seu `visited_`. // els sarcòfags puguin actualitzar el seu `visited_`.
tile_sub_id_ = bus_.subscribe("tile_painted", [this](const Event& ev) { tile_sub_id_ = bus_.subscribe("tile_painted", [this](const Event& ev) {
const auto* tile = std::get_if<TilePos>(&ev.payload); const auto* tile = std::get_if<TilePos>(&ev.payload);
if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row); } if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row, ev.player_id); }
}); });
// Subscripció a `player_exited` (publicat per Door en travessar la // Subscripció a `player_exited` (publicat per Door en travessar la
@@ -51,12 +52,23 @@ EntityManager::EntityManager() {
}); });
} }
}); });
// Connectar amb el check global de col·lisió del Map: les entitats
// sòlides genèriques (debris, columnes, futurs murs) viuen al manager,
// no a la graella fixa de sarcs, així que el `Map::collides` del Player
// les ha de consultar via callback. Es desregistra al dtor.
Map::setEntityCollider([this](const SDL_FRect& box) {
return entityCollidesAt(box);
});
} }
EntityManager::~EntityManager() { EntityManager::~EntityManager() {
bus_.unsubscribe(sarc_sub_id_); bus_.unsubscribe(sarc_sub_id_);
bus_.unsubscribe(tile_sub_id_); bus_.unsubscribe(tile_sub_id_);
bus_.unsubscribe(exit_sub_id_); bus_.unsubscribe(exit_sub_id_);
// Desconnectar del Map: si quedem en namespace global apuntant a un
// `this` que mor, qualsevol `Map::collides` posterior cauria en UB.
Map::setEntityCollider(nullptr);
} }
void EntityManager::setExpectedPlayerCount(int n) { void EntityManager::setExpectedPlayerCount(int n) {
@@ -129,12 +141,13 @@ void EntityManager::update(float delta_time) {
std::erase_if(entities_, [](const auto& e) { return e->isDead(); }); std::erase_if(entities_, [](const auto& e) { return e->isDead(); });
} }
void EntityManager::notifyPlayerTilePainted(int col, int row) { void EntityManager::notifyPlayerTilePainted(int col, int row, int player_id) {
// Fan-out polimòrfic a totes les entitats: cada una decideix què fer // Fan-out polimòrfic a totes les entitats: cada una decideix què fer
// (`Sarcophagus` actualitza el seu perímetre, la resta queden en // (`Sarcophagus` actualitza el seu perímetre i recorda qui va tancar
// no-op). Cost d'iterar 40 elements amb crida virtual buida ~ 1 µs; // el cercle, la resta queden en no-op). `player_id` ve del bus
// qui paga el cost guanya simplicitat al callsite. // (`ev.player_id`) — pot ser -1 al broadcast de càrrega inicial de
for (auto& e : entities_) { e->notifyTilePainted(col, row); } // nivell, els consumidors filtren si necessiten atribució.
for (auto& e : entities_) { e->notifyTilePainted(col, row, player_id); }
} }
auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* { auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* {
@@ -153,6 +166,17 @@ auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Ent
return nullptr; return nullptr;
} }
auto EntityManager::entityCollidesAt(const SDL_FRect& box) const -> bool {
// Iteració lineal sobre entitats amb `blocksMovement()`. La convenció
// d'overlap és la mateixa que `Map::collides` (estricta: el toc al
// píxel no compta) per coherència en els passadissos del laberint.
return std::ranges::any_of(entities_, [&box](const auto& e) {
if (!e->blocksMovement()) { return false; }
const auto R = e->getCollisionRect();
return (R.x + R.w) > box.x && (box.x + box.w) > R.x && (R.y + R.h) > box.y && (box.y + box.h) > R.y;
});
}
auto EntityManager::getEntities() const -> std::vector<Entity*> { auto EntityManager::getEntities() const -> std::vector<Entity*> {
std::vector<Entity*> out; std::vector<Entity*> out;
out.reserve(entities_.size()); out.reserve(entities_.size());
+19 -4
View File
@@ -21,9 +21,10 @@ class DynamicHazard;
// 2. `update(dt)` propaga el tick a totes. // 2. `update(dt)` propaga el tick a totes.
// 3. Comptador de sarcòfags oberts — quan iguala el total registrat, // 3. Comptador de sarcòfags oberts — quan iguala el total registrat,
// crida `door_->open()` automàticament. // crida `door_->open()` automàticament.
// 4. `notifyPlayerTilePainted(col, row)` — fan-out del hook per a tots // 4. `notifyPlayerTilePainted(col, row, player_id)` — fan-out del hook
// els sarcòfags (i qualsevol entitat futura que s'hi subscrigui via // per a tots els sarcòfags (i qualsevol entitat futura que s'hi
// `notifyTilePainted`). // subscrigui via `notifyTilePainted`). El `player_id` permet
// atribuir l'acció al jugador concret (p.ex. qui tanca un sarc).
// //
// NO és un singleton: es crea una instància per nivell. De moment no // NO és un singleton: es crea una instància per nivell. De moment no
// s'usa encara al GameScene (andamiatge pur). // s'usa encara al GameScene (andamiatge pur).
@@ -103,7 +104,7 @@ class EntityManager {
// notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte // notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte
// (veure Entity::notifyTilePainted). Invocat com a reacció a // (veure Entity::notifyTilePainted). Invocat com a reacció a
// `tile_painted` al bus (re-publicat pel PaintLayer un cop filtrat). // `tile_painted` al bus (re-publicat pel PaintLayer un cop filtrat).
void notifyPlayerTilePainted(int col, int row); void notifyPlayerTilePainted(int col, int row, int player_id);
// Carrega els spawns d'un YAML de nivell i els afegeix al manager: // Carrega els spawns d'un YAML de nivell i els afegeix al manager:
// 1. Llegeix el bloc `entities:` via EntityLoader. // 1. Llegeix el bloc `entities:` via EntityLoader.
@@ -134,6 +135,15 @@ class EntityManager {
// `respawnPlayersInstant` (salt debug). // `respawnPlayersInstant` (salt debug).
void spawnPendingActors(); void spawnPendingActors();
// Materialitza els pending actors al món sense disparar la cinemàtica
// de SPAWN (`onJustSpawned`). Pensat per a l'editor: vol veure les
// mòmies a la seva posició inicial per poder editar-les, però no
// disparar l'animació EMERGING (l'editor congela el món, l'animació
// no avançaria i quedaria invisible). En sortir de l'editor, GameScene
// fa un `reload` que torna els actors a `pending_actors_` i el flux
// normal els spawneja amb la cinemàtica al final del ROOM_INTRO.
void materializePendingActorsStatic();
// Crea un actor on-demand i el deixa en EMERGING (com a // Crea un actor on-demand i el deixa en EMERGING (com a
// `spawnPendingActors` però per a un sol). Pensat per a futurs // `spawnPendingActors` però per a un sol). Pensat per a futurs
// generadors (StaticHazards que produeixen DynamicHazards en runtime, // generadors (StaticHazards que produeixen DynamicHazards en runtime,
@@ -181,6 +191,11 @@ class EntityManager {
} }
private: private:
// Suport per al callback registrat a `Map::setEntityCollider`. Itera
// les entitats amb `blocksMovement()` i fa overlap estricte (mateixa
// convenció que `Map::collides`). El callback el registra el ctor.
[[nodiscard]] auto entityCollidesAt(const SDL_FRect& box) const -> bool;
EventBus bus_; EventBus bus_;
// Un sol contenidor lògic per a totes les entitats instanciades. La // Un sol contenidor lògic per a totes les entitats instanciades. La
// discriminació de tipus la fan els virtuals (`onPlayerContact`, // discriminació de tipus la fan els virtuals (`onPlayerContact`,
+210 -20
View File
@@ -4,6 +4,8 @@
// consumidors que només volen la base del manager (p.ex. el smoke test // consumidors que només volen la base del manager (p.ex. el smoke test
// stand-alone) enllacen `entity_manager.cpp` sense arrossegar aquest fitxer. // stand-alone) enllacen `entity_manager.cpp` sense arrossegar aquest fitxer.
#include <algorithm>
#include <cstdint>
#include <filesystem> #include <filesystem>
#include <memory> #include <memory>
#include <string> #include <string>
@@ -155,6 +157,147 @@ namespace {
return c["height"].get_value<int>(); return c["height"].get_value<int>();
} }
// Bloc opcional `light: { radius: N, flicker: bool, color: [r,g,b], offset: [x,y] }`
// de la plantilla. Si manca, el radi és 0 i l'entitat no entra a la
// pasada d'il·luminació. `flicker` activa la modulació "tipus foc"
// del radi (només antorxes l'haurien d'usar). `color` només té
// efecte en mode additive — defecte blanc (255,255,255). `offset`
// desplaça el centre del cercle de llum respecte el centre del
// sprite (default (0, 0); Y creix cap avall). La forma del gradient
// (3 anells) i els paràmetres del flicker viuen a
// data/config/lighting.yaml — no cal declarar-los per entitat.
struct LightTemplate {
int radius{0};
bool flicker{false};
std::uint8_t color_r{255};
std::uint8_t color_g{255};
std::uint8_t color_b{255};
int offset_x{0};
int offset_y{0};
// Inicialitzat al global a `readLight`; overrides parcials del
// tipus/sala/spawn modifiquen camps concrets sense tocar la resta.
Lighting::FlickerConfig flicker_params{};
};
// Helper compartit entre les tres capes (tipus, sala via Map, spawn)
// per llegir `color: [r, g, b]` amb clamp defensiu a 0..255.
auto readColorTriplet(const fkyaml::node& seq, std::uint8_t& r, std::uint8_t& g, std::uint8_t& b) -> bool {
if (!seq.is_sequence() || seq.size() != 3) { return false; }
r = static_cast<std::uint8_t>(std::clamp(seq[0].get_value<int>(), 0, 255));
g = static_cast<std::uint8_t>(std::clamp(seq[1].get_value<int>(), 0, 255));
b = static_cast<std::uint8_t>(std::clamp(seq[2].get_value<int>(), 0, 255));
return true;
}
// Helper paral·lel a `readColorTriplet` per a `offset: [x, y]` —
// píxels enters, sense clamp (offsets negatius són legítims; Y
// creix cap avall).
auto readOffsetPair(const fkyaml::node& seq, int& x, int& y) -> bool {
if (!seq.is_sequence() || seq.size() != 2) { return false; }
x = seq[0].get_value<int>();
y = seq[1].get_value<int>();
return true;
}
// Aplica els camps presents al bloc `flicker_params: { ... }` sobre
// una `FlickerConfig` ja inicialitzada (típicament amb el global o
// amb el resultat d'una capa anterior). Camps absents es respecten
// — per a poder sobreescriure'n només un.
void applyFlickerOverrides(const fkyaml::node& fp, Lighting::FlickerConfig& out) {
if (fp.contains("wave_amplitude_ratio")) { out.wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value<float>(); }
if (fp.contains("wave_frequency_hz")) { out.wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
if (fp.contains("noise_amplitude_ratio")) { out.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
if (fp.contains("noise_frequency_hz")) { out.noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
}
auto readLight(const fkyaml::node& tmpl) -> LightTemplate {
LightTemplate out;
// Punt de partida del flicker: el global de lighting.yaml.
out.flicker_params = Lighting::flickerConfig();
if (!tmpl.contains("light")) { return out; }
const auto& l = tmpl["light"];
if (l.contains("radius")) { out.radius = l["radius"].get_value<int>(); }
if (l.contains("flicker")) { out.flicker = l["flicker"].get_value<bool>(); }
if (l.contains("color")) { readColorTriplet(l["color"], out.color_r, out.color_g, out.color_b); }
if (l.contains("offset")) { readOffsetPair(l["offset"], out.offset_x, out.offset_y); }
if (l.contains("flicker_params")) { applyFlickerOverrides(l["flicker_params"], out.flicker_params); }
return out;
}
// Combina els defaults del tipus amb l'override de sala (si n'hi ha).
// Forma 1: la sala no diu res del tipus → es retornen els defaults
// del tipus tal qual (cas habitual / retrocompat).
// Forma 2: la sala declara `<tipus>: false` → radi 0 (entitat sense
// llum encara que el seu YAML en declari).
// Forma 3: la sala declara `<tipus>: true` o `<tipus>: { ... }` → els
// camps presents a l'override sobreescriuen el default; els absents
// (`std::nullopt`) cauen al default del tipus.
auto resolveLightWithRoom(const std::string& type, const LightTemplate& type_default) -> LightTemplate {
const auto* room = Map::getRoomEmitterOverride(type);
if (room == nullptr) { return type_default; }
if (!room->enabled) {
LightTemplate empty;
empty.flicker_params = Lighting::flickerConfig();
return empty;
}
LightTemplate out = type_default;
if (room->radius.has_value()) { out.radius = *room->radius; }
if (room->flicker.has_value()) { out.flicker = *room->flicker; }
if (room->color.has_value()) {
out.color_r = (*room->color)[0];
out.color_g = (*room->color)[1];
out.color_b = (*room->color)[2];
}
if (room->offset.has_value()) {
out.offset_x = (*room->offset)[0];
out.offset_y = (*room->offset)[1];
}
if (room->flicker_wave_amplitude_ratio.has_value()) { out.flicker_params.wave_amplitude_ratio = *room->flicker_wave_amplitude_ratio; }
if (room->flicker_wave_frequency_hz.has_value()) { out.flicker_params.wave_frequency_hz = *room->flicker_wave_frequency_hz; }
if (room->flicker_noise_amplitude_ratio.has_value()) { out.flicker_params.noise_amplitude_ratio = *room->flicker_noise_amplitude_ratio; }
if (room->flicker_noise_frequency_hz.has_value()) { out.flicker_params.noise_frequency_hz = *room->flicker_noise_frequency_hz; }
return out;
}
// Aplica l'override per-spawn (si existeix) per damunt del que ja
// viu a l'entitat (defaults del tipus + override de sala). Tres formes
// a l'entry del YAML de nivell:
// `light: false` → apaga aquesta instància
// `light: true` → informatiu (cau a capes inferiors)
// `light: { radius, flicker, color: [r,g,b], offset: [x,y] }` → camps presents sobreescriuen;
// els absents respecten el que
// ja duia l'entitat
void applySpawnLightOverride(Entity& entity, const fkyaml::node& spawn) {
if (!spawn.contains("light")) { return; }
const auto& l = spawn["light"];
if (l.is_boolean()) {
if (!l.get_value<bool>()) {
entity.setLightRadius(0);
entity.setLightFlicker(false);
}
return;
}
if (!l.is_mapping()) { return; }
if (l.contains("radius")) { entity.setLightRadius(l["radius"].get_value<int>()); }
if (l.contains("flicker")) { entity.setLightFlicker(l["flicker"].get_value<bool>()); }
if (l.contains("color")) {
std::uint8_t r{};
std::uint8_t g{};
std::uint8_t b{};
if (readColorTriplet(l["color"], r, g, b)) { entity.setLightColor(r, g, b); }
}
if (l.contains("offset")) {
int x{};
int y{};
if (readOffsetPair(l["offset"], x, y)) { entity.setLightOffset(x, y); }
}
if (l.contains("flicker_params")) {
Lighting::FlickerConfig fp = entity.getFlickerParams();
applyFlickerOverrides(l["flicker_params"], fp);
entity.setFlickerParams(fp);
}
}
// Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }` // Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }`
// de la plantilla d'entitat. // de la plantilla d'entitat.
// - Sense `base_size` ni `offset`: la caja queda alineada a la vora // - Sense `base_size` ni `offset`: la caja queda alineada a la vora
@@ -277,6 +420,16 @@ namespace {
entity->setCollisionBox(*BOX); entity->setCollisionBox(*BOX);
} }
entity->setHeight(readHeight(tmpl)); entity->setHeight(readHeight(tmpl));
// Defaults del tipus → resolució amb override de sala. El layer
// del spawn (per-instància) viu fora de `buildEntity`, perquè
// aquí encara no tenim el node del spawn — el sobreposem a
// `addSpawn` just després.
const auto LIGHT = resolveLightWithRoom(type, readLight(tmpl));
entity->setLightRadius(LIGHT.radius);
entity->setLightFlicker(LIGHT.flicker);
entity->setLightColor(LIGHT.color_r, LIGHT.color_g, LIGHT.color_b);
entity->setLightOffset(LIGHT.offset_x, LIGHT.offset_y);
entity->setFlickerParams(LIGHT.flicker_params);
anchor_out = readAnchor(tmpl); anchor_out = readAnchor(tmpl);
return entity; return entity;
} }
@@ -395,6 +548,17 @@ namespace {
} }
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height}); entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
// Variant del atlas: el spawn pot fixar un sub-sprite estàtic via
// `frame: N`. El render del manager ja sap clipar (col, row) a
// partir d'un índex lineal i la mida del frame; ací només cal
// muntar un `AnimationComponent` d'un sol frame i imposar-lo a la
// entitat. Cap entitat existent al joc emet aquest camp; només
// els muros decoratius amb variants (stone, column, ...) l'usen.
if (spawn.contains("frame")) {
const int FRAME = spawn["frame"].get_value<int>();
entity->setAnimation(AnimationComponent::uniform({FRAME}, 1000.0F, false));
}
manager.add(std::move(entity)); manager.add(std::move(entity));
return true; return true;
} }
@@ -428,6 +592,10 @@ auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* {
std::string anchor; std::string anchor;
auto entity = buildEntity(TYPE, sprite, anchor); auto entity = buildEntity(TYPE, sprite, anchor);
if (entity == nullptr) { return nullptr; } if (entity == nullptr) { return nullptr; }
// Tercera capa de resolució de llum: override per-instància declarat
// al spawn del YAML de la sala. Capes prèvies (tipus, sala) ja viuen
// dins de l'entitat. Aplica abans del std::move per no perdre l'accés.
applySpawnLightOverride(*entity, spawn);
Entity* observer = entity.get(); Entity* observer = entity.get();
bool ok = false; bool ok = false;
@@ -459,31 +627,45 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
if (addSpawn(spawn) != nullptr) { ++loaded; } if (addSpawn(spawn) != nullptr) { ++loaded; }
} }
// Broadcast inicial: marca com a "pintats" tots els tiles ocupats per // Broadcast inicial: marca com a "pintats" tots els tiles que el
// decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...). // jugador no pot trepitjar mai, de manera que el perímetre dels
// Imprescindible perquè el jugador no pot trepitjar aquests tiles // sarcòfags vecins ja els consideri visitats i la sala sigui
// (cauria al forat) i els sarcòfags vecins no podrien completar el // completable. Inclou dues famílies:
// perímetre — la sala seria incompletable. Usa el canal semàntic // - Decals de sòl (Hole, i futurs pinchos plans, plaques, charcos):
// `tile_painted` (lògica de joc), no `on_tile_entered` (presència // trigger-on-overlap que mataria/atraparia al jugador en trepitjar.
// física del jugador). Ho fem aquí, després del bucle de spawns, // - Entitats sòlides genèriques (debris, columnes...): obstacles
// perquè tots els sarcòfags ja estan registrats al fan-out (la // físics que `Map::collides` bloqueja, així `isPaintable` retorna
// subscripció del manager és al ctor, anterior a qualsevol load). // false per als seus tiles i ningú els pot pintar.
// Usa el canal semàntic `tile_painted` (lògica de joc), no
// `on_tile_entered` (presència física). Es fa aquí, després del bucle
// de spawns, perquè tots els sarcòfags ja estan registrats al fan-out
// (la subscripció del manager és al ctor, anterior a qualsevol load).
int decal_broadcast = 0; int decal_broadcast = 0;
// Els decals de sòl (Hole, futurs pinchos plans...) són StaticHazards;
// iterem tot `entities_` i el filtre per `isFloorDecal()` els tria.
for (const auto& entity : entities_) { for (const auto& entity : entities_) {
if (!entity->isFloorDecal()) { continue; } const bool IS_DECAL = entity->isFloorDecal();
const bool IS_SOLID = entity->blocksMovement();
if (!IS_DECAL && !IS_SOLID) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
const auto [COL, ROW] = Map::pixelToTile(BOX.x, BOX.y); // Per a entitats que ocupen més d'un tile (futurs murs 32×16,
bus_.publish(Event{ // 16×32, etc.), iterem tota la zona coberta pel rect. Per a decals
.name = "tile_painted", // i blocs d'un sol tile cau a un sol broadcast (mateix comportament
.payload = TilePos{.col = COL, .row = ROW}, // que abans).
.player_id = -1, 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;
++decal_broadcast; 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 = COL0 + dc, .row = ROW0 + dr},
.player_id = -1,
});
++decal_broadcast;
}
}
} }
if (decal_broadcast > 0) { if (decal_broadcast > 0) {
Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast); Log::info("EntityManager", "{} tile_painted broadcast inicial (decals + murs)", decal_broadcast);
} }
Log::info("EntityManager", "{} entitats + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path); Log::info("EntityManager", "{} entitats + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path);
@@ -502,6 +684,14 @@ void EntityManager::spawnPendingActors() {
pending_actors_.clear(); pending_actors_.clear();
} }
void EntityManager::materializePendingActorsStatic() {
// Igual que `spawnPendingActors` però sense `onJustSpawned`: l'actor
// queda en el seu estat inicial (mòmia: ROAMING, walk_east) en lloc
// d'EMERGING. Vegeu el comentari del header per la motivació.
for (const auto& pa : pending_actors_) { addSpawn(pa.spawn_node); }
pending_actors_.clear();
}
auto EntityManager::spawnActor(const std::string& type, int col, int row) -> DynamicHazard* { auto EntityManager::spawnActor(const std::string& type, int col, int row) -> DynamicHazard* {
// Construïm un node de spawn sintètic amb el mínim que `placeGeneric` // Construïm un node de spawn sintètic amb el mínim que `placeGeneric`
// necessita: `type` i `position: [col, row]`. Si el caller vol // necessita: `type` i `position: [col, row]`. Si el caller vol
+3
View File
@@ -60,6 +60,9 @@ namespace EntityWriter {
} else if (s.col.has_value() && s.row.has_value()) { } else if (s.col.has_value() && s.row.has_value()) {
out += " position: [" + std::to_string(*s.col) + ", " + std::to_string(*s.row) + "]\n"; out += " position: [" + std::to_string(*s.col) + ", " + std::to_string(*s.row) + "]\n";
} }
if (s.frame.has_value()) {
out += " frame: " + std::to_string(*s.frame) + "\n";
}
} }
return out; return out;
} }
+4
View File
@@ -35,12 +35,16 @@ namespace EntityWriter {
// Cada spawn duplica l'estructura mínima del que el lector espera: // Cada spawn duplica l'estructura mínima del que el lector espera:
// type sempre, i un dels (position, sarc_position) o cap (cas door). // type sempre, i un dels (position, sarc_position) o cap (cas door).
// `frame` és opcional i només l'emeten les entitats amb variants
// estàtiques (debris, columnes); fixa quin sub-sprite del atlas
// representa aquesta instància concreta.
struct Spawn { struct Spawn {
std::string type; std::string type;
std::optional<int> col; std::optional<int> col;
std::optional<int> row; std::optional<int> row;
std::optional<int> sarc_col; std::optional<int> sarc_col;
std::optional<int> sarc_row; std::optional<int> sarc_row;
std::optional<int> frame;
}; };
auto saveEntitiesBlock(const std::string& path, const std::vector<Spawn>& spawns) -> bool; auto saveEntitiesBlock(const std::string& path, const std::vector<Spawn>& spawns) -> bool;
+26 -3
View File
@@ -19,12 +19,21 @@ class Player;
// Event pub/sub minimalista. Hi ha un únic `EventBus` per mundus (propietat // Event pub/sub minimalista. Hi ha un únic `EventBus` per mundus (propietat
// de l'`EntityManager`); les entitats que el necessiten en reben un punter // de l'`EntityManager`); les entitats que el necessiten en reben un punter
// via `Entity::setEventBus`. Aquest sistema cobreix les necessitats // via `Entity::setEventBus`. Aquest sistema cobreix les necessitats
// actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered) // actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered,
// sense portar-hi senyalització més pesada. // player_stats_changed) sense portar-hi senyalització més pesada.
// //
// Decisió: `std::variant` per al payload — evita any/void* i facilita // Decisió: `std::variant` per al payload — evita any/void* i facilita
// consumir events tipats amb `std::get_if`. Si apareixen més tipus de // consumir events tipats amb `std::get_if`. Si apareixen més tipus de
// payload, s'amplia aquí sense tocar cridants existents. // payload, s'amplia aquí sense tocar cridants existents.
//
// `player_stats_changed`: l'únic canal numèric que toca el HUD. El `Player`
// és la font de veritat de vides/score/tresors i el publica cada vegada
// que muta qualsevol d'aquests camps. El HUD el consumeix com a "snapshot
// total" (no com a delta) — sobreescriu la seua caché amb el payload. Així
// qualsevol via que modifique stats al Player (mort per forat, recompensa
// de sarc, etc.) queda automàticament reflectida sense que cada caller
// haja de recordar publicar res addicional. `player_hit` queda només per
// al feedback visual (flash/shake), separat del comptador.
// Tile (col, row) — payload de `tile_painted` (i altres events de tile sense // Tile (col, row) — payload de `tile_painted` (i altres events de tile sense
// metadada extra). No és un `pair<int,int>` per evitar alias ambigus al // metadada extra). No és un `pair<int,int>` per evitar alias ambigus al
@@ -74,9 +83,20 @@ struct WhipSpawnPayload {
float range_px{0.0F}; float range_px{0.0F};
}; };
// Snapshot complet dels stats d'un jugador — payload de `player_stats_changed`.
// El Player publica un d'aquests cada vegada que muta vides/score/tresors.
// El HUD el consumeix com a estat absolut (no delta): sobreescriu la seua
// caché amb aquests valors. El `player_id` de l'`Event` indica a quin
// bloc del HUD va dirigit.
struct PlayerStatsPayload {
int lives{0};
int score{0};
int treasures{0};
};
struct Event { struct Event {
std::string name; std::string name;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos, WhipSpawnPayload>; using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos, WhipSpawnPayload, PlayerStatsPayload>;
Payload payload; Payload payload;
// Jugador que ha originat (o provocat) l'event. -1 = sense jugador // Jugador que ha originat (o provocat) l'event. -1 = sense jugador
// associat (events del món, com `sarcophagus_opened` des de la pròpia // associat (events del món, com `sarcophagus_opened` des de la pròpia
@@ -129,6 +149,9 @@ class EventBus {
void publish(const std::string& name, const WhipSpawnPayload& whip) { void publish(const std::string& name, const WhipSpawnPayload& whip) {
publish(Event{.name = name, .payload = whip}); publish(Event{.name = name, .payload = whip});
} }
void publish(const std::string& name, const PlayerStatsPayload& stats) {
publish(Event{.name = name, .payload = stats});
}
private: private:
struct Subscription { struct Subscription {
+5
View File
@@ -96,6 +96,11 @@ class DynamicHazard : public Hazard {
// Arma el comptador del SPAWN. A partir d'aquí, cada `update(dt)` // Arma el comptador del SPAWN. A partir d'aquí, cada `update(dt)`
// descompta `dt` fins arribar a 0 i transitar a ACTIVE. // descompta `dt` fins arribar a 0 i transitar a ACTIVE.
void triggerSpawnExit() { spawn_exit_armed_ = true; } 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 isSpawning() const -> bool { return spawn_state_ == SpawnState::SPAWNING; }
[[nodiscard]] auto getSpawnDurationS() const -> float { return spawn_duration_s_; } [[nodiscard]] auto getSpawnDurationS() const -> float { return spawn_duration_s_; }
+105 -27
View File
@@ -13,6 +13,7 @@
#include "core/input/gamepad_manager.hpp" #include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.hpp" #include "core/input/input_state.hpp"
#include "core/rendering/canvas.hpp" #include "core/rendering/canvas.hpp"
#include "core/rendering/effects/shadow.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "core/resources/palette_loader.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 // `duration_ms` és 0 (o el bloc manca) la mecànica queda
// desactivada i els hazards toquen al primer frame, com abans. // desactivada i els hazards toquen al primer frame, com abans.
readImmunityBlock(yaml); readImmunityBlock(yaml);
// Bloc opcional `light:` — radi i flicker. Extret a un mètode per
// mantenir `loadFrom` sota el llindar de complexitat de clang-tidy.
readLightBlock(yaml);
}
void Player::readLightBlock(const fkyaml::node& yaml) {
light_radius = 0;
light_flicker = false;
light_color_r = 255;
light_color_g = 255;
light_color_b = 255;
light_offset_x = 0;
light_offset_y = 0;
// Punt de partida del flicker: el global de lighting.yaml. El bloc
// opcional `flicker_params:` del personatge sobreescriu camps concrets.
flicker_params = Lighting::flickerConfig();
if (!yaml.contains("light")) { return; }
const auto& l = yaml["light"];
if (l.contains("radius")) { light_radius = l["radius"].get_value<int>(); }
if (l.contains("flicker")) { light_flicker = l["flicker"].get_value<bool>(); }
// `color: [r, g, b]` opcional, bytes 0..255. Si manca, blanc (sense
// tint) — comportament idèntic al mode subtractive de hui.
if (l.contains("color")) {
const auto& c = l["color"];
if (c.is_sequence() && c.size() == 3) {
light_color_r = static_cast<std::uint8_t>(std::clamp(c[0].get_value<int>(), 0, 255));
light_color_g = static_cast<std::uint8_t>(std::clamp(c[1].get_value<int>(), 0, 255));
light_color_b = static_cast<std::uint8_t>(std::clamp(c[2].get_value<int>(), 0, 255));
}
}
// `offset: [x, y]` opcional, píxels enters. Default (0, 0) → centre
// del sprite. Y creix cap avall, així que `y: -3` puja la llum 3 px.
if (l.contains("offset")) {
const auto& o = l["offset"];
if (o.is_sequence() && o.size() == 2) {
light_offset_x = o[0].get_value<int>();
light_offset_y = o[1].get_value<int>();
}
}
// `flicker_params: { ... }` opcional. Qualsevol dels 4 camps absent
// conserva el valor heretat del global (vegeu inicialització a sobre).
if (l.contains("flicker_params")) {
const auto& fp = l["flicker_params"];
if (fp.contains("wave_amplitude_ratio")) { flicker_params.wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value<float>(); }
if (fp.contains("wave_frequency_hz")) { flicker_params.wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
if (fp.contains("noise_amplitude_ratio")) { flicker_params.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
if (fp.contains("noise_frequency_hz")) { flicker_params.noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
}
} }
void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) { void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) {
@@ -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("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("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("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) // 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. // 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. // 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() { void Player::resetForJoin() {
lives_remaining_ = death_config.initial_lives; lives_remaining_ = death_config.initial_lives;
score_ = 0;
treasures_ = 0;
// Publica l'estat inicial perquè el HUD reompla el bloc en el moment
// de fer join (no només quan el comptador canvia després). Sense açò
// el HUD es quedaria amb la caché prèvia fins al primer event.
publishStatsChanged();
}
void Player::publishStatsChanged() {
if (bus_ == nullptr) { return; }
bus_->publish(Event{
.name = "player_stats_changed",
.payload = PlayerStatsPayload{
.lives = lives_remaining_,
.score = score_,
.treasures = treasures_,
},
.player_id = id_,
});
}
void Player::addScore(int delta) {
if (delta == 0) { return; }
score_ += delta;
publishStatsChanged();
}
void Player::addTreasures(int delta) {
if (delta == 0) { return; }
treasures_ += delta;
publishStatsChanged();
} }
void Player::respawnFromDying() { void Player::respawnFromDying() {
@@ -1283,6 +1363,12 @@ void Player::respawnFromDying() {
--lives_remaining_; --lives_remaining_;
death_animation_complete_ = false; death_animation_complete_ = false;
respawn_remaining_ms_ = 0.0F; respawn_remaining_ms_ = 0.0F;
// Notifica al HUD del nou comptador de vides abans de qualsevol
// transició d'estat. Aquesta crida tanca el bucle de qualsevol via
// de mort (mòmia, traps, rolling rock, FORAT…): totes acaben aquí, i
// ningú més ha de recordar publicar res. Era exactament el punt que
// s'oblidava al camí del forat.
publishStatsChanged();
if (lives_remaining_ <= 0) { if (lives_remaining_ <= 0) {
disable(); disable();
if (bus_ != nullptr) { if (bus_ != nullptr) {
@@ -1425,6 +1511,17 @@ void Player::render() {
if (CLIP_HOLE) { Rendering::popClip(); } if (CLIP_HOLE) { Rendering::popClip(); }
} }
auto Player::getLightCenter() const -> SDL_FPoint {
// Sprite top-left = (pos_.x, pos_.y). El render desplaça verticalment
// `jump_z_` píxels durant el salt; aplicar el mateix offset aquí fa
// que la llum "salti" amb el jugador en lloc de quedar-se al sòl.
// Sumem també l'offset estàtic configurat al YAML del personatge.
return {
.x = getPosX() + (static_cast<float>(sprite_w) * 0.5F) + static_cast<float>(light_offset_x),
.y = getPosY() - jump_z_ + (static_cast<float>(sprite_h) * 0.5F) + static_cast<float>(light_offset_y),
};
}
void Player::renderShadow() const { void Player::renderShadow() const {
// Sense salt actiu, no hi ha sombra: això simplifica els callsites, // Sense salt actiu, no hi ha sombra: això simplifica els callsites,
// que poden cridar `renderShadow` per a tots els jugadors sense // que poden cridar `renderShadow` per a tots els jugadors sense
@@ -1435,36 +1532,17 @@ void Player::renderShadow() const {
// que `enterDying` netegi el flag. // que `enterDying` netegi el flag.
if (falling_through_hole_) { return; } if (falling_through_hole_) { return; }
// Sombra centrada al sprite, recolzada a la línia dels peus reals // `height_factor`: 0 al terra → fotograma més gran, 1 al pic → més
// (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura // petit. Si el `jump_config` té un divisor zero (cas degenerat) caem
// del salt — al pic queda més estreta, donant la sensació de // a 0 per no propagar nan al renderer.
// "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.
const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F) 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) ? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2)
: 0.0F; : 0.0F;
const float NORM_Z = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F; const float HEIGHT_FACTOR = (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);
constexpr int SHADOW_H = 3; const auto CENTER_X = static_cast<int>(getPosX() + (static_cast<float>(sprite_w) * 0.5F));
const auto SHADOW_W = static_cast<int>(static_cast<float>(base_size) * SHRINK); const auto FEET_Y = static_cast<int>(getGroundFeetY());
const float CX = getPosX() + (static_cast<float>(sprite_w) * 0.5F); Effects::Shadow::render(CENTER_X, FEET_Y, HEIGHT_FACTOR);
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);
} }
// Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el // Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el
+72 -1
View File
@@ -7,6 +7,7 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
#include "core/rendering/lighting.hpp"
#include "core/rendering/sprite/animated_sprite.hpp" #include "core/rendering/sprite/animated_sprite.hpp"
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
@@ -124,6 +125,27 @@ class Player : public AnimatedSprite {
// creua mentre `jump_z_` és superior a aquest valor, no col·lisionem. // creua mentre `jump_z_` és superior a aquest valor, no col·lisionem.
inline static int body_height = 0; inline static int body_height = 0;
// Radi de la llum emesa pel jugador (en píxels del canvas), llegit
// del bloc opcional `light: { radius: N, flicker: bool, color: [r,g,b], offset: [x,y] }`
// del YAML. 0 = no emet. Es declara estàtic com la resta de configs
// del jugador (immunity, jump, ...): tots els jugadors del mateix YAML
// comparteixen el valor. `light_flicker` activa la modulació "tipus
// foc" del radi; `light_color_*` són el tint quan la sala usa mode
// additive (en subtractive s'ignora); `light_offset_*` desplaça el
// centre del cercle respecte el centre del sprite (Y creix avall:
// `y: -3` puja la llum 3 px).
inline static int light_radius = 0;
inline static bool light_flicker = false;
inline static std::uint8_t light_color_r = 255;
inline static std::uint8_t light_color_g = 255;
inline static std::uint8_t light_color_b = 255;
inline static int light_offset_x = 0;
inline static int light_offset_y = 0;
// Paràmetres del flicker resolts per al jugador (combinació del
// global + override del YAML del personatge + override de sala).
// Només té efecte si `light_flicker` és true.
inline static Lighting::FlickerConfig flicker_params{};
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
static void loadFrom(const std::string& yaml_path); static void loadFrom(const std::string& yaml_path);
@@ -132,6 +154,25 @@ class Player : public AnimatedSprite {
// marcador i `Player::resetForJoin` per a re-entrades mid-partida. // marcador i `Player::resetForJoin` per a re-entrades mid-partida.
[[nodiscard]] static auto getInitialLives() -> int { return death_config.initial_lives; } [[nodiscard]] static auto getInitialLives() -> int { return death_config.initial_lives; }
// Emissió de llum (mateix paper que `Entity::getLightRadius` per a
// les entitats). El centre puja amb el salt: durant `jump_z_>0` el
// sprite es dibuixa amb `pos_.y - jump_z_`, i la llum ha de seguir-lo
// per a que el cercle "salti" amb el jugador, no es quedi al sòl.
[[nodiscard]] static auto getLightRadius() -> int { return light_radius; }
[[nodiscard]] static auto getLightFlicker() -> bool { return light_flicker; }
[[nodiscard]] static auto getLightColorR() -> std::uint8_t { return light_color_r; }
[[nodiscard]] static auto getLightColorG() -> std::uint8_t { return light_color_g; }
[[nodiscard]] static auto getLightColorB() -> std::uint8_t { return light_color_b; }
[[nodiscard]] static auto getLightOffsetX() -> int { return light_offset_x; }
[[nodiscard]] static auto getLightOffsetY() -> int { return light_offset_y; }
[[nodiscard]] static auto getFlickerParams() -> const Lighting::FlickerConfig& { return flicker_params; }
// Seed estable per al flicker: reusem `id_` (0 o 1 en multijugador
// local) amb un offset gran per evitar col·lisions amb el contador
// d'Entity (que arrenca des de 1). Així P1 i P2 oscil·len en fases
// pròpies que no s'encavalquen amb cap entitat.
[[nodiscard]] auto getLightSeed() const -> int { return id_ + 1000; }
[[nodiscard]] auto getLightCenter() const -> SDL_FPoint;
Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1); Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1);
// Update en una sola crida: manté l'API anterior per als tests i // Update en una sola crida: manté l'API anterior per als tests i
@@ -158,6 +199,21 @@ class Player : public AnimatedSprite {
// xarxa — és únic per dins del procés. // xarxa — és únic per dins del procés.
[[nodiscard]] auto getId() const -> int { return id_; } [[nodiscard]] auto getId() const -> int { return id_; }
// Stats que pinta el HUD. El Player n'és la font de veritat; cada
// mutació es publica via `player_stats_changed` al bus (vegeu
// `publishStatsChanged`). Els getters són per a tests i diagnòstic;
// el HUD no els consulta — escolta el bus.
[[nodiscard]] auto getLives() const -> int { return lives_remaining_; }
[[nodiscard]] auto getScore() const -> int { return score_; }
[[nodiscard]] auto getTreasures() const -> int { return treasures_; }
// Acumuladors. Deltas positius i negatius (sense clamp). Publiquen
// `player_stats_changed` només si el delta no és 0, per estalviar
// soroll al bus. Els consumidors típics: GameScene reparteix puntuació
// de sarcs i pickups (futur) cridant aquests mètodes.
void addScore(int delta);
void addTreasures(int delta);
// Accés i mutació de la velocitat intencionada d'aquest frame. El // Accés i mutació de la velocitat intencionada d'aquest frame. El
// GameScene els toca entre `tick` i `applyMovement` per implementar // GameScene els toca entre `tick` i `applyMovement` per implementar
// col·lisions jugador↔jugador (escalar a push_factor, anul·lar en // col·lisions jugador↔jugador (escalar a push_factor, anul·lar en
@@ -633,7 +689,6 @@ class Player : public AnimatedSprite {
float impulse_px_per_s{0.0F}; // Velocitat vertical inicial. float impulse_px_per_s{0.0F}; // Velocitat vertical inicial.
float gravity_px_per_s2{0.0F}; // Acceleració descendent constant. float gravity_px_per_s2{0.0F}; // Acceleració descendent constant.
int landing_stun_ms{0}; // Stun en aterrar (no es mou ni salta). 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 sound_key; // Clau al Resource::Cache del so de despegue (buit = silenci).
std::string land_sound_key; // Clau del so d'aterratge (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. // readers — `loadFrom` el crida al final.
static void readImmunityBlock(const fkyaml::node& yaml); static void readImmunityBlock(const fkyaml::node& yaml);
// Llig el bloc opcional `light: { radius: N, flicker: bool }` del
// YAML i pobla `light_radius` / `light_flicker`. Si manca, els
// valors queden a 0 / false (jugador sense llum).
static void readLightBlock(const fkyaml::node& yaml);
// Activa l'estat ATTACKING en la direcció cardinal indicada (la // Activa l'estat ATTACKING en la direcció cardinal indicada (la
// direcció cap on mira el jugador) i publica `whip_spawn` al bus // direcció cap on mira el jugador) i publica `whip_spawn` al bus
// perquè el GameScene cree el `WhipAttack` corresponent. Posa el // perquè el GameScene cree el `WhipAttack` corresponent. Posa el
@@ -853,6 +913,17 @@ class Player : public AnimatedSprite {
// inicial quan el jugador re-entra mid-partida. // inicial quan el jugador re-entra mid-partida.
int lives_remaining_{0}; int lives_remaining_{0};
// Puntuació acumulada i nombre de tresors recollits. Es modifiquen
// via `addScore` / `addTreasures` (o reset a `resetForJoin`). El HUD
// els sincronitza via `player_stats_changed`.
int score_{0};
int treasures_{0};
// Publica un snapshot complet (vides/score/tresors) al bus si està
// injectat. Centralitza la generació de l'event perquè cap mutador
// se l'oblide.
void publishStatsChanged();
// Coordenades del spawn de respawn (cantó superior-esquerra del // Coordenades del spawn de respawn (cantó superior-esquerra del
// sprite). Les assigna el GameScene via `setSpawnPoint`. 0/0 fins que // sprite). Les assigna el GameScene via `setSpawnPoint`. 0/0 fins que
// el GameScene n'invoque el setter al ctor / `loadRoom`. // el GameScene n'invoque el setter al ctor / `loadRoom`.
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "game/entities/entity.hpp"
// Mur passiu: bloqueja moviment i salt però no té ni estat ni acció. Paral·lel
// a `Decoration` (sense col·lisió) i `Interactable` (col·lisiona i té
// estat/interact). Pensat per a debris/obstacles purament visuals que sí
// han de detenir el jugador: pedres, columnes, restes.
//
// Diferència amb `Interactable`: no override `interact()` ni
// `notifyTilePainted`. La caixa de col·lisió i alçada efectiva surten del
// YAML (`collision.size`, `collision.height`); aquest ctor només fixa els
// flags i un default raonable per a "mur infranquejable".
class SolidDecoration : public Entity {
public:
using Entity::Entity;
SolidDecoration() {
collides_ = true;
interactable_ = false;
height_ = 999;
// Sí bloqueja el moviment del jugador: `Map::collides` el consulta
// via callback per parar el moviment com si fos una paret sòlida.
blocks_movement_ = true;
}
};
+151
View File
@@ -1,10 +1,13 @@
#include "game/map/map.hpp" #include "game/map/map.hpp"
#include <algorithm>
#include <cmath> #include <cmath>
#include <stdexcept> #include <stdexcept>
#include <unordered_map>
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp" #include "utils/log.hpp"
namespace { namespace {
@@ -21,6 +24,138 @@ namespace {
namespace Map { namespace Map {
namespace {
// Predicat extra que el `collides` consulta com a sobre-capa,
// injectat des de `EntityManager` per a entitats sòlides genèriques
// (debris, columnes). El TU local manté l'estat sense exposar-lo
// al header.
std::function<bool(const SDL_FRect&)> g_entity_collider;
// Overrides d'emissor declarats al bloc `lighting.emitters` del YAML
// de la sala. Clau = tipus d'entitat en singular (per concordar amb
// el camp `type:` de cada plantilla). Buit per defecte: una sala
// sense bloc `lighting.emitters` deixa que cada entitat utilitze els
// seus propis defaults del seu YAML de tipus.
std::unordered_map<std::string, RoomEmitterOverride> g_room_emitters;
// Plural → singular per a tipus coneguts. El YAML de sala fa servir
// el plural perquè es llegeix natural ("mummies: false"); el codi
// treballa amb la forma singular que coincideix amb el camp `type:`
// del spawn ("type: mummy"). Si la clau no és reconeguda, passa tal
// qual (suposa que l'autor ja l'ha escrit en singular).
auto normalizeEmitterKey(const std::string& yaml_key) -> std::string {
if (yaml_key == "players") { return "player"; }
if (yaml_key == "mummies") { return "mummy"; }
if (yaml_key == "torches") { return "torch"; }
return yaml_key;
}
// Parser comú d'un color YAML 3-tupla a bytes. Retorna nullopt
// si el node no és una seqüència de 3 elements. Usat pels
// overrides de sala i, en `entity_manager_loadlevel.cpp`, també
// pels defaults de tipus i overrides de spawn (passa pel mateix
// helper per coherència). `clamp` defensiu per a YAMLs que
// entreguen valors fora de 0..255.
auto parseColorTriplet(const fkyaml::node& node) -> std::optional<std::array<std::uint8_t, 3>> {
if (!node.is_sequence() || node.size() != 3) { return std::nullopt; }
auto clamp = [](int v) { return static_cast<std::uint8_t>(std::clamp(v, 0, 255)); };
return std::array<std::uint8_t, 3>{
clamp(node[0].get_value<int>()),
clamp(node[1].get_value<int>()),
clamp(node[2].get_value<int>()),
};
}
// Parser comú d'una 2-tupla d'enters (offset de llum). Retorna
// nullopt si el node no és una seqüència de 2 elements. Sense
// clamp: els offsets negatius són legítims (Y creix cap avall,
// així que `y: -3` puja la llum 3 px).
auto parseIntPair(const fkyaml::node& node) -> std::optional<std::array<int, 2>> {
if (!node.is_sequence() || node.size() != 2) { return std::nullopt; }
return std::array<int, 2>{
node[0].get_value<int>(),
node[1].get_value<int>(),
};
}
// Llegeix el sub-bloc opcional `flicker_params:` d'un emissor de
// sala. Camps individuals — qualsevol absent conserva el valor
// del global (`Lighting::flickerConfig()`) o de la capa que ho
// precedeixi (tipus / sala).
void readFlickerOverrides(const fkyaml::node& fp, RoomEmitterOverride& ov) {
if (fp.contains("wave_amplitude_ratio")) { ov.flicker_wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value<float>(); }
if (fp.contains("wave_frequency_hz")) { ov.flicker_wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
if (fp.contains("noise_amplitude_ratio")) { ov.flicker_noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
if (fp.contains("noise_frequency_hz")) { ov.flicker_noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
}
// Llegeix una entrada del mapping `lighting.emitters.<tipus>` en
// qualsevol de les seves 3 formes (bool curt o mapping detallat).
// Extret per mantenir `loadLightingBlock` sota el llindar de
// complexitat cognitiva de clang-tidy.
auto readEmitterEntry(const fkyaml::node& val) -> RoomEmitterOverride {
RoomEmitterOverride ov;
if (val.is_boolean()) {
ov.enabled = val.get_value<bool>();
return ov;
}
if (!val.is_mapping()) { return ov; }
ov.enabled = true;
if (val.contains("radius")) { ov.radius = val["radius"].get_value<int>(); }
if (val.contains("flicker")) { ov.flicker = val["flicker"].get_value<bool>(); }
if (val.contains("color")) { ov.color = parseColorTriplet(val["color"]); }
if (val.contains("offset")) { ov.offset = parseIntPair(val["offset"]); }
if (val.contains("flicker_params")) { readFlickerOverrides(val["flicker_params"], ov); }
return ov;
}
// Llegeix la subclau `mode:` del bloc `lighting:` i actualitza
// `lighting_mode`. Avisa via Log si el valor no és reconegut.
// Extret de `loadLightingBlock` per la mateixa raó.
void readLightingMode(const fkyaml::node& lighting) {
if (!lighting.contains("mode")) { return; }
const auto MODE_STR = lighting["mode"].get_value<std::string>();
if (MODE_STR == "additive") {
lighting_mode = Lighting::Mode::ADDITIVE;
} else if (MODE_STR == "subtractive") {
lighting_mode = Lighting::Mode::SUBTRACTIVE;
} else {
Log::warn("Map", "lighting.mode '{}' desconegut — s'usa subtractive", MODE_STR);
}
}
// Llegeix el bloc opcional `lighting:` del YAML d'una sala i
// actualitza `light_level`, `lighting_mode` i el mapping
// `g_room_emitters`. Idempotent: cada `Map::loadFrom` reseteja
// tot l'estat abans de poblar-lo, així no s'arrosseguen valors
// d'una sala anterior.
void loadLightingBlock(const fkyaml::node& yaml) {
g_room_emitters.clear();
lighting_mode = Lighting::Mode::SUBTRACTIVE;
if (!yaml.contains("lighting")) { return; }
const auto& lighting = yaml["lighting"];
if (lighting.contains("level")) {
light_level = lighting["level"].get_value<float>();
}
readLightingMode(lighting);
if (!lighting.contains("emitters") || !lighting["emitters"].is_mapping()) { return; }
const auto& em = lighting["emitters"];
for (auto it = em.begin(); it != em.end(); ++it) {
const auto KEY = normalizeEmitterKey(it.key().get_value<std::string>());
g_room_emitters[KEY] = readEmitterEntry(it.value());
}
}
} // namespace
void setEntityCollider(std::function<bool(const SDL_FRect&)> fn) {
g_entity_collider = std::move(fn);
}
auto getRoomEmitterOverride(const std::string& type) -> const RoomEmitterOverride* {
const auto IT = g_room_emitters.find(type);
return IT == g_room_emitters.end() ? nullptr : &IT->second;
}
void loadFrom(const std::string& yaml_path) { void loadFrom(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path); auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) { if (bytes.empty()) {
@@ -90,6 +225,17 @@ namespace Map {
// canviarà el flag a true quan transiti a OPEN. // canviarà el flag a true quan transiti a OPEN.
door_open = false; door_open = false;
// Llum base de la sala. Dues formes acceptades:
// - `light_level: <float>` a l'arrel (forma antiga, encara
// suportada per retrocompat).
// - `lighting.level: <float>` dins del bloc nou `lighting:`,
// que també hostatja `lighting.emitters` (overrides per tipus).
// Si manca tot, queda al default 1.0 (sala totalment il·luminada).
// `loadLightingBlock` aplica el `lighting.level` per damunt del
// `light_level:` antic, així el nou bloc guanya si conviuen.
light_level = yaml.contains("light_level") ? yaml["light_level"].get_value<float>() : Defaults::Lighting::ROOM_LIGHT_DEFAULT;
loadLightingBlock(yaml);
Log::info("Map", "geometria {}x{} sarcs {}x{} pitch {}x{} door_col {} des de {}", grid_cols, grid_rows, sarc_w, sarc_h, pitch_x, pitch_y, door_col, yaml_path); Log::info("Map", "geometria {}x{} sarcs {}x{} pitch {}x{} door_col {} des de {}", grid_cols, grid_rows, sarc_w, sarc_h, pitch_x, pitch_y, door_col, yaml_path);
} }
@@ -123,6 +269,11 @@ namespace Map {
}; };
if (overlapStrict(base_box, CORRIDOR)) { return true; } if (overlapStrict(base_box, CORRIDOR)) { return true; }
} }
// Capa final: entitats sòlides genèriques registrades pel manager.
// S'avalua al final perquè els obstacles fixos (sarcs, porta) són
// l'origen del laberint i el camí més curt; les entitats addicionals
// són normalment poques.
if (g_entity_collider && g_entity_collider(base_box)) { return true; }
return false; return false;
} }
+77 -1
View File
@@ -2,9 +2,15 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <array>
#include <cstdint>
#include <functional>
#include <optional>
#include <string> #include <string>
#include <utility> #include <utility>
#include "core/rendering/lighting.hpp"
// Mapa d'una piràmide: sala rectangular amb marcador superior, muros pels // Mapa d'una piràmide: sala rectangular amb marcador superior, muros pels
// quatre costats i graella de sarcòfags envoltada per un passadís a cada // quatre costats i graella de sarcòfags envoltada per un passadís a cada
// cantó. La geometria (graella, pitch, amplada de murs) es carrega des del // cantó. La geometria (graella, pitch, amplada de murs) es carrega des del
@@ -49,6 +55,68 @@ namespace Map {
inline int back_wall_h = 0; inline int back_wall_h = 0;
inline int side_wall_w = 0; inline int side_wall_w = 0;
// Nivell d'il·luminació base de la sala. 1.0 = totalment il·luminada
// (com hui, sense efecte visual); 0.0 = completament negra fora del
// radi d'un emissor. La clau `light_level:` al YAML de la sala el
// sobreescriu — si manca, queda al default 1.0 i la pasada
// d'il·luminació del frame es salta com a no-op. La forma nova és
// declarar-ho dins del bloc `lighting:` (vegeu més avall); `light_level:`
// al root es manté per compatibilitat enrere.
inline float light_level = 1.0F;
// Mode de composició de la pasada d'il·luminació (subtractive per
// defecte; additive si la sala declara `lighting.mode: additive`).
// Veure el comentari de `core/rendering/lighting.hpp` per a la
// semàntica de cada mode. En subtractive, els colors dels emissors
// s'ignoren — el camí és el de sempre.
inline Lighting::Mode lighting_mode = Lighting::Mode::SUBTRACTIVE;
// Override d'emissor declarat al bloc opcional `lighting.emitters.<tipus>`
// de la sala. Tres formes acceptades al YAML:
// <tipus>: false → desactiva la llum per a tot el tipus
// <tipus>: true → activa amb els defaults del tipus
// <tipus>: { radius: N, flicker: B } → activa i sobreescriu camps
// Els camps absents (`std::nullopt`) cauen al default del tipus
// d'entitat (radi/flicker del seu YAML). Els tipus no presents al
// mapping conserven el seu comportament per defecte (com avui).
//
// Conversió de clau YAML → tipus d'entitat: el YAML usa plural
// ("players", "mummies", "torches") perquè es llegeix natural per a
// l'autor de la sala; `loadFrom` normalitza a la clau singular
// ("player", "mummy", "torch") que coincideix amb el camp `type:` de
// cada plantilla d'entitat. Tipus no reconeguts s'accepten "tal qual"
// (singular pass-through) per si en el futur s'hi afegeixen emissors
// nous sense tocar el mapatge.
struct RoomEmitterOverride {
bool enabled{true};
std::optional<int> radius;
std::optional<bool> flicker;
// Color emès en mode additive. `nullopt` → conservar el color
// del tipus (default blanc 255,255,255 si el tipus no en declara).
// La forma YAML és `color: [r, g, b]` (bytes 0..255).
std::optional<std::array<std::uint8_t, 3>> color;
// Offset del centre del cercle de llum respecte el centre del
// sprite (píxels enters; Y creix avall). `nullopt` → conservar
// l'offset del tipus (default [0, 0]). Forma YAML: `offset: [x, y]`.
std::optional<std::array<int, 2>> offset;
// Overrides parcials per cada camp del `FlickerConfig` (ràtio
// d'amplitud i freqüència del wave + noise). `nullopt` → no
// toca aquell camp (conserva el del tipus, que al seu torn pot
// ser el global). Forma YAML: `flicker_params: { wave_frequency_hz: 1.0, ... }`.
std::optional<float> flicker_wave_amplitude_ratio;
std::optional<float> flicker_wave_frequency_hz;
std::optional<float> flicker_noise_amplitude_ratio;
std::optional<float> flicker_noise_frequency_hz;
};
// Consulta l'override de sala per a un tipus d'entitat (clau singular,
// p.ex. "player" / "mummy" / "torch"). Retorna `nullptr` si la sala no
// declara res per aquest tipus — el caller usa els defaults del tipus
// sense modificació. El punter retornat és vàlid fins a la propera
// crida a `Map::loadFrom` (cada càrrega de nivell substitueix el
// mapping intern).
[[nodiscard]] auto getRoomEmitterOverride(const std::string& type) -> const RoomEmitterOverride*;
// Derivats de la geometria base: el pas entre sarcòfags ha de cabre // Derivats de la geometria base: el pas entre sarcòfags ha de cabre
// exactament el jugador, i el mateix passadís s'usa a l'exterior. // exactament el jugador, i el mateix passadís s'usa a l'exterior.
inline int corridor_w = 0; inline int corridor_w = 0;
@@ -91,9 +159,17 @@ namespace Map {
// Mapa de colisions (separat del mapa de dibuix): retorna true si el // Mapa de colisions (separat del mapa de dibuix): retorna true si el
// rectangle `base_box` (la base del jugador, per exemple) s'encavalca // rectangle `base_box` (la base del jugador, per exemple) s'encavalca
// amb cap sarcòfag. // amb cap sarcòfag, el passadís de la porta tancada, o cap entitat
// sòlida genèrica registrada via `setEntityCollider`.
[[nodiscard]] auto collides(const SDL_FRect& base_box) -> bool; [[nodiscard]] auto collides(const SDL_FRect& base_box) -> bool;
// Registre d'un predicat extern que `collides` consulta com a sobre-
// capa: l'usa `EntityManager` per a entitats amb `blocksMovement()`
// (debris, columnes...) que no estan a la graella fixa de sarcòfags.
// Passa `nullptr` per netejar. Reset implícit a `loadFrom` (cada
// càrrega de nivell sol portar un manager nou).
void setEntityCollider(std::function<bool(const SDL_FRect&)> fn);
// true si la caixa `box` queda completament dins del playfield (zona // true si la caixa `box` queda completament dins del playfield (zona
// interior, sense tocar els murs exteriors). El Player ja té clamp // interior, sense tocar els murs exteriors). El Player ja té clamp
// propi que aplica el mateix constrenyiment; això serveix a les // propi que aplica el mateix constrenyiment; això serveix a les
+256 -307
View File
@@ -16,6 +16,7 @@
#include "core/input/global_inputs.hpp" #include "core/input/global_inputs.hpp"
#include "core/input/key_config.hpp" #include "core/input/key_config.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/lighting.hpp"
#include "core/rendering/notifier.hpp" #include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp" #include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
@@ -26,6 +27,7 @@
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "game/entities/concrete/dust.hpp" #include "game/entities/concrete/dust.hpp"
#include "game/entities/concrete/sarcophagus.hpp"
#include "game/entities/concrete/whip_attack.hpp" #include "game/entities/concrete/whip_attack.hpp"
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
@@ -230,6 +232,39 @@ namespace {
return out; return out;
} }
// Override de sala per a la llum del jugador. Si el YAML de la sala
// declara `lighting.emitters.players`, substitueix els valors carregats
// per `Player::loadFrom`. Extret aquí per no inflar la complexitat
// cognitiva del ctor de GameScene (clang-tidy: cognitive-complexity).
// No s'aplica una "tercera capa" per-instància al jugador perquè no
// neix com a spawn del nivell; el dia d'un ítem "linterna" aquell
// sistema mutarà els mateixos camps en runtime — ortogonal a aquest
// override.
void applyRoomLightOverrideToPlayer() {
const auto* room = Map::getRoomEmitterOverride("player");
if (room == nullptr) { return; }
if (!room->enabled) {
Player::light_radius = 0;
Player::light_flicker = false;
return;
}
if (room->radius.has_value()) { Player::light_radius = *room->radius; }
if (room->flicker.has_value()) { Player::light_flicker = *room->flicker; }
if (room->color.has_value()) {
Player::light_color_r = (*room->color)[0];
Player::light_color_g = (*room->color)[1];
Player::light_color_b = (*room->color)[2];
}
if (room->offset.has_value()) {
Player::light_offset_x = (*room->offset)[0];
Player::light_offset_y = (*room->offset)[1];
}
if (room->flicker_wave_amplitude_ratio.has_value()) { Player::flicker_params.wave_amplitude_ratio = *room->flicker_wave_amplitude_ratio; }
if (room->flicker_wave_frequency_hz.has_value()) { Player::flicker_params.wave_frequency_hz = *room->flicker_wave_frequency_hz; }
if (room->flicker_noise_amplitude_ratio.has_value()) { Player::flicker_params.noise_amplitude_ratio = *room->flicker_noise_amplitude_ratio; }
if (room->flicker_noise_frequency_hz.has_value()) { Player::flicker_params.noise_frequency_hz = *room->flicker_noise_frequency_hz; }
}
} // namespace } // namespace
auto GameScene::defaultConfig() -> Config { auto GameScene::defaultConfig() -> Config {
@@ -287,6 +322,7 @@ GameScene::GameScene(Config config)
// del YAML de P1 és suficient (els dos personatges comparteixen // del YAML de P1 és suficient (els dos personatges comparteixen
// geometria — morcus és un repaletat de sam). // geometria — morcus és un repaletat de sam).
Player::loadFrom(config_.player_yaml); Player::loadFrom(config_.player_yaml);
applyRoomLightOverrideToPlayer();
// Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia, // Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia,
// etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa // etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa
@@ -321,6 +357,12 @@ GameScene::GameScene(Config config)
entity_manager_.getBus().subscribe("player_disabled", [this](const Event& ev) { entity_manager_.getBus().subscribe("player_disabled", [this](const Event& ev) {
onPlayerDisabled(ev.player_id); onPlayerDisabled(ev.player_id);
}); });
// `sarcophagus_opened`: extret a `onSarcophagusOpened` per acotar
// la complexitat cognitiva del ctor (mateix patró que `player_killed`
// i `player_disabled`).
entity_manager_.getBus().subscribe("sarcophagus_opened", [this](const Event& ev) {
onSarcophagusOpened(ev);
});
// Polvet: precarreguem el surface del Dust una sola vegada perquè // Polvet: precarreguem el surface del Dust una sola vegada perquè
// els spawns runtime de l'entitat no facin disc al primer salt. // els spawns runtime de l'entitat no facin disc al primer salt.
@@ -484,16 +526,8 @@ GameScene::GameScene(Config config)
// al Resource::Cache (preload al Director), per tant la primera // al Resource::Cache (preload al Director), per tant la primera
// crida a playMusic és immediata. // crida a playMusic és immediata.
// Font de l'overlay de pausa: reutilitzem la Surface ja cachejada al // Font del rètol de transició de sala. Reutilitzem la Surface ja
// Director (PLACEHOLDER_NAME + ".gif"). Text només parseja el .fnt a // cachejada al Director; només construïm el `Text` ací.
// l'arrencada de l'escena, un sol cop.
const std::string PAUSE_FONT_KEY = UiConfig::get().placeholderFont().name + ".gif";
auto pause_surface = Resource::Cache::get().getSurface(PAUSE_FONT_KEY);
pause_font_ = std::make_shared<Text>(pause_surface, UiConfig::get().placeholderFont().fnt);
pause_font_->setDefaultKerning(UiConfig::get().placeholderFont().kerning);
// Font del rètol de transició de sala. Mateixa convenció que la pausa
// (Surface preloaded pel Director, només construïm el `Text` ací).
const std::string ROOM_TEXT_FONT_KEY = UiConfig::get().roomTextFont().name + ".gif"; const std::string ROOM_TEXT_FONT_KEY = UiConfig::get().roomTextFont().name + ".gif";
auto room_text_surface = Resource::Cache::get().getSurface(ROOM_TEXT_FONT_KEY); auto room_text_surface = Resource::Cache::get().getSurface(ROOM_TEXT_FONT_KEY);
room_text_font_ = std::make_shared<Text>(room_text_surface, UiConfig::get().roomTextFont().fnt); room_text_font_ = std::make_shared<Text>(room_text_surface, UiConfig::get().roomTextFont().fnt);
@@ -560,6 +594,14 @@ void GameScene::loadRoom(const std::string& yaml_path) {
// CLOSED amb visited buit, momies i altres actors a la posició inicial. // CLOSED amb visited buit, momies i altres actors a la posició inicial.
config_.map_yaml = yaml_path; config_.map_yaml = yaml_path;
Map::loadFrom(yaml_path); Map::loadFrom(yaml_path);
// Refresquem els statics del Player a partir del YAML i tornem a
// aplicar l'override d'aquesta sala. Sense això, una sala amb
// `players: false` deixa `light_radius=0` i, en transicionar a una
// altra sala que no toqui el camp (o digui `players: true`), els
// jugadors no tornen a emetre — l'override actual és "additiu"
// sobre els statics existents, no un reset.
Player::loadFrom(config_.player_yaml);
applyRoomLightOverrideToPlayer();
// Tilemap pre-blita els tiles a un buffer ARGB intern al ctor — sense // Tilemap pre-blita els tiles a un buffer ARGB intern al ctor — sense
// reconstruir-lo, la imatge de la sala anterior es queda fixada per a // reconstruir-lo, la imatge de la sala anterior es queda fixada per a
// sempre. PaintLayer captura `tileset_path_`/`tileset_cols_` per valor // sempre. PaintLayer captura `tileset_path_`/`tileset_cols_` per valor
@@ -740,6 +782,21 @@ void GameScene::onPlayerDisabled(int player_id) {
players_active_[player_id] = false; players_active_[player_id] = false;
} }
void GameScene::onSarcophagusOpened(const Event& ev) {
// `player_id < 0`: pintada sense atribució (broadcast inicial). No
// hauria de tancar mai un sarc, però per defensa ignorem.
if (ev.player_id < 0) { return; }
const auto IDX = static_cast<std::size_t>(ev.player_id);
if (IDX >= players_.size()) { return; }
const auto* ent = std::get_if<Entity*>(&ev.payload);
if (ent == nullptr || *ent == nullptr) { return; }
auto* sarc = dynamic_cast<Sarcophagus*>(*ent);
if (sarc == nullptr) { return; }
auto& p = *players_[IDX];
p.addScore(sarc->getPointsReward());
p.addTreasures(sarc->getTreasuresReward());
}
void GameScene::preloadGameOverAssets() const { void GameScene::preloadGameOverAssets() const {
// Pre-load del so del countdown: el dispara `updateGameOverFlow` // Pre-load del so del countdown: el dispara `updateGameOverFlow`
// cada vegada que canvia el número visible de CONTINUE? (incloent // cada vegada que canvia el número visible de CONTINUE? (incloent
@@ -991,6 +1048,45 @@ void GameScene::updateRoomTransition(int delta_ms) {
} }
} }
void GameScene::applyLightingPass() {
// Atajo: si la sala està totalment il·luminada i ningú emet,
// `Lighting::apply` ja s'autosalta amb les mateixes condicions.
// Recollim igualment perquè el cas comú durant el desenvolupament
// serà tocar `Map::light_level` per sales concretes.
std::vector<Lighting::Emitter> emitters;
emitters.reserve(players_.size() + entity_manager_.entityCount());
const int PLAYER_R = Player::getLightRadius();
// Quan el YAML del jugador no declara `light:`, `PLAYER_R` és 0 i
// `Lighting::apply` salta el cercle al filtre intern. No fem un
// gating extern aquí (els jugadors són només 1-2 actors i el cost
// d'afegir-los al vector és menyspreable).
const bool PLAYER_FLICKER = Player::getLightFlicker();
const Lighting::Color PLAYER_COLOR{
.r = Player::getLightColorR(),
.g = Player::getLightColorG(),
.b = Player::getLightColorB(),
};
const Lighting::FlickerConfig PLAYER_FP = Player::getFlickerParams();
for (const auto& p : players_) {
if (p->isOutOfPlay()) { continue; }
const auto C = p->getLightCenter();
emitters.push_back({.cx = C.x, .cy = C.y, .radius = PLAYER_R, .seed = p->getLightSeed(), .flicker = PLAYER_FLICKER, .color = PLAYER_COLOR, .flicker_params = PLAYER_FP});
}
for (const auto* e : entity_manager_.getEntities()) {
const int R = e->getLightRadius();
if (R <= 0) { continue; }
const auto C = e->getLightCenter();
const Lighting::Color COL{.r = e->getLightColorR(), .g = e->getLightColorG(), .b = e->getLightColorB()};
emitters.push_back({.cx = C.x, .cy = C.y, .radius = R, .seed = e->getLightSeed(), .flicker = e->getLightFlicker(), .color = COL, .flicker_params = e->getFlickerParams()});
}
Lighting::apply(Screen::get().getCanvasArgb(),
Screen::get().getCanvasWidth(),
Screen::get().getCanvasHeight(),
Map::lighting_mode,
Map::light_level,
emitters);
}
void GameScene::renderRoomFade() { void GameScene::renderRoomFade() {
// Delegació als FadeToBlack: les durades, la direcció i el progrés // Delegació als FadeToBlack: les durades, la direcció i el progrés
// viuen dins de cada instància. Comportament: // viuen dins de cada instància. Comportament:
@@ -1011,11 +1107,6 @@ void GameScene::renderRoomFade() {
} }
void GameScene::renderRoomText() { 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): // Mapatge per estat (només els estats amb text actiu hi entren):
// ROOM_INTRO: phases 2..4 → enter / hold / exit; dues línies // ROOM_INTRO: phases 2..4 → enter / hold / exit; dues línies
// (piràmide damunt, sala davall) que travessen la // (piràmide damunt, sala davall) que travessen la
@@ -1024,82 +1115,35 @@ void GameScene::renderRoomText() {
// oposades ("fase" / "completada"). Phase 3 és el // oposades ("fase" / "completada"). Phase 3 és el
// fade-out i 4 la pausa en negre — cap text en cap // fade-out i 4 la pausa en negre — cap text en cap
// de les dues. // 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 PHASE = transition_.phase;
const int ELAPSED = transition_.elapsed_ms; const int ELAPSED = transition_.elapsed_ms;
if (state_ == RoomFsm::State::ROOM_INTRO && PHASE >= 2 && PHASE <= 4) { 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 auto& mgr = Levels::LevelManager::get();
const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid()); renderTextScreen(TextScreenSpec{
const std::string LINE_ROOM = RoomFsm::formatIntroRoomLine(mgr.currentRoom()); .line1 = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid()),
// Direccions oposades: piràmide entra per l'esquerra i surt per .line2 = RoomFsm::formatIntroRoomLine(mgr.currentRoom()),
// la dreta; sala fa el contrari. .phase_enter = 2,
const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING), .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, PHASE,
ELAPSED, 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);
} else if (state_ == RoomFsm::State::ROOM_OUTRO && PHASE >= 0 && PHASE <= 2) { } else if (state_ == RoomFsm::State::ROOM_OUTRO && PHASE >= 0 && PHASE <= 2) {
const int DUR_ENTER = transition_config_.outro_text_enter_ms; renderTextScreen(TextScreenSpec{
const int DUR_EXIT = transition_config_.outro_text_exit_ms; .line1 = Locale::get().text("game.room_complete_line1"),
drawRoomTextPanel(PLAY_CENTER_Y, .line2 = Locale::get().text("game.room_complete_line2"),
computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT)); .phase_enter = 0,
.phase_hold = 1,
const std::string LINE_1 = Locale::get().text("game.room_complete_line1"); .phase_exit = 2,
const std::string LINE_2 = Locale::get().text("game.room_complete_line2"); .dur_enter_ms = transition_config_.outro_text_enter_ms,
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING), .dur_exit_ms = transition_config_.outro_text_exit_ms,
.line_gap_px = transition_config_.outro_text_line_gap_px,
},
PHASE, PHASE,
ELAPSED, 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);
} }
} }
@@ -1179,6 +1223,51 @@ void GameScene::drawRoomTextLine(const std::string& text, int x, int y, std::uin
outline_color); 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) { void GameScene::setGameOverState(GameOverFsm::State new_state) {
if (new_state == game_over_state_) { return; } if (new_state == game_over_state_) { return; }
const auto PREV = game_over_state_; const auto PREV = game_over_state_;
@@ -1315,104 +1404,57 @@ void GameScene::emitCountdownTickIfChanged() {
} }
void GameScene::renderGameOverText() { 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) { if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT && game_over_state_ != GameOverFsm::State::GAME_OVER) {
return; 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 PHASE = game_over_transition_.phase;
const int ELAPSED = game_over_transition_.elapsed_ms; const int ELAPSED = game_over_transition_.elapsed_ms;
if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) { 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( const int SECONDS = GameOverFsm::countdownSecondsRemaining(
game_over_state_, game_over_state_,
game_over_transition_, game_over_transition_,
game_over_config_); game_over_config_);
// Línia 2 només té text durant la fase de hold (countdown). En renderTextScreen(TextScreenSpec{
// les fases d'enter i exit deixem només el "CONTINUE?" perquè .line1 = Locale::get().text("game.continue_prompt"),
// l'animació dels números no quedi rara. .line2 = (PHASE == 1) ? std::to_string(SECONDS) : std::string{},
const std::string LINE_2 = (PHASE == 1) ? std::to_string(SECONDS) : std::string{}; .phase_enter = 0,
.phase_hold = 1,
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING), .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, PHASE,
ELAPSED, 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);
}
return; return;
} }
// GAME_OVER: panel sempre amunt durant phase 0/1, decreix en phase 1 // GAME_OVER: phase 2 (wait) sense panel ni text — només temps mort
// (exit). Phase 2 (wait) sense panel ni text — només temps mort // abans del fade. phase_enter=-1 desactiva l'animació d'entrada
// abans del fade. // (el panel ja és amunt heretat del CONTINUE_PROMPT).
const int DUR_HOLD = game_over_config_.game_over_text_hold_ms;
const int DUR_EXIT = game_over_config_.game_over_text_exit_ms;
if (PHASE > 1) { return; } if (PHASE > 1) { return; }
renderTextScreen(TextScreenSpec{
// Panel: phase 0 = ple, phase 1 = decreix. Reaprofitem .line1 = Locale::get().text("game.game_over_line1"),
// computeRoomTextPanelHeight passant un phase_enter "fora de rang" .line2 = Locale::get().text("game.game_over_line2"),
// (-1) perquè la branca d'enter no s'activi mai. .phase_enter = -1,
drawRoomTextPanel(PLAY_CENTER_Y, .phase_hold = 0,
computeRoomTextPanelHeight(PHASE, ELAPSED, /*phase_enter=*/-1, .phase_exit = 1,
/*phase_hold=*/0, .dur_enter_ms = 0,
/*phase_exit=*/1, .dur_exit_ms = game_over_config_.game_over_text_exit_ms,
/*dur_enter=*/0, .line_gap_px = transition_config_.outro_text_line_gap_px,
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),
PHASE, PHASE,
ELAPSED, 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.).
} }
void GameScene::renderGameOverFade() { void GameScene::renderGameOverFade() {
@@ -1467,81 +1509,6 @@ void GameScene::spawnWhip(const WhipSpawnPayload& payload) {
entity_manager_.add(std::move(whip)); entity_manager_.add(std::move(whip));
} }
void GameScene::handlePauseInput() {
#ifdef _DEBUG
// Mutex amb l'editor: si està actiu, ignorem la tecla de pausa.
if (editor_.active()) { return; }
#endif
const SDL_Scancode PAUSE_SC = KeyConfig::get().scancode("pause");
for (const auto& event : EventBuffer::events) {
const bool KEY_HIT = (event.type == SDL_EVENT_KEY_DOWN && PAUSE_SC != SDL_SCANCODE_UNKNOWN && event.key.scancode == PAUSE_SC);
if (!KEY_HIT) { continue; }
// Determinem cap a quin estat anem. Si estem quiets (IDLE o
// PAUSED) arranquem la transició contrària. Si estem en marxa
// (ENTERING o EXITING) invertim: la `scale` actual es manté com
// a `from_scale` nou i la durada es recalcula proporcional a la
// distància restant — així mai es produeix un salt de velocitat.
const bool GOING_TO_PAUSE = (pause_.state == PauseState::IDLE || pause_.state == PauseState::EXITING);
// Reprenem l'stream ABANS de començar a pujar el volum: quan
// venim de PAUSED l'àudio està aturat de debò (ho fem al final
// d'ENTERING, veure updatePauseTransition); reprenem just al
// moment de començar EXITING per no perdre el primer frame
// d'stream. A les inversions mid-flight (ENTERING → EXITING
// sense tocar PAUSED) l'stream no s'ha aturat mai, així que no
// cal reprendre.
if (!GOING_TO_PAUSE && pause_.state == PauseState::PAUSED) {
Audio::get()->resumeMusic();
}
pause_.from_scale = pause_.scale;
pause_.to_scale = GOING_TO_PAUSE ? 0.0F : 1.0F;
const int TARGET_MS = GOING_TO_PAUSE ? UiConfig::get().pauseEnterMs() : UiConfig::get().pauseExitMs();
const float DISTANCE = std::fabs(pause_.to_scale - pause_.from_scale);
pause_.duration_ms = std::max(1, static_cast<int>(static_cast<float>(TARGET_MS) * DISTANCE));
pause_.elapsed_ms = 0;
pause_.curve = GOING_TO_PAUSE ? UiConfig::get().pauseEnterCurve() : UiConfig::get().pauseExitCurve();
pause_.state = GOING_TO_PAUSE ? PauseState::ENTERING : PauseState::EXITING;
Log::info("PAUSE", "{} from_scale={} duration_ms={}", GOING_TO_PAUSE ? "entering" : "exiting", pause_.from_scale, pause_.duration_ms);
}
}
void GameScene::updatePauseTransition(int delta_ms) {
if (pause_.state != PauseState::ENTERING && pause_.state != PauseState::EXITING) { return; }
pause_.elapsed_ms += delta_ms;
bool just_reached_pause = false;
if (pause_.elapsed_ms >= pause_.duration_ms) {
// Transició completa: enganxem al valor objectiu exacte i
// promocionem l'estat al terminal corresponent.
pause_.scale = pause_.to_scale;
const PauseState NEXT = (pause_.to_scale == 0.0F) ? PauseState::PAUSED : PauseState::IDLE;
just_reached_pause = (NEXT == PauseState::PAUSED);
pause_.state = NEXT;
} else {
const float T = static_cast<float>(pause_.elapsed_ms) / static_cast<float>(pause_.duration_ms);
const auto CURVE = (pause_.curve != nullptr) ? pause_.curve : &Easing::linear;
pause_.scale = std::lerp(pause_.from_scale, pause_.to_scale, CURVE(T));
}
// Fade del volum de música durant la transició. La base és el que
// l'usuari ha configurat a Options; multipliquem per la `scale` per
// obtenir el volum efectiu aquest frame.
const float BASE_VOLUME = Options::audio().music.volume;
Audio::get()->setMusicVolume(BASE_VOLUME * pause_.scale);
// En arribar a PAUSED aturem l'stream: així quan despausem la música
// tornarà des d'on s'ha quedat, no des d'on hauria anat a parar
// durant el temps que el joc ha estat en pausa. El `resumeMusic` es
// fa a `handlePauseInput` al començar l'EXITING, perquè la música
// sone ja mentre el volum puja des de 0.
if (just_reached_pause) {
Audio::get()->pauseMusic();
}
}
#ifdef _DEBUG #ifdef _DEBUG
namespace { namespace {
auto detectEditorToggle() -> bool { auto detectEditorToggle() -> bool {
@@ -1577,33 +1544,33 @@ namespace {
#endif #endif
void GameScene::update(int delta_ms) { void GameScene::update(int delta_ms) {
handlePauseInput(); // Congelació instantània mentre el menú de servei està obert: el
// món no avança gens. La música segueix sonant perquè el menú serveix
// Congelació instantània mentre el menú de servei està obert. A // entre altres coses per calibrar les opcions d'àudio.
// diferència de la pausa de gameplay (que és un dial amb easing i
// fade de música), aquesta és binària: el món no avança gens i la
// música no es toca. També posem en pausa el dial de la pausa
// regular si estava a mig camí, perquè no continui mentre l'usuari
// navega el menú.
const bool MENU_FROZEN = ServiceMenu::get().isOpen(); const bool MENU_FROZEN = ServiceMenu::get().isOpen();
if (!MENU_FROZEN) {
updatePauseTransition(delta_ms);
}
// Transició de sala: viu en temps real (no escalada per la pausa) // Transició de sala i sub-FSM de game over: viuen en temps real
// perquè és UI. Avança avans del bloc de món perquè quan PLAYING // perquè són UI. Avancen abans del bloc de món perquè quan PLAYING
// arrenque ja sigui efectiu en el mateix frame. // arrenque ja sigui efectiu en el mateix frame.
updateRoomTransition(delta_ms); updateRoomTransition(delta_ms);
// Game-over flow pausat mentre el menú de servei o l'editor estan
// Sub-FSM de game over (CONTINUE? + GAME OVER + fade-to-LOGO). // oberts. L'editor fa `loadRoom` complet i deixa els jugadors DISABLED
// També en temps real: el countdown de CONTINUE? no es congela // (`isOutOfPlay()==true`) esperant respawn instantani a la sortida;
// amb la pausa de gameplay. La detecció de "tots fora de partida" // si el flux corre durant aquest interval, `activePlayerCount()==0`
// s'arma dins quan estem a PLAYING. // mentre `state_==PLAYING` dispara un CONTINUE_PROMPT espuri que
updateGameOverFlow(delta_ms); // 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 #ifdef _DEBUG
// Toggle de l'editor: només si no hi ha cap transició de pausa ni // Toggle de l'editor. L'editor mateix no consumeix la tecla — la
// pausa consolidada. L'editor mateix no consumeix la tecla — la
// llegim ací per decidir la coexistència. Tant a l'entrada com a la // llegim ací per decidir la coexistència. Tant a l'entrada com a la
// sortida fem `loadRoom` complet per partir sempre del YAML del // sortida fem `loadRoom` complet per partir sempre del YAML del
// disc: les entitats tornen a la posició inicial (momies, sarcs), // disc: les entitats tornen a la posició inicial (momies, sarcs),
@@ -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 // l'editor sempre veu l'estat-base de la sala (no el d'una partida
// a meitat) i el joc, en sortir, arrenca fresc amb les edicions ja // a meitat) i el joc, en sortir, arrenca fresc amb les edicions ja
// persistides al YAML. // persistides al YAML.
if (pause_.state == PauseState::IDLE && detectEditorToggle()) { if (!MENU_FROZEN && detectEditorToggle()) {
const bool WAS_ACTIVE = editor_.active(); const bool WAS_ACTIVE = editor_.active();
editor_.toggle(tilemap_, entity_manager_); editor_.toggle(tilemap_, entity_manager_);
const bool NOW_ACTIVE = editor_.active(); const bool NOW_ACTIVE = editor_.active();
@@ -1621,6 +1588,14 @@ void GameScene::update(int delta_ms) {
// totes maneres) i es reactivaran amb el spawn instantani // totes maneres) i es reactivaran amb el spawn instantani
// a la sortida. // a la sortida.
loadRoom(Levels::LevelManager::get().currentPath()); loadRoom(Levels::LevelManager::get().currentPath());
// Materialitzem els dynamic hazards (mòmies, roques) en estat
// estàtic perquè es vegin a la posició inicial: així l'editor
// les pot seleccionar i moure com qualsevol altra entitat. El
// flux normal del joc les enruta a `pending_actors_` per a
// disparar la cinemàtica EMERGING al final del ROOM_INTRO; a
// l'editor el món està congelat i aquesta animació quedaria
// travada.
entity_manager_.materializePendingActorsStatic();
} else if (WAS_ACTIVE && !NOW_ACTIVE) { } else if (WAS_ACTIVE && !NOW_ACTIVE) {
// Acabem de sortir: el writer ja ha persistit el YAML. // Acabem de sortir: el writer ja ha persistit el YAML.
// Carreguem fresh i fem spawn instantani sense cinemàtica. // Carreguem fresh i fem spawn instantani sense cinemàtica.
@@ -1629,10 +1604,10 @@ void GameScene::update(int delta_ms) {
} }
} }
// Salts debug entre sales (`[` / `]`). Mútuament excloents amb la // Salts debug entre sales (`[` / `]`). Mútuament excloents amb el
// pausa i amb l'editor: si qualsevol està actiu, ignorem la tecla // menú de servei i amb l'editor: si qualsevol està actiu, ignorem la
// perquè el salt recarrega entitats i invalidaria l'estat editat. // tecla perquè el salt recarrega entitats i invalidaria l'estat editat.
if (pause_.state == PauseState::IDLE && !editor_.active()) { if (!MENU_FROZEN && !editor_.active()) {
const int DELTA = detectRoomJump(); const int DELTA = detectRoomJump();
if (DELTA != 0) { if (DELTA != 0) {
auto& mgr = Levels::LevelManager::get(); auto& mgr = Levels::LevelManager::get();
@@ -1646,7 +1621,7 @@ void GameScene::update(int delta_ms) {
// pintat/sarcs durant proves de progressió. La resta del flux // pintat/sarcs durant proves de progressió. La resta del flux
// (creuar → player_exited → level_complete → OUTRO) es manté. // (creuar → player_exited → level_complete → OUTRO) es manté.
// Mateixos gates que el salt entre sales. // Mateixos gates que el salt entre sales.
if (pause_.state == PauseState::IDLE && !editor_.active() && detectOpenDoor()) { if (!MENU_FROZEN && !editor_.active() && detectOpenDoor()) {
Log::info("DEBUG", "força obertura de la porta"); Log::info("DEBUG", "força obertura de la porta");
entity_manager_.forceOpenDoor(); entity_manager_.forceOpenDoor();
} }
@@ -1655,33 +1630,31 @@ void GameScene::update(int delta_ms) {
// Join in progress (regla 1): un slot DISABLED pot entrar a partida // Join in progress (regla 1): un slot DISABLED pot entrar a partida
// pulsant START durant PLAYING. Es processa abans del bloc de món // pulsant START durant PLAYING. Es processa abans del bloc de món
// per que el spawn aeri ja sigui efectiu en aquest mateix frame. // per que el spawn aeri ja sigui efectiu en aquest mateix frame.
// Gateat per pausa/menú/editor: no es vol que un START accidental // Gateat per menú/editor: no es vol que un START accidental mentre
// mentre l'usuari navega per un menú trigueri un join. // l'usuari navega per un menú trigueri un join.
#ifdef _DEBUG #ifdef _DEBUG
const bool EDITOR_OPEN = editor_.active(); const bool EDITOR_OPEN = editor_.active();
#else #else
constexpr bool EDITOR_OPEN = false; constexpr bool EDITOR_OPEN = false;
#endif #endif
if (pause_.state == PauseState::IDLE && !MENU_FROZEN && !EDITOR_OPEN) { if (!MENU_FROZEN && !EDITOR_OPEN) {
handleJoinInput(); handleJoinInput();
} }
GlobalInputs::update(delta_ms); GlobalInputs::update(delta_ms);
if (GlobalInputs::wantsQuit()) { wants_quit_ = true; } if (GlobalInputs::wantsQuit()) { wants_quit_ = true; }
// El món avança amb `delta_ms * pause_.scale`: scale=1 corre a velocitat // El bloc de món no s'executa si el menú de servei o l'editor de
// real, scale=0 el congela. Quan l'editor de debug està actiu també // debug estan oberts: tots dos congelen el joc instantàniament.
// es salta completament (no l'escalem, el saltem). Notifier, // Notifier, RenderInfo i HUD viuen fora d'aquest bloc perquè s'han
// RenderInfo i HUD reben el delta real, per això viuen fora d'aquest // de seguir actualitzant en aquests casos.
// bloc i no es ralenteixen amb el dial de pausa.
#ifdef _DEBUG #ifdef _DEBUG
const bool EDITOR_FROZEN = editor_.active(); const bool EDITOR_FROZEN = editor_.active();
#else #else
constexpr bool EDITOR_FROZEN = false; constexpr bool EDITOR_FROZEN = false;
#endif #endif
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale); if (!EDITOR_FROZEN && !MENU_FROZEN && delta_ms > 0) {
if (!EDITOR_FROZEN && !MENU_FROZEN && SCALED_DELTA_MS > 0) { const float DT = static_cast<float>(delta_ms) / 1000.0F;
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
// Pipeline de moviment en tres passes per poder intercalar la // Pipeline de moviment en tres passes per poder intercalar la
// resolució de contactes jugador↔jugador entre input i posició: // resolució de contactes jugador↔jugador entre input i posició:
@@ -1817,6 +1790,12 @@ void GameScene::render() {
} }
} }
// Pasada d'il·luminació: oscureix el canvas segons `Map::light_level`
// i hi obre cercles brillants al voltant dels emissors (jugadors,
// antorxes, mòmies, ...). Va abans del fade i del HUD perquè el
// fade pinta negre encima i la UI ha de quedar llegible.
applyLightingPass();
// Fade del món: pintat ENTRE actors i HUD perquè el marcador, les // Fade del món: pintat ENTRE actors i HUD perquè el marcador, les
// notificacions i el service menu queden per damunt sense afectar-se. // notificacions i el service menu queden per damunt sense afectar-se.
// Inactiu si l'estat és PLAYING. // Inactiu si l'estat és PLAYING.
@@ -1845,36 +1824,6 @@ void GameScene::render() {
editor_.render(tilemap_, entity_manager_); editor_.render(tilemap_, entity_manager_);
#endif #endif
// Overlay de pausa: text amb stroke blanc (índex 255) centrat amb X
// depenent de l'estat del dial. Entra lliscant des de l'esquerra
// (ENTERING), es queda al centre (PAUSED) i surt cap a la dreta
// (EXITING). La X es deriva directament de `pause_.scale`, que ja
// porta la corba aplicada — així text i món frenen alhora.
if (pause_.state != PauseState::IDLE && pause_font_) {
constexpr Uint8 OUTLINE_WHITE = 255;
constexpr Uint8 STROKE_DIST = 1;
constexpr int PAUSE_KERNING = 0;
const std::string TEXT = Locale::get().text("input.paused");
const int TEXT_WIDTH = pause_font_->length(TEXT, PAUSE_KERNING);
const int OFF_LEFT = -TEXT_WIDTH;
const int OFF_RIGHT = GameCanvas::WIDTH + TEXT_WIDTH;
int x = GameCanvas::CENTER_X;
if (pause_.state == PauseState::ENTERING) {
x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, 1.0F - pause_.scale);
} else if (pause_.state == PauseState::EXITING) {
x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, pause_.scale);
}
const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2);
pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG,
x,
Y,
TEXT,
PAUSE_KERNING,
UiConfig::get().notificationText(),
STROKE_DIST,
OUTLINE_WHITE);
}
Notifier::get().render(); Notifier::get().render();
RenderInfo::get().render(); RenderInfo::get().render();
Screen::get().present(); Screen::get().present();
+51 -53
View File
@@ -55,24 +55,11 @@ class GameScene : public Scene {
void setState(RoomFsm::State new_state); void setState(RoomFsm::State new_state);
// Avança la transició activa via `RoomFsm::advance`. S'executa cada // Avança la transició activa via `RoomFsm::advance`. S'executa cada
// frame amb delta real (no escalat per la pausa) perquè la transició // frame amb delta real perquè la transició viu en temps de UI. Si
// viu en temps de UI. Si l'estat retornat és diferent de l'actual, // l'estat retornat és diferent de l'actual, crida `setState` (que
// crida `setState` (que reseteja la `Transition`). // reseteja la `Transition`).
void updateRoomTransition(int delta_ms); void updateRoomTransition(int delta_ms);
// Consumeix events d'aquest frame (F11 del KeyConfig "pause") per
// disparar les transicions de pausa. El mando ja no participa: el
// botó START és ara una acció remapeable per dispositiu i el menú
// de servei fa de pausa quan cal. No alterna un flag binari: inicia
// (o inverteix mid-flight) un "dial" de temps que frena el món amb
// easing.
void handlePauseInput();
// Avança la transició activa, recalcula `pause_.scale` i aplica el
// fade de volum de música. S'executa cada frame amb el delta real
// (no escalat) perquè la transició viu en temps real.
void updatePauseTransition(int delta_ms);
// Spawna un Dust transitori centrat a (`feet_x_center`, `feet_y`). // Spawna un Dust transitori centrat a (`feet_x_center`, `feet_y`).
// Cridat per la subscripció del bus a `player_jumped` / // Cridat per la subscripció del bus a `player_jumped` /
// `player_landed` per pintar el polvet de despegue / aterratge. // `player_landed` per pintar el polvet de despegue / aterratge.
@@ -140,6 +127,14 @@ class GameScene : public Scene {
// mètode per acotar la complexitat cognitiva del subscriber/ctor. // mètode per acotar la complexitat cognitiva del subscriber/ctor.
void onPlayerDisabled(int player_id); void onPlayerDisabled(int player_id);
// Handler de l'event `sarcophagus_opened` (un jugador acaba de
// tancar el perímetre). Reparteix la recompensa (`points`,
// `treasures` del YAML del sarc) al Player corresponent, que al
// mutar score/treasures publicarà `player_stats_changed` i el HUD
// s'actualitzarà sol. Extret a mètode per acotar la complexitat
// cognitiva del ctor.
void onSarcophagusOpened(const Event& ev);
// Pre-load dels recursos d'àudio que s'usen mid-partida i no els // Pre-load dels recursos d'àudio que s'usen mid-partida i no els
// toca el Director (so del countdown, etc.). Si la clau és buida // toca el Director (so del countdown, etc.). Si la clau és buida
// o ja està al cache, és no-op. Extret a mètode per acotar la // o ja està al cache, és no-op. Extret a mètode per acotar la
@@ -239,41 +234,11 @@ class GameScene : public Scene {
// la destrucció. // la destrucció.
std::unique_ptr<Hud> hud_; std::unique_ptr<Hud> hud_;
// Pausa de gameplay com a "dial" de temps. En lloc d'un interruptor,
// `pause_.scale` va de 1 (món corrent) a 0 (món congelat) amb una
// corba d'easing durant ENTERING, i al revés durant EXITING. El
// `delta_ms` del món (players + EntityManager) es multiplica per
// aquesta escala; HUD, Notifier i GlobalInputs reben sempre el
// delta real. Les inversions mid-flight capturen la `scale` actual
// com a `from_scale` i escalen la durada a la distància restant per
// mantenir la sensació de velocitat. La font és la mateixa que
// abans — ja cachejada al Director.
std::shared_ptr<Text> pause_font_;
// Font del rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). // Font del rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO).
// Distinta de `pause_font_` perquè el rètol de fase té estètica // Té estètica pròpia (CaesarDressing per defecte, configurable via
// pròpia (CaesarDressing per defecte, configurable via // `data/config/ui.yaml > fonts.room_text`).
// `data/config/ui.yaml > fonts.room_text`). La pausa segueix amb la
// placeholder.
std::shared_ptr<Text> room_text_font_; std::shared_ptr<Text> room_text_font_;
enum class PauseState : std::uint8_t {
IDLE, // Joc corrent; cap transició en marxa. scale == 1.
ENTERING, // Frenant cap a la pausa. scale decreix a 0.
PAUSED, // Món congelat. scale == 0.
EXITING, // Accelerant de tornada. scale creix a 1.
};
struct PauseTransition {
PauseState state{PauseState::IDLE};
float scale{1.0F}; // 0 = parat, 1 = corrent.
float from_scale{1.0F}; // Valor de `scale` al començar la transició.
float to_scale{1.0F}; // Valor objectiu (0.0 o 1.0).
int elapsed_ms{0}; // Temps transcorregut dins la transició.
int duration_ms{0}; // Durada total (escalada a la distància).
UiConfig::CurveFn curve{nullptr}; // Corba activa.
} pause_;
// Configuració de la FSM de sala (intro/outro). Carregada al ctor des // Configuració de la FSM de sala (intro/outro). Carregada al ctor des
// de `data/scenes/game/game.yaml`; valors per defecte si manca el YAML. // de `data/scenes/game/game.yaml`; valors per defecte si manca el YAML.
RoomFsm::Config transition_config_{}; RoomFsm::Config transition_config_{};
@@ -306,6 +271,13 @@ class GameScene : public Scene {
void renderRoomFade(); void renderRoomFade();
void renderRoomText(); void renderRoomText();
// Pasada d'il·luminació CPU sobre el canvas, entre els actors i el
// HUD: recull els emissors actius (jugadors + entitats amb
// `light_radius>0`) i aplica el gradient amb `Map::light_level`
// com a llum de sala. El HUD/notificacions queden fora de la
// multiplicació perquè es pinten després.
void applyLightingPass();
// Configuració, estat i transició de la sub-FSM de game over // Configuració, estat i transició de la sub-FSM de game over
// (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la // (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la
// RoomFsm via `GameOverFsm::loadConfig`. // RoomFsm via `GameOverFsm::loadConfig`.
@@ -372,6 +344,33 @@ class GameScene : public Scene {
void drawRoomTextPanel(int center_y, int panel_height) const; 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; 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 // Posició inicial dels jugadors derivada de `Map::door_col`. Tots
// els jugadors comparteixen Y (centre del corredor exterior superior); // els jugadors comparteixen Y (centre del corredor exterior superior);
// X depèn del nombre actiu: amb un jugador queda centrat sota la // X depèn del nombre actiu: amb un jugador queda centrat sota la
@@ -418,11 +417,10 @@ class GameScene : public Scene {
#endif #endif
#ifdef _DEBUG #ifdef _DEBUG
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la // Editor modal del tilemap. Mútuament excloent amb el menú de servei:
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i // si el menú està obert, la tecla de toggle de l'editor s'ignora.
// viceversa. Quan `editor_.active()` és true, saltem entitats i // Quan `editor_.active()` és true, saltem entitats i jugador (update
// jugador (update i render) i deixem el tilemap visible perquè es // i render) i deixem el tilemap visible perquè es pugui editar.
// pugui editar.
Editor editor_; Editor editor_;
#endif #endif
}; };
+90 -22
View File
@@ -40,6 +40,25 @@ namespace {
// Separació extra entre camps dins d'una mateixa línia. // Separació extra entre camps dins d'una mateixa línia.
constexpr int FIELD_GAP = 6; // px entre "Vides 3" i "Tres. 00" 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ó. // Format zero-padded a 6 dígits per a la puntuació.
auto formatScore(int v) -> std::string { auto formatScore(int v) -> std::string {
const int CLAMPED = std::clamp(v, 0, 999999); 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].active = false;
players_[1].lives = initial_lives_; players_[1].lives = initial_lives_;
// Subscripcions al bus de l'EntityManager. De moment només `player_hit` // Subscripcions al bus de l'EntityManager.
// afecta el HUD visualment: decrementa vides, dispara flash i shake al //
// bloc del jugador. Quan els events evolucionin per portar `player_id` // `player_hit` només fa feedback visual (flash + shake) al bloc del
// i puntuacions, ampliarem aquí sense tocar callers. // jugador colpejat. **No** toca el comptador de vides — la veritat
// viu al Player i arriba per `player_stats_changed`. Així si un hit
// no és mortal (HURT recoverable) el flash es manté coherent sense
// mentir el comptador, i si és mortal el comptador baixa pel canal
// numèric quan el cicle DYING acaba a `respawnFromDying`.
subs_.push_back(bus_.subscribe("player_hit", [this](const Event& ev) { subs_.push_back(bus_.subscribe("player_hit", [this](const Event& ev) {
const auto* dmg = std::get_if<int>(&ev.payload); const auto* dmg = std::get_if<int>(&ev.payload);
// Dispatch pel `player_id` de l'event. 0 = P1 (bloc LEFT), 1 = P2 // Dispatch pel `player_id` de l'event. 0 = P1 (bloc LEFT), 1 = P2
@@ -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. // publishers de `player_hit` actuals sí que porten id.
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U; const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
if (!players_[IDX].active) { return; } if (!players_[IDX].active) { return; }
if (players_[IDX].lives > 0) { --players_[IDX].lives; }
const std::size_t BLK_IDX = (IDX == 0) ? static_cast<std::size_t>(BlockId::LEFT) const std::size_t BLK_IDX = (IDX == 0) ? static_cast<std::size_t>(BlockId::LEFT)
: static_cast<std::size_t>(BlockId::RIGHT); : static_cast<std::size_t>(BlockId::RIGHT);
auto& blk = blocks_[BLK_IDX]; auto& blk = blocks_[BLK_IDX];
blk.flash_ms_left = UiConfig::get().hudFlashMs(); blk.flash_ms_left = UiConfig::get().hudFlashMs();
blk.shake_ms_left = UiConfig::get().hudShakeMs(); blk.shake_ms_left = UiConfig::get().hudShakeMs();
blk.shake_intensity = 1.5F; blk.shake_intensity = 1.5F;
Log::info("HUD", "player_hit p{} dmg={} lives={}", IDX + 1, dmg != nullptr ? *dmg : 0, players_[IDX].lives); Log::info("HUD", "player_hit p{} dmg={}", IDX + 1, dmg != nullptr ? *dmg : 0);
}));
// `player_stats_changed`: snapshot total (vides/score/tresors) emès
// pel Player cada vegada que muta qualsevol dels tres. El HUD el
// consumeix com a estat absolut: sobreescriu la caché local sense
// raonament addicional. Això unifica totes les fonts de canvi (mort
// per qualsevol causa, recompensa de sarc, futurs pickups…) en un
// únic canal.
subs_.push_back(bus_.subscribe("player_stats_changed", [this](const Event& ev) {
const auto* st = std::get_if<PlayerStatsPayload>(&ev.payload);
if (st == nullptr) { return; }
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
players_[IDX].lives = st->lives;
players_[IDX].score = st->score;
players_[IDX].treasures = st->treasures;
})); }));
subs_.push_back(bus_.subscribe("level_complete", [](const Event& /*ev*/) { subs_.push_back(bus_.subscribe("level_complete", [](const Event& /*ev*/) {
@@ -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.shake_ms_left > 0) { b.shake_ms_left = std::max(0, b.shake_ms_left - delta_ms); }
if (b.flash_ms_left > 0) { b.flash_ms_left = std::max(0, b.flash_ms_left - delta_ms); } if (b.flash_ms_left > 0) { b.flash_ms_left = std::max(0, b.flash_ms_left - delta_ms); }
} }
const int CYCLE = UiConfig::get().hudBlinkCycleMs();
if (CYCLE > 0) {
blink_elapsed_ms_ = (blink_elapsed_ms_ + delta_ms) % CYCLE;
}
} }
void Hud::setMode(BlockId block, Mode mode) { void Hud::setMode(BlockId block, Mode mode) {
@@ -184,10 +225,11 @@ void Hud::activatePlayer(int player_one_based) {
if (player_one_based < 1 || player_one_based > 2) { return; } if (player_one_based < 1 || player_one_based > 2) { return; }
auto& p = players_[static_cast<std::size_t>(player_one_based - 1)]; auto& p = players_[static_cast<std::size_t>(player_one_based - 1)];
p.active = true; p.active = true;
p.lives = initial_lives_;
p.score = 0;
p.treasures = 0;
p.join_prompt = JoinPromptMode::NONE; p.join_prompt = JoinPromptMode::NONE;
// Els camps numèrics (lives/score/treasures) ja els ha sobreescrit el
// `player_stats_changed` que el Player publica dins de `resetForJoin`,
// així que aquí no els toquem — evitem trepitjar la veritat del bus
// amb defaults locals.
} }
void Hud::setJoinPrompt(int player_one_based, JoinPromptMode mode) { void Hud::setJoinPrompt(int player_one_based, JoinPromptMode mode) {
@@ -219,6 +261,16 @@ auto Hud::computeOffset(const Block& b) -> SDL_FPoint {
return off; return off;
} }
auto Hud::blinkVisible() const -> bool {
const auto& ui = UiConfig::get();
const int CYCLE = ui.hudBlinkCycleMs();
const float RATIO = ui.hudBlinkVisibleRatio();
if (CYCLE <= 0 || RATIO >= 1.0F) { return true; }
if (RATIO <= 0.0F) { return false; }
const int VISIBLE_MS = static_cast<int>(static_cast<float>(CYCLE) * RATIO);
return blink_elapsed_ms_ < VISIBLE_MS;
}
void Hud::render() const { void Hud::render() const {
// Backdrop de la franja del HUD. Es pinta abans dels tres blocs perquè, // Backdrop de la franja del HUD. Es pinta abans dels tres blocs perquè,
// quan un bloc es mou (entry slide o shake), la zona que deixa al // quan un bloc es mou (entry slide o shake), la zona que deixa al
@@ -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 { 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)]; 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 // Slot inactiu amb prompt actiu: substituïm el render de zeros pel
// missatge "PULSA START / PARA JUGAR" o "ESPERA / PARA JUGAR" segons // missatge "PULSA START / PARA JUGAR" o "ESPERA / PARA JUGAR" segons
// el mode fixat per la GameScene cada frame. El render de zeros // el mode fixat per la GameScene cada frame. El render de zeros
// queda com a fallback (mode NONE o slot actiu). // queda com a fallback (mode NONE o slot actiu).
if (!p.active && p.join_prompt != JoinPromptMode::NONE) { if (!p.active && p.join_prompt != JoinPromptMode::NONE) {
if (!blinkVisible()) { return; }
const bool CAN_JOIN = (p.join_prompt == JoinPromptMode::CAN_JOIN); const bool CAN_JOIN = (p.join_prompt == JoinPromptMode::CAN_JOIN);
const std::string L1 = Locale::get().text( const std::string L1 = Locale::get().text(
CAN_JOIN ? "hud.join_prompt_can_join_l1" : "hud.join_prompt_wait_l1"); CAN_JOIN ? "hud.join_prompt_can_join_l1" : "hud.join_prompt_wait_l1");
@@ -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 L2_W = font_->length(L2);
const int X1 = x + ((w - L1_W) / 2); const int X1 = x + ((w - L1_W) / 2);
const int X2 = x + ((w - L2_W) / 2); const int X2 = x + ((w - L2_W) / 2);
font_->writeColored(X1, y + LINE1_Y, L1, UiConfig::get().hudLabel()); drawShadowed(*font_, X1, y + LINE1_Y, L1, LBL_C, SHD_C);
font_->writeColored(X2, y + LINE2_Y, L2, UiConfig::get().hudValue()); drawShadowed(*font_, X2, y + LINE2_Y, L2, VAL_C, SHD_C);
return; 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 --- // --- Línia 1: "<label>-000000" centrada ---
const std::string LBL1 = label + SEPARATOR; 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 SC_W = font_->lengthMonoDigits(SCORE, MONO_DIGIT_W);
const int LINE1_W = LBL_W + SC_W; const int LINE1_W = LBL_W + SC_W;
int xx = x + ((w - LINE1_W) / 2); 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; 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 --- // --- Línia 2: "<vides>-<n> <tresors>-<n>" centrada ---
const std::string V_LBL = Locale::get().text("hud.lives") + SEPARATOR; 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 T_VW = font_->lengthMonoDigits(T_VAL, MONO_DIGIT_W);
const int LINE2_W = V_LW + V_VW + FIELD_GAP + T_LW + T_VW; const int LINE2_W = V_LW + V_VW + FIELD_GAP + T_LW + T_VW;
xx = x + ((w - LINE2_W) / 2); 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; 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; 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; 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 { 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 --- // --- Línia 1: "<piramide>-<n>" centrada ---
const std::string PIR_LBL = Locale::get().text("hud.pyramid") + SEPARATOR; const std::string PIR_LBL = Locale::get().text("hud.pyramid") + SEPARATOR;
const std::string PIR_VAL = std::to_string(pyramid_); 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 PV_W = font_->lengthMonoDigits(PIR_VAL, MONO_DIGIT_W);
const int LINE1_W = PL_W + PV_W; const int LINE1_W = PL_W + PV_W;
int xx = x + ((w - LINE1_W) / 2); 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; 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 --- // --- Línia 2: "<sala>-<r>" centrada ---
const std::string SALA_LBL = Locale::get().text("hud.room") + SEPARATOR; 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 SV_W = font_->lengthMonoDigits(SALA_VAL, MONO_DIGIT_W);
const int LINE2_W = SL_W + SV_W; const int LINE2_W = SL_W + SV_W;
xx = x + ((w - LINE2_W) / 2); 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; 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);
} }
+8
View File
@@ -170,6 +170,10 @@ class Hud {
// les constants de classe (no toca cap altre membre). // les constants de classe (no toca cap altre membre).
[[nodiscard]] static auto computeOffset(const Block& b) -> SDL_FPoint; [[nodiscard]] static auto computeOffset(const Block& b) -> SDL_FPoint;
// Estat actual del parpadeig dels prompts inactius. Llegit per
// renderIdleSide() per saltar el draw quan toca ocult.
[[nodiscard]] auto blinkVisible() const -> bool;
EventBus& bus_; EventBus& bus_;
std::vector<EventBus::SubId> subs_; std::vector<EventBus::SubId> subs_;
@@ -179,6 +183,10 @@ class Hud {
std::array<PlayerStats, 2> players_; std::array<PlayerStats, 2> players_;
int pyramid_{1}; int pyramid_{1};
int room_{1}; int room_{1};
// Acumulador continu (ms) per al parpadeig dels prompts inactius.
// Es manté entre canvis de mode (CAN_JOIN ↔ WAIT) per evitar salts
// visuals i sincronitza els dos slots — vegeu Hud::blinkVisible().
int blink_elapsed_ms_{0};
// Vides amb què s'arrenca un slot (`activatePlayer`). Cachejat al // Vides amb què s'arrenca un slot (`activatePlayer`). Cachejat al
// ctor des de `Player::death_config.initial_lives` per no llegir el // ctor des de `Player::death_config.initial_lives` per no llegir el
// singleton estàtic en el camí calent del bus de events. // singleton estàtic en el camí calent del bus de events.
+64 -22
View File
@@ -106,6 +106,44 @@ namespace Defaults {
constexpr float PUSH_FACTOR = 0.30F; constexpr float PUSH_FACTOR = 0.30F;
} // namespace Player } // namespace Player
// Fallbacks del sistema d'il·luminació. El gradient en viu viu a
// data/config/lighting.yaml; aquests valors només s'apliquen si el
// fitxer falta o li manca alguna clau. La forma final (3 anells) es
// descriu al YAML; veure `Lighting::GradientConfig` per a la semàntica.
namespace Lighting {
// Defaults del gradient subtractive (mode històric). Les
// intensitats són multiplicadors 0..1 que es multipliquen sobre
// el píxel: 1.0 = no toca, 0.0 = negre.
constexpr float INNER_RATIO = 0.8F;
constexpr float MID_RATIO = 0.9F;
constexpr float INNER_INTENSITY = 1.0F;
constexpr float MID_INTENSITY = 0.9F;
constexpr float OUTER_INTENSITY = 0.8F;
// Defaults del gradient additive. Les intensitats són "alfa"
// del color afegit (0..1): 1.0 = afegeix el color sencer i
// satura, 0.0 = no afegeix res. Valors baixos a propòsit perquè
// l'additive es nota fort molt fàcilment — només un toc de tint
// cromàtic al voltant de l'emissor.
constexpr float INNER_RATIO_ADDITIVE = 0.5F;
constexpr float MID_RATIO_ADDITIVE = 0.85F;
constexpr float INNER_INTENSITY_ADDITIVE = 0.15F;
constexpr float MID_INTENSITY_ADDITIVE = 0.10F;
constexpr float OUTER_INTENSITY_ADDITIVE = 0.05F;
// Nivell d'il·luminació de sala per defecte. Una sala sense la
// clau `light_level` queda totalment il·luminada (com hui).
constexpr float ROOM_LIGHT_DEFAULT = 1.0F;
// Defaults del flicker. Els reemplaça el bloc `flicker:` de
// data/config/lighting.yaml. Veure `Lighting::FlickerConfig`
// per a la semàntica de cada camp. Les amplituds són **ràtios**
// sobre el radi base de l'emissor: així el feel del flicker és
// independent del radi. Els valors per defecte conserven el feel
// anterior per a una torxa de radi 40 (2/40 = 0.05, 1/40 = 0.025).
constexpr float FLICKER_WAVE_AMPLITUDE_RATIO = 0.05F;
constexpr float FLICKER_WAVE_FREQUENCY_HZ = 3.0F;
constexpr float FLICKER_NOISE_AMPLITUDE_RATIO = 0.025F;
constexpr float FLICKER_NOISE_FREQUENCY_HZ = 7.0F;
} // namespace Lighting
// Fallbacks del tema d'UI: índexs de paleta, timings (ms) i fonts. // Fallbacks del tema d'UI: índexs de paleta, timings (ms) i fonts.
// El tema en viu és a data/config/ui.yaml, carregat per UiConfig a // El tema en viu és a data/config/ui.yaml, carregat per UiConfig a
// l'arrencada; aquests valors només s'apliquen si el fitxer falta o // l'arrencada; aquests valors només s'apliquen si el fitxer falta o
@@ -217,20 +255,28 @@ namespace Defaults {
constexpr Uint8 MENU_DISABLED = 16; // Label en gris quan no aplica. constexpr Uint8 MENU_DISABLED = 16; // Label en gris quan no aplica.
// Índexs de paleta per al marcador de joc (HUD de la GameScene). // Índexs de paleta per al marcador de joc (HUD de la GameScene).
// Arranca reciclant els colors del menú per coherència visual; // Tres "famílies" de color — un parell (label, value) + sombra per
// separant els noms a constants pròpies, tocar-los a posteriori // cada bloc — donen jerarquia visual: P1 càlid (vermell+crema),
// no afecta el menú. // CENTER ambiental (ambre+or), P2 fred (cian+blau pàl·lid). La
constexpr Uint8 HUD_BG = 0; // Fons de cada bloc. // sombra és un to més fosc del mateix matís per donar volum.
constexpr Uint8 HUD_LABEL = 58; // Etiquetes ("P1", "V", "T"). constexpr Uint8 HUD_BG = 0; // Fons de cada bloc.
constexpr Uint8 HUD_VALUE = 187; // Valors (score, vides, tresors). constexpr Uint8 HUD_P1_LABEL = 7; // P1 label: vermell pur (#ff0000).
constexpr Uint8 HUD_FLASH = 237; // Color temporal del valor en flash. 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). // Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO).
// Independent dels colors de notificació perquè el rètol de // Comparteix la família CENTER del HUD: or sobre marró cremat,
// fase és un canal visual diferent. `ROOM_TEXT_OUTLINE` és el // per coherència visual ("Piràmide N / Sala N" és sempre
// contorn que abans estava hardcodejat al `renderRoomText`. // daurat arreu del joc).
constexpr Uint8 ROOM_TEXT = 232; constexpr Uint8 ROOM_TEXT = 55; // Or brillant (= HUD_CENTER_VALUE).
constexpr Uint8 ROOM_TEXT_OUTLINE = 255; constexpr Uint8 ROOM_TEXT_OUTLINE = 10; // Marró cremat (= HUD_CENTER_SHADOW).
// Timings d'animació del HUD. L'entrada són els tres blocs lliscant // Timings d'animació del HUD. L'entrada són els tres blocs lliscant
// a la seua posició; flash i shake acompanyen un `player_hit`. // 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_FLASH_MS = 250;
constexpr int HUD_SHAKE_MS = 300; constexpr int HUD_SHAKE_MS = 300;
// Pausa graduada de la GameScene: el món no para d'un colp, es // Parpadeig dels prompts inactius del HUD ("Polsa start..." /
// frena amb una corba i torna a arrancar amb una altra. Els noms // "Espera..."). Un únic temporitzador global al Hud sincronitza
// de corba han de coincidir amb els registrats a // P1 i P2. cycle<=0 o ratio>=1 → sempre visible (disable trivial).
// UiConfig::curveByName (linear / outQuad / inQuad / inOutQuad / constexpr int HUD_BLINK_CYCLE_MS = 1000;
// outCubic / inCubic). Si un nom no es reconeix, es cau a constexpr float HUD_BLINK_VISIBLE_RATIO = 0.8F;
// Easing::linear.
constexpr int PAUSE_ENTER_MS = 400;
constexpr int PAUSE_EXIT_MS = 250;
constexpr const char* PAUSE_ENTER_CURVE = "outCubic";
constexpr const char* PAUSE_EXIT_CURVE = "inCubic";
// Sons del menú de servei. SELECT acompanya navegació (amunt/avall) // Sons del menú de servei. SELECT acompanya navegació (amunt/avall)
// i back; ACCEPT acompanya modificació d'opció (esq/dret/retorn). // i back; ACCEPT acompanya modificació d'opció (esq/dret/retorn).
@@ -495,6 +536,7 @@ namespace Defaults {
constexpr const char* SOUNDS_YAML = "data/config/sounds.yaml"; constexpr const char* SOUNDS_YAML = "data/config/sounds.yaml";
constexpr const char* INPUT_YAML = "data/config/input.yaml"; constexpr const char* INPUT_YAML = "data/config/input.yaml";
constexpr const char* EDITOR_YAML = "data/config/editor.yaml"; constexpr const char* EDITOR_YAML = "data/config/editor.yaml";
constexpr const char* LIGHTING_YAML = "data/config/lighting.yaml";
constexpr const char* UI_PALETTE = "data/palettes/uzebox.pal"; constexpr const char* UI_PALETTE = "data/palettes/uzebox.pal";
// Configuració d'escenes (yaml). El de la GameScene viu a // Configuració d'escenes (yaml). El de la GameScene viu a
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts { namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner"; constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.42.0"; // Single source of truth — Makefile el grep constexpr const char* VERSION = "0.45.0"; // Single source of truth — Makefile el grep
} // namespace Texts } // namespace Texts
namespace GameCanvas { namespace GameCanvas {
+3
View File
@@ -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*/) {
} }
void Audio::playSound(const std::string& /*name*/, Audio::Group /*group*/, float /*speed*/) {
}
namespace Resource { namespace Resource {
auto Cache::get() -> Cache& { auto Cache::get() -> Cache& {
+146 -44
View File
@@ -14,7 +14,8 @@
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions. // T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
// T8 decoration — Torch no col·lisiona ni és interactable. // T8 decoration — Torch no col·lisiona ni és interactable.
// T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame. // T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame.
// T11 sarcofag — tile_size [3,1] llegit; perímetre 8; falsa perspectiva: // T11 sarcofag — tile_size [3,1] llegit; perímetre 12 (incloent les
// quatre cantonades diagonals); falsa perspectiva:
// el sprite.height no canvia la caixa lògica 48×16. // el sprite.height no canvia la caixa lògica 48×16.
// T12 collision — `Entity::CollisionBox` encastada al sprite: la caixa // T12 collision — `Entity::CollisionBox` encastada al sprite: la caixa
// retornada coincideix amb les dimensions del bloc // retornada coincideix amb les dimensions del bloc
@@ -69,13 +70,14 @@ namespace {
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__) #define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra). // 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 // amb l'animació `raise` activa). Tickem amb dt gran fins sortir de
// el comptador i consumeix la durada amb un update. Tests que operin // SPAWNING — mateix path que `Mummy::update` executa al final de
// directament sobre HURT/ROAMING l'han de cridar abans. // l'animació en partida real. El for limita el bucle defensivament.
void exitMummySpawn(Mummy& mummy) { void exitMummySpawn(Mummy& mummy) {
mummy.triggerSpawnExit(); for (int i = 0; i < 32 && mummy.isSpawning(); ++i) {
mummy.update(mummy.getSpawnDurationS() + 0.001F); mummy.update(0.5F);
}
} }
void testFactory() { void testFactory() {
@@ -85,6 +87,7 @@ namespace {
const std::vector<std::string> TYPES = { const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy", "torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap", "hole", "rolling_rock", "wall_trap", "floor_trap", "hole",
"stone", "column",
}; };
for (const auto& type : TYPES) { for (const auto& type : TYPES) {
fkyaml::node tmpl = EntityLoader::loadTemplate(type); fkyaml::node tmpl = EntityLoader::loadTemplate(type);
@@ -142,32 +145,36 @@ namespace {
fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus"); fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus");
auto sarc = std::make_unique<Sarcophagus>(tmpl); auto sarc = std::make_unique<Sarcophagus>(tmpl);
// Ancorat a (col=1, row=1) amb tile_size=[3,1] → rectangle de tiles // Ancorat a (col=1, row=1) amb tile_size=[3,1] → rectangle de tiles
// cobreix (1,1), (2,1), (3,1). Perímetre ortogonal esperat (sense // cobreix (1,1), (2,1), (3,1). Perímetre esperat (sense
// BlockedPredicate): // BlockedPredicate), incloent les quatre cantonades diagonals:
// Nord: (1,0), (2,0), (3,0) // Nord (amb NO i NE): (0,0), (1,0), (2,0), (3,0), (4,0)
// Sud: (1,2), (2,2), (3,2) // Sud (amb SO i SE): (0,2), (1,2), (2,2), (3,2), (4,2)
// Oest: (0,1) // Oest: (0,1)
// Est: (4,1) // Est: (4,1)
// Total: 8 tiles. // Total: 12 tiles.
sarc->place(1, 1, {}); sarc->place(1, 1, {});
REQUIRE(sarc->getPerimeterSize() == 8); REQUIRE(sarc->getPerimeterSize() == 12);
REQUIRE(sarc->getState() == Sarcophagus::State::CLOSED); REQUIRE(sarc->getState() == Sarcophagus::State::CLOSED);
// Visitem un tile que NO és al perímetre: res canvia. // Visitem un tile que NO és al perímetre: res canvia.
sarc->notifyTilePainted(5, 5); sarc->notifyTilePainted(5, 5, /*player_id=*/0);
REQUIRE(sarc->getVisitedCount() == 0); REQUIRE(sarc->getVisitedCount() == 0);
// Visitem tots els del perímetre. // Visitem tots els del perímetre.
const std::vector<std::pair<int, int>> PERIM = { const std::vector<std::pair<int, int>> PERIM = {
{1, 0}, {2, 0}, {3, 0}, {1, 2}, {2, 2}, {3, 2}, {0, 1}, {4, 1}, {0, 0}, {1, 0}, {2, 0}, {3, 0}, {4, 0},
{0, 2}, {1, 2}, {2, 2}, {3, 2}, {4, 2},
{0, 1}, {4, 1},
}; };
for (const auto& [c, r] : PERIM) { sarc->notifyTilePainted(c, r); } for (const auto& [c, r] : PERIM) { sarc->notifyTilePainted(c, r, /*player_id=*/0); }
REQUIRE(sarc->getVisitedCount() == 8); REQUIRE(sarc->getVisitedCount() == 12);
REQUIRE(sarc->getState() == Sarcophagus::State::OPENING); REQUIRE(sarc->getState() == Sarcophagus::State::OPENING);
// Un update amb l'animació `opening` d'1 frame loop=false completa // Tickem prou per consumir l'animació `opening` (4 frames @ 6fps =
// la transició a OPEN. // ~0.67s). Quan completa, la transició OPENING → OPEN es dispara.
sarc->update(0.0F); for (int i = 0; i < 16 && sarc->getState() == Sarcophagus::State::OPENING; ++i) {
sarc->update(0.1F);
}
REQUIRE(sarc->getState() == Sarcophagus::State::OPEN); REQUIRE(sarc->getState() == Sarcophagus::State::OPEN);
} }
@@ -189,30 +196,33 @@ namespace {
manager.add(std::move(door)); manager.add(std::move(door));
manager.setDoor(door_ptr); manager.setDoor(door_ptr);
// Forcem l'obertura dels dos sarcs visitant els seus perímetres. // Forcem l'obertura dels dos sarcs visitant els seus perímetres
// (incloent les cantonades diagonals NO/NE/SO/SE).
auto forceOpen = [](Sarcophagus* s) { auto forceOpen = [](Sarcophagus* s) {
const int COL0 = s->getTilePos().col; const int COL0 = s->getTilePos().col;
const int ROW0 = s->getTilePos().row; const int ROW0 = s->getTilePos().row;
const int W = s->getTileWidth(); const int W = s->getTileWidth();
const int H = s->getTileHeight(); const int H = s->getTileHeight();
for (int dc = 0; dc < W; ++dc) { for (int dc = -1; dc <= W; ++dc) {
s->notifyTilePainted(COL0 + dc, ROW0 - 1); s->notifyTilePainted(COL0 + dc, ROW0 - 1, /*player_id=*/0);
s->notifyTilePainted(COL0 + dc, ROW0 + H); s->notifyTilePainted(COL0 + dc, ROW0 + H, /*player_id=*/0);
} }
for (int dr = 0; dr < H; ++dr) { for (int dr = 0; dr < H; ++dr) {
s->notifyTilePainted(COL0 - 1, ROW0 + dr); s->notifyTilePainted(COL0 - 1, ROW0 + dr, /*player_id=*/0);
s->notifyTilePainted(COL0 + W, ROW0 + dr); s->notifyTilePainted(COL0 + W, ROW0 + dr, /*player_id=*/0);
} }
}; };
forceOpen(s1); forceOpen(s1);
forceOpen(s2); forceOpen(s2);
// Un primer tick: cada sarc transiciona OPENING → OPEN i publica; // Tickem prou per consumir l'animació `opening` dels sarcs (4
// l'handler del manager compta i, en arribar al total, crida // frames @ 6fps) i la de la porta. Cada sarc transiciona
// door->open() — que en aquest mateix tick (la iteració sobre // OPENING → OPEN i publica; l'handler del manager compta i, en
// entities continua) farà transitar la porta OPENING → OPEN perquè // arribar al total, crida `door->open()`, que també necessita
// l'animació és d'1 frame loop=false. // tickar fins acabar la seua animació d'obertura.
manager.update(0.0F); for (int i = 0; i < 20 && door_ptr->getState() != Door::State::OPEN; ++i) {
manager.update(0.1F);
}
REQUIRE(manager.openedSarcophagusCount() == 2); REQUIRE(manager.openedSarcophagusCount() == 2);
REQUIRE(door_ptr->getState() == Door::State::OPEN); REQUIRE(door_ptr->getState() == Door::State::OPEN);
@@ -232,11 +242,12 @@ namespace {
void testAnimationComponent() { void testAnimationComponent() {
std::cout << "[T6] animation component\n"; std::cout << "[T6] animation component\n";
AnimationComponent anim({10, 20, 30}, 10.0F, true); // 100 ms per frame: a tick(0.15) avança 1 frame; tick(0.10) un altre.
AnimationComponent anim = AnimationComponent::uniform({10, 20, 30}, 100.0F, true);
REQUIRE(anim.frameCount() == 3); REQUIRE(anim.frameCount() == 3);
REQUIRE(anim.currentFrame() == 10); REQUIRE(anim.currentFrame() == 10);
anim.tick(0.15F); // 1.5 frames de 10 fps → hauria d'avançar a l'índex 1 anim.tick(0.15F); // 1.5 frames de 100 ms → hauria d'avançar a l'índex 1
REQUIRE(anim.currentFrame() == 20); REQUIRE(anim.currentFrame() == 20);
anim.tick(0.10F); anim.tick(0.10F);
@@ -247,21 +258,107 @@ namespace {
REQUIRE(anim.currentFrame() == 10); REQUIRE(anim.currentFrame() == 10);
// Variant no-loop: es para al darrer i queda completed. // Variant no-loop: es para al darrer i queda completed.
AnimationComponent once({1, 2}, 10.0F, false); AnimationComponent once = AnimationComponent::uniform({1, 2}, 100.0F, false);
once.tick(1.0F); // molts frames; ha d'arribar al final i quedar-se. once.tick(1.0F); // molts frames; ha d'arribar al final i quedar-se.
REQUIRE(once.currentFrame() == 2); REQUIRE(once.currentFrame() == 2);
REQUIRE(once.completed()); REQUIRE(once.completed());
// Variant d'1 frame no-loop: completed des del principi. // Variant d'1 frame no-loop: completed des del principi.
AnimationComponent single({5}, 1.0F, false); AnimationComponent single = AnimationComponent::uniform({5}, 1000.0F, false);
REQUIRE(single.completed()); REQUIRE(single.completed());
} }
// Cobertura específica de `AnimationComponent::fromYaml`: format
// `frame_ms` escalar i llista per-frame, gestió de `fps:` (rebutjat),
// i defaults quan la clau no existeix.
void testAnimationFromYaml() {
std::cout << "[T6b] animation fromYaml\n";
// 1) Format escalar `frame_ms`: tots els frames duren el mateix.
{
const std::string YAML = R"(
animations:
walk: { frames: [0, 1, 2, 3], frame_ms: 100, loop: true }
)";
auto node = fkyaml::node::deserialize(YAML);
auto anim = AnimationComponent::fromYaml(node, "walk");
REQUIRE(anim.frameCount() == 4);
REQUIRE(anim.currentFrame() == 0);
anim.tick(0.10F);
REQUIRE(anim.currentFrame() == 1);
}
// 2) Format llista `frame_ms`: el darrer frame aguanta més.
{
const std::string YAML = R"(
animations:
raise:
frames: [10, 11, 12]
frame_ms: [100, 100, 500]
loop: false
)";
auto node = fkyaml::node::deserialize(YAML);
auto anim = AnimationComponent::fromYaml(node, "raise");
REQUIRE(anim.frameCount() == 3);
anim.tick(0.10F);
REQUIRE(anim.currentFrame() == 11);
anim.tick(0.10F);
REQUIRE(anim.currentFrame() == 12);
// El darrer frame dura 500 ms: tick de 400 ms no completa l'anim.
anim.tick(0.40F);
REQUIRE(!anim.completed());
// 100 ms més (total 500 ms al frame 12) sí la completa.
anim.tick(0.10F);
REQUIRE(anim.completed());
}
// 3) Clau no existent: defaults segurs (1 frame loop, no peta).
{
const std::string YAML = R"(
animations:
walk: { frames: [0], frame_ms: 100, loop: true }
)";
auto node = fkyaml::node::deserialize(YAML);
auto anim = AnimationComponent::fromYaml(node, "missing");
REQUIRE(anim.frameCount() == 1);
REQUIRE(anim.loops());
}
// 4) `fps:` no és suportat: ha de llençar excepció.
{
const std::string YAML = R"(
animations:
walk: { frames: [0, 1], fps: 8, loop: true }
)";
auto node = fkyaml::node::deserialize(YAML);
bool threw = false;
try {
(void) AnimationComponent::fromYaml(node, "walk");
} catch (const std::runtime_error&) {
threw = true;
}
REQUIRE(threw);
}
// 5) Flip_h al YAML es preserva al component.
{
const std::string YAML = R"(
animations:
west: { frames: [0, 1], frame_ms: 100, loop: true, flip_h: true }
)";
auto node = fkyaml::node::deserialize(YAML);
auto anim = AnimationComponent::fromYaml(node, "west");
REQUIRE(anim.flipH());
REQUIRE(anim.flipMode() == SDL_FLIP_HORIZONTAL);
}
}
void testAllTemplatesLoad() { void testAllTemplatesLoad() {
std::cout << "[T7] plantilles YAML\n"; std::cout << "[T7] plantilles YAML\n";
const std::vector<std::string> TYPES = { const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy", "torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap", "hole", "rolling_rock", "wall_trap", "floor_trap", "hole",
"stone", "column",
}; };
for (const auto& type : TYPES) { for (const auto& type : TYPES) {
try { try {
@@ -331,11 +428,14 @@ namespace {
auto sarc = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus")); auto sarc = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
sarc->place(3, 4, {}); sarc->place(3, 4, {});
Sarcophagus* sarc_raw = manager.addSarcophagus(std::move(sarc)); Sarcophagus* sarc_raw = manager.addSarcophagus(std::move(sarc));
REQUIRE(sarc_raw->getPerimeterSize() == 8); REQUIRE(sarc_raw->getPerimeterSize() == 12);
// Les 8 tiles del perímetre: N (3..5,3), S (3..5,5), O (2,4), E (6,4). // Les 12 tiles del perímetre, incloent les cantonades diagonals:
// N (amb NO/NE): (2..6, 3)
// S (amb SO/SE): (2..6, 5)
// O: (2,4) E: (6,4)
const std::pair<int, int> PERIM[] = { const std::pair<int, int> PERIM[] = {
{3, 3}, {4, 3}, {5, 3}, {2, 3}, {3, 3}, {4, 3}, {5, 3}, {6, 3},
{3, 5}, {4, 5}, {5, 5}, {2, 5}, {3, 5}, {4, 5}, {5, 5}, {6, 5},
{2, 4}, {6, 4}, {2, 4}, {6, 4},
}; };
for (const auto& [c, r] : PERIM) { for (const auto& [c, r] : PERIM) {
@@ -356,12 +456,13 @@ namespace {
REQUIRE(tmpl["sprite"]["size"][0].get_value<int>() == 48); REQUIRE(tmpl["sprite"]["size"][0].get_value<int>() == 48);
REQUIRE(tmpl["sprite"]["size"][1].get_value<int>() == 32); REQUIRE(tmpl["sprite"]["size"][1].get_value<int>() == 32);
// Amb tile_size [3,1], un sarc a ancoratge (3,4) conserva els 8 // Amb tile_size [3,1], un sarc a ancoratge (3,4) té 12 tiles de
// tiles de perímetre (no es converteix en 10 per culpa del [3,2] // perímetre: 5 al nord (amb NO/NE), 5 al sud (amb SO/SE), 1 a
// antic). Això blinda la geometria contra regressions al YAML. // l'oest i 1 a l'est. Això blinda la geometria contra regressions
// al YAML (p. ex. un retorn accidental al tile_size [3,2] antic).
auto sarc = std::make_unique<Sarcophagus>(tmpl); auto sarc = std::make_unique<Sarcophagus>(tmpl);
sarc->place(3, 4, {}); sarc->place(3, 4, {});
REQUIRE(sarc->getPerimeterSize() == 8); REQUIRE(sarc->getPerimeterSize() == 12);
} }
void testCollisionBox() { void testCollisionBox() {
@@ -682,6 +783,7 @@ auto main() -> int {
testSarcophagusPerimeter(); testSarcophagusPerimeter();
testEntityManagerEndToEnd(); testEntityManagerEndToEnd();
testAnimationComponent(); testAnimationComponent();
testAnimationFromYaml();
testAllTemplatesLoad(); testAllTemplatesLoad();
testDecorationFlags(); testDecorationFlags();
testLoadFromLevel(); testLoadFromLevel();
+8 -5
View File
@@ -32,12 +32,15 @@ namespace {
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__) #define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra). Els // Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra
// tests del whip necessiten que la mòmia estigui ACTIVE per reaccionar // amb l'animació `raise` activa). Els tests del whip necessiten que la
// al fueteig — sense això, `enterHurt` torna early per `isSpawning()`. // 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) { void exitMummySpawn(Mummy& mummy) {
mummy.triggerSpawnExit(); for (int i = 0; i < 32 && mummy.isSpawning(); ++i) {
mummy.update(mummy.getSpawnDurationS() + 0.001F); mummy.update(0.5F);
}
} }
// Caja de jugador "estàndard" AEE: 16×32 amb cantonada (x, y). // Caja de jugador "estàndard" AEE: 16×32 amb cantonada (x, y).