Compare commits

...
39 Commits
Author SHA1 Message Date
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
JailDesigner fa27308897 chore(version): bump 0.41.0 -> 0.42.0 2026-05-11 13:34:01 +02:00
JailDesigner e58eb10d6d Merge branch 'feat/single-bucket-polymorphism'
Refactor del sistema d'entitats per substituir filtres i
dynamic_casts dispersos per dispatch polimòrfic.

Quatre virtuals nous a Entity (`onWhipHit`, `onJustSpawned`,
`onRemoved`, `shouldReceiveContact`) encapsulen dins de cada
subclasse les decisions que abans vivien al manager:
- WhipAttack::applyDamage ja no fa `dynamic_cast<Mummy*>`.
- spawnPendingActors/spawnActor ja no fan `dynamic_cast<DynamicHazard*>`.
- removeEntity ja no compara `getName() == "sarcophagus"`.
- checkPlayerCollisions filtra amb un sol predicat virtual.

Amb el polimorfisme madurat, el sac dual `map_entities_`/`actors_`
introduit a la rama anterior ja no aporta valor: tornem a un sol
`entities_` (manté `pending_actors_` per al spawn diferit). El
manager itera un sol vector i cada entitat decideix què fa.

Cap dynamic_cast nou per a lògica de joc — només queden els
inevitables (validació de factory i ruteig de la Door).
2026-05-11 13:33:46 +02:00
JailDesigner a29ea641ab refactor(em): un sol vector entities_ — polimorfisme per la
discriminació

Reverteix la separació en sacs introduïda al commit 62f4b85 (rama
anterior). Amb els virtuals afegits al commit precedent
(`onWhipHit`, `onJustSpawned`, `shouldReceiveContact`, `onRemoved`),
ja no cal separar `map_entities_` i `actors_`: cada entitat decideix
internament què fa, el manager itera un sol vector i confia en el
dispatch virtual.

Canvis:

- `entities_` torna a ser el contenidor únic. Es desfà el dispatch
  `dynamic_cast<DynamicHazard*>` que `add()` feia per enrutar.
- `addSarcophagus`, `removeEntity`, `update`, `notifyPlayerTilePainted`,
  `findFloorTriggerAt`, `renderAll`, `reload` simplificats: un sol
  bucle, sense distingir categories.
- `checkPlayerCollisions` només recorre `entities_` cridant
  `shouldReceiveContact` polimòrfic — la lògica de "saltar SPAWN"
  viu a `DynamicHazard::shouldReceiveContact`.
- `getMapEntities()` i `getActors()` desapareixen. Es manté
  `getEntities()` com a accessor únic.
- `WhipAttack::applyDamage` itera `getEntities()` cridant `onWhipHit`
  polimòrfic. Les entitats no-whippables retornen false (cost: una
  crida virtual buida) i el callsite no discrimina tipus.
- `GameScene::render` mergeja entities + players (dos transforms en
  comptes de tres) per al z-order.

`pending_actors_` es manté: és un concepte ortogonal — entitats que
encara no s'han instanciat al món fins que GameScene les "spawneja"
al text-exit de ROOM_INTRO.

Beneficis del nou disseny:
- Cap dynamic_cast dispers per a la lògica de joc (només queden els
  inevitables: validació de factory i ruteig de la Door).
- Afegir un nou enemic implica `class X : public DynamicHazard` i
  override dels virtuals rellevants — res al manager.
- Si un sarc, antorxa, o decoració futura volgués reaccionar al
  fueteig (trencar-se, encendre's), basta amb un `onWhipHit` override
  i no cal tocar `WhipAttack::applyDamage`.
2026-05-11 13:14:53 +02:00
JailDesigner 28ff425c89 refactor(entities): polimorfisme per substituir dynamic_casts
Quatre virtuals nous a `Entity` capten decisions que abans vivien al
manager com a discriminacions de tipus:

- `onWhipHit(WhipAttack*)` → bool. Default no-op/false. Override a
  `Mummy` per entrar HURT si és whippable. WhipAttack::applyDamage
  passa de `dynamic_cast<Mummy*>` + `isWhippable()` a una crida
  polimòrfica neta. RollingRock no override → immune al látigo per
  disseny, sense flag.

- `onJustSpawned()`. Default no-op. Override a `DynamicHazard` per
  disparar `triggerSpawnExit()`. `spawnPendingActors` i `spawnActor`
  passen de `dynamic_cast<DynamicHazard*>(observer)->triggerSpawnExit()`
  a `observer->onJustSpawned()`.

- `onRemoved()`. Default no-op. Override a `Sarcophagus` per cridar
  `entity_manager_->onSarcophagusRemoved()`. `removeEntity` passa de
  `getName() == "sarcophagus"` a dispatch polimòrfic.

- `shouldReceiveContact()`. Default `collides() || triggersOnOverlap()`.
  Override a `DynamicHazard` per excloure SPAWN. `checkPlayerCollisions`
  passa de tres condicions inline per a sacs distints a una crida
  unificada.

`whipDirToMummyDir` es mou de whip_attack.cpp a mummy.cpp — el mapping
viu on s'usa (dins de `Mummy::onWhipHit`).

`hitMummyIfApplicable` es renomena a `hitEntityIfApplicable`: ja no
discrimina tipus, aplica `entity->onWhipHit` i marca `already_hit_`
segons el retorn.

Comportament idèntic, tests verds. Aquest commit prepara el següent:
amb el polimorfisme madurat, el sac dual ja no aporta valor i podem
tornar a un únic `entities_`.
2026-05-11 13:01:26 +02:00
84 changed files with 2517 additions and 915 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/hole.cpp
source/game/entities/concrete/dust.cpp
source/game/entities/concrete/solid_props.cpp
)
# Entitats que criden directament símbols de Player (com WhipAttack via
@@ -80,6 +81,7 @@ set(APP_SOURCES
source/core/input/mouse.cpp
source/core/rendering/screen.cpp
source/core/rendering/canvas.cpp
source/core/rendering/lighting.cpp
source/core/rendering/notifier.cpp
source/core/rendering/render_info.cpp
source/core/rendering/gif.cpp
@@ -94,6 +96,7 @@ set(APP_SOURCES
source/core/rendering/sprite/animated_sprite_parser.cpp
source/core/rendering/effects/heat_haze.cpp
source/core/rendering/effects/scan_reveal.cpp
source/core/rendering/effects/shadow.cpp
source/core/rendering/transitions/mosaic.cpp
source/core/rendering/transitions/fade_to_black.cpp
source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp
+7 -1
View File
@@ -293,8 +293,14 @@ rebuild: clean all
# Empaqueta data/ a $(BUILDDIR)/resources.pack (format AE26). Força un rebuild
# del pack encara que res no hagi canviat dins data/. L'empaquetat també es fa
# automàticament a cada `make`/`make debug` via el target CMake `resource_pack`.
#
# NO passem `-DCMAKE_BUILD_TYPE`: així respectem el tipus ja configurat al
# `CMakeCache.txt` (Debug si vens de `make debug`, Release si vens de
# `make release`). Si forcéssim Release ací, el següent build de debug
# invalidaria la cache i recompilaria-ho tot. El binari `pack_resources`
# és una utilitat petita on el build type és irrellevant.
pack:
@cmake -S . -B $(BUILDDIR) $(CMAKE_GEN) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake -S . -B $(BUILDDIR) $(CMAKE_GEN) $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target pack_resources
@./$(BUILDDIR)/pack_resources data $(BUILDDIR)/resources.pack
Binary file not shown.
+2 -2
View File
@@ -8,6 +8,6 @@ picker:
# Colors ARGB en hex. Accepta enter (0xFF2244FF) o cadena ("#FF2244FF").
colors:
border: "0xFF2244FF" # vora 1 px que envolta canvas i picker
picker_bg: "0xFF101824" # fons del picker
border: "0xFF6A86C8" # vora 1 px que envolta canvas i picker (blau suau)
picker_bg: "0xFFC8C0D8" # fons del picker (lavanda clara)
picker_hover: "0xFFFFFFFF" # vora 1 px del tile sota el cursor
-3
View File
@@ -35,9 +35,6 @@ keys:
- id: toggle_render_info
code: "F10"
desc: "Cicle de l'overlay de debug: apagat / dalt / baix"
- id: pause
code: "F11"
desc: "Pausa el joc (o botó START del mando)"
- id: menu_toggle
code: "F12"
desc: "Obri / tanca el menú de servei"
+36
View File
@@ -0,0 +1,36 @@
# Configuració global del sistema d'il·luminació.
#
# Cada llum emissora (player, torxa, mòmia, ...) té un únic paràmetre
# propi: el radi en píxels. La forma de la llum dins d'aquest radi
# (els tres anells del gradient) viu en aquest fitxer perquè és una
# decisió de "feel" comuna a totes les entitats — així pots tocar la
# corba aquí sense haver de modificar 10 YAMLs.
#
# Forma del gradient (centre → vora):
# 0..inner_ratio del radi → inner_intensity (zona totalment il·luminada)
# inner_ratio..mid_ratio → mid_intensity (anell mig)
# mid_ratio..1.0 → outer_intensity (anell exterior)
# fora del radi → light_level de la sala
#
# Les intensitats són factors 0..1 que es multipliquen sobre el píxel
# final del canvas. inner=1.0 vol dir "píxel sense alterar".
gradient:
inner_ratio: 0.5
mid_ratio: 0.85
inner_intensity: 1.0
mid_intensity: 0.8
outer_intensity: 0.6
# Modulació del radi quan una entitat declara `flicker: true` al seu
# bloc `light:`. El radi efectiu per frame és:
# r_eff = r_base + A1·sin(2π·f1·t + φ1) + A2·sin(2π·f2·t + φ2)
# on (φ1, φ2) es deriven de la posició de l'emissor per a que cada
# antorxa balancegi en una fase diferent (sense estat per emissor).
# Combinant dues sinusoides amb freqüències incommensurables s'obté
# un patró "fluid" que el ull llegeix com a foc; pujar `noise_*`
# aporta més inquietud, baixar-ho fa la flama més serena.
flicker:
wave_amplitude_px: 2
wave_frequency_hz: 3.0
noise_amplitude_px: 1
noise_frequency_hz: 7.0
+30 -17
View File
@@ -17,15 +17,15 @@ colors:
menu_cursor: 71
menu_disabled: 16
hud_bg: 9
hud_label: 58
hud_value: 187
hud_flash: 237
hud_flash: 63
# Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). Independents
# de notification_* perquè la fase i les notificacions són dos canals
# visuals distints: ací pots canviar el color del rètol sense tocar
# els missatges del HUD.
room_text: 160
room_text_outline: 253
# els missatges del HUD. Comparteix la família del CENTER del HUD
# (or sobre marró) perquè "Piràmide N / Sala N" sigui sempre daurat
# arreu del joc.
room_text: 55 # or brillant (#FFDA00, = hud_center.value)
room_text_outline: 10 # marró cremat (#482400, = hud_center.shadow)
timings:
notification_ms: 4000
@@ -73,6 +73,30 @@ hud:
entry_ms: 800 # durada de l'animació d'entrada dels tres blocs
flash_ms: 250 # durada del flash quan un jugador rep un hit
shake_ms: 300 # durada del shake que acompanya el flash
# Parpadeig dels prompts inactius ("Polsa start..." / "Espera..."). Un
# únic temporitzador global al Hud sincronitza P1 i P2 i no es reinicia
# quan el text canvia entre CAN_JOIN i WAIT. cycle<=0 o ratio>=1 →
# sempre visible (disable trivial).
blink_cycle_ms: 1000 # durada del cicle complet
blink_visible_ratio: 0.8 # proporció del cicle visible (0..1)
# Famílies de color del HUD per bloc. Cada bloc té `label` (to apagat
# per etiquetes), `value` (to viu per a la xifra) i `shadow` (to fosc
# del mateix matís — efecte embossing 1 px a baix-dreta). Si manca un
# camp, cau al default compilat a `Defaults::UI`. El `colors.hud_flash`
# és comú a totes tres famílies.
hud_p1:
label: 7 # vermell pur
value: 183 # crema-préssec
shadow: 2 # vermell fosc
hud_center:
label: 29 # ambre cremat
value: 55 # or brillant
shadow: 10 # marró cremat
hud_p2:
label: 248 # cian pur
value: 253 # blau pàl·lid
shadow: 64 # blau marí
# Geometria del popup superior (Notifier). En píxels del canvas, llevat de
# slide_ms (durada en ms de l'animació d'entrada i d'eixida; constant
@@ -90,17 +114,6 @@ notifier:
slide_in_curve: outCubic
slide_out_curve: inCubic
# Pausa graduada de la GameScene: el món no para d'un colp, s'atura amb
# easing i torna a arrancar amb una altra corba. Els timings es poden
# afinar independentment, i les corbes permeten estils diferents entrant
# i sortint (p.ex. frenar suau i arrancar seca). Corbes disponibles:
# linear, outQuad, inQuad, inOutQuad, outCubic, inCubic.
pause:
enter_ms: 400
exit_ms: 250
enter_curve: outCubic
exit_curve: inCubic
# Sons del menú de servei. Els fitxers viuen a data/audio/sounds/. SELECT
# acompanya navegació (amunt/avall) i back; ACCEPT acompanya modificació
# d'opció (esquerra/dreta/retorn) o entrada a un submenú.
+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: ../debris/column.gif
size: [16, 32]
# Ancoratge "feet": el tile [col, row] del spawn és la base de la columna.
# El sprite de 16×32 sobresurt 16 px cap amunt sense afectar col·lisió ni
# z-order (la caixa lògica és el tile inferior).
anchor: feet
tile_size: [1, 1]
collision:
size: [16, 16]
# Mur infranquejable al salt: el jugador no pot passar pel damunt.
height: 999
flags:
collides: true
interactable: false
animations:
default: { frames: [0], fps: 1, loop: false }
Binary file not shown.

After

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+13 -7
View File
@@ -36,18 +36,17 @@ animations:
walk_north: { frames: [4, 5, 6, 7], fps: 8, loop: true }
walk_east: { frames: [8, 9, 10, 11], fps: 8, loop: true }
walk_west: { frames: [8, 9, 10, 11], fps: 8, loop: true, flip_h: true }
raise: { frames: [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27], fps: 8 , loop: false }
ai:
type: corridor_wanderer
direction_choice: random
initial_direction: east
# Estat SPAWN: la mòmia s'instancia visible al pixel_rect_ inicial just
# quan GameScene crida `spawnPendingActors()` (a la subfase text-exit
# de ROOM_INTRO, fase 4). El `triggerSpawnExit` es dispara llavors
# mateix; passats `duration_s` segons transita a ROAMING i empieza a
# deambular. Sense aquest bloc, default 0 ⇒ ACTIVE immediat.
spawn:
duration_s: 1.0
# Estat SPAWN: la mòmia s'instancia al pixel_rect_ inicial quan
# GameScene crida `spawnPendingActors()` (subfase text-exit de
# ROOM_INTRO, fase 4) amb l'animació `raise` activa. La duració del
# SPAWN la marca la pròpia animació (no-loop): al `completed()`,
# transita a ROAMING i comença a deambular.
stats:
speed: 30
damage: 1
@@ -69,3 +68,10 @@ hurt:
# So opcional: basename del fitxer dins `data/audio/sounds/`. Es dispara
# un sol cop per fuetada quan la mòmia entra a HURT. Si manca, no sona.
sound: mummy_hurt.wav
# Halo tènue al voltant de la mòmia. Radi més curt que el del jugador
# perquè el rastre lumínic delati la seva posició sense "il·luminar
# la sala" — vol ser una pista visual a la foscor.
light:
radius: 24
flicker: true
-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_west: { frames: [18], flip_h: true }
walk_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
walk_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
walk_east: { frames: [8, 9, 10, 11], frame_ms: 100 }
walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true }
walk_south: { frames: [1, 2, 3, 0], frame_ms: 100 }
walk_north: { frames: [5, 6, 7, 4], frame_ms: 100 }
walk_east: { frames: [9, 10, 11, 8], frame_ms: 100 }
walk_west: { frames: [9, 10, 11, 8], frame_ms: 100, flip_h: true }
hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
@@ -97,13 +97,10 @@ turn:
south_to_north: west
# Salt vertical (px de z, no toca la posició al món).
# `shadow_y_offset`: puja la sombra per compensar la franja
# transparent sota els peus del sprite.
jump:
impulse: 200
gravity: 600
landing_stun_ms: 400
shadow_y_offset: 3
sound: jump.wav
land_sound: land.wav
@@ -142,4 +139,12 @@ death:
immunity:
duration_ms: 3000
blink_period_ms: 100
palette: immune
palette: morcus_wash
# Llum emesa pel jugador. `radius` és el radi del cercle de llum
# en píxels del canvas; el gradient (3 anells: 1.0 / 0.9 / 0.8) viu
# a data/config/lighting.yaml i és compartit per tots els emissors.
# Omet el bloc per desactivar la llum d'aquest personatge.
light:
radius: 64
flicker: true
+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_west: { frames: [18], flip_h: true }
walk_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
walk_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
walk_east: { frames: [8, 9, 10, 11], frame_ms: 100 }
walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true }
walk_south: { frames: [1, 2, 3, 0], frame_ms: 100 }
walk_north: { frames: [5, 6, 7, 4], frame_ms: 100 }
walk_east: { frames: [9, 10, 11, 8], frame_ms: 100 }
walk_west: { frames: [9, 10, 11, 8], frame_ms: 100, flip_h: true }
hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
@@ -97,13 +97,10 @@ turn:
south_to_north: west
# Salt vertical (px de z, no toca la posició al món).
# `shadow_y_offset`: puja la sombra per compensar la franja
# transparent sota els peus del sprite.
jump:
impulse: 200
gravity: 600
landing_stun_ms: 400
shadow_y_offset: 3
sound: jump.wav
land_sound: land.wav
@@ -142,4 +139,12 @@ death:
immunity:
duration_ms: 3000
blink_period_ms: 100
palette: immune
palette: sam_wash
# Llum emesa pel jugador. `radius` és el radi del cercle de llum
# en píxels del canvas; el gradient (3 anells: 1.0 / 0.9 / 0.8) viu
# a data/config/lighting.yaml i és compartit per tots els emissors.
# Omet el bloc per desactivar la llum d'aquest personatge.
light:
radius: 64
flicker: true
+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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.6 KiB

+20 -2
View File
@@ -20,11 +20,29 @@ flags:
interactable: true
animations:
closed: { frames: [0], fps: 1, loop: true }
opening: { frames: [0], fps: 6, loop: false }
open: { frames: [0], fps: 1, loop: true }
opening: { frames: [1, 2], fps: 6, loop: false }
open: { frames: [3], fps: 1, loop: true }
behaviour:
states: [CLOSED, OPENING, OPEN]
initial_state: CLOSED
on_perimeter_complete: OPENING
on_opening_end: OPEN
emits_event_on_open: sarcophagus_opened
# Sons opcionals. `opening` es dispara un sol cop en la transició
# CLOSED → OPENING (quan el jugador completa el perímetre). Si la clau
# manca, no sona res. El basename val tant de clau al Resource::Cache
# com de path (`Defaults::Paths::AUDIO_SOUNDS_DIR + key`).
#
# `opening_pitch_spread`: variació uniforme del ratio de reproducció
# centrada a 1.0. Amb 0.05 cada obertura sona entre 0.95× i 1.05× la
# velocitat normal (puja/baixa també el to, efecte arcade clàssic).
# 0 ⇒ sempre 1.0 (sense variació).
sound:
opening: open_sarc.wav
opening_pitch_spread: 0.5
# Recompensa que rep el jugador que pinta el tile que tanca el perímetre
# (el GameScene la reparteix consumint `sarcophagus_opened`). Si es lleva
# aquest bloc o un dels camps, el camp absent val 0 i no s'aplica.
reward:
points: 100
treasures: 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

+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: ../debris/stone.gif
size: [16, 16]
tile_size: [1, 1]
collision:
size: [16, 16]
# Mur infranquejable al salt: el jugador no la pot saltar per damunt.
height: 999
flags:
collides: true
interactable: false
# Animació "default" amb un sol frame: el spawn YAML pot sobreescriure-la
# via `frame: N` per fixar la variant escollida al editor.
animations:
default: { frames: [0], fps: 1, loop: false }
+11
View File
@@ -16,3 +16,14 @@ flags:
# El render clipa cada frame per `anim.currentFrame()` × 16 píxels en Y.
animations:
idle: { frames: [0, 1, 2, 3], fps: 8, loop: true }
# Cercle de llum centrat a l'antorxa. El radi és en píxels del canvas;
# el gradient global (data/config/lighting.yaml) hi posa la forma (3
# anells). Amb `flicker: true` el radi efectiu oscil·la per frame
# (suma de dues sinusoides amb fase derivada de la posició) per a
# que el cercle "respiri" com una flama; els paràmetres del flicker
# també viuen al config global. Omet el bloc per fer una antorxa
# "apagada".
light:
radius: 40
flicker: true
+7 -7
View File
@@ -2,7 +2,7 @@
# Generado con font_gen.py
box_width 12
box_height 14
box_height 13
columns 15
# codepoint_decimal ancho_visual
@@ -105,8 +105,8 @@ columns 15
193 8 # Á
200 7 # È
201 7 # É
204 4 # Ì
205 4 # Í
204 3 # Ì
205 3 # Í
210 9 # Ò
211 9 # Ó
217 7 # Ù
@@ -117,8 +117,8 @@ columns 15
225 7 # á
232 7 # è
233 7 # é
236 4 # ì
237 4 # í
236 3 # ì
237 3 # í
242 7 # ò
243 7 # ó
249 7 # ù
@@ -127,7 +127,7 @@ columns 15
231 7 # ç
161 3 # ¡
191 7 # ¿
171 10 # «
187 10 # »
171 7 # «
187 7 # »
183 3 # ·
169 10 # ©
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
box_width 12
box_height 14
box_height 13
columns 15
# codepoint_decimal ancho_visual
@@ -105,8 +105,8 @@ columns 15
193 9 # Á
200 8 # È
201 8 # É
204 4 # Ì
205 4 # Í
204 3 # Ì
205 3 # Í
210 10 # Ò
211 10 # Ó
217 8 # Ù
@@ -117,8 +117,8 @@ columns 15
225 8 # á
232 8 # è
233 8 # é
236 4 # ì
237 4 # í
236 3 # ì
237 3 # í
242 8 # ò
243 8 # ó
249 8 # ù
@@ -127,7 +127,7 @@ columns 15
231 8 # ç
161 3 # ¡
191 8 # ¿
171 10 # «
187 10 # »
171 7 # «
187 7 # »
183 3 # ·
169 10 # ©
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"
vsync_on: "VSync activat"
vsync_off: "VSync desactivat"
screenshot: "Captura"
screenshot: "Imatge {} guardada a {}"
shader: "Shader"
preset: "Preset"
scaling_disabled: "Escalat sense"
@@ -23,7 +23,6 @@ input:
scaling_overscan: "Escalat overscan"
scaling_integer: "Escalat enter"
press_again_exit: "Prem una altra volta per eixir"
paused: "Pausa"
editor_on: "Editor ON"
editor_off: "Editor OFF"
editor_save_error: "Editor: error guardant el mapa"
@@ -41,10 +40,10 @@ hud:
# Prompts mostrats al bloc d'un slot inactiu (en lloc dels zeros)
# quan un jugador encara no ha pulsat START o ha esgotat vides.
# Dues línies per fer-los caber al bloc lateral (~106 px).
join_prompt_can_join_l1: "POLSA START"
join_prompt_can_join_l2: "PER JUGAR"
join_prompt_wait_l1: "ESPERA"
join_prompt_wait_l2: "PER JUGAR"
join_prompt_can_join_l1: "Polsa start"
join_prompt_can_join_l2: "per jugar"
join_prompt_wait_l1: "Espera"
join_prompt_wait_l2: "per jugar"
# Textos de l'escena TITLE.
title:
+5 -6
View File
@@ -14,7 +14,7 @@ input:
shader_off: "Shaders off"
vsync_on: "VSync on"
vsync_off: "VSync off"
screenshot: "Screenshot"
screenshot: "Image {} saved at {}"
shader: "Shader"
preset: "Preset"
scaling_disabled: "Scaling off"
@@ -23,7 +23,6 @@ input:
scaling_overscan: "Scaling overscan"
scaling_integer: "Scaling integer"
press_again_exit: "Press again to exit"
paused: "Paused"
editor_on: "Editor ON"
editor_off: "Editor OFF"
editor_save_error: "Editor: map save failed"
@@ -41,10 +40,10 @@ hud:
# Prompts shown in an inactive slot's block (instead of zeros) while
# a player hasn't pressed START yet or has run out of lives. Two
# short lines so they fit in the side block (~106 px wide).
join_prompt_can_join_l1: "PRESS START"
join_prompt_can_join_l2: "TO PLAY"
join_prompt_wait_l1: "PLEASE"
join_prompt_wait_l2: "WAIT"
join_prompt_can_join_l1: "Press start"
join_prompt_can_join_l2: "to play"
join_prompt_wait_l1: "Please"
join_prompt_wait_l2: "wait"
# Texts of the TITLE scene.
title:
+32 -5
View File
@@ -15,6 +15,12 @@ tileset: data/tilesets/pyramid_1.gif
tile_size: 16
origin: [-8, 0]
# Il·luminació base de la sala (0..1). 1.0 = sala totalment il·luminada
# (com hui, sense efecte visual); 0.0 = completament negra fora del radi
# d'un emissor. El gradient dels cercles d'emissor viu a
# data/config/lighting.yaml. Omet aquesta clau per deixar la sala neta.
light_level: 0.25
# Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl
# pintable. Un índex per jugador perquè a multijugador local el recorregut
# es distingeix a simple vista. Provisional fins que l'art del pintat
@@ -88,8 +94,6 @@ entities:
position: [12, 2]
- type: torch
position: [16, 2]
- type: sarcophagus
sarc_position: [0, 0]
- type: sarcophagus
sarc_position: [1, 0]
- type: sarcophagus
@@ -116,8 +120,6 @@ entities:
sarc_position: [0, 3]
- type: sarcophagus
sarc_position: [1, 3]
- type: sarcophagus
sarc_position: [2, 3]
- type: sarcophagus
sarc_position: [3, 3]
- type: sarcophagus
@@ -131,5 +133,30 @@ entities:
- type: torch
position: [4, 2]
- type: door
- type: column
position: [3, 4]
frame: 2
- type: stone
position: [5, 4]
frame: 16
- type: stone
position: [4, 4]
frame: 19
- type: column
position: [13, 10]
frame: 4
- type: stone
position: [12, 10]
frame: 13
- type: stone
position: [11, 10]
frame: 23
- type: hole
position: [5, 9]
- type: hole
position: [9, 11]
- type: stone
position: [10, 10]
frame: 18
- type: mummy
position: [10, 11]
position: [10, 13]
-3
View File
@@ -131,9 +131,6 @@ entities:
- type: sarcophagus
sarc_position: [3, 4]
- type: door
# Sense `position`: la porta sempre viu al mur trasser. La columna
# transitable la dicta `geometry.door_col`; el render i la caixa
# de col·lisió es deriven d'aquesta dada al loadFromLevel.
- type: mummy
position: [10, 7]
- type: mummy
+6 -3
View File
@@ -1,6 +1,6 @@
# Configuració de la GameScene: timings i corbes de la FSM de sala
# (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria
# i font del text es reutilitzen del tema UI (data/config/ui.yaml > pause +
# i font del text es reutilitzen del tema UI (data/config/ui.yaml >
# notification). Si manca el fitxer o alguna clau, GameScene cau als
# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp.
@@ -43,8 +43,11 @@ text:
# text i decreix amb l'exit_curve. `color` són tres canals RGB (0..255);
# `alpha` és la transparència del blend (0 = invisible, 255 = opac).
text_panel:
color: [36, 182, 170]
alpha: 128
# Violeta cerimonial: contrasta amb l'or del text (room_text), no
# compite amb el blau del bloc P2 del HUD, i deixa la sorra de les
# sales respirar al darrere.
color: [72, 36, 85]
alpha: 160
height_px: 32
# Sub-FSM de game over: dispara CONTINUE? quan tots els jugadors
+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
// 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.
+6
View File
@@ -85,6 +85,12 @@ class Audio {
// --- Control de sons ---
void playSound(const std::string& name, Group group = Group::GAME); // Reproduir so puntual per nom (muta globals de Ja)
void playSound(Ja::Sound* sound, Group group = Group::GAME); // Reproduir so puntual per punter (muta globals de Ja)
// Reprodueix un so amb la velocitat (i to) escalats per `speed`:
// 1.0 = normal, 0.95 ≈ -5% (més greu i lent), 1.05 ≈ +5% (més
// agut i ràpid). Mateixa semàntica que `setMusicSpeed`. Útil per a
// variacions subtils que eviten la fatiga d'escoltar el mateix
// sample idèntic (p.ex. obertures de sarcòfag, picks d'ítems).
void playSound(const std::string& name, Group group, float speed);
// Reprodueix un so processat per un efecte definit a data/config/sounds.yaml
// (preset_name busca a SoundEffectsConfig). Si el preset no existeix
// o el motor està al cap de canals amb efecte, fa fallback a playSound
+6
View File
@@ -430,6 +430,12 @@ namespace Ja {
return channel;
}
void Engine::setChannelSpeed(const int channel, const float ratio) {
if (channel < 0 || channel >= MAX_SIMULTANEOUS_CHANNELS) { return; }
if (channels_[channel].stream == nullptr) { return; }
SDL_SetAudioStreamFrequencyRatio(channels_[channel].stream, ratio);
}
void Engine::pauseChannel(const int channel) {
forEachTargetChannel(channel, [](Channel& ch) {
if (ch.state == ChannelState::PLAYING) {
+8
View File
@@ -192,6 +192,14 @@ namespace Ja {
// --- So ---
auto playSound(Sound* sound, int loop = 0, int group = 0) -> int;
auto playSoundOnChannel(Sound* sound, int channel, int loop = 0, int group = 0) -> int;
// Ajusta la velocitat de reproducció d'un canal actiu via
// `SDL_SetAudioStreamFrequencyRatio`. 1.0 = normal. Igual que a
// `setMusicSpeed`, puja/baixa el to junt amb la velocitat
// (efecte "chipmunk"); per a SFX curts arcade és el que volem.
// No-op si el canal no està actiu. Cridar-lo just després de
// `playSound`/`playSoundOnChannel` perquè el ratio cobreixi
// tota la reproducció.
void setChannelSpeed(int channel, float ratio);
// Reproducció amb so processat per un efecte. Retorna el canal
// assignat o -1 si no queden slots d'efecte (MAX_EFFECT_CHANNELS).
// El sound original només s'usa per consultar el spec/buffer; el
+17 -5
View File
@@ -3,6 +3,7 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <format>
#include <string>
#include "core/input/key_config.hpp"
@@ -69,15 +70,26 @@ namespace GlobalInputs {
notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off");
}
// Volca el canvas ARGB actual a PNG dins de `screenshots/`. Si ha
// fallat l'escriptura, el handler no mostra notificació (retorn buit).
// Volca el canvas ARGB actual a PNG dins del directori de
// captures (configurat pel Director). La notificació informa
// del nom i de la ruta absoluta — així l'usuari sap on anar
// a buscar-la encara que el binari s'executi des d'un sitio
// estrany. Si l'escriptura falla, no es mostra notificació.
void onScreenshot() {
const auto* buffer = Screen::get().getCanvasArgb();
const int W = Screen::get().getCanvasWidth();
const int H = Screen::get().getCanvasHeight();
const std::string FILENAME = Screenshot::save(buffer, W, H);
if (!FILENAME.empty()) {
Notification::notify(Locale::get().text("input.screenshot") + ' ' + FILENAME);
const auto R = Screenshot::save(buffer, W, H);
if (!R.filename.empty()) {
// `make_format_args` retorna un store que conté referències
// als arguments; cal lligar-lo a una lvalue local perquè
// sobrevisqui fins que `vformat` el consumeixi. Passar-lo
// directament a `vformat` és UB en alguns compiladors
// (DR de C++20 / fix de C++23) i ha provocat un segfault
// post-captura en aquest projecte.
const std::string TEMPLATE = Locale::get().text("input.screenshot");
auto args = std::make_format_args(R.filename, R.folder);
Notification::notify(std::vformat(TEMPLATE, args));
}
}
+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
+234
View File
@@ -0,0 +1,234 @@
#include "core/rendering/lighting.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <numbers>
#include <vector>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp"
namespace Lighting {
namespace {
// Buffer de luminància compartit entre frames. Es redimensiona
// només si canvia la mida del canvas (mai en un run normal: el
// canvas del joc és 320×240 fix). El reusem perquè una `apply`
// allotjant cada frame seria un cost evitable.
auto luminanceBuffer() -> std::vector<std::uint8_t>& {
static std::vector<std::uint8_t> buf_;
return buf_;
}
// Configuració global, inicialitzada amb els defaults compilats.
// loadConfig() la reemplaça si troba el fitxer.
auto activeConfig() -> GradientConfig& {
static GradientConfig cfg_{
.inner_ratio = Defaults::Lighting::INNER_RATIO,
.mid_ratio = Defaults::Lighting::MID_RATIO,
.inner_intensity = Defaults::Lighting::INNER_INTENSITY,
.mid_intensity = Defaults::Lighting::MID_INTENSITY,
.outer_intensity = Defaults::Lighting::OUTER_INTENSITY,
};
return cfg_;
}
auto activeFlicker() -> FlickerConfig& {
static FlickerConfig fc_{
.wave_amplitude_px = Defaults::Lighting::FLICKER_WAVE_AMPLITUDE_PX,
.wave_frequency_hz = Defaults::Lighting::FLICKER_WAVE_FREQUENCY_HZ,
.noise_amplitude_px = Defaults::Lighting::FLICKER_NOISE_AMPLITUDE_PX,
.noise_frequency_hz = Defaults::Lighting::FLICKER_NOISE_FREQUENCY_HZ,
};
return fc_;
}
// Modula el radi base d'un emissor amb la suma de dues sinusoides
// (`wave` + `noise`) i una fase derivada de `seed`. La seed és un
// identificador estable de l'emissor (no canvia amb la posició) —
// així el patró no "deriva" quan l'emissor es mou. El factor
// ~φ (golden ratio) dispersa les fases per a seeds consecutives.
auto computeFlickerRadius(int base_radius, int seed, float t_s, const FlickerConfig& fc) -> int {
constexpr float TWO_PI = 2.0F * std::numbers::pi_v<float>;
constexpr float GOLDEN_FRACTION = 0.6180339F;
const float PHASE = static_cast<float>(seed) * GOLDEN_FRACTION;
const float W1 = std::sin((TWO_PI * fc.wave_frequency_hz * t_s) + PHASE);
const float W2 = std::sin((TWO_PI * fc.noise_frequency_hz * t_s) + (PHASE * 2.7F));
const float DELTA = (fc.wave_amplitude_px * W1) + (fc.noise_amplitude_px * W2);
const int R = base_radius + static_cast<int>(std::lround(DELTA));
return std::max(1, R);
}
// Quantitza un factor 0..1 a 0..255. `clamp` per defensar-nos de
// YAMLs que entreguen valors fora de rang sense forçar la mà.
auto toByte(float v) -> std::uint8_t {
const float CLAMPED = std::clamp(v, 0.0F, 1.0F);
return static_cast<std::uint8_t>(std::lround(CLAMPED * 255.0F));
}
void readField(const fkyaml::node& node, const char* key, float& dst) {
if (node.contains(key)) { dst = node[key].get_value<float>(); }
}
// Pre-calcula els 3 quadrats de radi i les intensitats que farem
// servir dins del bucle per píxel. Es resol una sola vegada per
// emissor i s'evita arrastrar 6 paràmetres a `rasterizeEmitter`.
struct EmitterRings {
int radius_sq{0};
int mid_sq{0};
int inner_sq{0};
std::uint8_t inner{0};
std::uint8_t mid{0};
std::uint8_t outer{0};
};
auto computeRings(int radius, const GradientConfig& cfg) -> EmitterRings {
const auto R_F = static_cast<float>(radius);
const float R_INNER = R_F * cfg.inner_ratio;
const float R_MID = R_F * cfg.mid_ratio;
return EmitterRings{
.radius_sq = radius * radius,
.mid_sq = static_cast<int>(R_MID * R_MID),
.inner_sq = static_cast<int>(R_INNER * R_INNER),
.inner = toByte(cfg.inner_intensity),
.mid = toByte(cfg.mid_intensity),
.outer = toByte(cfg.outer_intensity),
};
}
// Itera la bounding box de l'emissor clipada al canvas i actualitza
// la fila de luminància corresponent amb `max(actual, ring)`. Es
// crida un cop per emissor, així extreure-la deixa `apply` simple.
void rasterizeEmitter(const Emitter& e, const EmitterRings& rings, std::uint8_t* lum, int width, int height) {
const int R = e.radius;
const int CX = static_cast<int>(e.cx);
const int CY = static_cast<int>(e.cy);
const int X0 = std::max(0, CX - R);
const int Y0 = std::max(0, CY - R);
const int X1 = std::min(width - 1, CX + R);
const int Y1 = std::min(height - 1, CY + R);
for (int y = Y0; y <= Y1; ++y) {
const int DY = y - CY;
const int DY_SQ = DY * DY;
if (DY_SQ > rings.radius_sq) { continue; }
std::uint8_t* row = lum + (static_cast<std::ptrdiff_t>(y) * width);
for (int x = X0; x <= X1; ++x) {
const int DX = x - CX;
const int DIST_SQ = (DX * DX) + DY_SQ;
if (DIST_SQ > rings.radius_sq) { continue; }
std::uint8_t intensity = rings.outer;
if (DIST_SQ <= rings.inner_sq) {
intensity = rings.inner;
} else if (DIST_SQ <= rings.mid_sq) {
intensity = rings.mid;
}
row[x] = std::max(intensity, row[x]);
}
}
}
// Pasada final: pixel = pixel * lum / 255 per canal. El canal alpha
// es preserva (la resta del pipeline assumeix opacitat total al
// canvas; veure Rendering::OPAQUE_ALPHA).
void modulateCanvas(std::uint32_t* canvas, const std::uint8_t* lum, int width, int height) {
for (int y = 0; y < height; ++y) {
std::uint32_t* px = canvas + (static_cast<std::ptrdiff_t>(y) * width);
const std::uint8_t* lrow = lum + (static_cast<std::ptrdiff_t>(y) * width);
for (int x = 0; x < width; ++x) {
const std::uint8_t L = lrow[x];
if (L == 255) { continue; } // píxel intacte.
const std::uint32_t SRC = px[x];
const std::uint8_t SR = (SRC >> 16) & 0xFF;
const std::uint8_t SG = (SRC >> 8) & 0xFF;
const std::uint8_t SB = SRC & 0xFF;
const auto R = static_cast<std::uint8_t>((SR * L) / 255);
const auto G = static_cast<std::uint8_t>((SG * L) / 255);
const auto B = static_cast<std::uint8_t>((SB * L) / 255);
px[x] = (SRC & 0xFF000000U) |
(static_cast<std::uint32_t>(R) << 16) |
(static_cast<std::uint32_t>(G) << 8) |
static_cast<std::uint32_t>(B);
}
}
}
} // namespace
auto config() -> const GradientConfig& {
return activeConfig();
}
void loadConfig(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
Log::warn("Lighting", "no s'ha pogut obrir {} — s'usaran els defaults compilats", yaml_path);
return;
}
try {
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
if (yaml.contains("gradient")) {
const auto& g = yaml["gradient"];
auto& cfg = activeConfig();
readField(g, "inner_ratio", cfg.inner_ratio);
readField(g, "mid_ratio", cfg.mid_ratio);
readField(g, "inner_intensity", cfg.inner_intensity);
readField(g, "mid_intensity", cfg.mid_intensity);
readField(g, "outer_intensity", cfg.outer_intensity);
}
if (yaml.contains("flicker")) {
const auto& f = yaml["flicker"];
auto& fc = activeFlicker();
readField(f, "wave_amplitude_px", fc.wave_amplitude_px);
readField(f, "wave_frequency_hz", fc.wave_frequency_hz);
readField(f, "noise_amplitude_px", fc.noise_amplitude_px);
readField(f, "noise_frequency_hz", fc.noise_frequency_hz);
}
} catch (const std::exception& ex) {
Log::warn("Lighting", "error parsejant {}: {} — s'usaran els defaults compilats", yaml_path, ex.what());
}
}
void apply(std::uint32_t* canvas, int width, int height, float room_light, std::span<const Emitter> emitters) {
if (canvas == nullptr || width <= 0 || height <= 0) { return; }
// Atajo: si la sala està totalment il·luminada i no hi ha
// emissors, la pasada seria un `pixel * 1.0` global — no cal
// tocar res. És el camí curt habitual mentre no posem
// `light_level` a les sales.
const std::uint8_t ROOM = toByte(room_light);
if (ROOM == 255 && emitters.empty()) { return; }
auto& lum = luminanceBuffer();
const std::size_t TOTAL = static_cast<std::size_t>(width) * static_cast<std::size_t>(height);
if (lum.size() != TOTAL) { lum.resize(TOTAL); }
std::ranges::fill(lum, ROOM);
const auto& cfg = activeConfig();
const auto& fc = activeFlicker();
const float T_S = static_cast<float>(SDL_GetTicks()) / 1000.0F;
for (const auto& e : emitters) {
if (e.radius <= 0) { continue; }
const int R_EFF = e.flicker
? computeFlickerRadius(e.radius, e.seed, T_S, fc)
: e.radius;
const auto RINGS = computeRings(R_EFF, cfg);
// El rasteritzador llegeix el radi de l'`Emitter` per al
// càlcul de la bounding box, així que li passem un emissor
// "patxat" amb el radi efectiu d'aquest frame. Mantenim el
// valor original al `Emitter` del caller intacte.
const Emitter EFF{.cx = e.cx, .cy = e.cy, .radius = R_EFF, .seed = e.seed, .flicker = e.flicker};
rasterizeEmitter(EFF, RINGS, lum.data(), width, height);
}
modulateCanvas(canvas, lum.data(), width, height);
}
} // namespace Lighting
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <cstdint>
#include <span>
#include <string>
// Sistema d'il·luminació CPU. Treballa sobre el mateix canvas ARGB del
// joc (320×240) que tot el pipeline de render: després que GameScene
// haja pintat tilemap, paint layer, sombres i actors, una pasada final
// multiplica cada píxel del canvas per un factor de luminància 0..1
// derivat de la combinació de "llum de la sala" + cercles emissors al
// voltant de certes entitats.
//
// Forma de cada emissor (radial, 3 anells configurables al fitxer
// data/config/lighting.yaml; veure `GradientConfig`):
// 0..inner_ratio del radi → inner_intensity
// inner_ratio..mid_ratio → mid_intensity
// mid_ratio..1.0 → outer_intensity
// fora del radi → light_level de la sala
//
// Combinació entre emissors: per a cada píxel es pren el màxim del
// llum de sala i de la intensitat aportada per cada emissor que el
// solapa — no se "sumen" intensitats. Coherent amb la idea de "centre
// 1.0": el píxel que cau dins de qualsevol nucli no s'enfosqueix.
namespace Lighting {
// Posició al canvas (en píxels, mateixa unitat que la resta del
// motor) i radi efectiu de l'emissor. El centre és el del sprite
// visible: és l'entitat qui sap on cau (veure `Entity::getLightCenter`).
// `flicker` activa la modulació del radi per frame; `seed` és un
// identificador estable per emissor (no canvia amb la posició) del
// qual es deriva la fase del flicker — així els emissors mòbils
// mantenen un patró propi que no "deriva" pel moviment.
struct Emitter {
float cx{0.0F};
float cy{0.0F};
int radius{0};
int seed{0};
bool flicker{false};
};
// Forma del gradient. Compartida per tots els emissors per evitar
// duplicar la corba a cada YAML d'entitat. Càrrega via loadConfig.
struct GradientConfig {
float inner_ratio{0.8F};
float mid_ratio{0.9F};
float inner_intensity{1.0F};
float mid_intensity{0.9F};
float outer_intensity{0.8F};
};
// Paràmetres del flicker per a emissors amb `flicker=true`. La
// forma final del radi és la suma d'una sinusoide "respiració"
// (wave_*) i una sinusoide secundària de freqüència més alta
// (noise_*) que aporta la sensació d'inquietud. Les fases es
// deriven de la posició de cada emissor — sense estat per emissor.
struct FlickerConfig {
float wave_amplitude_px{2.0F};
float wave_frequency_hz{3.0F};
float noise_amplitude_px{1.0F};
float noise_frequency_hz{7.0F};
};
// Llegeix `yaml_path` (data/config/lighting.yaml) i pobla la
// configuració global. Qualsevol clau absent cau als Defaults::Lighting.
// No es crida més d'una vegada en un run normal; tornar-la a cridar
// simplement reemplaça la configuració activa.
void loadConfig(const std::string& yaml_path);
// Configuració activa (per a debug/inspectors). Inicialitzada amb els
// Defaults::Lighting fins que loadConfig() es crida.
[[nodiscard]] auto config() -> const GradientConfig&;
// Aplica la pasada d'il·luminació al canvas in-place. `room_light`
// és l'atenuació base de la sala (0..1, 1 = sense atenuació). El
// span d'emissors pot ser buit — en aquest cas tota la sala queda
// multiplicada per `room_light`. Aquesta funció no fa cap allocació
// si es crida amb la mateixa mida de canvas múltiples cops: manté
// un buffer intern reutilitzable.
void apply(std::uint32_t* canvas, int width, int height, float room_light, std::span<const Emitter> emitters);
} // namespace Lighting
+48 -21
View File
@@ -12,11 +12,52 @@
namespace {
// Parteix `text` en una llista de línies que caben cadascuna dins
// `max_width`. Algoritme de projecte_2026: tall per espais, la paraula
// que no cap passa a la línia següent. Les paraules aïllades més
// llargues que `max_width` es queden a la seva pròpia línia sense
// trencar-les (cas improbable amb els pretty_names de gamepad retallats
// a ~25 caràcters).
// Trenca una "paraula" llarga (sense espais) en trossos contigus
// que càpiguen a `max_width`. Si un sol caràcter ja excedeix
// (font enorme), el deixa sol per garantir progrés. La part final
// que encara cap queda a `current` per a fusionar-se amb paraules
// posteriors via espai.
void hardWrapWord(const std::string& word, int max_width, const Text& font, int kerning, std::vector<std::string>& lines, std::string& current) {
std::string buf;
for (char c : word) {
std::string candidate = buf;
candidate += c;
if (buf.empty() || font.length(candidate, kerning) <= max_width) {
buf = std::move(candidate);
} else {
lines.push_back(buf);
buf = std::string(1, c);
}
}
current = std::move(buf);
}
// Posa la `word` a la `current` line si hi cap (amb separador
// implícit si current no està buida), o fa flush + reintenta a
// línia neta. Si tampoc cap sola, hard-wrap caràcter a caràcter.
void appendWord(const std::string& word, int max_width, const Text& font, int kerning, std::vector<std::string>& lines, std::string& current) {
if (!current.empty()) {
std::string candidate = current;
candidate += ' ';
candidate += word;
if (font.length(candidate, kerning) <= max_width) {
current = std::move(candidate);
return;
}
lines.push_back(current);
current.clear();
}
if (font.length(word, kerning) <= max_width) {
current = word;
} else {
hardWrapWord(word, max_width, font, kerning, lines, current);
}
}
// `max_width`. Tall per espais; les paraules més llargues que
// `max_width` (rutes, URLs...) es trenquen caràcter a caràcter
// — sense això, una caixa que excedís el canvas faria que
// `fillRect` rebés rangs invertits i petés.
auto wrapToWidth(const std::string& text, int max_width, const Text& font, int kerning) -> std::vector<std::string> {
std::vector<std::string> lines;
if (text.empty()) {
@@ -30,26 +71,12 @@ namespace {
const std::size_t SPACE_POS = text.find(' ', i);
const bool LAST = (SPACE_POS == std::string::npos);
const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i);
if (current.empty()) {
current = WORD;
} else {
std::string candidate = current;
candidate += ' ';
candidate += WORD;
if (font.length(candidate, kerning) <= max_width) {
current = std::move(candidate);
} else {
lines.push_back(current);
current = WORD;
}
}
appendWord(WORD, max_width, font, kerning, lines, current);
if (LAST) { break; }
i = SPACE_POS + 1;
}
lines.push_back(current);
if (!current.empty()) { lines.push_back(current); }
return lines;
}
+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 X1 = std::min(x + w, CLIP.x + CLIP.w);
const int Y1 = std::min(y + h, CLIP.y + CLIP.h);
// Bail-out defensiu: si el rang és buit o invertit després del clip,
// `std::fill(first, last)` amb `first > last` és UB i a la pràctica
// entra en bucle infinit (vist en un crash a l'overflow del Notifier
// quan la caixa quedava més ampla que el canvas). El `blendRect`
// d'aquí baix ja és segur perquè usa `for(ix; ix < X1; ...)`.
if (X1 <= X0 || Y1 <= Y0) { return; }
for (int iy = Y0; iy < Y1; ++iy) {
std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_);
std::fill(row + X0, row + X1, argb);
+1 -1
View File
@@ -72,7 +72,7 @@ class Screen {
// Alpha-blend sobre el canvas: `alpha=0` no fa res, `alpha=255` equival
// a fillRect. El color entra com a ARGB little-endian; `alpha` és el
// factor de mescla amb el que ja hi ha al canvas. Pensat per a overlays
// (menú, pauses) que volen veure's a través el joc atenuat.
// (menú de servei) que volen veure's a través el joc atenuat.
void blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha);
// Hook invocat al principi de `present()`, abans de pujar els píxels al
// backend. Serveix per a pintar overlays que han de quedar per damunt
+32 -20
View File
@@ -5,6 +5,7 @@
#include <ctime>
#include <filesystem>
#include <string>
#include <utility>
#include <vector>
#include "external/stb_image_write.h"
@@ -13,21 +14,18 @@ namespace Screenshot {
namespace {
// Crea la carpeta `screenshots/` si no existeix i torna la ruta
// completa al fitxer. El paràmetre `filename` rep el nom nu (sense
// directori) per poder mostrar-lo a la notificació.
auto generateFilePath(std::string& filename) -> std::string {
std::filesystem::create_directories("screenshots");
// Estat de mòdul: ruta base on s'escriu la subcarpeta
// `screenshots/`. Buida per defecte ⇒ relativa al CWD (fallback
// si el Director no ha pogut establir la ruta per plataforma).
std::string g_base_dir; // NOLINT(*-avoid-non-const-global-variables)
const auto NOW = std::chrono::system_clock::now();
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW);
const std::tm* tm = std::localtime(&TIME);
char timestamp[20];
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm);
filename = std::string("scr_") + timestamp + ".png";
return "screenshots/" + filename;
// Construeix la ruta del directori on van les captures:
// `<base>/screenshots/`. Si la base és buida, retorna
// "screenshots/" relativa al CWD.
auto screenshotsDir() -> std::string {
if (g_base_dir.empty()) { return "screenshots/"; }
const bool ENDS_WITH_SEP = (g_base_dir.back() == '/' || g_base_dir.back() == '\\');
return g_base_dir + (ENDS_WITH_SEP ? "" : "/") + "screenshots/";
}
// Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write
@@ -47,14 +45,28 @@ namespace Screenshot {
} // namespace
auto save(const std::uint32_t* buffer, int width, int height) -> std::string {
void setBaseDir(std::string dir) {
g_base_dir = std::move(dir);
}
auto save(const std::uint32_t* buffer, int width, int height) -> Result {
const std::string FOLDER = screenshotsDir();
std::error_code ec;
std::filesystem::create_directories(FOLDER, ec);
const auto NOW = std::chrono::system_clock::now();
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW);
const std::tm* tm = std::localtime(&TIME);
char timestamp[20];
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm);
const std::string FILENAME = std::string("scr_") + timestamp + ".png";
const std::string FILEPATH = FOLDER + FILENAME;
// Còpia local per no tocar el canvas original durant la conversió.
std::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height));
std::string filename;
const std::string FILEPATH = generateFilePath(filename);
return writePng(copy.data(), width, height, FILEPATH) ? filename : "";
if (!writePng(copy.data(), width, height, FILEPATH)) { return {}; }
return {.filename = FILENAME, .folder = FOLDER};
}
} // namespace Screenshot
+23 -4
View File
@@ -3,11 +3,30 @@
#include <cstdint>
#include <string>
// Volca un buffer ARGB8888 a un fitxer PNG dins de la carpeta `screenshots/`
// (relativa al CWD). El nom es genera amb un timestamp i la funció retorna
// el nom del fitxer creat, o una cadena buida si l'escriptura ha fallat.
// Volca un buffer ARGB8888 a un fitxer PNG dins de la subcarpeta
// `screenshots/` del directori configurat amb `setBaseDir`. El nom es
// genera amb un timestamp i la funció retorna el basename i la ruta
// del directori on s'ha escrit (o un Result amb `filename` buit si
// l'escriptura ha fallat).
namespace Screenshot {
auto save(const std::uint32_t* buffer, int width, int height) -> std::string;
// Configura el directori base on s'escriuran les captures (s'hi
// crea una subcarpeta `screenshots/`). El Director el crida una
// vegada al boot amb `system_folder_` perquè les captures vagin
// al mateix lloc per plataforma que la resta de fitxers generats
// (config.yaml, gamepad_configs.yaml, shaders/, etc.). Si no es
// crida, el fallback és el CWD — comportament antic.
void setBaseDir(std::string dir);
// Resultat d'una captura: el `filename` és el basename del PNG
// (per a notificacions/títol) i el `folder` és la ruta absoluta
// on s'ha guardat (per a poder dir "guardada a {folder}"). Si
// l'escriptura falla, ambdós camps tornen buits.
struct Result {
std::string filename;
std::string folder;
};
auto save(const std::uint32_t* buffer, int width, int height) -> Result;
} // namespace Screenshot
+18
View File
@@ -18,9 +18,12 @@
#include "core/input/key_config.hpp"
#include "core/input/mouse.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/effects/shadow.hpp"
#include "core/rendering/lighting.hpp"
#include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/screenshot.hpp"
#include "core/rendering/surface.hpp"
#include "core/rendering/text.hpp"
#include "core/resources/resource_cache.hpp"
@@ -129,6 +132,10 @@ Director::Director() {
// Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a
// data/config/ui.yaml cau al valor compilat a Defaults::UI.
UiConfig::get().load(Defaults::Paths::UI_YAML);
// Forma del gradient d'il·luminació (3 anells radials). El radi
// per emissor i el `light_level` per sala viuen als YAMLs propis;
// ací només la corba compartida per a tots els emissors.
Lighting::loadConfig(Defaults::Paths::LIGHTING_YAML);
// Configuració específica de les escenes TITLE i LOGO. Es carreguen
// additivament sobre el mateix UiConfig: cada fitxer només conté la
// seua subclau (`title:` o `logo:`), i les claus absents a la base
@@ -162,6 +169,11 @@ Director::Director() {
Resource::Cache::get().loadSurface(ROOM_TEXT_FONT_GIF_KEY, UiConfig::get().roomTextFont().gif);
Resource::Cache::get().getSurface(ROOM_TEXT_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
// Sprite compartit de la sombra (tira horitzontal de fotogrames de
// 16x16, de petit a gran). El consumeix `Effects::Shadow::render`,
// que el reaprofiten Player i qualsevol entitat futura compatible.
Resource::Cache::get().loadSurface(Effects::Shadow::CACHE_KEY, "data/entities/shadow/shadow.gif");
// Pistes de música del cicle de sala (intro/playing/outro). Es preloden
// ací perquè formen part del preload d'assets, com la font i la paleta;
// la lògica de quina sona en cada estat viu a `GameScene`.
@@ -373,6 +385,12 @@ void Director::createSystemFolder() {
if (error) {
Log::error("Director", "no s'ha pogut crear {}: {}", system_folder_, error.message());
}
// Les captures de pantalla viuen sota la mateixa carpeta per
// plataforma. Sense aquesta crida, `Screenshot::save` cauria al
// CWD del procés (raïz del projecte si llances `build/aee_arcade`
// a mà, lloc imprevisible en una release de macOS/Windows).
Screenshot::setBaseDir(system_folder_);
}
void Director::processEvents() {
+28 -16
View File
@@ -82,8 +82,15 @@ UiConfig::UiConfig()
menu_cursor_(Defaults::UI::MENU_CURSOR),
menu_disabled_(Defaults::UI::MENU_DISABLED),
hud_bg_(Defaults::UI::HUD_BG),
hud_label_(Defaults::UI::HUD_LABEL),
hud_value_(Defaults::UI::HUD_VALUE),
hud_p1_label_(Defaults::UI::HUD_P1_LABEL),
hud_p1_value_(Defaults::UI::HUD_P1_VALUE),
hud_p1_shadow_(Defaults::UI::HUD_P1_SHADOW),
hud_center_label_(Defaults::UI::HUD_CENTER_LABEL),
hud_center_value_(Defaults::UI::HUD_CENTER_VALUE),
hud_center_shadow_(Defaults::UI::HUD_CENTER_SHADOW),
hud_p2_label_(Defaults::UI::HUD_P2_LABEL),
hud_p2_value_(Defaults::UI::HUD_P2_VALUE),
hud_p2_shadow_(Defaults::UI::HUD_P2_SHADOW),
hud_flash_(Defaults::UI::HUD_FLASH),
room_text_(Defaults::UI::ROOM_TEXT),
room_text_outline_(Defaults::UI::ROOM_TEXT_OUTLINE),
@@ -92,10 +99,8 @@ UiConfig::UiConfig()
hud_entry_ms_(Defaults::UI::HUD_ENTRY_MS),
hud_flash_ms_(Defaults::UI::HUD_FLASH_MS),
hud_shake_ms_(Defaults::UI::HUD_SHAKE_MS),
pause_enter_ms_(Defaults::UI::PAUSE_ENTER_MS),
pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS),
pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)),
pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)),
hud_blink_cycle_ms_(Defaults::UI::HUD_BLINK_CYCLE_MS),
hud_blink_visible_ratio_(Defaults::UI::HUD_BLINK_VISIBLE_RATIO),
title_duration_ms_(Defaults::UI::Title::DURATION_MS),
title_bg_color_(Defaults::UI::Title::BG_COLOR),
title_text_color_(Defaults::UI::Title::TEXT_COLOR),
@@ -218,13 +223,26 @@ void UiConfig::load(const std::string& file_path) {
readColorIndex(c, "menu_cursor", menu_cursor_);
readColorIndex(c, "menu_disabled", menu_disabled_);
readColorIndex(c, "hud_bg", hud_bg_);
readColorIndex(c, "hud_label", hud_label_);
readColorIndex(c, "hud_value", hud_value_);
readColorIndex(c, "hud_flash", hud_flash_);
readColorIndex(c, "room_text", room_text_);
readColorIndex(c, "room_text_outline", room_text_outline_);
}
// Famílies de color del HUD: blocs `hud_p1`, `hud_center`,
// `hud_p2` amb claus `label`, `value`, `shadow`. Cada bloc i
// cada clau són opcionals — el que manqui cau al default
// compilat a `Defaults::UI`.
auto read_hud_family = [&](const std::string& key, Uint8& label, Uint8& value, Uint8& shadow) {
if (!yaml.contains(key)) { return; }
const auto& f = yaml[key];
readColorIndex(f, "label", label);
readColorIndex(f, "value", value);
readColorIndex(f, "shadow", shadow);
};
read_hud_family("hud_p1", hud_p1_label_, hud_p1_value_, hud_p1_shadow_);
read_hud_family("hud_center", hud_center_label_, hud_center_value_, hud_center_shadow_);
read_hud_family("hud_p2", hud_p2_label_, hud_p2_value_, hud_p2_shadow_);
if (yaml.contains("timings")) {
const auto& t = yaml["timings"];
readField(t, "notification_ms", notification_ms_);
@@ -247,14 +265,8 @@ void UiConfig::load(const std::string& file_path) {
readField(h, "entry_ms", hud_entry_ms_);
readField(h, "flash_ms", hud_flash_ms_);
readField(h, "shake_ms", hud_shake_ms_);
}
if (yaml.contains("pause")) {
const auto& p = yaml["pause"];
readField(p, "enter_ms", pause_enter_ms_);
readField(p, "exit_ms", pause_exit_ms_);
readCurve(p, "enter_curve", pause_enter_curve_);
readCurve(p, "exit_curve", pause_exit_curve_);
readField(h, "blink_cycle_ms", hud_blink_cycle_ms_);
readField(h, "blink_visible_ratio", hud_blink_visible_ratio_);
}
if (yaml.contains("sounds")) {
+26 -15
View File
@@ -66,8 +66,19 @@ class UiConfig {
[[nodiscard]] auto menuCursor() const -> Uint8 { return menu_cursor_; }
[[nodiscard]] auto menuDisabled() const -> Uint8 { return menu_disabled_; }
[[nodiscard]] auto hudBg() const -> Uint8 { return hud_bg_; }
[[nodiscard]] auto hudLabel() const -> Uint8 { return hud_label_; }
[[nodiscard]] auto hudValue() const -> Uint8 { return hud_value_; }
// Famílies de color del HUD per bloc (LEFT/CENTER/RIGHT). Cada
// família té label (etiqueta apagada), value (valor destacat) i
// shadow (to fosc per a la sombra-embossing). El flash és comú a
// les tres famílies (groc neó al guanyar punts).
[[nodiscard]] auto hudP1Label() const -> Uint8 { return hud_p1_label_; }
[[nodiscard]] auto hudP1Value() const -> Uint8 { return hud_p1_value_; }
[[nodiscard]] auto hudP1Shadow() const -> Uint8 { return hud_p1_shadow_; }
[[nodiscard]] auto hudCenterLabel() const -> Uint8 { return hud_center_label_; }
[[nodiscard]] auto hudCenterValue() const -> Uint8 { return hud_center_value_; }
[[nodiscard]] auto hudCenterShadow() const -> Uint8 { return hud_center_shadow_; }
[[nodiscard]] auto hudP2Label() const -> Uint8 { return hud_p2_label_; }
[[nodiscard]] auto hudP2Value() const -> Uint8 { return hud_p2_value_; }
[[nodiscard]] auto hudP2Shadow() const -> Uint8 { return hud_p2_shadow_; }
[[nodiscard]] auto hudFlash() const -> Uint8 { return hud_flash_; }
[[nodiscard]] auto roomText() const -> Uint8 { return room_text_; }
[[nodiscard]] auto roomTextOutline() const -> Uint8 { return room_text_outline_; }
@@ -78,8 +89,8 @@ class UiConfig {
[[nodiscard]] auto hudEntryMs() const -> int { return hud_entry_ms_; }
[[nodiscard]] auto hudFlashMs() const -> int { return hud_flash_ms_; }
[[nodiscard]] auto hudShakeMs() const -> int { return hud_shake_ms_; }
[[nodiscard]] auto pauseEnterMs() const -> int { return pause_enter_ms_; }
[[nodiscard]] auto pauseExitMs() const -> int { return pause_exit_ms_; }
[[nodiscard]] auto hudBlinkCycleMs() const -> int { return hud_blink_cycle_ms_; }
[[nodiscard]] auto hudBlinkVisibleRatio() const -> float { return hud_blink_visible_ratio_; }
// Escena TITLE (placeholder visual fins que tinguem artwork).
struct TitleHeatHaze {
@@ -158,11 +169,6 @@ class UiConfig {
[[nodiscard]] auto logoTimeline() const -> const LogoTimeline& { return logo_timeline_; }
// Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa
// Easing::linear com a fallback.
[[nodiscard]] auto pauseEnterCurve() const -> CurveFn { return pause_enter_curve_; }
[[nodiscard]] auto pauseExitCurve() const -> CurveFn { return pause_exit_curve_; }
// Resol un nom de corba a la seua funció. Pública per permetre a
// futurs camps d'UI triar corbes pel mateix mecanisme (ex.: HUD
// entry, notifications slide...). Si el nom no està registrat,
@@ -209,8 +215,15 @@ class UiConfig {
Uint8 menu_cursor_;
Uint8 menu_disabled_;
Uint8 hud_bg_;
Uint8 hud_label_;
Uint8 hud_value_;
Uint8 hud_p1_label_;
Uint8 hud_p1_value_;
Uint8 hud_p1_shadow_;
Uint8 hud_center_label_;
Uint8 hud_center_value_;
Uint8 hud_center_shadow_;
Uint8 hud_p2_label_;
Uint8 hud_p2_value_;
Uint8 hud_p2_shadow_;
Uint8 hud_flash_;
Uint8 room_text_;
Uint8 room_text_outline_;
@@ -220,10 +233,8 @@ class UiConfig {
int hud_entry_ms_;
int hud_flash_ms_;
int hud_shake_ms_;
int pause_enter_ms_;
int pause_exit_ms_;
CurveFn pause_enter_curve_;
CurveFn pause_exit_curve_;
int hud_blink_cycle_ms_;
float hud_blink_visible_ratio_;
int title_duration_ms_;
Uint8 title_bg_color_;
+199 -9
View File
@@ -176,6 +176,23 @@ namespace {
return {PX, PY};
}
// Compta sub-sprites del atlas i decideix si el template demana
// sub-picker de frames. Extret de `ensureEditableEntities` per acotar
// la complexitat cognitiva del callsite. Per a strips d'1 frame
// (la majoria del catàleg) retorna (1, false).
struct AtlasVariants {
int frame_count;
bool has_variants;
};
auto computeAtlasVariants(const Surface& surface, int frame_w, int frame_h, const fkyaml::node& tmpl) -> AtlasVariants {
const int COLS = (frame_w > 0) ? (surface.getWidth() / frame_w) : 1;
const int ROWS = (frame_h > 0) ? (surface.getHeight() / frame_h) : 1;
const int FRAME_COUNT = std::max(1, COLS * ROWS);
const bool FLAG = tmpl.contains("editable_variants") && tmpl["editable_variants"].get_value<bool>();
return {.frame_count = FRAME_COUNT, .has_variants = FLAG && FRAME_COUNT > 1};
}
} // namespace
void Editor::enter() {
@@ -190,6 +207,10 @@ void Editor::enter() {
picker_ = Picker::NONE;
brush_tile_ = -1;
brush_entity_.clear();
brush_entity_frame_ = -1;
entity_frame_picker_source_ = nullptr;
entity_frame_picker_hover_ = -1;
entity_frame_picker_scroll_ = 0;
hand_selected_idx_ = -1;
hand_hover_idx_ = -1;
ensureEditableEntities();
@@ -256,7 +277,15 @@ void Editor::ensureEditableEntities() {
}
if (surface == nullptr) { continue; }
editable_entities_.push_back({.name = name, .sprite = surface, .frame_w = FRAME_W, .frame_h = FRAME_H});
const auto [FRAME_COUNT, HAS_VARIANTS] = computeAtlasVariants(*surface, FRAME_W, FRAME_H, tmpl);
editable_entities_.push_back({
.name = name,
.sprite = surface,
.frame_w = FRAME_W,
.frame_h = FRAME_H,
.frame_count = FRAME_COUNT,
.has_variants = HAS_VARIANTS,
});
}
}
@@ -301,6 +330,13 @@ namespace {
s.col = (static_cast<int>(R.x) - Map::tilemap_origin_x) / Map::tile_size;
s.row = (static_cast<int>(R.y) - Map::tilemap_origin_y) / Map::tile_size;
}
// Variant del atlas: si l'entitat té un AnimationComponent
// d'un sol frame, és una entitat amb variant escollida
// (debris, columna). Persistim l'índex perquè la pròxima
// càrrega restauri la mateixa cara del sub-sprite.
if (const auto& anim = e->getAnimation(); anim.has_value() && anim->frameCount() == 1) {
s.frame = anim->currentFrame();
}
}
out.push_back(std::move(s));
}
@@ -374,6 +410,7 @@ auto Editor::applyHotkey(int scancode) -> bool {
// tracte per a la seva acció de confirmar-eixida.
if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) {
picker_ = Picker::NONE;
entity_frame_picker_source_ = nullptr;
return true;
}
return false;
@@ -426,6 +463,8 @@ void Editor::update(int /*delta_ms*/, Tilemap& tilemap, EntityManager& entities)
updateTilePicker(tilemap, CX, CY);
} else if (picker_ == Picker::ENTITY) {
updateEntityPicker(CX, CY);
} else if (picker_ == Picker::ENTITY_FRAME) {
updateEntityFramePicker(CX, CY);
} else {
switch (mode_) {
case Mode::TILE:
@@ -514,7 +553,9 @@ namespace {
// Composa el spawn YAML per al brush actual. Retorna empty si el snap
// no és vàlid (fora de rejilla, solapament, o duplicat de porta).
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing) -> std::string {
// `frame` (-1 = no emetre) afegeix el camp `frame: N` per a entitats
// amb variants estàtiques (debris, columna).
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing, int frame) -> std::string {
std::ostringstream y;
y << "type: " << brush << '\n';
if (brush == "sarcophagus") {
@@ -530,6 +571,7 @@ namespace {
const auto DEST = tileCellRect(cursor_col, cursor_row, Map::tile_size);
if (cellOccupied(existing, DEST)) { return {}; }
y << "position: [" << cursor_col << ", " << cursor_row << "]\n";
if (frame >= 0) { y << "frame: " << frame << '\n'; }
return y.str();
}
@@ -546,6 +588,7 @@ void Editor::updateEntityBrush(EntityManager& entities, int /*canvas_x*/, int /*
// H (mà): manté la simetria amb el comportament del brush
// de tiles.
brush_entity_.clear();
brush_entity_frame_ = -1;
}
}
}
@@ -665,7 +708,14 @@ auto Editor::tryMoveSelected(const Tilemap& tilemap, EntityManager& entities) co
filtered.reserve(list.size() - 1);
std::ranges::copy_if(list, std::back_inserter(filtered), [target](const Entity* e) { return e != target; });
const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered);
// Si l'entitat moguda té una variant fixada (AnimationComponent d'un
// sol frame, convenció per a debris/columnes), la preservem al spawn
// reconstruït — així moure no perd la cara escollida.
int frame = -1;
if (const auto& anim = target->getAnimation(); anim.has_value() && anim->frameCount() == 1) {
frame = anim->currentFrame();
}
const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered, frame);
if (STR.empty()) { return false; }
if (!entities.removeEntity(target)) { return false; }
try {
@@ -739,8 +789,10 @@ void Editor::renderEditorHud(const EntityManager& entities) const {
? "(" + std::to_string(cursor_col_) + "," + std::to_string(cursor_row_) + ")"
: "(--,--)";
const auto LBL = UiConfig::get().hudLabel();
const auto VAL = UiConfig::get().hudValue();
// El HUD ara té tres famílies (P1/CENTER/P2); l'editor n'agafa la
// del CENTER (ambre/or) per ser neutra i contrastar amb el mapa.
const auto LBL = UiConfig::get().hudCenterLabel();
const auto VAL = UiConfig::get().hudCenterValue();
const auto FLASH = UiConfig::get().hudFlash();
const int KERN = UiConfig::get().hudFont().kerning;
// Mateixos Y que el HUD del joc per simetria visual: línia 1 = 4,
@@ -804,7 +856,7 @@ void Editor::renderEntityFrame(const Entity& e, std::uint32_t argb) {
auto Editor::tryPlaceEntity(EntityManager& entities) -> bool {
if (brush_entity_.empty() || cursor_col_ < 0 || cursor_row_ < 0) { return false; }
const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities());
const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities(), brush_entity_frame_);
if (STR.empty()) { return false; }
try {
auto node = fkyaml::node::deserialize(STR.begin(), STR.end());
@@ -838,9 +890,21 @@ void Editor::updateEntityPicker(int canvas_x, int canvas_y) {
entity_picker_scroll_ = std::clamp(entity_picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (entity_picker_hover_ >= 0 && entity_picker_hover_ < static_cast<int>(editable_entities_.size())) {
brush_entity_ = editable_entities_[entity_picker_hover_].name;
mode_ = Mode::ENTITY;
picker_ = Picker::NONE;
const auto& sel = editable_entities_[entity_picker_hover_];
if (sel.has_variants) {
// Entitat amb variants: encara no fixem brush. Obrim
// el sub-picker de frames; el brush es fixarà només
// quan l'usuari trii un frame concret allà.
entity_frame_picker_source_ = &sel;
entity_frame_picker_scroll_ = 0;
entity_frame_picker_hover_ = -1;
picker_ = Picker::ENTITY_FRAME;
} else {
brush_entity_ = sel.name;
brush_entity_frame_ = -1;
mode_ = Mode::ENTITY;
picker_ = Picker::NONE;
}
}
}
}
@@ -894,6 +958,106 @@ void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const {
screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER);
}
void Editor::updateEntityFramePicker(int canvas_x, int canvas_y) {
// Si la font ha desaparegut (no hauria de passar mentre el picker
// estigui obert, però defensem el cas), tanquem el sub-picker en lloc
// de fer crash.
if (entity_frame_picker_source_ == nullptr) {
picker_ = Picker::ENTITY;
return;
}
const auto LAY = layoutEntityPicker(entity_frame_picker_source_->frame_count);
entity_frame_picker_hover_ = -1;
const int INNER_X = LAY.x + PICKER_BORDER;
const int INNER_Y = LAY.y + PICKER_BORDER;
const int RX = canvas_x - INNER_X;
const int RY = canvas_y - INNER_Y;
if (RX >= 0 && RY >= 0 && LAY.pitch > 0 && LAY.cols > 0) {
const int VC = RX / LAY.pitch;
const int VR = RY / LAY.pitch;
const bool IN_SPACING_X = (RX % LAY.pitch) >= ENTITY_CELL;
const bool IN_SPACING_Y = (RY % LAY.pitch) >= ENTITY_CELL;
if (!IN_SPACING_X && !IN_SPACING_Y && VC < LAY.cols && VR < LAY.rows_visible) {
const int IDX = ((entity_frame_picker_scroll_ + VR) * LAY.cols) + VC;
if (IDX < LAY.total_items) { entity_frame_picker_hover_ = IDX; }
}
}
for (const auto& ev : EventBuffer::events) {
if (ev.type == SDL_EVENT_MOUSE_WHEEL) {
entity_frame_picker_scroll_ -= static_cast<int>(ev.wheel.y);
entity_frame_picker_scroll_ = std::clamp(entity_frame_picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (entity_frame_picker_hover_ >= 0) {
brush_entity_ = entity_frame_picker_source_->name;
brush_entity_frame_ = entity_frame_picker_hover_;
mode_ = Mode::ENTITY;
picker_ = Picker::NONE;
}
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_RIGHT) {
// Botó dret: tanca el sub-picker i torna al picker d'entitats
// sense fixar brush. Coherent amb la convenció de "right =
// cancel·la l'acció actual" dels altres modes.
picker_ = Picker::ENTITY;
entity_frame_picker_source_ = nullptr;
}
}
}
void Editor::renderEntityFramePicker() const {
if (entity_frame_picker_source_ == nullptr) { return; }
const auto& src = *entity_frame_picker_source_;
const auto LAY = layoutEntityPicker(src.frame_count);
if (LAY.cols <= 0 || LAY.rows_visible <= 0) { return; }
auto& screen = Screen::get();
const auto& cfg = EditorConfig::get();
screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, cfg.pickerBgArgb());
// Cols del atlas (sub-sprites per fila): es dedueix de la mida del
// surface i del frame, igual que fa `EntityManager::renderEntity`.
const int ATLAS_COLS = (src.frame_w > 0) ? (src.sprite->getWidth() / src.frame_w) : 1;
const int ATLAS_COLS_SAFE = (ATLAS_COLS > 0) ? ATLAS_COLS : 1;
for (int vr = 0; vr < LAY.rows_visible; ++vr) {
const int LOGICAL_ROW = entity_frame_picker_scroll_ + vr;
for (int vc = 0; vc < LAY.cols; ++vc) {
const int IDX = (LOGICAL_ROW * LAY.cols) + vc;
if (IDX >= LAY.total_items) { break; }
const auto [PX, PY] = entityCellOrigin(LAY, vc, vr);
const int OX = PX + ((ENTITY_CELL - src.frame_w) / 2);
const int OY = PY + ((ENTITY_CELL - src.frame_h) / 2);
const int A_COL = IDX % ATLAS_COLS_SAFE;
const int A_ROW = IDX / ATLAS_COLS_SAFE;
const SDL_FRect SRC{
.x = static_cast<float>(A_COL * src.frame_w),
.y = static_cast<float>(A_ROW * src.frame_h),
.w = static_cast<float>(src.frame_w),
.h = static_cast<float>(src.frame_h),
};
src.sprite->render(OX, OY, &SRC, SDL_FLIP_NONE);
}
}
if (entity_frame_picker_hover_ >= 0) {
const int VR = (entity_frame_picker_hover_ / LAY.cols) - entity_frame_picker_scroll_;
const int VC = entity_frame_picker_hover_ % LAY.cols;
if (VR >= 0 && VR < LAY.rows_visible) {
const auto [PX, PY] = entityCellOrigin(LAY, VC, VR);
const auto C = cfg.pickerHoverArgb();
screen.fillRect(PX, PY, ENTITY_CELL, 1, C);
screen.fillRect(PX, PY + ENTITY_CELL - 1, ENTITY_CELL, 1, C);
screen.fillRect(PX, PY, 1, ENTITY_CELL, C);
screen.fillRect(PX + ENTITY_CELL - 1, PY, 1, ENTITY_CELL, C);
}
}
const auto BORDER = cfg.borderArgb();
screen.fillRect(LAY.x, LAY.y, LAY.w, 1, BORDER);
screen.fillRect(LAY.x, LAY.y + LAY.h - 1, LAY.w, 1, BORDER);
screen.fillRect(LAY.x, LAY.y, 1, LAY.h, BORDER);
screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER);
}
void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
const auto LAY = layoutPicker(tilemap);
tile_picker_hover_ = -1;
@@ -928,6 +1092,28 @@ void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y
}
}
void Editor::renderEntityBrushPreview(const Tilemap& tilemap) const {
auto it = std::ranges::find_if(editable_entities_, [this](const auto& e) { return e.name == brush_entity_; });
if (it == editable_entities_.end() || it->sprite == nullptr) { return; }
const int FRAME = (brush_entity_frame_ >= 0) ? brush_entity_frame_ : 0;
const int ATLAS_COLS = (it->frame_w > 0) ? (it->sprite->getWidth() / it->frame_w) : 1;
const int ATLAS_COLS_SAFE = (ATLAS_COLS > 0) ? ATLAS_COLS : 1;
const int A_COL = FRAME % ATLAS_COLS_SAFE;
const int A_ROW = FRAME / ATLAS_COLS_SAFE;
const SDL_FRect SRC{
.x = static_cast<float>(A_COL * it->frame_w),
.y = static_cast<float>(A_ROW * it->frame_h),
.w = static_cast<float>(it->frame_w),
.h = static_cast<float>(it->frame_h),
};
const int TS = tilemap.getTileSize();
const int TILE_PX = tilemap.getOriginX() + (cursor_col_ * TS);
const int TILE_PY = tilemap.getOriginY() + (cursor_row_ * TS);
const int DST_X = TILE_PX + ((TS - it->frame_w) / 2);
const int DST_Y = TILE_PY + TS - it->frame_h;
it->sprite->render(DST_X, DST_Y, &SRC, SDL_FLIP_NONE);
}
void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const {
if (!active_) { return; }
auto& screen = Screen::get();
@@ -938,11 +1124,15 @@ void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const
renderTilePicker(tilemap);
} else if (picker_ == Picker::ENTITY) {
renderEntityPicker(tilemap);
} else if (picker_ == Picker::ENTITY_FRAME) {
renderEntityFramePicker();
} else if (mode_ == Mode::TILE && brush_tile_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
// Preview del brush sota el cursor (només quan el picker és tancat).
const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize());
const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize());
tilemap.renderTilePreview(PX, PY, brush_tile_);
} else if (mode_ == Mode::ENTITY && !brush_entity_.empty() && cursor_col_ >= 0 && cursor_row_ >= 0) {
renderEntityBrushPreview(tilemap);
} else if (mode_ == Mode::HAND && picker_ == Picker::NONE) {
// Marcs d'entitat: hover (tenue) i selecció (brillant). El
// pickerHoverArgb el reusem com a "selecció brillant"; el
+37 -7
View File
@@ -78,9 +78,13 @@ class Editor {
HAND };
// Quin picker està obert (si n'hi ha). Mútuament excloents.
// - ENTITY_FRAME: sub-picker que apareix després de clicar una entitat
// amb variants al picker principal. Mostra tots els sub-sprites del
// atlas (índex frame) per a triar quina cara representa la instància.
enum class Picker : std::uint8_t { NONE,
TILE,
ENTITY };
ENTITY,
ENTITY_FRAME };
// Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`.
// Es crida al principi d'`update` perquè altres handlers no vegin
@@ -100,6 +104,7 @@ class Editor {
void updateHand(const Tilemap& tilemap, EntityManager& entities, int canvas_x, int canvas_y);
void updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y);
void updateEntityPicker(int canvas_x, int canvas_y);
void updateEntityFramePicker(int canvas_x, int canvas_y);
// Intent de col·locació segons brush_entity_ a la cel·la del cursor.
// Construeix un node spawn sintètic, fa el snap concret per tipus
@@ -138,6 +143,13 @@ class Editor {
void renderTilePicker(const Tilemap& tilemap) const;
void renderEntityPicker(const Tilemap& tilemap) const;
void renderEntityFramePicker() const;
// Preview del sprite de l'entitat seleccionada com a brush, snapped al
// tile sota el cursor. Anchor "feet": base del sprite a la base inferior
// del tile, centrat horitzontalment — així stone cau exactament i column
// sobresurt cap amunt, coincidint amb el resultat de col·locar.
void renderEntityBrushPreview(const Tilemap& tilemap) const;
bool active_{false};
Mode mode_{Mode::TILE};
@@ -153,21 +165,22 @@ class Editor {
int tile_picker_scroll_{0};
int tile_picker_hover_{-1};
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
std::string brush_entity_;
// Estat de l'EntityPicker.
int entity_picker_scroll_{0};
int entity_picker_hover_{-1}; // Índex a la llista de noms.
// Entrada editable del picker. Cada item recorda el nom (clau de
// factory + YAML), el surface del sprite (frame 0 com a icona) i la
// mida de cada frame al GIF — necessària per clipar el primer frame
// quan el GIF és un strip multi-frame.
// - `frame_count` és el nombre total de sub-sprites del atlas
// (cols × rows segons les dimensions del surface i del frame).
// - `has_variants` és `true` quan el template declara `editable_variants:
// true` i hi ha més d'un frame: aleshores clicar al picker obre el
// sub-picker de frames en lloc de fixar el brush directament.
struct EditableEntity {
std::string name;
std::shared_ptr<Surface> sprite;
int frame_w{0};
int frame_h{0};
int frame_count{1};
bool has_variants{false};
};
// Construïda lazy al primer enter() i preservada entre sessions:
@@ -178,6 +191,23 @@ class Editor {
std::vector<EditableEntity> editable_entities_;
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
std::string brush_entity_;
// Frame escollit per al brush actiu (-1 = no aplicable: entitat sense
// variants o brush encara per fixar). El emet el sub-picker en clicar
// un frame; `tryPlaceEntity` el passa al spawn YAML com a `frame: N`.
int brush_entity_frame_{-1};
// Estat de l'EntityPicker.
int entity_picker_scroll_{0};
int entity_picker_hover_{-1}; // Índex a la llista de noms.
// Estat del sub-picker de frames. `entity_frame_picker_source_` apunta
// a la entitat activa (vista al EntityPicker) per a la qual el
// sub-picker mostra les variants. Quan el sub-picker es tanca sense
// selecció, ho deixem a nullptr i `brush_entity_frame_ = -1`.
int entity_frame_picker_scroll_{0};
int entity_frame_picker_hover_{-1};
const EditableEntity* entity_frame_picker_source_{nullptr};
// Font compartida amb el Notifier/HUD: el surface el carrega Director
// a l'arrencada, ací només prenem la referència del cache. shared_ptr
// perquè `Text` accepta el surface per shared_ptr i així no copiem.
+63 -11
View File
@@ -10,6 +10,7 @@
#include "core/audio/audio.hpp"
#include "core/resources/resource_cache.hpp"
#include "game/entities/concrete/whip_attack.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp"
@@ -65,6 +66,24 @@ namespace {
// més d'una lane (cas marginal a velocitats típiques, però pot passar
// amb dt grans). Comparació estricta a un costat: no dispara quan la
// caixa ja es troba alineada a la lane (hi està després del snap).
// Mapatge WhipAttack::Direction → Mummy::Direction. Viuen amb noms i
// valors paral·lels però són dos enums distints (cada classe modela el
// seu propi domini). Conversió 1-a-1; `NONE` no apareix com a possible
// sortida del whip, però la deixem per coherència defensiva.
auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction {
switch (d) {
case WhipAttack::Direction::NORTH:
return Mummy::Direction::NORTH;
case WhipAttack::Direction::SOUTH:
return Mummy::Direction::SOUTH;
case WhipAttack::Direction::EAST:
return Mummy::Direction::EAST;
case WhipAttack::Direction::WEST:
return Mummy::Direction::WEST;
}
return Mummy::Direction::NONE;
}
auto findLaneCross(float prev, float delta, float base, float pitch, int max_idx) -> std::optional<float> {
const float NEXT = prev + delta;
if (delta > 0.0F) {
@@ -87,7 +106,8 @@ Mummy::Mummy(const fkyaml::node& yaml)
: anim_walk_south_(buildAnimation(yaml, "walk_south")),
anim_walk_north_(buildAnimation(yaml, "walk_north")),
anim_walk_east_(buildAnimation(yaml, "walk_east")),
anim_walk_west_(buildAnimation(yaml, "walk_west")) {
anim_walk_west_(buildAnimation(yaml, "walk_west")),
anim_raise_(buildAnimation(yaml, "raise")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy");
collides_ = true;
interactable_ = false;
@@ -122,16 +142,10 @@ Mummy::Mummy(const fkyaml::node& yaml)
changeDirection(0.0F, 1.0F);
}
// Estat SPAWN inicial: la mòmia naix visible i quieta, esperant que el
// GameScene li armi el comptador via `triggerSpawnExit()` (cridat al
// final de la subfase text-exit de ROOM_INTRO). Si el YAML no declara
// `spawn.duration_s`, default 0 ⇒ al primer update post-trigger passa
// directament a ACTIVE.
float spawn_duration_s = 0.0F;
if (yaml.contains("spawn") && yaml["spawn"].contains("duration_s")) {
spawn_duration_s = yaml["spawn"]["duration_s"].get_value<float>();
}
enterSpawn(spawn_duration_s);
// L'estat SPAWNING amb l'animació `raise` s'arma a `onJustSpawned`,
// no al ctor. Així el manager pot instanciar la mòmia sense disparar
// la cinemàtica (cas del mode editor) i veure-la ja en ROAMING amb
// la `walk_*` triada per `changeDirection` més amunt.
// Configuració de l'estat HURT: opcional. Si el bloc no apareix, la
// mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la
@@ -158,6 +172,20 @@ Mummy::Mummy(const fkyaml::node& yaml)
}
}
void Mummy::onJustSpawned() {
// Cinemàtica d'aparició: la mòmia "s'aixeca" reproduint `raise`
// (16 frames no-loop). El countdown del DynamicHazard NO s'utilitza
// (passem 0): `Mummy::update` controla la transició a ACTIVE per
// `anim_->completed()`. Cap crida a la base perquè el `triggerSpawnExit`
// del DynamicHazard arma aquest mateix countdown que aquí no toca.
enterSpawn(0.0F);
anim_ = anim_raise_;
if (anim_.has_value()) {
anim_->reset();
flip_ = anim_->flipMode();
}
}
void Mummy::changeDirection(float dx, float dy) {
setDirection(dx, dy);
// Una animació per cada cardinal — el flip viu al YAML
@@ -520,6 +548,15 @@ void Mummy::applyChoice(Direction dir) {
}
}
auto Mummy::onWhipHit(WhipAttack* attack) -> bool {
// Defensiu: el filtre `shouldReceiveContact` ja salta DynamicHazards en
// SPAWN, però blindem aquesta crida si arribara per un camí no central.
if (isSpawning()) { return false; }
if (!whippable_ || attack == nullptr) { return false; }
enterHurt(whipDirToMummyDir(attack->getDirection()));
return true;
}
void Mummy::onPlayerContact(Player* player) {
// Defensiu: el filtre central a EntityManager::checkPlayerCollisions ja
// salta DynamicHazards en SPAWN, però blindem la crida directa.
@@ -664,6 +701,21 @@ void Mummy::hurtStep(float delta_time) {
}
void Mummy::update(float delta_time) {
if (isSpawning()) {
// SPAWN dirigit per animació: la `raise` és no-loop, així que
// `completed()` ens marca el final. Mentre dura, NO cridem
// `DynamicHazard::update`: la base hi té el countdown i ja
// hauria saltat a ACTIVE (passem `enterSpawn(0)`), i el `step()`
// d'IA mouria la mòmia abans de "aixecar-se". En completar,
// tornem a la `walk_*` que toca segons la direcció ja triada
// al ctor (initial_direction de l'IA).
if (anim_.has_value()) { anim_->tick(delta_time); }
if (anim_.has_value() && anim_->completed()) {
endSpawn();
changeDirection(getDirX(), getDirY());
}
return;
}
if (state_ == State::HURT) {
// Avancem l'animació inline (no podem cridar `Entity::update` ni
// `DynamicHazard::update`: el primer salta una capa virtual i el
+18
View File
@@ -61,9 +61,21 @@ class Mummy : public DynamicHazard {
// En estat ROAMING delega a `step()` com sempre via la base.
void update(float delta_time) override;
// Inicia la fase SPAWNING amb l'animació `raise`. Es crida al
// spawnPendingActors() del joc, no al ctor: així l'editor (que
// materialitza les entitats sense aquest hook) veu la mòmia ja en
// ROAMING visible, sense quedar-se "soterrada".
void onJustSpawned() override;
// Contacte amb el jugador: publica `player_hit` amb el dany.
void onPlayerContact(Player* player) override;
// Contacte amb el fueteig: si la mòmia és whippable, entra a HURT amb
// la direcció derivada del whip. Retorna true si efectivament ha
// rebut dany — el WhipAttack ho usa per al `already_hit_`. Substitueix
// el `dynamic_cast<Mummy*>` que feia el whip abans per discriminar.
auto onWhipHit(WhipAttack* attack) -> bool override;
// Activa l'estat HURT amb la direcció de fuga preferida `flee_dir`.
// Si està bloquejada, prova perpendiculars; si totes estan bloquejades,
// la mòmia queda quieta però en HURT durant tot el timer.
@@ -171,6 +183,12 @@ class Mummy : public DynamicHazard {
AnimationComponent anim_walk_east_;
AnimationComponent anim_walk_west_;
// Animació d'aparició (no-loop). Mentre `isSpawning()`, `update()`
// la tickeja sense moure la mòmia; quan `completed()`, la mòmia
// transita a ACTIVE via `endSpawn()` i conmuta a la walk_* que
// toca segons direcció.
AnimationComponent anim_raise_;
// Estat HURT i la seua configuració. `hurt_speed_` és l'efectiva
// (resultat d'aplicar el `speed` del bloc `hurt:` o, si val 0, la
// velocitat normal de la mòmia).
+88 -12
View File
@@ -1,13 +1,26 @@
#include "game/entities/concrete/sarcophagus.hpp"
#include <random>
#include <string>
#include <vector>
#include "core/audio/audio.hpp"
#include "core/resources/resource_cache.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp"
#include "utils/defaults.hpp"
namespace {
// RNG local al mòdul, mateix patró que `mummy.cpp`: llavor fixa per
// facilitar la depuració. Si algun dia volem aleatoritat real entre
// partides, prou amb llegir-la de std::random_device.
auto rng() -> std::mt19937& {
static std::mt19937 k_rng_{0xCAFEU};
return k_rng_;
}
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
@@ -56,6 +69,29 @@ Sarcophagus::Sarcophagus(const fkyaml::node& yaml)
interactable_ = true;
readTileSize(yaml, tile_size_w_, tile_size_h_);
anim_ = anim_closed_;
// Sons opcionals (bloc `sound:` del YAML). El basename val tant de
// clau al Resource::Cache com de path dins `Defaults::Paths::AUDIO_SOUNDS_DIR`.
// Mateix patró que `mummy.hurt.sound` i `player.jump.sound`.
if (yaml.contains("sound")) {
const auto& s = yaml["sound"];
if (s.contains("opening")) {
opening_sound_key_ = s["opening"].get_value<std::string>();
Resource::Cache::get().loadSound(opening_sound_key_, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + opening_sound_key_);
}
if (s.contains("opening_pitch_spread")) {
opening_pitch_spread_ = s["opening_pitch_spread"].get_value<float>();
}
}
// Bloc `reward:` opcional — la recompensa que rebrà el jugador que
// tanqui el sarcòfag. Si no hi és, els camps queden a 0 i el GameScene
// simplement no aplica res al consumir `sarcophagus_opened`.
if (yaml.contains("reward")) {
const auto& r = yaml["reward"];
if (r.contains("points")) { points_reward_ = r["points"].get_value<int>(); }
if (r.contains("treasures")) { treasures_reward_ = r["treasures"].get_value<int>(); }
}
}
void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) {
@@ -70,14 +106,16 @@ void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate&
void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) {
perimeter_tiles_.clear();
// El perímetre ortogonal d'un rectangle (w,h) de tiles són les files
// de tiles immediatament N, S, E, O. No s'hi inclouen les cantonades
// diagonals (tile NO, NE, SO, SE), perquè el jugador fa passes pels
// quatre costats i no pot "tallar" per una diagonal: això garanteix
// que el recorregut completa només si passa per cada costat.
// El perímetre d'un rectangle (w,h) de tiles són les files de tiles
// immediatament adjacents: N, S, E, O *més* les quatre cantonades
// diagonals (NO, NE, SO, SE). En la pràctica el jugador trepitja
// aquestes cantonades en doblar les cantonades del sarcòfag, així que
// si no s'hi compten, el cerco no es pot tancar visualment encara
// que es completi a nivell de codi (o, pitjor, s'obre sense que el
// jugador hagi clos realment el contorn).
//
// - Fila Nord: (col, row-1) per col ∈ [col0, col0+w-1]
// - Fila Sud: (col, row+h) per col ∈ [col0, col0+w-1]
// - Fila Nord: (col, row-1) per col ∈ [col0-1, col0+w] (inclou NO i NE)
// - Fila Sud: (col, row+h) per col ∈ [col0-1, col0+w] (inclou SO i SE)
// - Columna O: (col0-1, row) per row ∈ [row0, row0+h-1]
// - Columna E: (col0+w, row) per row ∈ [row0, row0+h-1]
@@ -86,12 +124,12 @@ void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) {
perimeter_tiles_.insert({col, row});
};
// Nord i Sud
for (int dc = 0; dc < tile_size_w_; ++dc) {
// Nord i Sud (incloent les quatre cantonades diagonals)
for (int dc = -1; dc <= tile_size_w_; ++dc) {
maybe_insert(anchor_col_ + dc, anchor_row_ - 1);
maybe_insert(anchor_col_ + dc, anchor_row_ + tile_size_h_);
}
// Oest i Est
// Oest i Est (les cantonades ja les ha posat el bucle N/S)
for (int dr = 0; dr < tile_size_h_; ++dr) {
maybe_insert(anchor_col_ - 1, anchor_row_ + dr);
maybe_insert(anchor_col_ + tile_size_w_, anchor_row_ + dr);
@@ -106,21 +144,59 @@ void Sarcophagus::update(float delta_time) {
anim_ = anim_open_;
if (anim_.has_value()) { anim_->reset(); }
if (!opened_event_emitted_ && bus_ != nullptr) {
bus_->publish("sarcophagus_opened", static_cast<Entity*>(this));
// Publica amb `player_id = last_closer_player_id_` perquè el
// GameScene pugui atribuir la recompensa al jugador correcte.
// El payload manté el punter al sarc perquè el consumidor
// pugui llegir `getPointsReward()` i `getTreasuresReward()`.
bus_->publish(Event{
.name = "sarcophagus_opened",
.payload = static_cast<Entity*>(this),
.player_id = last_closer_player_id_,
});
opened_event_emitted_ = true;
}
}
}
void Sarcophagus::notifyTilePainted(int col, int row) {
void Sarcophagus::notifyTilePainted(int col, int row, int player_id) {
if (state_ != State::CLOSED) { return; }
const TileKey KEY{.col = col, .row = row};
if (perimeter_tiles_.find(KEY) == perimeter_tiles_.end()) { return; }
visited_.insert(KEY);
if (visited_.size() >= perimeter_tiles_.size()) {
// Atribució: el jugador que pinta el tile que tanca el cercle es
// queda amb la recompensa. Si el broadcast inicial de càrrega
// arribara a tancar un sarc (no hauria de passar perquè els tiles
// d'aquest broadcast són decals/murs, no de corredor), `player_id`
// seria -1 i el GameScene saltarà el repartiment.
last_closer_player_id_ = player_id;
state_ = State::OPENING;
anim_ = anim_opening_;
if (anim_.has_value()) { anim_->reset(); }
if (!opening_sound_key_.empty() && Audio::get() != nullptr) {
// Variació de pitch/velocitat per evitar la fatiga d'escoltar
// el mateix sample idèntic a cada obertura. Si l'spread és 0
// (default), reproduïm amb ratio 1.0; en cas contrari sortegem
// uniformement [1 - spread, 1 + spread].
if (opening_pitch_spread_ > 0.0F) {
std::uniform_real_distribution<float> dist(1.0F - opening_pitch_spread_, 1.0F + opening_pitch_spread_);
Audio::get()->playSound(opening_sound_key_, Audio::Group::GAME, dist(rng()));
} else {
Audio::get()->playSound(opening_sound_key_, Audio::Group::GAME);
}
}
}
}
void Sarcophagus::onRemoved() {
// El manager comptabilitza els sarcòfags actius en `total_sarcs_` per
// saber quan ha d'obrir la porta automàticament. Quan el editor
// n'esborra un, cal informar-lo: sense aquesta crida, `total_sarcs_`
// quedaria descompassat i la porta no s'obriria mai. Substitueix el
// `getName() == "sarcophagus"` que el manager feia abans —
// polimorfisme net, no comparació de strings.
if (entity_manager_ != nullptr) {
entity_manager_->onSarcophagusRemoved();
}
}
+36 -2
View File
@@ -2,6 +2,7 @@
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_set>
#include "external/fkyaml_node.hpp"
@@ -17,7 +18,10 @@
// El càlcul de `perimeter_tiles_` es fa al ctor a partir de la posició del
// sarcòfag a la graella del Map (`anchor_col`, `anchor_row`) i de les
// dimensions en tiles (`tile_size_w_`, `tile_size_h_`, llegits del YAML
// plantilla). Es recorren els tiles adjacents per N/S/E/O; els que
// plantilla). S'inclouen els tiles adjacents per N/S/E/O i també les
// quatre cantonades diagonals (NO/NE/SO/SE), perquè el jugador les
// trepitja en doblar les cantonades del sarcòfag i, sense comptar-les,
// el cerco s'obriria abans d'haver-se tancat visualment. Els tiles que
// cauen dins d'un altre sarcòfag es descarten (predicat opcional). Si no
// s'aporta el predicat, s'inclouen tots.
class Sarcophagus : public Interactable {
@@ -42,7 +46,8 @@ class Sarcophagus : public Interactable {
void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked);
void update(float delta_time) override;
void notifyTilePainted(int col, int row) override;
void notifyTilePainted(int col, int row, int player_id) override;
void onRemoved() override;
[[nodiscard]] auto getState() const -> State { return state_; }
[[nodiscard]] auto getPerimeterSize() const -> std::size_t { return perimeter_tiles_.size(); }
@@ -53,6 +58,17 @@ class Sarcophagus : public Interactable {
[[nodiscard]] auto getTileWidth() const -> int { return tile_size_w_; }
[[nodiscard]] auto getTileHeight() const -> int { return tile_size_h_; }
// Recompensa que es donarà al jugador que pinta el tile que tanca el
// perímetre (el `last_closer_player_id_`). Llegits del YAML del sarc;
// 0/0 si el bloc `reward:` no existeix. El GameScene escolta
// `sarcophagus_opened` i aplica aquests valors al Player corresponent.
[[nodiscard]] auto getPointsReward() const -> int { return points_reward_; }
[[nodiscard]] auto getTreasuresReward() const -> int { return treasures_reward_; }
// Jugador atribuit (qui pinta el tile que dispara CLOSED→OPENING). -1
// si el sarcòfag s'ha tancat per pintades sense atribució (p.ex. el
// broadcast inicial de càrrega) — cas que el GameScene ignora.
[[nodiscard]] auto getLastCloserPlayerId() const -> int { return last_closer_player_id_; }
private:
struct TileKey {
int col;
@@ -82,5 +98,23 @@ class Sarcophagus : public Interactable {
AnimationComponent anim_opening_;
AnimationComponent anim_open_;
// Clau (basename del fitxer) al Resource::Cache del so que es dispara
// a la transició CLOSED → OPENING. Buit ⇒ no sona res.
std::string opening_sound_key_;
// Variació uniforme del ratio de reproducció al voltant de 1.0. Cada
// obertura sorteja [1 - spread, 1 + spread]. 0 ⇒ ratio fix a 1.0.
float opening_pitch_spread_{0.0F};
// Recompenses llegides del YAML (bloc `reward:`). 0/0 si el bloc no
// hi és — el GameScene salta el pas de repartiment quan són 0.
int points_reward_{0};
int treasures_reward_{0};
// Jugador que ha pintat el tile que tanca el perímetre. Es fixa una
// sola vegada, en la transició CLOSED→OPENING dins de notifyTilePainted.
// -1 mentre està a CLOSED, o si la pintada que el tanca no porta
// atribució (player_id == -1 al broadcast inicial).
int last_closer_player_id_{-1};
bool opened_event_emitted_{false};
};
@@ -0,0 +1,27 @@
#include <string>
#include "external/fkyaml_node.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/solid_decoration.hpp"
// Murs decoratius estàtics que comparteixen el mateix codi C++ però es
// registren amb claus de factory diferents per a que el YAML els pugui
// referenciar pel seu tipus i el editor els pugui distingir al picker.
// La selecció del sub-sprite (frame del atlas) viatja al spawn YAML
// (`frame: N`); l'aplica `placeGeneric` via `Entity::setAnimation`. Aquí no
// cal cap lògica específica per a la variant.
namespace {
class StaticSolid : public SolidDecoration {
public:
explicit StaticSolid(const fkyaml::node& yaml) {
if (yaml.contains("name")) {
name_ = yaml["name"].get_value<std::string>();
}
}
};
} // namespace
REGISTER_ENTITY_AS("stone", StaticSolid, Stone)
REGISTER_ENTITY_AS("column", StaticSolid, Column)
+27 -35
View File
@@ -262,19 +262,10 @@ void WhipAttack::update(float delta_time) {
namespace {
auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction {
switch (d) {
case WhipAttack::Direction::NORTH:
return Mummy::Direction::NORTH;
case WhipAttack::Direction::SOUTH:
return Mummy::Direction::SOUTH;
case WhipAttack::Direction::EAST:
return Mummy::Direction::EAST;
case WhipAttack::Direction::WEST:
return Mummy::Direction::WEST;
}
return Mummy::Direction::NONE;
}
// (El mapping WhipAttack::Direction → Mummy::Direction viu ara a
// mummy.cpp, on s'usa des de `Mummy::onWhipHit`. Aquí només queda el
// mapping a Player::Cardinal — usat per al friendly-fire entre
// jugadors, que no és polimòrfic via Entity.)
auto whipDirToPlayerCardinal(WhipAttack::Direction d) -> Player::Cardinal {
switch (d) {
@@ -296,25 +287,24 @@ namespace {
return STATE != Player::State::HURT && STATE != Player::State::EXITED && STATE != Player::State::DISABLED;
}
// Tracta una mòmia candidata: si és whippable i la seua caja solapa
// amb `damage_rect`, la posa a HURT i la marca al set. Extret per
// acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar).
// Rep un `DynamicHazard*` (l'iterador d'`applyDamage` ja filtra el
// sac d'actors); només cal el dynamic_cast a Mummy per descartar
// altres hermanes (RollingRock i futurs DynamicHazards no whippables).
void hitMummyIfApplicable(
DynamicHazard* actor,
// Tracta una entitat candidata: si la seva caixa solapa amb
// `damage_rect`, delega la decisió a `entity->onWhipHit(attack)`.
// Cada entitat decideix què fer (Mummy entra HURT, Torch retorna
// false sense efecte, RollingRock no override → no-op). El retorn
// true marca el set `already_hit` per evitar segons impactes dins del
// mateix swing. Polimorfisme net — sense dynamic_cast.
void hitEntityIfApplicable(
Entity* entity,
const SDL_FRect& damage_rect,
WhipAttack::Direction dir,
WhipAttack* attack,
std::unordered_set<void*>& already_hit) {
if (actor == nullptr || actor->isDead()) { return; }
if (already_hit.contains(static_cast<void*>(actor))) { return; }
auto* mummy = dynamic_cast<Mummy*>(actor);
if (mummy == nullptr || !mummy->isWhippable()) { return; }
const auto BOX = mummy->getCollisionRect();
if (entity == nullptr || entity->isDead()) { return; }
if (already_hit.contains(static_cast<void*>(entity))) { return; }
const auto BOX = entity->getCollisionRect();
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
mummy->enterHurt(whipDirToMummyDir(dir));
already_hit.insert(static_cast<void*>(actor));
if (entity->onWhipHit(attack)) {
already_hit.insert(static_cast<void*>(entity));
}
}
void hitPlayerIfApplicable(
@@ -339,12 +329,14 @@ namespace {
void WhipAttack::applyDamage() {
if (entity_manager_ != nullptr) {
// Només els actors poden rebre el fuetejo (decoracions, sarcs,
// forats... no són hits del whip). Iterem el sac `actors_`
// directament — sense recórrer tot el mapa per descartar-ne la
// major part.
for (auto* actor : entity_manager_->getActors()) {
hitMummyIfApplicable(actor, damage_rect_, direction_, already_hit_);
// Iterem totes les entitats. La discriminació la fa cada una via
// `onWhipHit`: Mummy entra HURT si és whippable, la resta retorna
// false (no-op). El cost d'iterar antorxes/sarcs/forats és una
// crida virtual buida cada una — menyspreable comparat amb la
// claredat del callsite. Si demà un sarc vol trencar-se al
// fueteig, només cal override de `onWhipHit` allà.
for (auto* entity : entity_manager_->getEntities()) {
hitEntityIfApplicable(entity, damage_rect_, this, already_hit_);
}
}
if (players_ != nullptr) {
+36 -1
View File
@@ -24,10 +24,35 @@ void Entity::onPlayerContact(Player* /*player*/) {
// No-op per defecte.
}
void Entity::notifyTilePainted(int /*col*/, int /*row*/) {
void Entity::notifyTilePainted(int /*col*/, int /*row*/, int /*player_id*/) {
// No-op per defecte; només `Sarcophagus` l'usa.
}
auto Entity::onWhipHit(WhipAttack* /*attack*/) -> bool {
// No-op per defecte; les entitats no-whippables (Torch, Hole, Sarc,
// Door, RollingRock...) retornen `false`. `Mummy` n'és l'override
// efectiu. El caller usa el retorn per marcar `already_hit_` —
// entitats que no han rebut dany NO bloquegen futurs frames del whip.
return false;
}
void Entity::onJustSpawned() {
// No-op per defecte. `DynamicHazard` override per disparar el seu
// `triggerSpawnExit` (anima la fase EMERGING just després de néixer).
}
void Entity::onRemoved() {
// No-op per defecte. `Sarcophagus` override per decrementar el
// comptador `total_sarcs_` del manager.
}
auto Entity::shouldReceiveContact() const -> bool {
// Default: l'entitat rep contacte si és sòlida (`collides_`) o si és
// un trigger d'àrea (`triggers_on_overlap_`). `DynamicHazard` afegeix
// el filtre de SPAWN — mentre està emergint, ignora contactes.
return collides_ || triggers_on_overlap_;
}
auto Entity::getCollisionRect() const -> SDL_FRect {
if (!collision_box_.has_value()) { return pixel_rect_; }
return {
@@ -38,6 +63,16 @@ auto Entity::getCollisionRect() const -> SDL_FRect {
};
}
auto Entity::getLightCenter() const -> SDL_FPoint {
// Centre del rect de render. Per a entitats que es desplacen
// visualment sobre la seva posició lògica (p.ex. el Player saltant),
// la subclasse override aquest mètode per restar el desplaçament.
return {
.x = pixel_rect_.x + (pixel_rect_.w * 0.5F),
.y = pixel_rect_.y + (pixel_rect_.h * 0.5F),
};
}
auto Entity::getFeetY() const -> float {
// 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
+96 -2
View File
@@ -19,6 +19,7 @@ class Player;
class EventBus;
class Surface;
class EntityManager;
class WhipAttack;
// Entitat genèrica del món. Actua com a dada en comú per a tots els actors
// del joc (decoratius, interactables, perills mòbils i estàtics): manté
@@ -83,8 +84,47 @@ class Entity {
// Hook que el `EntityManager` invoca per a cada entitat cada vegada
// que un tile es pinta (via PaintLayer, com a conseqüència d'un
// jugador trepitjant un sòl pintable). Només `Sarcophagus` ho consumeix
// (per marcar perímetre); per defecte no-op.
virtual void notifyTilePainted(int col, int row);
// (per marcar perímetre); per defecte no-op. `player_id` és l'id del
// jugador que ha pintat el tile, o -1 si la pintada ve del broadcast
// inicial de càrrega de nivell (decals + murs, sense atribució). Els
// consumidors han de filtrar per `player_id >= 0` si necessiten
// atribució.
virtual void notifyTilePainted(int col, int row, int player_id);
// L'entitat ha rebut un fueteig amb solapament. Es passa el punter al
// `WhipAttack` perquè la subclasse pugui llegir-ne direcció o atributs
// (mateix patró que `onPlayerContact(Player*)`). Retorna `true` si
// efectivament ha aplicat dany — el caller en marca el set
// `already_hit_` per evitar segons impactes dins del mateix swing.
// Default: no fa res, retorna `false`. Override a `Mummy` (whippable)
// i a futurs enemics whippables. `RollingRock` no override → roca
// immune al látigo per defecte.
virtual auto onWhipHit(WhipAttack* attack) -> bool;
// Hook just després de néixer al món. El manager el crida quan
// `addSpawn` / `spawnActor` / `spawnPendingActors` instancien una
// entitat. La base no fa res; `DynamicHazard` override per disparar
// el seu `triggerSpawnExit` (anima la fase EMERGING). Substitueix el
// `dynamic_cast<DynamicHazard*>(observer)->triggerSpawnExit()` que el
// manager feia abans — ara el dispatch és polimòrfic. Entitats que
// requereixen una fase d'aparició (mòmia amb animació `raise`)
// l'inicien aquí, NO al ctor: així `materializePendingActorsStatic`
// (editor) les salta i queden visibles directament.
virtual void onJustSpawned();
// Hook just abans que el manager esborri l'entitat (via
// `removeEntity`, típicament al editor). Permet a subclasses
// notificar al manager o publicar events per mantenir invariants
// (p.ex. `Sarcophagus` decrementa `total_sarcs_`). Default no-op.
virtual void onRemoved();
// Decisió polimòrfica de si aquesta entitat ha de rebre el callback
// `onPlayerContact` aquest frame. La base permet contacte si `collides_`
// o `triggers_on_overlap_` estan posats. Subclasses poden afegir
// condicions transitòries (p.ex. `DynamicHazard` retorna `false` mentre
// està en SPAWN). Centralitza el filtre que abans vivia al manager
// amb dynamic_casts/isSpawning explícits.
[[nodiscard]] virtual auto shouldReceiveContact() const -> bool;
[[nodiscard]] auto getName() const -> const std::string& { return name_; }
[[nodiscard]] auto getTilePos() const -> TilePos { return tile_; }
@@ -92,6 +132,15 @@ class Entity {
[[nodiscard]] auto collides() const -> bool { return collides_; }
[[nodiscard]] auto interactable() const -> bool { return interactable_; }
// Marca d'entitat que ha de bloquejar el moviment del jugador (com un
// mur). `collides_=true` només garanteix que l'entitat reb el callback
// `onPlayerContact` quan el jugador la toca, no que el seu rect sigui
// sòlid: dynamic hazards (mòmies, roques) tenen `collides_=true` però
// no han de bloquejar el pas — el contacte resol el dany i la roca es
// mou independentment. Per a "murs passius" (sarcs, columnes, debris)
// posem aquest flag a true i `Map::collides` els tracta com a obstacle.
[[nodiscard]] auto blocksMovement() const -> bool { return blocks_movement_; }
// Trigger d'àrea: l'entitat NO bloqueja el moviment (`collides_=false`)
// però sí vol rebre `onPlayerContact` quan el jugador la solapa. Pensat
// per a hazards "no físics" (forats al sòl, picos retràctils, plaques
@@ -154,6 +203,14 @@ class Entity {
// Entitats sense animació retornen `nullopt` → frame 0 per defecte.
[[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; }
// Sobreescriu l'`AnimationComponent` actual. L'usa el loader del nivell
// per aplicar el camp opcional `frame: N` del spawn a entitats amb
// variants estàtiques (debris, columnes, etc.): el spawn fixa una
// animació de 1 frame al índex escollit i el render del manager clipa
// el sub-sprite corresponent. També és útil per a tests que volen
// controlar el frame mostrat sense passar pel YAML.
void setAnimation(AnimationComponent anim) { anim_ = std::move(anim); }
// Alçada del cos de l'entitat en píxels. La consulten els filtres
// de col·lisió per altura: si un jugador en l'aire passa per damunt
// d'aquest valor (jump_z_ > height), no col·lisiona amb l'entitat —
@@ -196,6 +253,34 @@ class Entity {
[[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; }
void setFlip(SDL_FlipMode flip) { flip_ = flip; }
// Radi de la llum emesa per aquesta entitat (en píxels del canvas).
// 0 = no emet (cas habitual). El bloc opcional `light: { radius: N }`
// del YAML de la plantilla el pobla via `EntityManager::buildEntity`.
[[nodiscard]] auto getLightRadius() const -> int { return light_radius_; }
void setLightRadius(int r) { light_radius_ = r; }
// Activa la modulació "tipus foc" del radi a la pasada d'il·luminació
// (per a antorxes, fogueres, etc.). El bloc opcional
// `light: { flicker: true }` del YAML el pobla. Per defecte false:
// la llum queda perfectament estable (cas player, mòmia).
[[nodiscard]] auto getLightFlicker() const -> bool { return light_flicker_; }
void setLightFlicker(bool f) { light_flicker_ = f; }
// Identificador estable per al flicker. Cada Entity rep un valor
// únic a la construcció (contador estàtic) i el conserva fins a la
// mort — així el patró del flicker no "deriva" quan l'entitat es
// mou. Si l'entitat respawneja, la nova instància tindrà una seed
// nova; com que el cicle visual ja queda trencat per la mort, no és
// un problema.
[[nodiscard]] auto getLightSeed() const -> int { return light_seed_; }
// Centre de la llum en píxels del canvas. Per defecte és el centre del
// `pixel_rect_` (que és la caixa de render: sprite top-left + mida).
// Virtual perquè entitats amb desplaçament visual sobre la posició
// lògica (p.ex. el Player en l'aire, que rebaixa visualment `pos_.y`
// amb `jump_z_`) puguin retornar el centre que realment es veu.
[[nodiscard]] virtual auto getLightCenter() const -> SDL_FPoint;
protected:
std::string name_;
TilePos tile_{};
@@ -207,7 +292,16 @@ class Entity {
bool interactable_{false};
bool triggers_on_overlap_{false};
bool is_floor_decal_{false};
bool blocks_movement_{false};
int height_{0};
int light_radius_{0};
// Comptador global d'instàncies; cada Entity nova s'emporta el
// valor incrementat com a seed propi. Estàtic perquè no calgui
// estat extern (sense risc de threads: les entitats es construeixen
// sempre des del fil del joc).
static inline int s_next_light_seed{0};
int light_seed_{++s_next_light_seed};
bool light_flicker_{false};
bool dead_{false};
SDL_FlipMode flip_{SDL_FLIP_NONE};
EventBus* bus_{nullptr};
+52 -64
View File
@@ -7,6 +7,7 @@
#include "game/entities/concrete/door.hpp"
#include "game/entities/concrete/sarcophagus.hpp"
#include "game/entities/entity.hpp"
#include "game/map/map.hpp"
// NOTA: `loadFromLevel` viu a `entity_manager_loadlevel.cpp` per evitar
// arrossegar `Resource::Cache`, `Surface` i les seues dependències (GIF,
@@ -30,7 +31,7 @@ EntityManager::EntityManager() {
// els sarcòfags puguin actualitzar el seu `visited_`.
tile_sub_id_ = bus_.subscribe("tile_painted", [this](const Event& ev) {
const auto* tile = std::get_if<TilePos>(&ev.payload);
if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row); }
if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row, ev.player_id); }
});
// Subscripció a `player_exited` (publicat per Door en travessar la
@@ -51,12 +52,23 @@ EntityManager::EntityManager() {
});
}
});
// Connectar amb el check global de col·lisió del Map: les entitats
// sòlides genèriques (debris, columnes, futurs murs) viuen al manager,
// no a la graella fixa de sarcs, així que el `Map::collides` del Player
// les ha de consultar via callback. Es desregistra al dtor.
Map::setEntityCollider([this](const SDL_FRect& box) {
return entityCollidesAt(box);
});
}
EntityManager::~EntityManager() {
bus_.unsubscribe(sarc_sub_id_);
bus_.unsubscribe(tile_sub_id_);
bus_.unsubscribe(exit_sub_id_);
// Desconnectar del Map: si quedem en namespace global apuntant a un
// `this` que mor, qualsevol `Map::collides` posterior cauria en UB.
Map::setEntityCollider(nullptr);
}
void EntityManager::setExpectedPlayerCount(int n) {
@@ -74,19 +86,7 @@ auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
entity->setEventBus(&bus_);
entity->setEntityManager(this);
Entity* raw = entity.get();
// Enrutament per categoria semàntica: si l'entitat és (o deriva de)
// DynamicHazard va al sac `actors_`, la resta al sac `map_entities_`.
// Així els consumidors que només volen actors itera el sac petit i
// ens estalviem dynamic_casts dispersos al codi (colisió, whip...).
if (dynamic_cast<DynamicHazard*>(entity.get()) != nullptr) {
// Transferim l'ownership al unique_ptr<DynamicHazard> tipat fort.
// El `release()` consumeix el seu valor com a argument del ctor —
// sense crida nua que el linter pugui marcar com a ignorada.
std::unique_ptr<DynamicHazard> hazard{static_cast<DynamicHazard*>(entity.release())};
actors_.push_back(std::move(hazard));
} else {
map_entities_.push_back(std::move(entity));
}
entities_.push_back(std::move(entity));
// cppcheck-suppress returnDanglingLifetime
return raw;
}
@@ -102,18 +102,15 @@ void EntityManager::setDoor(Door* door) {
auto EntityManager::removeEntity(Entity* observer) -> bool {
if (observer == nullptr) { return false; }
const bool WAS_DOOR = (door_ == observer);
const bool WAS_SARC = (observer->getName() == "sarcophagus");
// Busquem en els dos sacs. El sarc i la door sempre viuen a
// `map_entities_` per disseny; un actor (DynamicHazard) viu a `actors_`.
if (auto it = std::ranges::find_if(map_entities_, [observer](const auto& up) { return up.get() == observer; }); it != map_entities_.end()) {
map_entities_.erase(it);
} else if (auto it_a = std::ranges::find_if(actors_, [observer](const auto& up) { return up.get() == observer; }); it_a != actors_.end()) {
actors_.erase(it_a);
} else {
return false;
}
// `onRemoved` és polimòrfic: cada subclasse decideix què cal
// notificar quan se l'esborra. Avui només Sarcophagus l'override per
// decrementar el seu comptador. La door es detecta per comparació de
// punter (és única, no cal RTTI).
observer->onRemoved();
auto it = std::ranges::find_if(entities_, [observer](const auto& up) { return up.get() == observer; });
if (it == entities_.end()) { return false; }
entities_.erase(it);
if (WAS_DOOR) { door_ = nullptr; }
if (WAS_SARC && total_sarcs_ > 0) { --total_sarcs_; }
// No tocar `opened_sarcs_` aquí: l'editor congela el joc i no pot
// disparar-se cap esdeveniment derivat. Quan el GameScene faça el
// reload posterior, els comptadors es re-deriven d'inici.
@@ -125,8 +122,7 @@ auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcoph
sarc->setEventBus(&bus_);
sarc->setEntityManager(this);
Sarcophagus* raw = sarc.get();
// El sarc és Interactable, viu al sac `map_entities_`.
map_entities_.push_back(std::move(sarc));
entities_.push_back(std::move(sarc));
++total_sarcs_;
// cppcheck-suppress returnDanglingLifetime
return raw;
@@ -134,34 +130,33 @@ auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcoph
void EntityManager::update(float delta_time) {
// ATENCIÓ: si en el futur una entitat publica al bus events que
// resulten en `entity_manager_.add()` (push_back als sacs), açò
// resulten en `entity_manager_.add()` (push_back a `entities_`), açò
// invalidaria l'iterador de la ranged-for. Avui no passa: els spawns
// runtime venen de fora del cicle d'`update` (p.ex. el polvet del
// saltador es spawneja durant `Player::tick`, abans d'aquest mètode).
for (auto& e : map_entities_) { e->update(delta_time); }
for (auto& a : actors_) { a->update(delta_time); }
for (auto& e : entities_) { e->update(delta_time); }
// Purga d'entitats marcades mortes aquest frame (efectes acabats,
// p.ex. polvet d'aterratge). Les persistents (sarcs, momies, etc.)
// mai no es marquen.
std::erase_if(map_entities_, [](const auto& e) { return e->isDead(); });
std::erase_if(actors_, [](const auto& a) { return a->isDead(); });
std::erase_if(entities_, [](const auto& e) { return e->isDead(); });
}
void EntityManager::notifyPlayerTilePainted(int col, int row) {
// Fan-out als dos sacs. Avui només Sarcophagus consumeix (viu a
// map_entities_), però els actors també reben la notificació per
// si en el futur volen reaccionar (cost: una crida virtual buida).
for (auto& e : map_entities_) { e->notifyTilePainted(col, row); }
for (auto& a : actors_) { a->notifyTilePainted(col, row); }
void EntityManager::notifyPlayerTilePainted(int col, int row, int player_id) {
// Fan-out polimòrfic a totes les entitats: cada una decideix què fer
// (`Sarcophagus` actualitza el seu perímetre i recorda qui va tancar
// el cercle, la resta queden en no-op). `player_id` ve del bus
// (`ev.player_id`) — pot ser -1 al broadcast de càrrega inicial de
// nivell, els consumidors filtren si necessiten atribució.
for (auto& e : entities_) { e->notifyTilePainted(col, row, player_id); }
}
auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* {
// Cerca lineal sobre `map_entities_`: els floor decals trigger són
// exclusivament StaticHazards (Hole; en el futur pinchos plans,
// plaques, charcos). Els actors no qualifiquen com a decals — saltem
// directament aquest sac, sense `triggersOnOverlap`/`isFloorDecal` per
// a cada un. La doble condició filtra dins de map_entities_.
for (const auto& entity : map_entities_) {
// Cerca lineal: només `Hole` (i futurs decals trigger) compleixen la
// doble condició `triggersOnOverlap` + `isFloorDecal`. Costos típics
// (poques desenes d'entitats) menyspreables, una crida per frame des
// dels consumidors actius (Player a l'aterratge, Mòmia a la decisió
// de direcció).
for (const auto& entity : entities_) {
if (!entity->triggersOnOverlap()) { continue; }
if (!entity->isFloorDecal()) { continue; }
const auto BOX = entity->getCollisionRect();
@@ -171,28 +166,21 @@ auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Ent
return nullptr;
}
auto EntityManager::entityCollidesAt(const SDL_FRect& box) const -> bool {
// Iteració lineal sobre entitats amb `blocksMovement()`. La convenció
// d'overlap és la mateixa que `Map::collides` (estricta: el toc al
// píxel no compta) per coherència en els passadissos del laberint.
return std::ranges::any_of(entities_, [&box](const auto& e) {
if (!e->blocksMovement()) { return false; }
const auto R = e->getCollisionRect();
return (R.x + R.w) > box.x && (box.x + box.w) > R.x && (R.y + R.h) > box.y && (box.y + box.h) > R.y;
});
}
auto EntityManager::getEntities() const -> std::vector<Entity*> {
// Concatenació map + actors per retro-compat (editor, z-sort debug).
// Els callers que ja saben quin sac volen poden cridar
// `getMapEntities` o `getActors` i estalviar la construcció del temp.
std::vector<Entity*> out;
out.reserve(map_entities_.size() + actors_.size());
std::ranges::transform(map_entities_, std::back_inserter(out), [](const auto& e) { return e.get(); });
std::ranges::transform(actors_, std::back_inserter(out), [](const auto& a) -> Entity* { return a.get(); });
return out;
}
auto EntityManager::getMapEntities() const -> std::vector<Entity*> {
std::vector<Entity*> out;
out.reserve(map_entities_.size());
std::ranges::transform(map_entities_, std::back_inserter(out), [](const auto& e) { return e.get(); });
return out;
}
auto EntityManager::getActors() const -> std::vector<DynamicHazard*> {
std::vector<DynamicHazard*> out;
out.reserve(actors_.size());
std::ranges::transform(actors_, std::back_inserter(out), [](const auto& a) { return a.get(); });
out.reserve(entities_.size());
std::ranges::transform(entities_, std::back_inserter(out), [](const auto& e) { return e.get(); });
return out;
}
+42 -24
View File
@@ -9,21 +9,22 @@
#include "external/fkyaml_node.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/hazard.hpp"
class Entity;
class Sarcophagus;
class Door;
class Player;
class DynamicHazard;
// Contenidor i coordinador d'entitats d'un nivell. Responsabilitats:
// 1. `add` registra una entitat, la connecta al bus i manté propietat.
// 2. `update(dt)` propaga el tick a totes.
// 3. Comptador de sarcòfags oberts — quan iguala el total registrat,
// crida `door_->open()` automàticament.
// 4. `notifyPlayerTilePainted(col, row)` — fan-out del hook per a tots
// els sarcòfags (i qualsevol entitat futura que s'hi subscrigui via
// `notifyTilePainted`).
// 4. `notifyPlayerTilePainted(col, row, player_id)` — fan-out del hook
// per a tots els sarcòfags (i qualsevol entitat futura que s'hi
// subscrigui via `notifyTilePainted`). El `player_id` permet
// atribuir l'acció al jugador concret (p.ex. qui tanca un sarc).
//
// NO és un singleton: es crea una instància per nivell. De moment no
// s'usa encara al GameScene (andamiatge pur).
@@ -74,14 +75,12 @@ class EntityManager {
static void renderEntity(const Entity& entity);
// Accés als punters observacionals a les entitats per a consumidors
// externs que necessiten construir una llista ordenada (z-order).
// `getEntities()` concatena els dos sacs interns (mapa + actors) per
// a consumidors retro-compat (editor, z-sort debug). `getMapEntities`
// i `getActors` exposen cada sac per separat — útil per a iteracions
// que ja saben quin tipus volen i estalvien filtres O(n).
// externs que necessiten iterar (z-order, aplicar dany, etc.). El
// polimorfisme dins de cada entitat fa la discriminació: l'iterador
// recorre tot i les entitats que no hagin de respondre tornen no-op
// (p.ex. Torch::onWhipHit retorna false sense efecte). Sense sacs
// separats — el `if (entity_is_X)` viu dins de l'entitat, no del caller.
[[nodiscard]] auto getEntities() const -> std::vector<Entity*>;
[[nodiscard]] auto getMapEntities() const -> std::vector<Entity*>;
[[nodiscard]] auto getActors() const -> std::vector<DynamicHazard*>;
// Comprova contacte del(s) jugador(s) amb entitats `collides()=true`.
// Si la caixa base del jugador solapa amb la caixa de col·lisió de
@@ -105,7 +104,7 @@ class EntityManager {
// notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte
// (veure Entity::notifyTilePainted). Invocat com a reacció a
// `tile_painted` al bus (re-publicat pel PaintLayer un cop filtrat).
void notifyPlayerTilePainted(int col, int row);
void notifyPlayerTilePainted(int col, int row, int player_id);
// Carrega els spawns d'un YAML de nivell i els afegeix al manager:
// 1. Llegeix el bloc `entities:` via EntityLoader.
@@ -136,6 +135,15 @@ class EntityManager {
// `respawnPlayersInstant` (salt debug).
void spawnPendingActors();
// Materialitza els pending actors al món sense disparar la cinemàtica
// de SPAWN (`onJustSpawned`). Pensat per a l'editor: vol veure les
// mòmies a la seva posició inicial per poder editar-les, però no
// disparar l'animació EMERGING (l'editor congela el món, l'animació
// no avançaria i quedaria invisible). En sortir de l'editor, GameScene
// fa un `reload` que torna els actors a `pending_actors_` i el flux
// normal els spawneja amb la cinemàtica al final del ROOM_INTRO.
void materializePendingActorsStatic();
// Crea un actor on-demand i el deixa en EMERGING (com a
// `spawnPendingActors` però per a un sol). Pensat per a futurs
// generadors (StaticHazards que produeixen DynamicHazards en runtime,
@@ -160,7 +168,7 @@ class EntityManager {
void setExpectedPlayerCount(int n);
[[nodiscard]] auto getBus() -> EventBus& { return bus_; }
[[nodiscard]] auto entityCount() const -> std::size_t { return map_entities_.size() + actors_.size(); }
[[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); }
[[nodiscard]] auto pendingActorCount() const -> std::size_t { return pending_actors_.size(); }
[[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; }
[[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; }
@@ -173,19 +181,29 @@ class EntityManager {
// sarcs. No-op si la porta ja està oberta o si el nivell no en té.
void forceOpenDoor();
// Hook que `Sarcophagus::onRemoved` crida per decrementar el
// comptador `total_sarcs_`. Públic perquè el sarcòfag és la única
// entitat que necessita aquest manteniment, però l'API es manté
// genèrica: si en el futur Door volgués notificar quelcom semblant,
// afegim el seu propi callback.
void onSarcophagusRemoved() {
if (total_sarcs_ > 0) { --total_sarcs_; }
}
private:
// Suport per al callback registrat a `Map::setEntityCollider`. Itera
// les entitats amb `blocksMovement()` i fa overlap estricte (mateixa
// convenció que `Map::collides`). El callback el registra el ctor.
[[nodiscard]] auto entityCollidesAt(const SDL_FRect& box) const -> bool;
EventBus bus_;
// Dos sacs lògics:
// `map_entities_` — decoració, static_hazards (Hole, FloorTrap,
// WallTrap) i interactables (Sarcophagus, Door).
// Cargades al loadFromLevel i persistents durant
// tota la sala.
// `actors_` — DynamicHazards (Mummy, RollingRock, futurs
// enemics). Separats per eliminar dynamic_casts
// i preparar el spawn diferit (commit posterior).
// `add()` enruta automàticament segons sigui o no DynamicHazard.
std::vector<std::unique_ptr<Entity>> map_entities_;
std::vector<std::unique_ptr<DynamicHazard>> actors_;
// Un sol contenidor lògic per a totes les entitats instanciades. La
// discriminació de tipus la fan els virtuals (`onPlayerContact`,
// `onWhipHit`, `shouldReceiveContact`, `notifyTilePainted`...) — el
// manager itera i cada entitat decideix què fa. Hi conviuen
// decoracions, static_hazards, interactables i dynamic_hazards
// (actors).
std::vector<std::unique_ptr<Entity>> entities_;
// Cua d'actors declarats al YAML del mapa però encara no instanciats:
// `loadFromLevel` hi empeny tots els DynamicHazards. `spawnPendingActors`
// o `spawnActor` consumeixen aquesta cua (o creen un actor on-demand)
@@ -57,11 +57,10 @@ void EntityManager::renderEntity(const Entity& e) {
}
void EntityManager::renderAll() {
// Sense ordenació per z: l'ordre és el d'inserció dins de cada sac.
// El render del joc real (GameScene) construeix el seu propi vector
// Sense ordenació per z: l'ordre és el d'inserció al vector. El
// render del joc real (GameScene) construeix el seu propi vector
// ordenat per `feet_y`; aquest helper queda per al editor i tests.
for (const auto& e : map_entities_) { renderEntity(*e); }
for (const auto& a : actors_) { renderEntity(*a); }
for (const auto& e : entities_) { renderEntity(*e); }
}
namespace {
@@ -100,22 +99,15 @@ void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
}
};
// Sac de mapa: filtre per col·lisió / trigger. Tanquem el gap latent
// de FloorTrap/WallTrap (que tenien `collides_=false` i mai no es
// disparaven) i obrim la porta a hazards no-físics com `Hole`.
for (const auto& entity : map_entities_) {
if (!entity->collides() && !entity->triggersOnOverlap()) { continue; }
// Filtre polimòrfic unificat: cada entitat decideix si ha de rebre
// contacte (`shouldReceiveContact`). Per defecte equival a `collides()
// || triggersOnOverlap()`; `DynamicHazard` afegeix el filtre de SPAWN
// (mentre està emergint torna false). El callsite no fa dynamic_cast
// ni distingeix categories.
for (const auto& entity : entities_) {
if (!entity->shouldReceiveContact()) { continue; }
apply_contacts(entity.get());
}
// Sac d'actors (DynamicHazards): tots fan dany quan són actius.
// Saltem els que estan en SPAWN: visibles però sense interacció
// durant la fase inicial de ROOM_INTRO i durant futurs spawns en
// runtime. Sense dynamic_cast: el tipus ja és DynamicHazard.
for (const auto& actor : actors_) {
if (actor->isSpawning()) { continue; }
if (!actor->collides() && !actor->triggersOnOverlap()) { continue; }
apply_contacts(actor.get());
}
}
namespace {
@@ -163,6 +155,25 @@ namespace {
return c["height"].get_value<int>();
}
// Bloc opcional `light: { radius: N, flicker: bool }` de la plantilla.
// Si manca, el radi és 0 i l'entitat no entra a la pasada d'il·luminació.
// `flicker` activa la modulació "tipus foc" del radi (només antorxes
// l'haurien d'usar). La forma del gradient (3 anells) i els paràmetres
// del flicker viuen a data/config/lighting.yaml — no cal declarar-los
// per entitat.
struct LightTemplate {
int radius{0};
bool flicker{false};
};
auto readLight(const fkyaml::node& tmpl) -> LightTemplate {
if (!tmpl.contains("light")) { return {}; }
const auto& l = tmpl["light"];
LightTemplate out;
if (l.contains("radius")) { out.radius = l["radius"].get_value<int>(); }
if (l.contains("flicker")) { out.flicker = l["flicker"].get_value<bool>(); }
return out;
}
// Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }`
// de la plantilla d'entitat.
// - Sense `base_size` ni `offset`: la caja queda alineada a la vora
@@ -285,6 +296,9 @@ namespace {
entity->setCollisionBox(*BOX);
}
entity->setHeight(readHeight(tmpl));
const auto LIGHT = readLight(tmpl);
entity->setLightRadius(LIGHT.radius);
entity->setLightFlicker(LIGHT.flicker);
anchor_out = readAnchor(tmpl);
return entity;
}
@@ -403,6 +417,17 @@ namespace {
}
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
// Variant del atlas: el spawn pot fixar un sub-sprite estàtic via
// `frame: N`. El render del manager ja sap clipar (col, row) a
// partir d'un índex lineal i la mida del frame; ací només cal
// muntar un `AnimationComponent` d'un sol frame i imposar-lo a la
// entitat. Cap entitat existent al joc emet aquest camp; només
// els muros decoratius amb variants (stone, column, ...) l'usen.
if (spawn.contains("frame")) {
const int FRAME = spawn["frame"].get_value<int>();
entity->setAnimation(AnimationComponent({FRAME}, 1.0F, false));
}
manager.add(std::move(entity));
return true;
}
@@ -413,8 +438,7 @@ void EntityManager::reload(const std::string& level_yaml_path) {
// subscripcions implícites desapareixen amb la seua vida (cap d'elles
// se subscriu directament al bus — només publiquen). El bus i les
// subscripcions externes (PaintLayer, HUD, GameScene) no es toquen.
map_entities_.clear();
actors_.clear();
entities_.clear();
pending_actors_.clear();
door_ = nullptr;
total_sarcs_ = 0;
@@ -468,53 +492,71 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
if (addSpawn(spawn) != nullptr) { ++loaded; }
}
// Broadcast inicial: marca com a "pintats" tots els tiles ocupats per
// decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...).
// Imprescindible perquè el jugador no pot trepitjar aquests tiles
// (cauria al forat) i els sarcòfags vecins no podrien completar el
// perímetre — la sala seria incompletable. Usa el canal semàntic
// `tile_painted` (lògica de joc), no `on_tile_entered` (presència
// física del jugador). Ho fem aquí, després del bucle de spawns,
// perquè tots els sarcòfags ja estan registrats al fan-out (la
// subscripció del manager és al ctor, anterior a qualsevol load).
// Broadcast inicial: marca com a "pintats" tots els tiles que el
// jugador no pot trepitjar mai, de manera que el perímetre dels
// sarcòfags vecins ja els consideri visitats i la sala sigui
// completable. Inclou dues famílies:
// - Decals de sòl (Hole, i futurs pinchos plans, plaques, charcos):
// trigger-on-overlap que mataria/atraparia al jugador en trepitjar.
// - Entitats sòlides genèriques (debris, columnes...): obstacles
// físics que `Map::collides` bloqueja, així `isPaintable` retorna
// false per als seus tiles i ningú els pot pintar.
// Usa el canal semàntic `tile_painted` (lògica de joc), no
// `on_tile_entered` (presència física). Es fa aquí, després del bucle
// de spawns, perquè tots els sarcòfags ja estan registrats al fan-out
// (la subscripció del manager és al ctor, anterior a qualsevol load).
int decal_broadcast = 0;
// Els decals de sòl (Hole, futurs pinchos plans...) són StaticHazards
// i viuen exclusivament a `map_entities_`. Saltem `actors_` per estalviar.
for (const auto& entity : map_entities_) {
if (!entity->isFloorDecal()) { continue; }
for (const auto& entity : entities_) {
const bool IS_DECAL = entity->isFloorDecal();
const bool IS_SOLID = entity->blocksMovement();
if (!IS_DECAL && !IS_SOLID) { continue; }
const auto BOX = entity->getCollisionRect();
const auto [COL, ROW] = Map::pixelToTile(BOX.x, BOX.y);
bus_.publish(Event{
.name = "tile_painted",
.payload = TilePos{.col = COL, .row = ROW},
.player_id = -1,
});
++decal_broadcast;
// Per a entitats que ocupen més d'un tile (futurs murs 32×16,
// 16×32, etc.), iterem tota la zona coberta pel rect. Per a decals
// i blocs d'un sol tile cau a un sol broadcast (mateix comportament
// que abans).
const auto [COL0, ROW0] = Map::pixelToTile(BOX.x, BOX.y);
const int COLS = (Map::tile_size > 0) ? std::max(1, static_cast<int>(BOX.w) / Map::tile_size) : 1;
const int ROWS = (Map::tile_size > 0) ? std::max(1, static_cast<int>(BOX.h) / Map::tile_size) : 1;
for (int dr = 0; dr < ROWS; ++dr) {
for (int dc = 0; dc < COLS; ++dc) {
bus_.publish(Event{
.name = "tile_painted",
.payload = TilePos{.col = COL0 + dc, .row = ROW0 + dr},
.player_id = -1,
});
++decal_broadcast;
}
}
}
if (decal_broadcast > 0) {
Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast);
Log::info("EntityManager", "{} tile_painted broadcast inicial (decals + murs)", decal_broadcast);
}
Log::info("EntityManager", "{} map entities + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path);
Log::info("EntityManager", "{} entitats + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path);
}
void EntityManager::spawnPendingActors() {
// Itera els pending i els instancia via `addSpawn`. El dispatch d'`add()`
// els enruta a `actors_`. El `triggerSpawnExit()` posterior arma el
// comptador de SPAWN — la mòmia neix ja en EMERGING, sense esperes.
// els enruta a `actors_`. `onJustSpawned()` és polimòrfic: la base
// no fa res, DynamicHazard l'override per disparar `triggerSpawnExit()`
// i entrar a EMERGING. Sense dynamic_cast: la decisió viu dins de
// cada entitat.
for (const auto& pa : pending_actors_) {
Entity* observer = addSpawn(pa.spawn_node);
if (observer == nullptr) { continue; }
// Per categoria YAML (`type: dynamic_hazard`) sabem que és
// DynamicHazard — el dynamic_cast aquí val zero i evita
// assumptions implícites sobre la jerarquia.
if (auto* dh = dynamic_cast<DynamicHazard*>(observer); dh != nullptr) {
dh->triggerSpawnExit();
}
if (observer != nullptr) { observer->onJustSpawned(); }
}
pending_actors_.clear();
}
void EntityManager::materializePendingActorsStatic() {
// Igual que `spawnPendingActors` però sense `onJustSpawned`: l'actor
// queda en el seu estat inicial (mòmia: ROAMING, walk_east) en lloc
// d'EMERGING. Vegeu el comentari del header per la motivació.
for (const auto& pa : pending_actors_) { addSpawn(pa.spawn_node); }
pending_actors_.clear();
}
auto EntityManager::spawnActor(const std::string& type, int col, int row) -> DynamicHazard* {
// Construïm un node de spawn sintètic amb el mínim que `placeGeneric`
// necessita: `type` i `position: [col, row]`. Si el caller vol
@@ -530,7 +572,9 @@ auto EntityManager::spawnActor(const std::string& type, int col, int row) -> Dyn
}
Entity* observer = addSpawn(spawn);
if (observer == nullptr) { return nullptr; }
auto* dh = dynamic_cast<DynamicHazard*>(observer);
if (dh != nullptr) { dh->triggerSpawnExit(); }
return dh;
// `onJustSpawned` polimòrfic: per a DynamicHazards arma EMERGING; per
// a un type erroni (no dynamic_hazard) queda no-op i retornem nullptr
// mitjançant el cast final, deixant la signatura `DynamicHazard*` neta.
observer->onJustSpawned();
return dynamic_cast<DynamicHazard*>(observer);
}
+3
View File
@@ -60,6 +60,9 @@ namespace EntityWriter {
} else if (s.col.has_value() && s.row.has_value()) {
out += " position: [" + std::to_string(*s.col) + ", " + std::to_string(*s.row) + "]\n";
}
if (s.frame.has_value()) {
out += " frame: " + std::to_string(*s.frame) + "\n";
}
}
return out;
}
+4
View File
@@ -35,12 +35,16 @@ namespace EntityWriter {
// Cada spawn duplica l'estructura mínima del que el lector espera:
// type sempre, i un dels (position, sarc_position) o cap (cas door).
// `frame` és opcional i només l'emeten les entitats amb variants
// estàtiques (debris, columnes); fixa quin sub-sprite del atlas
// representa aquesta instància concreta.
struct Spawn {
std::string type;
std::optional<int> col;
std::optional<int> row;
std::optional<int> sarc_col;
std::optional<int> sarc_row;
std::optional<int> frame;
};
auto saveEntitiesBlock(const std::string& path, const std::vector<Spawn>& spawns) -> bool;
+26 -3
View File
@@ -19,12 +19,21 @@ class Player;
// Event pub/sub minimalista. Hi ha un únic `EventBus` per mundus (propietat
// de l'`EntityManager`); les entitats que el necessiten en reben un punter
// via `Entity::setEventBus`. Aquest sistema cobreix les necessitats
// actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered)
// sense portar-hi senyalització més pesada.
// actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered,
// player_stats_changed) sense portar-hi senyalització més pesada.
//
// Decisió: `std::variant` per al payload — evita any/void* i facilita
// consumir events tipats amb `std::get_if`. Si apareixen més tipus de
// payload, s'amplia aquí sense tocar cridants existents.
//
// `player_stats_changed`: l'únic canal numèric que toca el HUD. El `Player`
// és la font de veritat de vides/score/tresors i el publica cada vegada
// que muta qualsevol d'aquests camps. El HUD el consumeix com a "snapshot
// total" (no com a delta) — sobreescriu la seua caché amb el payload. Així
// qualsevol via que modifique stats al Player (mort per forat, recompensa
// de sarc, etc.) queda automàticament reflectida sense que cada caller
// haja de recordar publicar res addicional. `player_hit` queda només per
// al feedback visual (flash/shake), separat del comptador.
// Tile (col, row) — payload de `tile_painted` (i altres events de tile sense
// metadada extra). No és un `pair<int,int>` per evitar alias ambigus al
@@ -74,9 +83,20 @@ struct WhipSpawnPayload {
float range_px{0.0F};
};
// Snapshot complet dels stats d'un jugador — payload de `player_stats_changed`.
// El Player publica un d'aquests cada vegada que muta vides/score/tresors.
// El HUD el consumeix com a estat absolut (no delta): sobreescriu la seua
// caché amb aquests valors. El `player_id` de l'`Event` indica a quin
// bloc del HUD va dirigit.
struct PlayerStatsPayload {
int lives{0};
int score{0};
int treasures{0};
};
struct Event {
std::string name;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos, WhipSpawnPayload>;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos, WhipSpawnPayload, PlayerStatsPayload>;
Payload payload;
// Jugador que ha originat (o provocat) l'event. -1 = sense jugador
// associat (events del món, com `sarcophagus_opened` des de la pròpia
@@ -129,6 +149,9 @@ class EventBus {
void publish(const std::string& name, const WhipSpawnPayload& whip) {
publish(Event{.name = name, .payload = whip});
}
void publish(const std::string& name, const PlayerStatsPayload& stats) {
publish(Event{.name = name, .payload = stats});
}
private:
struct Subscription {
+19
View File
@@ -96,9 +96,28 @@ class DynamicHazard : public Hazard {
// Arma el comptador del SPAWN. A partir d'aquí, cada `update(dt)`
// descompta `dt` fins arribar a 0 i transitar a ACTIVE.
void triggerSpawnExit() { spawn_exit_armed_ = true; }
// Transició directa a ACTIVE, sense passar pel countdown. Pensat
// per a subclasses amb SPAWN dirigit per animació: gestionen el
// seu propi `update()` mentre `isSpawning()` i criden ací quan la
// seua animació d'aparició ha completat.
void endSpawn() { spawn_state_ = SpawnState::ACTIVE; }
[[nodiscard]] auto isSpawning() const -> bool { return spawn_state_ == SpawnState::SPAWNING; }
[[nodiscard]] auto getSpawnDurationS() const -> float { return spawn_duration_s_; }
// Just després d'instanciar-se al món (via spawnPendingActors /
// spawnActor), l'actor entra automàticament en la fase EMERGING:
// arma el comptador i comença l'animació de sortida. Substitueix el
// dynamic_cast que el manager feia abans per cridar `triggerSpawnExit`.
void onJustSpawned() override { triggerSpawnExit(); }
// Mentre està en SPAWN (HIDDEN/EMERGING), l'actor NO rep contacte
// ni amb el jugador ni amb attacks: visible però "no juga". Quan
// passa a ACTIVE, recupera el comportament estàndard d'Entity.
[[nodiscard]] auto shouldReceiveContact() const -> bool override {
if (isSpawning()) { return false; }
return Entity::shouldReceiveContact();
}
// Política de moviment per frame. La base desplaça el pixel_rect_
// proporcionalment a velocitat i direcció; les subclasses amb IA la
// sobreescriuen per triar nova direcció a les interseccions o rebotar.
+68 -27
View File
@@ -13,6 +13,7 @@
#include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.hpp"
#include "core/rendering/canvas.hpp"
#include "core/rendering/effects/shadow.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
#include "core/resources/palette_loader.hpp"
@@ -140,6 +141,19 @@ void Player::loadFrom(const std::string& yaml_path) {
// `duration_ms` és 0 (o el bloc manca) la mecànica queda
// desactivada i els hazards toquen al primer frame, com abans.
readImmunityBlock(yaml);
// Bloc opcional `light:` — radi i flicker. Extret a un mètode per
// mantenir `loadFrom` sota el llindar de complexitat de clang-tidy.
readLightBlock(yaml);
}
void Player::readLightBlock(const fkyaml::node& yaml) {
light_radius = 0;
light_flicker = false;
if (!yaml.contains("light")) { return; }
const auto& l = yaml["light"];
if (l.contains("radius")) { light_radius = l["radius"].get_value<int>(); }
if (l.contains("flicker")) { light_flicker = l["flicker"].get_value<bool>(); }
}
void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) {
@@ -179,7 +193,6 @@ void Player::readJumpBlock(const fkyaml::node& yaml) {
if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value<float>(); }
if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
if (j.contains("shadow_y_offset")) { jump_config.shadow_y_offset = j["shadow_y_offset"].get_value<int>(); }
// Sons opcionals: el nom del fitxer (relatiu a Defaults::Paths::AUDIO_SOUNDS_DIR)
// val tant de clau al Resource::Cache com de basename per a la càrrega.
// Si el camp manca, la clau queda buida i `playSound` no es crida.
@@ -1272,6 +1285,37 @@ void Player::applyDyingMovement(float delta_time) {
void Player::resetForJoin() {
lives_remaining_ = death_config.initial_lives;
score_ = 0;
treasures_ = 0;
// Publica l'estat inicial perquè el HUD reompla el bloc en el moment
// de fer join (no només quan el comptador canvia després). Sense açò
// el HUD es quedaria amb la caché prèvia fins al primer event.
publishStatsChanged();
}
void Player::publishStatsChanged() {
if (bus_ == nullptr) { return; }
bus_->publish(Event{
.name = "player_stats_changed",
.payload = PlayerStatsPayload{
.lives = lives_remaining_,
.score = score_,
.treasures = treasures_,
},
.player_id = id_,
});
}
void Player::addScore(int delta) {
if (delta == 0) { return; }
score_ += delta;
publishStatsChanged();
}
void Player::addTreasures(int delta) {
if (delta == 0) { return; }
treasures_ += delta;
publishStatsChanged();
}
void Player::respawnFromDying() {
@@ -1283,6 +1327,12 @@ void Player::respawnFromDying() {
--lives_remaining_;
death_animation_complete_ = false;
respawn_remaining_ms_ = 0.0F;
// Notifica al HUD del nou comptador de vides abans de qualsevol
// transició d'estat. Aquesta crida tanca el bucle de qualsevol via
// de mort (mòmia, traps, rolling rock, FORAT…): totes acaben aquí, i
// ningú més ha de recordar publicar res. Era exactament el punt que
// s'oblidava al camí del forat.
publishStatsChanged();
if (lives_remaining_ <= 0) {
disable();
if (bus_ != nullptr) {
@@ -1425,6 +1475,16 @@ void Player::render() {
if (CLIP_HOLE) { Rendering::popClip(); }
}
auto Player::getLightCenter() const -> SDL_FPoint {
// Sprite top-left = (pos_.x, pos_.y). El render desplaça verticalment
// `jump_z_` píxels durant el salt; aplicar el mateix offset aquí fa
// que la llum "salti" amb el jugador en lloc de quedar-se al sòl.
return {
.x = getPosX() + (static_cast<float>(sprite_w) * 0.5F),
.y = getPosY() - jump_z_ + (static_cast<float>(sprite_h) * 0.5F),
};
}
void Player::renderShadow() const {
// Sense salt actiu, no hi ha sombra: això simplifica els callsites,
// que poden cridar `renderShadow` per a tots els jugadors sense
@@ -1435,36 +1495,17 @@ void Player::renderShadow() const {
// que `enterDying` netegi el flag.
if (falling_through_hole_) { return; }
// Sombra centrada al sprite, recolzada a la línia dels peus reals
// (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura
// del salt — al pic queda més estreta, donant la sensació de
// "elevació" sense necessitat de coordenades 3D reals. Si el
// jump_config té un divisor zero (cas degenerat), només dibuixem
// la sombra a mida plena per evitar nan.
// `height_factor`: 0 al terra → fotograma més gran, 1 al pic → més
// petit. Si el `jump_config` té un divisor zero (cas degenerat) caem
// a 0 per no propagar nan al renderer.
const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F)
? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2)
: 0.0F;
const float NORM_Z = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F;
constexpr float MAX_SHRINK = 0.4F; // Fins a un 40% més estreta al pic.
const float SHRINK = 1.0F - (NORM_Z * MAX_SHRINK);
const float HEIGHT_FACTOR = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F;
constexpr int SHADOW_H = 3;
const auto SHADOW_W = static_cast<int>(static_cast<float>(base_size) * SHRINK);
const float CX = getPosX() + (static_cast<float>(sprite_w) * 0.5F);
const float GROUND_Y = getGroundFeetY();
const auto SX = static_cast<int>(CX - (static_cast<float>(SHADOW_W) * 0.5F));
// Pugem la sombra `shadow_y_offset` px per compensar la franja
// transparent que el sprite del jugador té sota els peus reals
// (perquè la silueta camine pel mig del passadís en falsa
// perspectiva). Sense aquest ajust, la sombra apareix per sota
// del passadís.
const auto SY = static_cast<int>(GROUND_Y) - SHADOW_H - jump_config.shadow_y_offset;
// ARGB negre amb alpha ~40%. `blendRect` mescla sobre el canvas; així
// la sombra fosqueja el sòl (pintat o no) sense tapar-lo del tot.
constexpr std::uint32_t SHADOW_ARGB = 0x00000000U;
constexpr std::uint8_t SHADOW_ALPHA = 100;
Screen::get().blendRect(SX, SY, SHADOW_W, SHADOW_H, SHADOW_ARGB, SHADOW_ALPHA);
const auto CENTER_X = static_cast<int>(getPosX() + (static_cast<float>(sprite_w) * 0.5F));
const auto FEET_Y = static_cast<int>(getGroundFeetY());
Effects::Shadow::render(CENTER_X, FEET_Y, HEIGHT_FACTOR);
}
// Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el
+52 -1
View File
@@ -124,6 +124,14 @@ class Player : public AnimatedSprite {
// creua mentre `jump_z_` és superior a aquest valor, no col·lisionem.
inline static int body_height = 0;
// Radi de la llum emesa pel jugador (en píxels del canvas), llegit
// del bloc opcional `light: { radius: N, flicker: bool }` del YAML.
// 0 = no emet. Es declara estàtic com la resta de configs del jugador
// (immunity, jump, ...): tots els jugadors del mateix YAML comparteixen
// el valor. `light_flicker` activa la modulació "tipus foc" del radi.
inline static int light_radius = 0;
inline static bool light_flicker = false;
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
static void loadFrom(const std::string& yaml_path);
@@ -132,6 +140,19 @@ class Player : public AnimatedSprite {
// marcador i `Player::resetForJoin` per a re-entrades mid-partida.
[[nodiscard]] static auto getInitialLives() -> int { return death_config.initial_lives; }
// Emissió de llum (mateix paper que `Entity::getLightRadius` per a
// les entitats). El centre puja amb el salt: durant `jump_z_>0` el
// sprite es dibuixa amb `pos_.y - jump_z_`, i la llum ha de seguir-lo
// per a que el cercle "salti" amb el jugador, no es quedi al sòl.
[[nodiscard]] static auto getLightRadius() -> int { return light_radius; }
[[nodiscard]] static auto getLightFlicker() -> bool { return light_flicker; }
// Seed estable per al flicker: reusem `id_` (0 o 1 en multijugador
// local) amb un offset gran per evitar col·lisions amb el contador
// d'Entity (que arrenca des de 1). Així P1 i P2 oscil·len en fases
// pròpies que no s'encavalquen amb cap entitat.
[[nodiscard]] auto getLightSeed() const -> int { return id_ + 1000; }
[[nodiscard]] auto getLightCenter() const -> SDL_FPoint;
Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1);
// Update en una sola crida: manté l'API anterior per als tests i
@@ -158,6 +179,21 @@ class Player : public AnimatedSprite {
// xarxa — és únic per dins del procés.
[[nodiscard]] auto getId() const -> int { return id_; }
// Stats que pinta el HUD. El Player n'és la font de veritat; cada
// mutació es publica via `player_stats_changed` al bus (vegeu
// `publishStatsChanged`). Els getters són per a tests i diagnòstic;
// el HUD no els consulta — escolta el bus.
[[nodiscard]] auto getLives() const -> int { return lives_remaining_; }
[[nodiscard]] auto getScore() const -> int { return score_; }
[[nodiscard]] auto getTreasures() const -> int { return treasures_; }
// Acumuladors. Deltas positius i negatius (sense clamp). Publiquen
// `player_stats_changed` només si el delta no és 0, per estalviar
// soroll al bus. Els consumidors típics: GameScene reparteix puntuació
// de sarcs i pickups (futur) cridant aquests mètodes.
void addScore(int delta);
void addTreasures(int delta);
// Accés i mutació de la velocitat intencionada d'aquest frame. El
// GameScene els toca entre `tick` i `applyMovement` per implementar
// col·lisions jugador↔jugador (escalar a push_factor, anul·lar en
@@ -633,7 +669,6 @@ class Player : public AnimatedSprite {
float impulse_px_per_s{0.0F}; // Velocitat vertical inicial.
float gravity_px_per_s2{0.0F}; // Acceleració descendent constant.
int landing_stun_ms{0}; // Stun en aterrar (no es mou ni salta).
int shadow_y_offset{0}; // Px que puja la sombra (compensa transparències al peu del sprite).
std::string sound_key; // Clau al Resource::Cache del so de despegue (buit = silenci).
std::string land_sound_key; // Clau del so d'aterratge (buit = silenci).
};
@@ -728,6 +763,11 @@ class Player : public AnimatedSprite {
// readers — `loadFrom` el crida al final.
static void readImmunityBlock(const fkyaml::node& yaml);
// Llig el bloc opcional `light: { radius: N, flicker: bool }` del
// YAML i pobla `light_radius` / `light_flicker`. Si manca, els
// valors queden a 0 / false (jugador sense llum).
static void readLightBlock(const fkyaml::node& yaml);
// Activa l'estat ATTACKING en la direcció cardinal indicada (la
// direcció cap on mira el jugador) i publica `whip_spawn` al bus
// perquè el GameScene cree el `WhipAttack` corresponent. Posa el
@@ -853,6 +893,17 @@ class Player : public AnimatedSprite {
// inicial quan el jugador re-entra mid-partida.
int lives_remaining_{0};
// Puntuació acumulada i nombre de tresors recollits. Es modifiquen
// via `addScore` / `addTreasures` (o reset a `resetForJoin`). El HUD
// els sincronitza via `player_stats_changed`.
int score_{0};
int treasures_{0};
// Publica un snapshot complet (vides/score/tresors) al bus si està
// injectat. Centralitza la generació de l'event perquè cap mutador
// se l'oblide.
void publishStatsChanged();
// Coordenades del spawn de respawn (cantó superior-esquerra del
// sprite). Les assigna el GameScene via `setSpawnPoint`. 0/0 fins que
// el GameScene n'invoque el setter al ctor / `loadRoom`.
+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;
}
};
+22
View File
@@ -5,6 +5,7 @@
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp"
namespace {
@@ -21,6 +22,18 @@ namespace {
namespace Map {
namespace {
// Predicat extra que el `collides` consulta com a sobre-capa,
// injectat des de `EntityManager` per a entitats sòlides genèriques
// (debris, columnes). El TU local manté l'estat sense exposar-lo
// al header.
std::function<bool(const SDL_FRect&)> g_entity_collider;
} // namespace
void setEntityCollider(std::function<bool(const SDL_FRect&)> fn) {
g_entity_collider = std::move(fn);
}
void loadFrom(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
@@ -90,6 +103,10 @@ namespace Map {
// canviarà el flag a true quan transiti a OPEN.
door_open = false;
// Llum base de la sala. La clau és opcional a l'arrel del YAML;
// per defecte 1.0 (sala totalment il·luminada, comportament hui).
light_level = yaml.contains("light_level") ? yaml["light_level"].get_value<float>() : Defaults::Lighting::ROOM_LIGHT_DEFAULT;
Log::info("Map", "geometria {}x{} sarcs {}x{} pitch {}x{} door_col {} des de {}", grid_cols, grid_rows, sarc_w, sarc_h, pitch_x, pitch_y, door_col, yaml_path);
}
@@ -123,6 +140,11 @@ namespace Map {
};
if (overlapStrict(base_box, CORRIDOR)) { return true; }
}
// Capa final: entitats sòlides genèriques registrades pel manager.
// S'avalua al final perquè els obstacles fixos (sarcs, porta) són
// l'origen del laberint i el camí més curt; les entitats addicionals
// són normalment poques.
if (g_entity_collider && g_entity_collider(base_box)) { return true; }
return false;
}
+17 -1
View File
@@ -2,6 +2,7 @@
#include <SDL3/SDL.h>
#include <functional>
#include <string>
#include <utility>
@@ -49,6 +50,13 @@ namespace Map {
inline int back_wall_h = 0;
inline int side_wall_w = 0;
// Nivell d'il·luminació base de la sala. 1.0 = totalment il·luminada
// (com hui, sense efecte visual); 0.0 = completament negra fora del
// radi d'un emissor. La clau `light_level:` al YAML de la sala el
// sobreescriu — si manca, queda al default 1.0 i la pasada
// d'il·luminació del frame es salta com a no-op.
inline float light_level = 1.0F;
// Derivats de la geometria base: el pas entre sarcòfags ha de cabre
// exactament el jugador, i el mateix passadís s'usa a l'exterior.
inline int corridor_w = 0;
@@ -91,9 +99,17 @@ namespace Map {
// Mapa de colisions (separat del mapa de dibuix): retorna true si el
// rectangle `base_box` (la base del jugador, per exemple) s'encavalca
// amb cap sarcòfag.
// amb cap sarcòfag, el passadís de la porta tancada, o cap entitat
// sòlida genèrica registrada via `setEntityCollider`.
[[nodiscard]] auto collides(const SDL_FRect& base_box) -> bool;
// Registre d'un predicat extern que `collides` consulta com a sobre-
// capa: l'usa `EntityManager` per a entitats amb `blocksMovement()`
// (debris, columnes...) que no estan a la graella fixa de sarcòfags.
// Passa `nullptr` per netejar. Reset implícit a `loadFrom` (cada
// càrrega de nivell sol portar un manager nou).
void setEntityCollider(std::function<bool(const SDL_FRect&)> fn);
// true si la caixa `box` queda completament dins del playfield (zona
// interior, sense tocar els murs exteriors). El Player ja té clamp
// propi que aplica el mateix constrenyiment; això serveix a les
+210 -317
View File
@@ -16,6 +16,7 @@
#include "core/input/global_inputs.hpp"
#include "core/input/key_config.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/lighting.hpp"
#include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp"
@@ -26,6 +27,7 @@
#include "core/ui/ui_config.hpp"
#include "external/fkyaml_node.hpp"
#include "game/entities/concrete/dust.hpp"
#include "game/entities/concrete/sarcophagus.hpp"
#include "game/entities/concrete/whip_attack.hpp"
#include "game/entities/entity.hpp"
#include "game/entities/entity_factory.hpp"
@@ -321,6 +323,12 @@ GameScene::GameScene(Config config)
entity_manager_.getBus().subscribe("player_disabled", [this](const Event& ev) {
onPlayerDisabled(ev.player_id);
});
// `sarcophagus_opened`: extret a `onSarcophagusOpened` per acotar
// la complexitat cognitiva del ctor (mateix patró que `player_killed`
// i `player_disabled`).
entity_manager_.getBus().subscribe("sarcophagus_opened", [this](const Event& ev) {
onSarcophagusOpened(ev);
});
// Polvet: precarreguem el surface del Dust una sola vegada perquè
// els spawns runtime de l'entitat no facin disc al primer salt.
@@ -484,16 +492,8 @@ GameScene::GameScene(Config config)
// al Resource::Cache (preload al Director), per tant la primera
// crida a playMusic és immediata.
// Font de l'overlay de pausa: reutilitzem la Surface ja cachejada al
// Director (PLACEHOLDER_NAME + ".gif"). Text només parseja el .fnt a
// l'arrencada de l'escena, un sol cop.
const std::string PAUSE_FONT_KEY = UiConfig::get().placeholderFont().name + ".gif";
auto pause_surface = Resource::Cache::get().getSurface(PAUSE_FONT_KEY);
pause_font_ = std::make_shared<Text>(pause_surface, UiConfig::get().placeholderFont().fnt);
pause_font_->setDefaultKerning(UiConfig::get().placeholderFont().kerning);
// Font del rètol de transició de sala. Mateixa convenció que la pausa
// (Surface preloaded pel Director, només construïm el `Text` ací).
// Font del rètol de transició de sala. Reutilitzem la Surface ja
// cachejada al Director; només construïm el `Text` ací.
const std::string ROOM_TEXT_FONT_KEY = UiConfig::get().roomTextFont().name + ".gif";
auto room_text_surface = Resource::Cache::get().getSurface(ROOM_TEXT_FONT_KEY);
room_text_font_ = std::make_shared<Text>(room_text_surface, UiConfig::get().roomTextFont().fnt);
@@ -740,6 +740,21 @@ void GameScene::onPlayerDisabled(int player_id) {
players_active_[player_id] = false;
}
void GameScene::onSarcophagusOpened(const Event& ev) {
// `player_id < 0`: pintada sense atribució (broadcast inicial). No
// hauria de tancar mai un sarc, però per defensa ignorem.
if (ev.player_id < 0) { return; }
const auto IDX = static_cast<std::size_t>(ev.player_id);
if (IDX >= players_.size()) { return; }
const auto* ent = std::get_if<Entity*>(&ev.payload);
if (ent == nullptr || *ent == nullptr) { return; }
auto* sarc = dynamic_cast<Sarcophagus*>(*ent);
if (sarc == nullptr) { return; }
auto& p = *players_[IDX];
p.addScore(sarc->getPointsReward());
p.addTreasures(sarc->getTreasuresReward());
}
void GameScene::preloadGameOverAssets() const {
// Pre-load del so del countdown: el dispara `updateGameOverFlow`
// cada vegada que canvia el número visible de CONTINUE? (incloent
@@ -991,6 +1006,37 @@ void GameScene::updateRoomTransition(int delta_ms) {
}
}
void GameScene::applyLightingPass() {
// Atajo: si la sala està totalment il·luminada i ningú emet,
// `Lighting::apply` ja s'autosalta amb les mateixes condicions.
// Recollim igualment perquè el cas comú durant el desenvolupament
// serà tocar `Map::light_level` per sales concretes.
std::vector<Lighting::Emitter> emitters;
emitters.reserve(players_.size() + entity_manager_.entityCount());
const int PLAYER_R = Player::getLightRadius();
// Quan el YAML del jugador no declara `light:`, `PLAYER_R` és 0 i
// `Lighting::apply` salta el cercle al filtre intern. No fem un
// gating extern aquí (els jugadors són només 1-2 actors i el cost
// d'afegir-los al vector és menyspreable).
const bool PLAYER_FLICKER = Player::getLightFlicker();
for (const auto& p : players_) {
if (p->isOutOfPlay()) { continue; }
const auto C = p->getLightCenter();
emitters.push_back({.cx = C.x, .cy = C.y, .radius = PLAYER_R, .seed = p->getLightSeed(), .flicker = PLAYER_FLICKER});
}
for (const auto* e : entity_manager_.getEntities()) {
const int R = e->getLightRadius();
if (R <= 0) { continue; }
const auto C = e->getLightCenter();
emitters.push_back({.cx = C.x, .cy = C.y, .radius = R, .seed = e->getLightSeed(), .flicker = e->getLightFlicker()});
}
Lighting::apply(Screen::get().getCanvasArgb(),
Screen::get().getCanvasWidth(),
Screen::get().getCanvasHeight(),
Map::light_level,
emitters);
}
void GameScene::renderRoomFade() {
// Delegació als FadeToBlack: les durades, la direcció i el progrés
// viuen dins de cada instància. Comportament:
@@ -1011,11 +1057,6 @@ void GameScene::renderRoomFade() {
}
void GameScene::renderRoomText() {
// Orquestrador del rètol de transició de sala. La feina pesada
// (animació de X, animació d'altura del panel i pintat) la fan
// quatre helpers privats; aquesta funció només decideix quin
// text dibuixar i amb quines fases segons l'estat.
//
// Mapatge per estat (només els estats amb text actiu hi entren):
// ROOM_INTRO: phases 2..4 → enter / hold / exit; dues línies
// (piràmide damunt, sala davall) que travessen la
@@ -1024,82 +1065,35 @@ void GameScene::renderRoomText() {
// oposades ("fase" / "completada"). Phase 3 és el
// fade-out i 4 la pausa en negre — cap text en cap
// de les dues.
if (!room_text_font_) { return; }
constexpr int KERNING = 0;
// Centre vertical de la zona de joc (canvas sense la HUD). Reemplaça
// l'antic GameCanvas::CENTER_Y per descomptar la franja del marcador
// i tenir el rètol equilibrat sobre el playfield.
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
// Colors del rètol via UiConfig (`data/config/ui.yaml > colors.room_text` i
// `room_text_outline`). Independents dels colors de notificació.
const Uint8 FILL = UiConfig::get().roomText();
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
const int LINE_HEIGHT = room_text_font_->getLineHeight();
const int PHASE = transition_.phase;
const int ELAPSED = transition_.elapsed_ms;
if (state_ == RoomFsm::State::ROOM_INTRO && PHASE >= 2 && PHASE <= 4) {
const int DUR_ENTER = transition_config_.intro_text_enter_ms;
const int DUR_EXIT = transition_config_.intro_text_exit_ms;
// Panel primer (queda sota el text gràcies a l'ordre de blits).
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(PHASE, ELAPSED, 2, 3, 4, DUR_ENTER, DUR_EXIT));
const auto& mgr = Levels::LevelManager::get();
const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid());
const std::string LINE_ROOM = RoomFsm::formatIntroRoomLine(mgr.currentRoom());
// Direccions oposades: piràmide entra per l'esquerra i surt per
// la dreta; sala fa el contrari.
const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING),
renderTextScreen(TextScreenSpec{
.line1 = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid()),
.line2 = RoomFsm::formatIntroRoomLine(mgr.currentRoom()),
.phase_enter = 2,
.phase_hold = 3,
.phase_exit = 4,
.dur_enter_ms = transition_config_.intro_text_enter_ms,
.dur_exit_ms = transition_config_.intro_text_exit_ms,
.line_gap_px = transition_config_.intro_text_line_gap_px,
},
PHASE,
ELAPSED,
2,
4,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/false);
const int X_ROOM = computeRoomTextX(room_text_font_->length(LINE_ROOM, KERNING),
PHASE,
ELAPSED,
2,
4,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/true);
// Bloc de dues línies centrat al voltant del PLAY_CENTER_Y. El
// gap (`intro_text_line_gap_px`) es reparteix simètricament:
// mig amunt a la piràmide, mig avall a la sala.
const int GAP = transition_config_.intro_text_line_gap_px;
drawRoomTextLine(LINE_PYRAMID, X_PYRAMID, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
drawRoomTextLine(LINE_ROOM, X_ROOM, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
ELAPSED);
} else if (state_ == RoomFsm::State::ROOM_OUTRO && PHASE >= 0 && PHASE <= 2) {
const int DUR_ENTER = transition_config_.outro_text_enter_ms;
const int DUR_EXIT = transition_config_.outro_text_exit_ms;
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT));
const std::string LINE_1 = Locale::get().text("game.room_complete_line1");
const std::string LINE_2 = Locale::get().text("game.room_complete_line2");
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
renderTextScreen(TextScreenSpec{
.line1 = Locale::get().text("game.room_complete_line1"),
.line2 = Locale::get().text("game.room_complete_line2"),
.phase_enter = 0,
.phase_hold = 1,
.phase_exit = 2,
.dur_enter_ms = transition_config_.outro_text_enter_ms,
.dur_exit_ms = transition_config_.outro_text_exit_ms,
.line_gap_px = transition_config_.outro_text_line_gap_px,
},
PHASE,
ELAPSED,
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/false);
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
PHASE,
ELAPSED,
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/true);
const int GAP = transition_config_.outro_text_line_gap_px;
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
ELAPSED);
}
}
@@ -1179,6 +1173,51 @@ void GameScene::drawRoomTextLine(const std::string& text, int x, int y, std::uin
outline_color);
}
void GameScene::renderTextScreen(const TextScreenSpec& spec, int phase, int elapsed_ms) const {
// Coreografia comuna als quatre rètols (ROOM_INTRO, ROOM_OUTRO,
// CONTINUE?, GAME OVER): panel translúcid de fons + dues línies
// que entren/surten per costats oposats. Els quatre helpers
// existents (`drawRoomTextPanel`, `computeRoomTextPanelHeight`,
// `computeRoomTextX`, `drawRoomTextLine`) fan la feina pesada;
// aquesta funció només els encadena en el mateix ordre per als
// quatre casos. Una `lineN` buida es salta sense pintar — així
// CONTINUE? amaga la línia del countdown fora del hold sense
// que els callers hagin de duplicar la branca.
if (!room_text_font_) { return; }
constexpr int KERNING = 0;
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
const Uint8 FILL = UiConfig::get().roomText();
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
const int LINE_HEIGHT = room_text_font_->getLineHeight();
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(phase, elapsed_ms, spec.phase_enter, spec.phase_hold, spec.phase_exit, spec.dur_enter_ms, spec.dur_exit_ms));
if (!spec.line1.empty()) {
const int X_1 = computeRoomTextX(room_text_font_->length(spec.line1, KERNING),
phase,
elapsed_ms,
spec.phase_enter,
spec.phase_exit,
spec.dur_enter_ms,
spec.dur_exit_ms,
/*from_right=*/false);
drawRoomTextLine(spec.line1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (spec.line_gap_px / 2), FILL, OUTLINE);
}
if (!spec.line2.empty()) {
const int X_2 = computeRoomTextX(room_text_font_->length(spec.line2, KERNING),
phase,
elapsed_ms,
spec.phase_enter,
spec.phase_exit,
spec.dur_enter_ms,
spec.dur_exit_ms,
/*from_right=*/true);
drawRoomTextLine(spec.line2, X_2, PLAY_CENTER_Y + (spec.line_gap_px / 2), FILL, OUTLINE);
}
}
void GameScene::setGameOverState(GameOverFsm::State new_state) {
if (new_state == game_over_state_) { return; }
const auto PREV = game_over_state_;
@@ -1315,104 +1354,57 @@ void GameScene::emitCountdownTickIfChanged() {
}
void GameScene::renderGameOverText() {
if (!room_text_font_) { return; }
// Mapatge per estat:
// CONTINUE_PROMPT: phases 0=enter, 1=hold (countdown visible),
// 2=exit. La línia 2 (número) només es pinta
// durant la fase 1 perquè els números no
// travessin la pantalla.
// GAME_OVER: phases 0=hold, 1=exit (sense enter anim — el
// panel ja és amunt del CONTINUE_PROMPT). Phase 2
// (wait) sense text ni panel.
if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT && game_over_state_ != GameOverFsm::State::GAME_OVER) {
return;
}
constexpr int KERNING = 0;
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
const Uint8 FILL = UiConfig::get().roomText();
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
const int LINE_HEIGHT = room_text_font_->getLineHeight();
const int PHASE = game_over_transition_.phase;
const int ELAPSED = game_over_transition_.elapsed_ms;
if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) {
const int DUR_ENTER = game_over_config_.continue_text_enter_ms;
const int DUR_EXIT = game_over_config_.continue_text_exit_ms;
// Phases del CONTINUE_PROMPT: 0=enter, 1=hold, 2=exit. El panel
// segueix la mateixa corba enter/hold/exit que el room text.
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT));
const std::string LINE_1 = Locale::get().text("game.continue_prompt");
const int SECONDS = GameOverFsm::countdownSecondsRemaining(
game_over_state_,
game_over_transition_,
game_over_config_);
// Línia 2 només té text durant la fase de hold (countdown). En
// les fases d'enter i exit deixem només el "CONTINUE?" perquè
// l'animació dels números no quedi rara.
const std::string LINE_2 = (PHASE == 1) ? std::to_string(SECONDS) : std::string{};
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
renderTextScreen(TextScreenSpec{
.line1 = Locale::get().text("game.continue_prompt"),
.line2 = (PHASE == 1) ? std::to_string(SECONDS) : std::string{},
.phase_enter = 0,
.phase_hold = 1,
.phase_exit = 2,
.dur_enter_ms = game_over_config_.continue_text_enter_ms,
.dur_exit_ms = game_over_config_.continue_text_exit_ms,
.line_gap_px = transition_config_.intro_text_line_gap_px,
},
PHASE,
ELAPSED,
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/false);
const int X_2 = computeRoomTextX(LINE_2.empty() ? 0 : room_text_font_->length(LINE_2, KERNING),
PHASE,
ELAPSED,
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/true);
const int GAP = transition_config_.intro_text_line_gap_px;
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
if (!LINE_2.empty()) {
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
}
ELAPSED);
return;
}
// GAME_OVER: panel sempre amunt durant phase 0/1, decreix en phase 1
// (exit). Phase 2 (wait) sense panel ni text — només temps mort
// abans del fade.
const int DUR_HOLD = game_over_config_.game_over_text_hold_ms;
const int DUR_EXIT = game_over_config_.game_over_text_exit_ms;
// GAME_OVER: phase 2 (wait) sense panel ni text — només temps mort
// abans del fade. phase_enter=-1 desactiva l'animació d'entrada
// (el panel ja és amunt heretat del CONTINUE_PROMPT).
if (PHASE > 1) { return; }
// Panel: phase 0 = ple, phase 1 = decreix. Reaprofitem
// computeRoomTextPanelHeight passant un phase_enter "fora de rang"
// (-1) perquè la branca d'enter no s'activi mai.
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(PHASE, ELAPSED, /*phase_enter=*/-1,
/*phase_hold=*/0,
/*phase_exit=*/1,
/*dur_enter=*/0,
DUR_EXIT));
const std::string LINE_1 = Locale::get().text("game.game_over_line1");
const std::string LINE_2 = Locale::get().text("game.game_over_line2");
// Phase 0 (hold): text quiet al CENTER_X. Phase 1 (exit): el text
// marxa pels costats com el room outro. Reaprofitem computeRoomTextX
// amb phase_enter=-1 (no enter anim) i phase_exit=1.
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
renderTextScreen(TextScreenSpec{
.line1 = Locale::get().text("game.game_over_line1"),
.line2 = Locale::get().text("game.game_over_line2"),
.phase_enter = -1,
.phase_hold = 0,
.phase_exit = 1,
.dur_enter_ms = 0,
.dur_exit_ms = game_over_config_.game_over_text_exit_ms,
.line_gap_px = transition_config_.outro_text_line_gap_px,
},
PHASE,
ELAPSED,
/*phase_enter=*/-1,
/*phase_exit=*/1,
/*dur_enter=*/0,
DUR_EXIT,
/*from_right=*/false);
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
PHASE,
ELAPSED,
/*phase_enter=*/-1,
/*phase_exit=*/1,
/*dur_enter=*/0,
DUR_EXIT,
/*from_right=*/true);
const int GAP = transition_config_.outro_text_line_gap_px;
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
(void)DUR_HOLD; // Reservat per a futurs efectes de hold (pulse, etc.).
ELAPSED);
}
void GameScene::renderGameOverFade() {
@@ -1467,81 +1459,6 @@ void GameScene::spawnWhip(const WhipSpawnPayload& payload) {
entity_manager_.add(std::move(whip));
}
void GameScene::handlePauseInput() {
#ifdef _DEBUG
// Mutex amb l'editor: si està actiu, ignorem la tecla de pausa.
if (editor_.active()) { return; }
#endif
const SDL_Scancode PAUSE_SC = KeyConfig::get().scancode("pause");
for (const auto& event : EventBuffer::events) {
const bool KEY_HIT = (event.type == SDL_EVENT_KEY_DOWN && PAUSE_SC != SDL_SCANCODE_UNKNOWN && event.key.scancode == PAUSE_SC);
if (!KEY_HIT) { continue; }
// Determinem cap a quin estat anem. Si estem quiets (IDLE o
// PAUSED) arranquem la transició contrària. Si estem en marxa
// (ENTERING o EXITING) invertim: la `scale` actual es manté com
// a `from_scale` nou i la durada es recalcula proporcional a la
// distància restant — així mai es produeix un salt de velocitat.
const bool GOING_TO_PAUSE = (pause_.state == PauseState::IDLE || pause_.state == PauseState::EXITING);
// Reprenem l'stream ABANS de començar a pujar el volum: quan
// venim de PAUSED l'àudio està aturat de debò (ho fem al final
// d'ENTERING, veure updatePauseTransition); reprenem just al
// moment de començar EXITING per no perdre el primer frame
// d'stream. A les inversions mid-flight (ENTERING → EXITING
// sense tocar PAUSED) l'stream no s'ha aturat mai, així que no
// cal reprendre.
if (!GOING_TO_PAUSE && pause_.state == PauseState::PAUSED) {
Audio::get()->resumeMusic();
}
pause_.from_scale = pause_.scale;
pause_.to_scale = GOING_TO_PAUSE ? 0.0F : 1.0F;
const int TARGET_MS = GOING_TO_PAUSE ? UiConfig::get().pauseEnterMs() : UiConfig::get().pauseExitMs();
const float DISTANCE = std::fabs(pause_.to_scale - pause_.from_scale);
pause_.duration_ms = std::max(1, static_cast<int>(static_cast<float>(TARGET_MS) * DISTANCE));
pause_.elapsed_ms = 0;
pause_.curve = GOING_TO_PAUSE ? UiConfig::get().pauseEnterCurve() : UiConfig::get().pauseExitCurve();
pause_.state = GOING_TO_PAUSE ? PauseState::ENTERING : PauseState::EXITING;
Log::info("PAUSE", "{} from_scale={} duration_ms={}", GOING_TO_PAUSE ? "entering" : "exiting", pause_.from_scale, pause_.duration_ms);
}
}
void GameScene::updatePauseTransition(int delta_ms) {
if (pause_.state != PauseState::ENTERING && pause_.state != PauseState::EXITING) { return; }
pause_.elapsed_ms += delta_ms;
bool just_reached_pause = false;
if (pause_.elapsed_ms >= pause_.duration_ms) {
// Transició completa: enganxem al valor objectiu exacte i
// promocionem l'estat al terminal corresponent.
pause_.scale = pause_.to_scale;
const PauseState NEXT = (pause_.to_scale == 0.0F) ? PauseState::PAUSED : PauseState::IDLE;
just_reached_pause = (NEXT == PauseState::PAUSED);
pause_.state = NEXT;
} else {
const float T = static_cast<float>(pause_.elapsed_ms) / static_cast<float>(pause_.duration_ms);
const auto CURVE = (pause_.curve != nullptr) ? pause_.curve : &Easing::linear;
pause_.scale = std::lerp(pause_.from_scale, pause_.to_scale, CURVE(T));
}
// Fade del volum de música durant la transició. La base és el que
// l'usuari ha configurat a Options; multipliquem per la `scale` per
// obtenir el volum efectiu aquest frame.
const float BASE_VOLUME = Options::audio().music.volume;
Audio::get()->setMusicVolume(BASE_VOLUME * pause_.scale);
// En arribar a PAUSED aturem l'stream: així quan despausem la música
// tornarà des d'on s'ha quedat, no des d'on hauria anat a parar
// durant el temps que el joc ha estat en pausa. El `resumeMusic` es
// fa a `handlePauseInput` al començar l'EXITING, perquè la música
// sone ja mentre el volum puja des de 0.
if (just_reached_pause) {
Audio::get()->pauseMusic();
}
}
#ifdef _DEBUG
namespace {
auto detectEditorToggle() -> bool {
@@ -1577,33 +1494,33 @@ namespace {
#endif
void GameScene::update(int delta_ms) {
handlePauseInput();
// Congelació instantània mentre el menú de servei està obert. A
// diferència de la pausa de gameplay (que és un dial amb easing i
// fade de música), aquesta és binària: el món no avança gens i la
// música no es toca. També posem en pausa el dial de la pausa
// regular si estava a mig camí, perquè no continui mentre l'usuari
// navega el menú.
// Congelació instantània mentre el menú de servei està obert: el
// món no avança gens. La música segueix sonant perquè el menú serveix
// entre altres coses per calibrar les opcions d'àudio.
const bool MENU_FROZEN = ServiceMenu::get().isOpen();
if (!MENU_FROZEN) {
updatePauseTransition(delta_ms);
}
// Transició de sala: viu en temps real (no escalada per la pausa)
// perquè és UI. Avança avans del bloc de món perquè quan PLAYING
// Transició de sala i sub-FSM de game over: viuen en temps real
// perquè són UI. Avancen abans del bloc de món perquè quan PLAYING
// arrenque ja sigui efectiu en el mateix frame.
updateRoomTransition(delta_ms);
// Sub-FSM de game over (CONTINUE? + GAME OVER + fade-to-LOGO).
// També en temps real: el countdown de CONTINUE? no es congela
// amb la pausa de gameplay. La detecció de "tots fora de partida"
// s'arma dins quan estem a PLAYING.
updateGameOverFlow(delta_ms);
// Game-over flow pausat mentre el menú de servei o l'editor estan
// oberts. L'editor fa `loadRoom` complet i deixa els jugadors DISABLED
// (`isOutOfPlay()==true`) esperant respawn instantani a la sortida;
// si el flux corre durant aquest interval, `activePlayerCount()==0`
// mentre `state_==PLAYING` dispara un CONTINUE_PROMPT espuri que
// acaba portant al LOGO. La pausa aquí és simètrica amb com el menú
// i l'editor congelen la resta del món.
#ifdef _DEBUG
const bool GAME_OVER_PAUSED = MENU_FROZEN || editor_.active();
#else
const bool GAME_OVER_PAUSED = MENU_FROZEN;
#endif
if (!GAME_OVER_PAUSED) {
updateGameOverFlow(delta_ms);
}
#ifdef _DEBUG
// Toggle de l'editor: només si no hi ha cap transició de pausa ni
// pausa consolidada. L'editor mateix no consumeix la tecla — la
// Toggle de l'editor. L'editor mateix no consumeix la tecla — la
// llegim ací per decidir la coexistència. Tant a l'entrada com a la
// sortida fem `loadRoom` complet per partir sempre del YAML del
// disc: les entitats tornen a la posició inicial (momies, sarcs),
@@ -1611,7 +1528,7 @@ void GameScene::update(int delta_ms) {
// l'editor sempre veu l'estat-base de la sala (no el d'una partida
// a meitat) i el joc, en sortir, arrenca fresc amb les edicions ja
// persistides al YAML.
if (pause_.state == PauseState::IDLE && detectEditorToggle()) {
if (!MENU_FROZEN && detectEditorToggle()) {
const bool WAS_ACTIVE = editor_.active();
editor_.toggle(tilemap_, entity_manager_);
const bool NOW_ACTIVE = editor_.active();
@@ -1621,6 +1538,14 @@ void GameScene::update(int delta_ms) {
// totes maneres) i es reactivaran amb el spawn instantani
// a la sortida.
loadRoom(Levels::LevelManager::get().currentPath());
// Materialitzem els dynamic hazards (mòmies, roques) en estat
// estàtic perquè es vegin a la posició inicial: així l'editor
// les pot seleccionar i moure com qualsevol altra entitat. El
// flux normal del joc les enruta a `pending_actors_` per a
// disparar la cinemàtica EMERGING al final del ROOM_INTRO; a
// l'editor el món està congelat i aquesta animació quedaria
// travada.
entity_manager_.materializePendingActorsStatic();
} else if (WAS_ACTIVE && !NOW_ACTIVE) {
// Acabem de sortir: el writer ja ha persistit el YAML.
// Carreguem fresh i fem spawn instantani sense cinemàtica.
@@ -1629,10 +1554,10 @@ void GameScene::update(int delta_ms) {
}
}
// Salts debug entre sales (`[` / `]`). Mútuament excloents amb la
// pausa i amb l'editor: si qualsevol està actiu, ignorem la tecla
// perquè el salt recarrega entitats i invalidaria l'estat editat.
if (pause_.state == PauseState::IDLE && !editor_.active()) {
// Salts debug entre sales (`[` / `]`). Mútuament excloents amb el
// menú de servei i amb l'editor: si qualsevol està actiu, ignorem la
// tecla perquè el salt recarrega entitats i invalidaria l'estat editat.
if (!MENU_FROZEN && !editor_.active()) {
const int DELTA = detectRoomJump();
if (DELTA != 0) {
auto& mgr = Levels::LevelManager::get();
@@ -1646,7 +1571,7 @@ void GameScene::update(int delta_ms) {
// pintat/sarcs durant proves de progressió. La resta del flux
// (creuar → player_exited → level_complete → OUTRO) es manté.
// Mateixos gates que el salt entre sales.
if (pause_.state == PauseState::IDLE && !editor_.active() && detectOpenDoor()) {
if (!MENU_FROZEN && !editor_.active() && detectOpenDoor()) {
Log::info("DEBUG", "força obertura de la porta");
entity_manager_.forceOpenDoor();
}
@@ -1655,33 +1580,31 @@ void GameScene::update(int delta_ms) {
// Join in progress (regla 1): un slot DISABLED pot entrar a partida
// pulsant START durant PLAYING. Es processa abans del bloc de món
// per que el spawn aeri ja sigui efectiu en aquest mateix frame.
// Gateat per pausa/menú/editor: no es vol que un START accidental
// mentre l'usuari navega per un menú trigueri un join.
// Gateat per menú/editor: no es vol que un START accidental mentre
// l'usuari navega per un menú trigueri un join.
#ifdef _DEBUG
const bool EDITOR_OPEN = editor_.active();
#else
constexpr bool EDITOR_OPEN = false;
#endif
if (pause_.state == PauseState::IDLE && !MENU_FROZEN && !EDITOR_OPEN) {
if (!MENU_FROZEN && !EDITOR_OPEN) {
handleJoinInput();
}
GlobalInputs::update(delta_ms);
if (GlobalInputs::wantsQuit()) { wants_quit_ = true; }
// El món avança amb `delta_ms * pause_.scale`: scale=1 corre a velocitat
// real, scale=0 el congela. Quan l'editor de debug està actiu també
// es salta completament (no l'escalem, el saltem). Notifier,
// RenderInfo i HUD reben el delta real, per això viuen fora d'aquest
// bloc i no es ralenteixen amb el dial de pausa.
// El bloc de món no s'executa si el menú de servei o l'editor de
// debug estan oberts: tots dos congelen el joc instantàniament.
// Notifier, RenderInfo i HUD viuen fora d'aquest bloc perquè s'han
// de seguir actualitzant en aquests casos.
#ifdef _DEBUG
const bool EDITOR_FROZEN = editor_.active();
#else
constexpr bool EDITOR_FROZEN = false;
#endif
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
if (!EDITOR_FROZEN && !MENU_FROZEN && SCALED_DELTA_MS > 0) {
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
if (!EDITOR_FROZEN && !MENU_FROZEN && delta_ms > 0) {
const float DT = static_cast<float>(delta_ms) / 1000.0F;
// Pipeline de moviment en tres passes per poder intercalar la
// resolució de contactes jugador↔jugador entre input i posició:
@@ -1793,18 +1716,12 @@ void GameScene::render() {
};
std::vector<Renderable> order;
order.reserve(entity_manager_.entityCount() + players_.size());
// Mergem els dos sacs (map + actors) i els jugadors en un sol
// vector temporal — el `stable_sort` per `feet_y` posterior fa el
// z-order interleaving entre els tres orígens sense importar el
// sac d'on venen.
const auto MAP = entity_manager_.getMapEntities();
std::ranges::transform(MAP, std::back_inserter(order), [](Entity* e) {
// Mergem entitats i jugadors en un sol vector temporal — el
// `stable_sort` per `feet_y` posterior fa el z-order interleaving.
const auto ENTITIES = entity_manager_.getEntities();
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
});
const auto ACTORS = entity_manager_.getActors();
std::ranges::transform(ACTORS, std::back_inserter(order), [](DynamicHazard* a) -> Renderable {
return Renderable{.feet_y = a->getFeetY(), .entity = a, .player = nullptr};
});
// Tots els jugadors entren al z-sort. Els fora de joc fan no-op a
// `render()`; els actius (inclosos AIRBORNE durant l'spawn aeri)
// es pinten amb el seu feet_y normal. La sombra que afecta la
@@ -1823,6 +1740,12 @@ void GameScene::render() {
}
}
// Pasada d'il·luminació: oscureix el canvas segons `Map::light_level`
// i hi obre cercles brillants al voltant dels emissors (jugadors,
// antorxes, mòmies, ...). Va abans del fade i del HUD perquè el
// fade pinta negre encima i la UI ha de quedar llegible.
applyLightingPass();
// Fade del món: pintat ENTRE actors i HUD perquè el marcador, les
// notificacions i el service menu queden per damunt sense afectar-se.
// Inactiu si l'estat és PLAYING.
@@ -1851,36 +1774,6 @@ void GameScene::render() {
editor_.render(tilemap_, entity_manager_);
#endif
// Overlay de pausa: text amb stroke blanc (índex 255) centrat amb X
// depenent de l'estat del dial. Entra lliscant des de l'esquerra
// (ENTERING), es queda al centre (PAUSED) i surt cap a la dreta
// (EXITING). La X es deriva directament de `pause_.scale`, que ja
// porta la corba aplicada — així text i món frenen alhora.
if (pause_.state != PauseState::IDLE && pause_font_) {
constexpr Uint8 OUTLINE_WHITE = 255;
constexpr Uint8 STROKE_DIST = 1;
constexpr int PAUSE_KERNING = 0;
const std::string TEXT = Locale::get().text("input.paused");
const int TEXT_WIDTH = pause_font_->length(TEXT, PAUSE_KERNING);
const int OFF_LEFT = -TEXT_WIDTH;
const int OFF_RIGHT = GameCanvas::WIDTH + TEXT_WIDTH;
int x = GameCanvas::CENTER_X;
if (pause_.state == PauseState::ENTERING) {
x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, 1.0F - pause_.scale);
} else if (pause_.state == PauseState::EXITING) {
x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, pause_.scale);
}
const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2);
pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG,
x,
Y,
TEXT,
PAUSE_KERNING,
UiConfig::get().notificationText(),
STROKE_DIST,
OUTLINE_WHITE);
}
Notifier::get().render();
RenderInfo::get().render();
Screen::get().present();
+51 -53
View File
@@ -55,24 +55,11 @@ class GameScene : public Scene {
void setState(RoomFsm::State new_state);
// Avança la transició activa via `RoomFsm::advance`. S'executa cada
// frame amb delta real (no escalat per la pausa) perquè la transició
// viu en temps de UI. Si l'estat retornat és diferent de l'actual,
// crida `setState` (que reseteja la `Transition`).
// frame amb delta real perquè la transició viu en temps de UI. Si
// l'estat retornat és diferent de l'actual, crida `setState` (que
// reseteja la `Transition`).
void updateRoomTransition(int delta_ms);
// Consumeix events d'aquest frame (F11 del KeyConfig "pause") per
// disparar les transicions de pausa. El mando ja no participa: el
// botó START és ara una acció remapeable per dispositiu i el menú
// de servei fa de pausa quan cal. No alterna un flag binari: inicia
// (o inverteix mid-flight) un "dial" de temps que frena el món amb
// easing.
void handlePauseInput();
// Avança la transició activa, recalcula `pause_.scale` i aplica el
// fade de volum de música. S'executa cada frame amb el delta real
// (no escalat) perquè la transició viu en temps real.
void updatePauseTransition(int delta_ms);
// Spawna un Dust transitori centrat a (`feet_x_center`, `feet_y`).
// Cridat per la subscripció del bus a `player_jumped` /
// `player_landed` per pintar el polvet de despegue / aterratge.
@@ -140,6 +127,14 @@ class GameScene : public Scene {
// mètode per acotar la complexitat cognitiva del subscriber/ctor.
void onPlayerDisabled(int player_id);
// Handler de l'event `sarcophagus_opened` (un jugador acaba de
// tancar el perímetre). Reparteix la recompensa (`points`,
// `treasures` del YAML del sarc) al Player corresponent, que al
// mutar score/treasures publicarà `player_stats_changed` i el HUD
// s'actualitzarà sol. Extret a mètode per acotar la complexitat
// cognitiva del ctor.
void onSarcophagusOpened(const Event& ev);
// Pre-load dels recursos d'àudio que s'usen mid-partida i no els
// toca el Director (so del countdown, etc.). Si la clau és buida
// o ja està al cache, és no-op. Extret a mètode per acotar la
@@ -239,41 +234,11 @@ class GameScene : public Scene {
// la destrucció.
std::unique_ptr<Hud> hud_;
// Pausa de gameplay com a "dial" de temps. En lloc d'un interruptor,
// `pause_.scale` va de 1 (món corrent) a 0 (món congelat) amb una
// corba d'easing durant ENTERING, i al revés durant EXITING. El
// `delta_ms` del món (players + EntityManager) es multiplica per
// aquesta escala; HUD, Notifier i GlobalInputs reben sempre el
// delta real. Les inversions mid-flight capturen la `scale` actual
// com a `from_scale` i escalen la durada a la distància restant per
// mantenir la sensació de velocitat. La font és la mateixa que
// abans — ja cachejada al Director.
std::shared_ptr<Text> pause_font_;
// Font del rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO).
// Distinta de `pause_font_` perquè el rètol de fase té estètica
// pròpia (CaesarDressing per defecte, configurable via
// `data/config/ui.yaml > fonts.room_text`). La pausa segueix amb la
// placeholder.
// Té estètica pròpia (CaesarDressing per defecte, configurable via
// `data/config/ui.yaml > fonts.room_text`).
std::shared_ptr<Text> room_text_font_;
enum class PauseState : std::uint8_t {
IDLE, // Joc corrent; cap transició en marxa. scale == 1.
ENTERING, // Frenant cap a la pausa. scale decreix a 0.
PAUSED, // Món congelat. scale == 0.
EXITING, // Accelerant de tornada. scale creix a 1.
};
struct PauseTransition {
PauseState state{PauseState::IDLE};
float scale{1.0F}; // 0 = parat, 1 = corrent.
float from_scale{1.0F}; // Valor de `scale` al començar la transició.
float to_scale{1.0F}; // Valor objectiu (0.0 o 1.0).
int elapsed_ms{0}; // Temps transcorregut dins la transició.
int duration_ms{0}; // Durada total (escalada a la distància).
UiConfig::CurveFn curve{nullptr}; // Corba activa.
} pause_;
// Configuració de la FSM de sala (intro/outro). Carregada al ctor des
// de `data/scenes/game/game.yaml`; valors per defecte si manca el YAML.
RoomFsm::Config transition_config_{};
@@ -306,6 +271,13 @@ class GameScene : public Scene {
void renderRoomFade();
void renderRoomText();
// Pasada d'il·luminació CPU sobre el canvas, entre els actors i el
// HUD: recull els emissors actius (jugadors + entitats amb
// `light_radius>0`) i aplica el gradient amb `Map::light_level`
// com a llum de sala. El HUD/notificacions queden fora de la
// multiplicació perquè es pinten després.
void applyLightingPass();
// Configuració, estat i transició de la sub-FSM de game over
// (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la
// RoomFsm via `GameOverFsm::loadConfig`.
@@ -372,6 +344,33 @@ class GameScene : public Scene {
void drawRoomTextPanel(int center_y, int panel_height) const;
void drawRoomTextLine(const std::string& text, int x, int y, std::uint8_t fill_color, std::uint8_t outline_color) const;
// Especificació declarativa d'un rètol de transició de dues línies
// amb panel translúcid. ROOM_INTRO, ROOM_OUTRO, CONTINUE? i GAME OVER
// comparteixen la mateixa coreografia (panel + dues línies en
// direccions oposades) i només divergeixen en el text, els índexs
// de fase i les durades. `phase_enter = -1` desactiva l'animació
// d'entrada (cas GAME_OVER, que apareix amb el panel ja amunt).
// Una `lineN` buida indica que la línia no s'ha de pintar (cas
// CONTINUE? fora del hold, on la línia 2 mostra el countdown).
struct TextScreenSpec {
std::string line1;
std::string line2;
int phase_enter{0};
int phase_hold{0};
int phase_exit{0};
int dur_enter_ms{0};
int dur_exit_ms{0};
int line_gap_px{0};
};
// Orquestrador unificat dels quatre rètols de transició. Pinta el
// panel + dues línies a partir de `spec`, deixant la decisió de
// QUÈ pintar (text, fases, durades) als callers `renderRoomText`
// i `renderGameOverText`. Sense estat propi: la fase i temps
// arriben pels paràmetres perquè la mateixa funció serveix les
// dues sub-FSMs (RoomFsm i GameOverFsm).
void renderTextScreen(const TextScreenSpec& spec, int phase, int elapsed_ms) const;
// Posició inicial dels jugadors derivada de `Map::door_col`. Tots
// els jugadors comparteixen Y (centre del corredor exterior superior);
// X depèn del nombre actiu: amb un jugador queda centrat sota la
@@ -418,11 +417,10 @@ class GameScene : public Scene {
#endif
#ifdef _DEBUG
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i
// viceversa. Quan `editor_.active()` és true, saltem entitats i
// jugador (update i render) i deixem el tilemap visible perquè es
// pugui editar.
// Editor modal del tilemap. Mútuament excloent amb el menú de servei:
// si el menú està obert, la tecla de toggle de l'editor s'ignora.
// Quan `editor_.active()` és true, saltem entitats i jugador (update
// i render) i deixem el tilemap visible perquè es pugui editar.
Editor editor_;
#endif
};
+90 -22
View File
@@ -40,6 +40,25 @@ namespace {
// Separació extra entre camps dins d'una mateixa línia.
constexpr int FIELD_GAP = 6; // px entre "Vides 3" i "Tres. 00"
// Desplaçament de l'ombra del text en píxels (sota-dreta). Petit
// perquè la font del HUD és 8 px d'alçada — qualsevol valor més
// gran tapa el següent caràcter.
constexpr int SHADOW_DX = 1;
constexpr int SHADOW_DY = 1;
// Helpers per pintar text amb ombra. Primer la sombra desplaçada,
// després el text principal al damunt — l'ordre garanteix que la
// sombra mai cobreix el text. El kerning queda al default del
// Text (1 px), igual que la resta del HUD.
void drawShadowed(Text& font, int x, int y, const std::string& s, Uint8 color, Uint8 shadow) {
font.writeColored(x + SHADOW_DX, y + SHADOW_DY, s, shadow);
font.writeColored(x, y, s, color);
}
void drawMonoShadowed(Text& font, int x, int y, const std::string& s, Uint8 color, Uint8 shadow, int cell_w) {
font.writeMonoDigits(x + SHADOW_DX, y + SHADOW_DY, s, shadow, cell_w);
font.writeMonoDigits(x, y, s, color, cell_w);
}
// Format zero-padded a 6 dígits per a la puntuació.
auto formatScore(int v) -> std::string {
const int CLAMPED = std::clamp(v, 0, 999999);
@@ -116,10 +135,14 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font, int initial_lives)
players_[1].active = false;
players_[1].lives = initial_lives_;
// Subscripcions al bus de l'EntityManager. De moment només `player_hit`
// afecta el HUD visualment: decrementa vides, dispara flash i shake al
// bloc del jugador. Quan els events evolucionin per portar `player_id`
// i puntuacions, ampliarem aquí sense tocar callers.
// Subscripcions al bus de l'EntityManager.
//
// `player_hit` només fa feedback visual (flash + shake) al bloc del
// jugador colpejat. **No** toca el comptador de vides — la veritat
// viu al Player i arriba per `player_stats_changed`. Així si un hit
// no és mortal (HURT recoverable) el flash es manté coherent sense
// mentir el comptador, i si és mortal el comptador baixa pel canal
// numèric quan el cicle DYING acaba a `respawnFromDying`.
subs_.push_back(bus_.subscribe("player_hit", [this](const Event& ev) {
const auto* dmg = std::get_if<int>(&ev.payload);
// Dispatch pel `player_id` de l'event. 0 = P1 (bloc LEFT), 1 = P2
@@ -128,14 +151,28 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font, int initial_lives)
// publishers de `player_hit` actuals sí que porten id.
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
if (!players_[IDX].active) { return; }
if (players_[IDX].lives > 0) { --players_[IDX].lives; }
const std::size_t BLK_IDX = (IDX == 0) ? static_cast<std::size_t>(BlockId::LEFT)
: static_cast<std::size_t>(BlockId::RIGHT);
auto& blk = blocks_[BLK_IDX];
blk.flash_ms_left = UiConfig::get().hudFlashMs();
blk.shake_ms_left = UiConfig::get().hudShakeMs();
blk.shake_intensity = 1.5F;
Log::info("HUD", "player_hit p{} dmg={} lives={}", IDX + 1, dmg != nullptr ? *dmg : 0, players_[IDX].lives);
Log::info("HUD", "player_hit p{} dmg={}", IDX + 1, dmg != nullptr ? *dmg : 0);
}));
// `player_stats_changed`: snapshot total (vides/score/tresors) emès
// pel Player cada vegada que muta qualsevol dels tres. El HUD el
// consumeix com a estat absolut: sobreescriu la caché local sense
// raonament addicional. Això unifica totes les fonts de canvi (mort
// per qualsevol causa, recompensa de sarc, futurs pickups…) en un
// únic canal.
subs_.push_back(bus_.subscribe("player_stats_changed", [this](const Event& ev) {
const auto* st = std::get_if<PlayerStatsPayload>(&ev.payload);
if (st == nullptr) { return; }
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
players_[IDX].lives = st->lives;
players_[IDX].score = st->score;
players_[IDX].treasures = st->treasures;
}));
subs_.push_back(bus_.subscribe("level_complete", [](const Event& /*ev*/) {
@@ -169,6 +206,10 @@ void Hud::update(int delta_ms) {
if (b.shake_ms_left > 0) { b.shake_ms_left = std::max(0, b.shake_ms_left - delta_ms); }
if (b.flash_ms_left > 0) { b.flash_ms_left = std::max(0, b.flash_ms_left - delta_ms); }
}
const int CYCLE = UiConfig::get().hudBlinkCycleMs();
if (CYCLE > 0) {
blink_elapsed_ms_ = (blink_elapsed_ms_ + delta_ms) % CYCLE;
}
}
void Hud::setMode(BlockId block, Mode mode) {
@@ -184,10 +225,11 @@ void Hud::activatePlayer(int player_one_based) {
if (player_one_based < 1 || player_one_based > 2) { return; }
auto& p = players_[static_cast<std::size_t>(player_one_based - 1)];
p.active = true;
p.lives = initial_lives_;
p.score = 0;
p.treasures = 0;
p.join_prompt = JoinPromptMode::NONE;
// Els camps numèrics (lives/score/treasures) ja els ha sobreescrit el
// `player_stats_changed` que el Player publica dins de `resetForJoin`,
// així que aquí no els toquem — evitem trepitjar la veritat del bus
// amb defaults locals.
}
void Hud::setJoinPrompt(int player_one_based, JoinPromptMode mode) {
@@ -219,6 +261,16 @@ auto Hud::computeOffset(const Block& b) -> SDL_FPoint {
return off;
}
auto Hud::blinkVisible() const -> bool {
const auto& ui = UiConfig::get();
const int CYCLE = ui.hudBlinkCycleMs();
const float RATIO = ui.hudBlinkVisibleRatio();
if (CYCLE <= 0 || RATIO >= 1.0F) { return true; }
if (RATIO <= 0.0F) { return false; }
const int VISIBLE_MS = static_cast<int>(static_cast<float>(CYCLE) * RATIO);
return blink_elapsed_ms_ < VISIBLE_MS;
}
void Hud::render() const {
// Backdrop de la franja del HUD. Es pinta abans dels tres blocs perquè,
// quan un bloc es mou (entry slide o shake), la zona que deixa al
@@ -261,11 +313,22 @@ void Hud::renderBlock(BlockId id) const {
void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index, const std::string& label) const {
const auto& p = players_[static_cast<std::size_t>(player_index)];
// Família de color del bloc: P1 (vermell-crema-vermell-fosc) per al
// jugador 0, P2 (cian-blau-blau-marí) per al jugador 1. Mantenir el
// mateix matís a label/value/shadow dóna jerarquia visual coherent
// entre línies sense empastar (la sombra és un to fosc del mateix
// matís → efecte embossing).
const auto& ui = UiConfig::get();
const Uint8 LBL_C = (player_index == 0) ? ui.hudP1Label() : ui.hudP2Label();
const Uint8 VAL_C = (player_index == 0) ? ui.hudP1Value() : ui.hudP2Value();
const Uint8 SHD_C = (player_index == 0) ? ui.hudP1Shadow() : ui.hudP2Shadow();
// Slot inactiu amb prompt actiu: substituïm el render de zeros pel
// missatge "PULSA START / PARA JUGAR" o "ESPERA / PARA JUGAR" segons
// el mode fixat per la GameScene cada frame. El render de zeros
// queda com a fallback (mode NONE o slot actiu).
if (!p.active && p.join_prompt != JoinPromptMode::NONE) {
if (!blinkVisible()) { return; }
const bool CAN_JOIN = (p.join_prompt == JoinPromptMode::CAN_JOIN);
const std::string L1 = Locale::get().text(
CAN_JOIN ? "hud.join_prompt_can_join_l1" : "hud.join_prompt_wait_l1");
@@ -275,12 +338,12 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
const int L2_W = font_->length(L2);
const int X1 = x + ((w - L1_W) / 2);
const int X2 = x + ((w - L2_W) / 2);
font_->writeColored(X1, y + LINE1_Y, L1, UiConfig::get().hudLabel());
font_->writeColored(X2, y + LINE2_Y, L2, UiConfig::get().hudValue());
drawShadowed(*font_, X1, y + LINE1_Y, L1, LBL_C, SHD_C);
drawShadowed(*font_, X2, y + LINE2_Y, L2, VAL_C, SHD_C);
return;
}
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? UiConfig::get().hudFlash() : UiConfig::get().hudValue();
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? ui.hudFlash() : VAL_C;
// --- Línia 1: "<label>-000000" centrada ---
const std::string LBL1 = label + SEPARATOR;
@@ -289,9 +352,9 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
const int SC_W = font_->lengthMonoDigits(SCORE, MONO_DIGIT_W);
const int LINE1_W = LBL_W + SC_W;
int xx = x + ((w - LINE1_W) / 2);
font_->writeColored(xx, y + LINE1_Y, LBL1, UiConfig::get().hudLabel());
drawShadowed(*font_, xx, y + LINE1_Y, LBL1, LBL_C, SHD_C);
xx += LBL_W;
font_->writeMonoDigits(xx, y + LINE1_Y, SCORE, SCORE_C, MONO_DIGIT_W);
drawMonoShadowed(*font_, xx, y + LINE1_Y, SCORE, SCORE_C, SHD_C, MONO_DIGIT_W);
// --- Línia 2: "<vides>-<n> <tresors>-<n>" centrada ---
const std::string V_LBL = Locale::get().text("hud.lives") + SEPARATOR;
@@ -304,16 +367,21 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
const int T_VW = font_->lengthMonoDigits(T_VAL, MONO_DIGIT_W);
const int LINE2_W = V_LW + V_VW + FIELD_GAP + T_LW + T_VW;
xx = x + ((w - LINE2_W) / 2);
font_->writeColored(xx, y + LINE2_Y, V_LBL, UiConfig::get().hudLabel());
drawShadowed(*font_, xx, y + LINE2_Y, V_LBL, LBL_C, SHD_C);
xx += V_LW;
font_->writeMonoDigits(xx, y + LINE2_Y, V_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
drawMonoShadowed(*font_, xx, y + LINE2_Y, V_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
xx += V_VW + FIELD_GAP;
font_->writeColored(xx, y + LINE2_Y, T_LBL, UiConfig::get().hudLabel());
drawShadowed(*font_, xx, y + LINE2_Y, T_LBL, LBL_C, SHD_C);
xx += T_LW;
font_->writeMonoDigits(xx, y + LINE2_Y, T_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
drawMonoShadowed(*font_, xx, y + LINE2_Y, T_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
}
void Hud::renderIdleCenter(int x, int y, int w) const {
const auto& ui = UiConfig::get();
const Uint8 LBL_C = ui.hudCenterLabel();
const Uint8 VAL_C = ui.hudCenterValue();
const Uint8 SHD_C = ui.hudCenterShadow();
// --- Línia 1: "<piramide>-<n>" centrada ---
const std::string PIR_LBL = Locale::get().text("hud.pyramid") + SEPARATOR;
const std::string PIR_VAL = std::to_string(pyramid_);
@@ -321,9 +389,9 @@ void Hud::renderIdleCenter(int x, int y, int w) const {
const int PV_W = font_->lengthMonoDigits(PIR_VAL, MONO_DIGIT_W);
const int LINE1_W = PL_W + PV_W;
int xx = x + ((w - LINE1_W) / 2);
font_->writeColored(xx, y + LINE1_Y, PIR_LBL, UiConfig::get().hudLabel());
drawShadowed(*font_, xx, y + LINE1_Y, PIR_LBL, LBL_C, SHD_C);
xx += PL_W;
font_->writeMonoDigits(xx, y + LINE1_Y, PIR_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
drawMonoShadowed(*font_, xx, y + LINE1_Y, PIR_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
// --- Línia 2: "<sala>-<r>" centrada ---
const std::string SALA_LBL = Locale::get().text("hud.room") + SEPARATOR;
@@ -332,7 +400,7 @@ void Hud::renderIdleCenter(int x, int y, int w) const {
const int SV_W = font_->lengthMonoDigits(SALA_VAL, MONO_DIGIT_W);
const int LINE2_W = SL_W + SV_W;
xx = x + ((w - LINE2_W) / 2);
font_->writeColored(xx, y + LINE2_Y, SALA_LBL, UiConfig::get().hudLabel());
drawShadowed(*font_, xx, y + LINE2_Y, SALA_LBL, LBL_C, SHD_C);
xx += SL_W;
font_->writeMonoDigits(xx, y + LINE2_Y, SALA_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
drawMonoShadowed(*font_, xx, y + LINE2_Y, SALA_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
}
+8
View File
@@ -170,6 +170,10 @@ class Hud {
// les constants de classe (no toca cap altre membre).
[[nodiscard]] static auto computeOffset(const Block& b) -> SDL_FPoint;
// Estat actual del parpadeig dels prompts inactius. Llegit per
// renderIdleSide() per saltar el draw quan toca ocult.
[[nodiscard]] auto blinkVisible() const -> bool;
EventBus& bus_;
std::vector<EventBus::SubId> subs_;
@@ -179,6 +183,10 @@ class Hud {
std::array<PlayerStats, 2> players_;
int pyramid_{1};
int room_{1};
// Acumulador continu (ms) per al parpadeig dels prompts inactius.
// Es manté entre canvis de mode (CAN_JOIN ↔ WAIT) per evitar salts
// visuals i sincronitza els dos slots — vegeu Hud::blinkVisible().
int blink_elapsed_ms_{0};
// Vides amb què s'arrenca un slot (`activatePlayer`). Cachejat al
// ctor des de `Player::death_config.initial_lives` per no llegir el
// singleton estàtic en el camí calent del bus de events.
+48 -22
View File
@@ -106,6 +106,28 @@ namespace Defaults {
constexpr float PUSH_FACTOR = 0.30F;
} // namespace Player
// Fallbacks del sistema d'il·luminació. El gradient en viu viu a
// data/config/lighting.yaml; aquests valors només s'apliquen si el
// fitxer falta o li manca alguna clau. La forma final (3 anells) es
// descriu al YAML; veure `Lighting::GradientConfig` per a la semàntica.
namespace Lighting {
constexpr float INNER_RATIO = 0.8F;
constexpr float MID_RATIO = 0.9F;
constexpr float INNER_INTENSITY = 1.0F;
constexpr float MID_INTENSITY = 0.9F;
constexpr float OUTER_INTENSITY = 0.8F;
// Nivell d'il·luminació de sala per defecte. Una sala sense la
// clau `light_level` queda totalment il·luminada (com hui).
constexpr float ROOM_LIGHT_DEFAULT = 1.0F;
// Defaults del flicker. Els reemplaça el bloc `flicker:` de
// data/config/lighting.yaml. Veure `Lighting::FlickerConfig`
// per a la semàntica de cada camp.
constexpr float FLICKER_WAVE_AMPLITUDE_PX = 2.0F;
constexpr float FLICKER_WAVE_FREQUENCY_HZ = 3.0F;
constexpr float FLICKER_NOISE_AMPLITUDE_PX = 1.0F;
constexpr float FLICKER_NOISE_FREQUENCY_HZ = 7.0F;
} // namespace Lighting
// Fallbacks del tema d'UI: índexs de paleta, timings (ms) i fonts.
// El tema en viu és a data/config/ui.yaml, carregat per UiConfig a
// l'arrencada; aquests valors només s'apliquen si el fitxer falta o
@@ -217,20 +239,28 @@ namespace Defaults {
constexpr Uint8 MENU_DISABLED = 16; // Label en gris quan no aplica.
// Índexs de paleta per al marcador de joc (HUD de la GameScene).
// Arranca reciclant els colors del menú per coherència visual;
// separant els noms a constants pròpies, tocar-los a posteriori
// no afecta el menú.
constexpr Uint8 HUD_BG = 0; // Fons de cada bloc.
constexpr Uint8 HUD_LABEL = 58; // Etiquetes ("P1", "V", "T").
constexpr Uint8 HUD_VALUE = 187; // Valors (score, vides, tresors).
constexpr Uint8 HUD_FLASH = 237; // Color temporal del valor en flash.
// Tres "famílies" de color — un parell (label, value) + sombra per
// cada bloc — donen jerarquia visual: P1 càlid (vermell+crema),
// CENTER ambiental (ambre+or), P2 fred (cian+blau pàl·lid). La
// sombra és un to més fosc del mateix matís per donar volum.
constexpr Uint8 HUD_BG = 0; // Fons de cada bloc.
constexpr Uint8 HUD_P1_LABEL = 7; // P1 label: vermell pur (#ff0000).
constexpr Uint8 HUD_P1_VALUE = 183; // P1 value: crema-préssec (#ffdaaa).
constexpr Uint8 HUD_P1_SHADOW = 2; // P1 ombra: vermell fosc (#480000).
constexpr Uint8 HUD_CENTER_LABEL = 29; // CENTER label: ambre cremat (#b66d00).
constexpr Uint8 HUD_CENTER_VALUE = 55; // CENTER value: or brillant (#ffda00).
constexpr Uint8 HUD_CENTER_SHADOW = 10; // CENTER ombra: marró cremat (#482400).
constexpr Uint8 HUD_P2_LABEL = 248; // P2 label: cian pur (#00ffff).
constexpr Uint8 HUD_P2_VALUE = 253; // P2 value: blau pàl·lid (#b6ffff).
constexpr Uint8 HUD_P2_SHADOW = 64; // P2 ombra: blau marí (#000055).
constexpr Uint8 HUD_FLASH = 63; // Flash (groc neó, comú a totes les famílies).
// Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO).
// Independent dels colors de notificació perquè el rètol de
// fase és un canal visual diferent. `ROOM_TEXT_OUTLINE` és el
// contorn que abans estava hardcodejat al `renderRoomText`.
constexpr Uint8 ROOM_TEXT = 232;
constexpr Uint8 ROOM_TEXT_OUTLINE = 255;
// Comparteix la família CENTER del HUD: or sobre marró cremat,
// per coherència visual ("Piràmide N / Sala N" és sempre
// daurat arreu del joc).
constexpr Uint8 ROOM_TEXT = 55; // Or brillant (= HUD_CENTER_VALUE).
constexpr Uint8 ROOM_TEXT_OUTLINE = 10; // Marró cremat (= HUD_CENTER_SHADOW).
// Timings d'animació del HUD. L'entrada són els tres blocs lliscant
// a la seua posició; flash i shake acompanyen un `player_hit`.
@@ -238,16 +268,11 @@ namespace Defaults {
constexpr int HUD_FLASH_MS = 250;
constexpr int HUD_SHAKE_MS = 300;
// Pausa graduada de la GameScene: el món no para d'un colp, es
// frena amb una corba i torna a arrancar amb una altra. Els noms
// de corba han de coincidir amb els registrats a
// UiConfig::curveByName (linear / outQuad / inQuad / inOutQuad /
// outCubic / inCubic). Si un nom no es reconeix, es cau a
// Easing::linear.
constexpr int PAUSE_ENTER_MS = 400;
constexpr int PAUSE_EXIT_MS = 250;
constexpr const char* PAUSE_ENTER_CURVE = "outCubic";
constexpr const char* PAUSE_EXIT_CURVE = "inCubic";
// Parpadeig dels prompts inactius del HUD ("Polsa start..." /
// "Espera..."). Un únic temporitzador global al Hud sincronitza
// P1 i P2. cycle<=0 o ratio>=1 → sempre visible (disable trivial).
constexpr int HUD_BLINK_CYCLE_MS = 1000;
constexpr float HUD_BLINK_VISIBLE_RATIO = 0.8F;
// Sons del menú de servei. SELECT acompanya navegació (amunt/avall)
// i back; ACCEPT acompanya modificació d'opció (esq/dret/retorn).
@@ -495,6 +520,7 @@ namespace Defaults {
constexpr const char* SOUNDS_YAML = "data/config/sounds.yaml";
constexpr const char* INPUT_YAML = "data/config/input.yaml";
constexpr const char* EDITOR_YAML = "data/config/editor.yaml";
constexpr const char* LIGHTING_YAML = "data/config/lighting.yaml";
constexpr const char* UI_PALETTE = "data/palettes/uzebox.pal";
// Configuració d'escenes (yaml). El de la GameScene viu a
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.41.0"; // Single source of truth — Makefile el grep
constexpr const char* VERSION = "0.44.0"; // Single source of truth — Makefile el grep
} // namespace Texts
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*/, float /*speed*/) {
}
namespace Resource {
auto Cache::get() -> Cache& {
+55 -40
View File
@@ -14,7 +14,8 @@
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
// T8 decoration — Torch no col·lisiona ni és interactable.
// T10 torch anim — Torch llegeix 4 frames del YAML i el tick avança frame.
// T11 sarcofag — tile_size [3,1] llegit; perímetre 8; falsa perspectiva:
// T11 sarcofag — tile_size [3,1] llegit; perímetre 12 (incloent les
// quatre cantonades diagonals); falsa perspectiva:
// el sprite.height no canvia la caixa lògica 48×16.
// T12 collision — `Entity::CollisionBox` encastada al sprite: la caixa
// retornada coincideix amb les dimensions del bloc
@@ -69,13 +70,14 @@ namespace {
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra).
// Equival al que fa GameScene al final del text-exit de ROOM_INTRO: arma
// el comptador i consumeix la durada amb un update. Tests que operin
// directament sobre HURT/ROAMING l'han de cridar abans.
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra
// amb l'animació `raise` activa). Tickem amb dt gran fins sortir de
// SPAWNING — mateix path que `Mummy::update` executa al final de
// l'animació en partida real. El for limita el bucle defensivament.
void exitMummySpawn(Mummy& mummy) {
mummy.triggerSpawnExit();
mummy.update(mummy.getSpawnDurationS() + 0.001F);
for (int i = 0; i < 32 && mummy.isSpawning(); ++i) {
mummy.update(0.5F);
}
}
void testFactory() {
@@ -85,6 +87,7 @@ namespace {
const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap", "hole",
"stone", "column",
};
for (const auto& type : TYPES) {
fkyaml::node tmpl = EntityLoader::loadTemplate(type);
@@ -142,32 +145,36 @@ namespace {
fkyaml::node tmpl = EntityLoader::loadTemplate("sarcophagus");
auto sarc = std::make_unique<Sarcophagus>(tmpl);
// Ancorat a (col=1, row=1) amb tile_size=[3,1] → rectangle de tiles
// cobreix (1,1), (2,1), (3,1). Perímetre ortogonal esperat (sense
// BlockedPredicate):
// Nord: (1,0), (2,0), (3,0)
// Sud: (1,2), (2,2), (3,2)
// cobreix (1,1), (2,1), (3,1). Perímetre esperat (sense
// BlockedPredicate), incloent les quatre cantonades diagonals:
// Nord (amb NO i NE): (0,0), (1,0), (2,0), (3,0), (4,0)
// Sud (amb SO i SE): (0,2), (1,2), (2,2), (3,2), (4,2)
// Oest: (0,1)
// Est: (4,1)
// Total: 8 tiles.
// Total: 12 tiles.
sarc->place(1, 1, {});
REQUIRE(sarc->getPerimeterSize() == 8);
REQUIRE(sarc->getPerimeterSize() == 12);
REQUIRE(sarc->getState() == Sarcophagus::State::CLOSED);
// Visitem un tile que NO és al perímetre: res canvia.
sarc->notifyTilePainted(5, 5);
sarc->notifyTilePainted(5, 5, /*player_id=*/0);
REQUIRE(sarc->getVisitedCount() == 0);
// Visitem tots els del perímetre.
const std::vector<std::pair<int, int>> PERIM = {
{1, 0}, {2, 0}, {3, 0}, {1, 2}, {2, 2}, {3, 2}, {0, 1}, {4, 1},
{0, 0}, {1, 0}, {2, 0}, {3, 0}, {4, 0},
{0, 2}, {1, 2}, {2, 2}, {3, 2}, {4, 2},
{0, 1}, {4, 1},
};
for (const auto& [c, r] : PERIM) { sarc->notifyTilePainted(c, r); }
REQUIRE(sarc->getVisitedCount() == 8);
for (const auto& [c, r] : PERIM) { sarc->notifyTilePainted(c, r, /*player_id=*/0); }
REQUIRE(sarc->getVisitedCount() == 12);
REQUIRE(sarc->getState() == Sarcophagus::State::OPENING);
// Un update amb l'animació `opening` d'1 frame loop=false completa
// la transició a OPEN.
sarc->update(0.0F);
// Tickem prou per consumir l'animació `opening` (4 frames @ 6fps =
// ~0.67s). Quan completa, la transició OPENING → OPEN es dispara.
for (int i = 0; i < 16 && sarc->getState() == Sarcophagus::State::OPENING; ++i) {
sarc->update(0.1F);
}
REQUIRE(sarc->getState() == Sarcophagus::State::OPEN);
}
@@ -189,30 +196,33 @@ namespace {
manager.add(std::move(door));
manager.setDoor(door_ptr);
// Forcem l'obertura dels dos sarcs visitant els seus perímetres.
// Forcem l'obertura dels dos sarcs visitant els seus perímetres
// (incloent les cantonades diagonals NO/NE/SO/SE).
auto forceOpen = [](Sarcophagus* s) {
const int COL0 = s->getTilePos().col;
const int ROW0 = s->getTilePos().row;
const int W = s->getTileWidth();
const int H = s->getTileHeight();
for (int dc = 0; dc < W; ++dc) {
s->notifyTilePainted(COL0 + dc, ROW0 - 1);
s->notifyTilePainted(COL0 + dc, ROW0 + H);
for (int dc = -1; dc <= W; ++dc) {
s->notifyTilePainted(COL0 + dc, ROW0 - 1, /*player_id=*/0);
s->notifyTilePainted(COL0 + dc, ROW0 + H, /*player_id=*/0);
}
for (int dr = 0; dr < H; ++dr) {
s->notifyTilePainted(COL0 - 1, ROW0 + dr);
s->notifyTilePainted(COL0 + W, ROW0 + dr);
s->notifyTilePainted(COL0 - 1, ROW0 + dr, /*player_id=*/0);
s->notifyTilePainted(COL0 + W, ROW0 + dr, /*player_id=*/0);
}
};
forceOpen(s1);
forceOpen(s2);
// Un primer tick: cada sarc transiciona OPENING → OPEN i publica;
// l'handler del manager compta i, en arribar al total, crida
// door->open() — que en aquest mateix tick (la iteració sobre
// entities continua) farà transitar la porta OPENING → OPEN perquè
// l'animació és d'1 frame loop=false.
manager.update(0.0F);
// Tickem prou per consumir l'animació `opening` dels sarcs (4
// frames @ 6fps) i la de la porta. Cada sarc transiciona
// OPENING → OPEN i publica; l'handler del manager compta i, en
// arribar al total, crida `door->open()`, que també necessita
// tickar fins acabar la seua animació d'obertura.
for (int i = 0; i < 20 && door_ptr->getState() != Door::State::OPEN; ++i) {
manager.update(0.1F);
}
REQUIRE(manager.openedSarcophagusCount() == 2);
REQUIRE(door_ptr->getState() == Door::State::OPEN);
@@ -262,6 +272,7 @@ namespace {
const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap", "hole",
"stone", "column",
};
for (const auto& type : TYPES) {
try {
@@ -331,11 +342,14 @@ namespace {
auto sarc = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
sarc->place(3, 4, {});
Sarcophagus* sarc_raw = manager.addSarcophagus(std::move(sarc));
REQUIRE(sarc_raw->getPerimeterSize() == 8);
// Les 8 tiles del perímetre: N (3..5,3), S (3..5,5), O (2,4), E (6,4).
REQUIRE(sarc_raw->getPerimeterSize() == 12);
// Les 12 tiles del perímetre, incloent les cantonades diagonals:
// N (amb NO/NE): (2..6, 3)
// S (amb SO/SE): (2..6, 5)
// O: (2,4) E: (6,4)
const std::pair<int, int> PERIM[] = {
{3, 3}, {4, 3}, {5, 3},
{3, 5}, {4, 5}, {5, 5},
{2, 3}, {3, 3}, {4, 3}, {5, 3}, {6, 3},
{2, 5}, {3, 5}, {4, 5}, {5, 5}, {6, 5},
{2, 4}, {6, 4},
};
for (const auto& [c, r] : PERIM) {
@@ -356,12 +370,13 @@ namespace {
REQUIRE(tmpl["sprite"]["size"][0].get_value<int>() == 48);
REQUIRE(tmpl["sprite"]["size"][1].get_value<int>() == 32);
// Amb tile_size [3,1], un sarc a ancoratge (3,4) conserva els 8
// tiles de perímetre (no es converteix en 10 per culpa del [3,2]
// antic). Això blinda la geometria contra regressions al YAML.
// Amb tile_size [3,1], un sarc a ancoratge (3,4) té 12 tiles de
// perímetre: 5 al nord (amb NO/NE), 5 al sud (amb SO/SE), 1 a
// l'oest i 1 a l'est. Això blinda la geometria contra regressions
// al YAML (p. ex. un retorn accidental al tile_size [3,2] antic).
auto sarc = std::make_unique<Sarcophagus>(tmpl);
sarc->place(3, 4, {});
REQUIRE(sarc->getPerimeterSize() == 8);
REQUIRE(sarc->getPerimeterSize() == 12);
}
void testCollisionBox() {
+8 -5
View File
@@ -32,12 +32,15 @@ namespace {
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra). Els
// tests del whip necessiten que la mòmia estigui ACTIVE per reaccionar
// al fueteig — sense això, `enterHurt` torna early per `isSpawning()`.
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra
// amb l'animació `raise` activa). Els tests del whip necessiten que la
// mòmia estigui ACTIVE per reaccionar al fueteig — sense això,
// `enterHurt` torna early per `isSpawning()`. Tickem amb dt gran fins
// sortir de SPAWNING (mateix path real, només accelerat).
void exitMummySpawn(Mummy& mummy) {
mummy.triggerSpawnExit();
mummy.update(mummy.getSpawnDurationS() + 0.001F);
for (int i = 0; i < 32 && mummy.isSpawning(); ++i) {
mummy.update(0.5F);
}
}
// Caja de jugador "estàndard" AEE: 16×32 amb cantonada (x, y).