Compare commits

..
28 Commits
Author SHA1 Message Date
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
JailDesigner e8418f89e0 chore(version): bump 0.40.0 -> 0.41.0 2026-05-11 11:31:24 +02:00
JailDesigner 9ca09484a6 Merge branch 'feat/dynamic-hazard-spawn-state'
Spawn on-demand de DynamicHazards via dos sacs a EntityManager:
les mòmies (i futurs hazards mòbils) ara s'instancien just quan
GameScene les "spawneja", no abans. Cinemàtica ROOM_INTRO neta:
mapa buit durant el text, mòmies surten emergint al text-exit
(fase 4, durada 1.0 s), jugadors cauen del cel a continuació.

Estat SPAWN/ACTIVE a DynamicHazard reutilitzable per a futurs
generadors (gotes, roques, etc.) via `EntityManager::spawnActor`.

Refactor col·lateral: EntityManager separa `map_entities_` /
`actors_` / `pending_actors_`. Render mergeja els tres orígens
(amb players). dynamic_casts dispersos a WhipAttack i col·lisió
substituïts per iteracions sobre el sac d'actors.

Bonus: arregla 4 tests latents de test_whip_attack que mai no
havien passat (timing off-by-one al primer update).
2026-05-11 11:31:03 +02:00
JailDesigner 9291ef6572 test(whip): arregla W4/W6 — first update ha d'arribar al primer active frame
El YAML del whip declara frame_ms=80, així que el frame 0 viu [0, 80] ms
i `active_frames=[1, 2]` comença a 80 ms. Els tests feien `update(0.06F)`
amb el comentari "60 ms → frame 1 (active)" — incorrecte: 60 < 80, encara
al frame 0, sense danyar res. Tres asserts d'`getState() == HURT` mai
no es complien.

Canvi a `update(0.09F)`: assegura que el primer tick entra al frame 1
i `applyDamage()` corre. Comentari actualitzat amb el càlcul correcte.
2026-05-11 11:28:51 +02:00
JailDesigner 6f8ab74d6b refactor(whip): applyDamage itera getActors() en lloc de getEntities() 2026-05-11 11:23:14 +02:00
JailDesignerandClaude Opus 4.7 881e0ea1f6 feat(room): mòmies surten emergint quan comença text-exit (fase 4)
Tres canvis coordinats que tanquen la cinemàtica desitjada de
ROOM_INTRO:

1. `spawn.duration_s` de mummy.yaml passa de 0.6 a 1.0 s. Les mòmies
   tarden ~1 segon a "emergir" — temps suficient perquè el jugador
   noti que estan apareixent, no que ja eren a la sala.

2. El disparador es mou de fase 5 (music_tail) a fase 4 (text-exit).
   Ara, just quan el text "PIRÀMIDE X, SALA Y" comença a sortir de
   pantalla, GameScene crida `entity_manager_.spawnPendingActors()`
   i les mòmies s'instancien ja en EMERGING. Visualment apareixen
   alhora que el text desapareix; durant la fase 5 acaben d'emergir
   i a l'entrada a PLAYING ja són ACTIVE — coincidint amb la caiguda
   aèria dels jugadors.

3. `completeIntro` afegeix un fallback: si arribem a PLAYING sense
   haver passat per fase 4 (config patològica, fases molt curtes),
   instanciem els pending igualment per garantir que la sala mai no
   queda sense enemics.

Renomenat: `hazards_spawn_triggered_` → `pending_actors_spawned_`,
nom que reflecteix què representa el flag al model nou.

El mètode privat `triggerHazardSpawnExit()` desapareix: era un
wrapper que ja només cridava `spawnPendingActors()`. Crida directa
des de `updateRoomTransition`, `respawnPlayersInstant` i
`completeIntro` (fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:40:28 +02:00
JailDesignerandClaude Opus 4.7 6a6a1a52c7 feat(em): spawn diferit de DynamicHazards (pending_actors_)
`loadFromLevel` ja no instancia immediatament les DynamicHazards del
YAML del mapa: les acumula a `pending_actors_` (estructura {type,
spawn_node}). El loader decideix per la categoria semàntica de la
plantilla (`type:` del YAML d'entity: "dynamic_hazard" → pending; la
resta → addSpawn d'instant).

Dues APIs noves consumeixen pending_actors_:
- `spawnPendingActors()` — itera la cua, instancia tots via addSpawn
  i arma `triggerSpawnExit()` a cada un. Vacia la cua. El path
  d'aparició massiva al final del text-exit de ROOM_INTRO.
- `spawnActor(type, col, row)` — instancia un únic actor on-demand
  des d'un spawn YAML sintètic. Pensat per a futurs generadors
  (StaticHazards que produeixen DynamicHazards en runtime: gotes,
  roques, etc.). Cada actor neix ja en EMERGING.

`GameScene::triggerHazardSpawnExit` ara és un wrapper de
`spawnPendingActors()` — el dispar segueix en la subfase actual
(fase 5 de ROOM_INTRO) i a `respawnPlayersInstant`. El canvi de timing
ve en el commit següent.

`reload` també buida pending_actors_. Log: "N map entities + M actors
pending des de PATH" — visible per a diagnòstic.

Sense canvis de comportament observables al joc: les mòmies segueixen
apareixent al final del text-exit, però ara la "aparició" és la seua
instanciació real (abans ja existien quietes des del fade-in).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:35:02 +02:00
JailDesignerandClaude Opus 4.7 62f4b857ef refactor(em): separa entitats en dos sacs (map_entities_/actors_)
EntityManager guanya dues llistes lògiques:
- `map_entities_` — decoració, static_hazards (Hole, FloorTrap, WallTrap)
  i interactables (Sarcophagus, Door). Persistents tota la sala.
- `actors_` — DynamicHazards (Mummy, RollingRock, futurs enemics).
  Tipats com `unique_ptr<DynamicHazard>`, sense dynamic_cast.

`add()` enruta automàticament segons sigui DynamicHazard o no.
`update`, `notifyPlayerTilePainted`, `findFloorTriggerAt`, `removeEntity`,
`reload` i `renderAll` iteren el(s) sac(s) que toca.

`checkPlayerCollisions`: el filtre de SPAWN sobre el sac d'actors ja no
necessita dynamic_cast — `actor->isSpawning()` és directe. El filtre per
floor decals només toca map_entities_ (els decals viuen exclusivament
allà). Filtres per-jugador extrets a helpers per acotar complexitat.

API nova: `getMapEntities()` i `getActors()` exposen cada sac per
separat. `getEntities()` es manté com a concatenació per retro-compat
(editor, z-sort debug).

`GameScene::triggerHazardSpawnExit` itera `getActors()` directe — fora
dynamic_cast.

`GameScene::render` mergeja els tres orígens (map + actors + players)
en un sol vector temporal abans del stable_sort per `feet_y` — mateix
patró que ja s'usava amb players, ara amb una transformació més.

Comportament idèntic: les momies segueixen instanciant-se al
loadFromLevel. La preparació per al spawn diferit ve en el commit
següent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:23:44 +02:00
JailDesignerandClaude Opus 4.7 3b378e2b29 feat(room): allibera DynamicHazards del SPAWN al final del text-INTRO
GameScene ara crida `triggerHazardSpawnExit()` quan ROOM_INTRO entra a
la fase 5 (music_tail, immediatament després del text-exit): itera els
DynamicHazards de la sala i els arma el comptador `spawn.duration_s`.

Així la cinemàtica queda:
  fade-in → text-enter → text-hold → text-exit
    └─> mòmies surten "del terra" (durada `spawn.duration_s` del YAML)
        └─> jugadors cauen del cel (delay player_spawn_delay_ms)

`respawnPlayersInstant` (salt debug a sala) també crida el trigger
perquè les mòmies no quedin congelades quan es curt-circuita la
cinemàtica.

Flag `hazards_spawn_triggered_` garanteix idempotència dins el cicle;
es reseteja a `enterRoomIntro` i al `setState(ROOM_INTRO)` defensiu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:56:14 +02:00
JailDesignerandClaude Opus 4.7 041447eaa4 feat(mummy): naix en SPAWN, llig spawn.duration_s del YAML
La mòmia ara entra a l'estat SPAWN al construir-se: visible al
pixel_rect_ inicial, sense moviment ni dany, esperant que algú extern
(GameScene) li armi el comptador via `triggerSpawnExit()`.

La durada (`spawn.duration_s` al YAML, default 0) controla quan passa
a ROAMING una vegada armat el comptador. Posat a 0.6s a mummy.yaml.

Aquest commit encara no enganxa el trigger: la mòmia es queda quieta
tota la partida — comportament visual intermedi, esperat. El commit
següent posarà el trigger a GameScene al final del text-exit de
ROOM_INTRO.

Tests de smoke i whip ajustats: les mòmies construïdes en stand-alone
necessiten `triggerSpawnExit()` + un update gran abans d'interactuar
amb `enterHurt`/contacto. Helper `exitMummySpawn` afegit a cada fitxer.

Guards defensius a `enterHurt` i `onPlayerContact`: encara que el filtre
central a EntityManager salta DynamicHazards en SPAWN, blindem la
crida directa per a futurs camins (whip propers, scripts...).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:49:23 +02:00
JailDesignerandClaude Opus 4.7 a6b2b02930 feat(hazard): afegeix estat SPAWN a DynamicHazard
DynamicHazard guanya un mecanisme de SPAWN amb dues fases: SPAWNING
(visible, sense moviment ni dany, comptador en pausa) i ACTIVE (lògica
normal). `enterSpawn(duration_s)` deixa l'entitat congelada esperant
indefinidament; `triggerSpawnExit()` arma el comptador i, en arribar a
zero, transita a ACTIVE.

Per defecte les noves DynamicHazard arrenquen en ACTIVE — backward-compat
amb subclasses existents (RollingRock) que encara no usen el mecanisme.

EntityManager::checkPlayerCollisions salta DynamicHazards en SPAWN: no
disparen onPlayerContact, així no fan dany ni triggers durant la fase.

Aquest commit només afegeix la infraestructura — cap subclasse l'usa
encara, comportament del joc inalterat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:43:38 +02:00
JailDesigner b7811180ba feat(game): spawn inicial de ROOM_INTRO sense immunitat 2026-05-11 07:53:56 +02:00
51 changed files with 1153 additions and 404 deletions

No files matched your search

+1
View File
@@ -94,6 +94,7 @@ set(APP_SOURCES
source/core/rendering/sprite/animated_sprite_parser.cpp source/core/rendering/sprite/animated_sprite_parser.cpp
source/core/rendering/effects/heat_haze.cpp source/core/rendering/effects/heat_haze.cpp
source/core/rendering/effects/scan_reveal.cpp source/core/rendering/effects/scan_reveal.cpp
source/core/rendering/effects/shadow.cpp
source/core/rendering/transitions/mosaic.cpp source/core/rendering/transitions/mosaic.cpp
source/core/rendering/transitions/fade_to_black.cpp source/core/rendering/transitions/fade_to_black.cpp
source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp
+7 -1
View File
@@ -293,8 +293,14 @@ rebuild: clean all
# Empaqueta data/ a $(BUILDDIR)/resources.pack (format AE26). Força un rebuild # Empaqueta data/ a $(BUILDDIR)/resources.pack (format AE26). Força un rebuild
# del pack encara que res no hagi canviat dins data/. L'empaquetat també es fa # del pack encara que res no hagi canviat dins data/. L'empaquetat també es fa
# automàticament a cada `make`/`make debug` via el target CMake `resource_pack`. # automàticament a cada `make`/`make debug` via el target CMake `resource_pack`.
#
# NO passem `-DCMAKE_BUILD_TYPE`: així respectem el tipus ja configurat al
# `CMakeCache.txt` (Debug si vens de `make debug`, Release si vens de
# `make release`). Si forcéssim Release ací, el següent build de debug
# invalidaria la cache i recompilaria-ho tot. El binari `pack_resources`
# és una utilitat petita on el build type és irrellevant.
pack: pack:
@cmake -S . -B $(BUILDDIR) $(CMAKE_GEN) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS) @cmake -S . -B $(BUILDDIR) $(CMAKE_GEN) $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target pack_resources @cmake --build $(BUILDDIR) --target pack_resources
@./$(BUILDDIR)/pack_resources data $(BUILDDIR)/resources.pack @./$(BUILDDIR)/pack_resources data $(BUILDDIR)/resources.pack
Binary file not shown.
+24 -6
View File
@@ -17,15 +17,15 @@ colors:
menu_cursor: 71 menu_cursor: 71
menu_disabled: 16 menu_disabled: 16
hud_bg: 9 hud_bg: 9
hud_label: 58 hud_flash: 63
hud_value: 187
hud_flash: 237
# Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). Independents # Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). Independents
# de notification_* perquè la fase i les notificacions són dos canals # de notification_* perquè la fase i les notificacions són dos canals
# visuals distints: ací pots canviar el color del rètol sense tocar # visuals distints: ací pots canviar el color del rètol sense tocar
# els missatges del HUD. # els missatges del HUD. Comparteix la família del CENTER del HUD
room_text: 160 # (or sobre marró) perquè "Piràmide N / Sala N" sigui sempre daurat
room_text_outline: 253 # arreu del joc.
room_text: 55 # or brillant (#FFDA00, = hud_center.value)
room_text_outline: 10 # marró cremat (#482400, = hud_center.shadow)
timings: timings:
notification_ms: 4000 notification_ms: 4000
@@ -74,6 +74,24 @@ hud:
flash_ms: 250 # durada del flash quan un jugador rep un hit flash_ms: 250 # durada del flash quan un jugador rep un hit
shake_ms: 300 # durada del shake que acompanya el flash shake_ms: 300 # durada del shake que acompanya el flash
# Famílies de color del HUD per bloc. Cada bloc té `label` (to apagat
# per etiquetes), `value` (to viu per a la xifra) i `shadow` (to fosc
# del mateix matís — efecte embossing 1 px a baix-dreta). Si manca un
# camp, cau al default compilat a `Defaults::UI`. El `colors.hud_flash`
# és comú a totes tres famílies.
hud_p1:
label: 7 # vermell pur
value: 183 # crema-préssec
shadow: 2 # vermell fosc
hud_center:
label: 29 # ambre cremat
value: 55 # or brillant
shadow: 10 # marró cremat
hud_p2:
label: 248 # cian pur
value: 253 # blau pàl·lid
shadow: 64 # blau marí
# Geometria del popup superior (Notifier). En píxels del canvas, llevat de # Geometria del popup superior (Notifier). En píxels del canvas, llevat de
# slide_ms (durada en ms de l'animació d'entrada i d'eixida; constant # slide_ms (durada en ms de l'animació d'entrada i d'eixida; constant
# independentment de la mida de la caixa). El padding vertical és asimètric a # independentment de la mida de la caixa). El padding vertical és asimètric a
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

+7
View File
@@ -36,10 +36,17 @@ animations:
walk_north: { frames: [4, 5, 6, 7], fps: 8, loop: true } walk_north: { frames: [4, 5, 6, 7], fps: 8, loop: true }
walk_east: { frames: [8, 9, 10, 11], fps: 8, loop: true } walk_east: { frames: [8, 9, 10, 11], fps: 8, loop: true }
walk_west: { frames: [8, 9, 10, 11], fps: 8, loop: true, flip_h: true } walk_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: ai:
type: corridor_wanderer type: corridor_wanderer
direction_choice: random direction_choice: random
initial_direction: east initial_direction: east
# Estat SPAWN: la mòmia s'instancia 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: stats:
speed: 30 speed: 30
damage: 1 damage: 1
+4 -7
View File
@@ -30,10 +30,10 @@ animations:
stun_east: { frames: [18] } stun_east: { frames: [18] }
stun_west: { frames: [18], flip_h: true } stun_west: { frames: [18], flip_h: true }
walk_south: { frames: [0, 1, 2, 3], frame_ms: 100 } walk_south: { frames: [1, 2, 3, 0], frame_ms: 100 }
walk_north: { frames: [4, 5, 6, 7], frame_ms: 100 } walk_north: { frames: [5, 6, 7, 4], frame_ms: 100 }
walk_east: { frames: [8, 9, 10, 11], frame_ms: 100 } walk_east: { frames: [9, 10, 11, 8], frame_ms: 100 }
walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true } walk_west: { frames: [9, 10, 11, 8], frame_ms: 100, flip_h: true }
hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 } hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 } hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
@@ -97,13 +97,10 @@ turn:
south_to_north: west south_to_north: west
# Salt vertical (px de z, no toca la posició al món). # Salt vertical (px de z, no toca la posició al món).
# `shadow_y_offset`: puja la sombra per compensar la franja
# transparent sota els peus del sprite.
jump: jump:
impulse: 200 impulse: 200
gravity: 600 gravity: 600
landing_stun_ms: 400 landing_stun_ms: 400
shadow_y_offset: 3
sound: jump.wav sound: jump.wav
land_sound: land.wav land_sound: land.wav
+4 -7
View File
@@ -30,10 +30,10 @@ animations:
stun_east: { frames: [18] } stun_east: { frames: [18] }
stun_west: { frames: [18], flip_h: true } stun_west: { frames: [18], flip_h: true }
walk_south: { frames: [0, 1, 2, 3], frame_ms: 100 } walk_south: { frames: [1, 2, 3, 0], frame_ms: 100 }
walk_north: { frames: [4, 5, 6, 7], frame_ms: 100 } walk_north: { frames: [5, 6, 7, 4], frame_ms: 100 }
walk_east: { frames: [8, 9, 10, 11], frame_ms: 100 } walk_east: { frames: [9, 10, 11, 8], frame_ms: 100 }
walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true } walk_west: { frames: [9, 10, 11, 8], frame_ms: 100, flip_h: true }
hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 } hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 } hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
@@ -97,13 +97,10 @@ turn:
south_to_north: west south_to_north: west
# Salt vertical (px de z, no toca la posició al món). # Salt vertical (px de z, no toca la posició al món).
# `shadow_y_offset`: puja la sombra per compensar la franja
# transparent sota els peus del sprite.
jump: jump:
impulse: 200 impulse: 200
gravity: 600 gravity: 600
landing_stun_ms: 400 landing_stun_ms: 400
shadow_y_offset: 3
sound: jump.wav sound: jump.wav
land_sound: land.wav land_sound: land.wav
Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.6 KiB

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

After

Width:  |  Height:  |  Size: 98 B

+5 -5
View File
@@ -14,7 +14,7 @@ input:
shader_off: "Shaders desactivats" shader_off: "Shaders desactivats"
vsync_on: "VSync activat" vsync_on: "VSync activat"
vsync_off: "VSync desactivat" vsync_off: "VSync desactivat"
screenshot: "Captura" screenshot: "Imatge {} guardada a {}"
shader: "Shader" shader: "Shader"
preset: "Preset" preset: "Preset"
scaling_disabled: "Escalat sense" scaling_disabled: "Escalat sense"
@@ -41,10 +41,10 @@ hud:
# Prompts mostrats al bloc d'un slot inactiu (en lloc dels zeros) # Prompts mostrats al bloc d'un slot inactiu (en lloc dels zeros)
# quan un jugador encara no ha pulsat START o ha esgotat vides. # quan un jugador encara no ha pulsat START o ha esgotat vides.
# Dues línies per fer-los caber al bloc lateral (~106 px). # Dues línies per fer-los caber al bloc lateral (~106 px).
join_prompt_can_join_l1: "POLSA START" join_prompt_can_join_l1: "Polsa start"
join_prompt_can_join_l2: "PER JUGAR" join_prompt_can_join_l2: "per jugar"
join_prompt_wait_l1: "ESPERA" join_prompt_wait_l1: "Espera"
join_prompt_wait_l2: "PER JUGAR" join_prompt_wait_l2: "per jugar"
# Textos de l'escena TITLE. # Textos de l'escena TITLE.
title: title:
+5 -5
View File
@@ -14,7 +14,7 @@ input:
shader_off: "Shaders off" shader_off: "Shaders off"
vsync_on: "VSync on" vsync_on: "VSync on"
vsync_off: "VSync off" vsync_off: "VSync off"
screenshot: "Screenshot" screenshot: "Image {} saved at {}"
shader: "Shader" shader: "Shader"
preset: "Preset" preset: "Preset"
scaling_disabled: "Scaling off" scaling_disabled: "Scaling off"
@@ -41,10 +41,10 @@ hud:
# Prompts shown in an inactive slot's block (instead of zeros) while # Prompts shown in an inactive slot's block (instead of zeros) while
# a player hasn't pressed START yet or has run out of lives. Two # a player hasn't pressed START yet or has run out of lives. Two
# short lines so they fit in the side block (~106 px wide). # short lines so they fit in the side block (~106 px wide).
join_prompt_can_join_l1: "PRESS START" join_prompt_can_join_l1: "Press start"
join_prompt_can_join_l2: "TO PLAY" join_prompt_can_join_l2: "to play"
join_prompt_wait_l1: "PLEASE" join_prompt_wait_l1: "Please"
join_prompt_wait_l2: "WAIT" join_prompt_wait_l2: "wait"
# Texts of the TITLE scene. # Texts of the TITLE scene.
title: title:
+5 -2
View File
@@ -43,8 +43,11 @@ text:
# text i decreix amb l'exit_curve. `color` són tres canals RGB (0..255); # text i decreix amb l'exit_curve. `color` són tres canals RGB (0..255);
# `alpha` és la transparència del blend (0 = invisible, 255 = opac). # `alpha` és la transparència del blend (0 = invisible, 255 = opac).
text_panel: text_panel:
color: [36, 182, 170] # Violeta cerimonial: contrasta amb l'or del text (room_text), no
alpha: 128 # compite amb el blau del bloc P2 del HUD, i deixa la sorra de les
# sales respirar al darrere.
color: [72, 36, 85]
alpha: 160
height_px: 32 height_px: 32
# Sub-FSM de game over: dispara CONTINUE? quan tots els jugadors # Sub-FSM de game over: dispara CONTINUE? quan tots els jugadors
+16
View File
@@ -136,6 +136,22 @@ void Audio::playSound(Ja::Sound* sound, Group group) {
} }
} }
// Variant amb velocitat (i to) escalats. Apliquem el ratio al canal
// just retornat per `playSound`: així el `SDL_AudioStream` recent creat
// processa tot el sample amb el ratio des del primer pull del callback.
// Si l'engine torna -1 (sense canal lliure) o el so no existeix, no fem
// la crida al ratio — sense efectes col·laterals.
// NOLINTNEXTLINE(readability-make-member-function-const)
void Audio::playSound(const std::string& name, Group group, float speed) {
if (!sound_enabled_) { return; }
auto* sound = AudioResource::getSound(name);
if (sound == nullptr) { return; }
const int CH = engine_->playSound(sound, 0, static_cast<int>(group));
if (CH >= 0 && speed != 1.0F) {
engine_->setChannelSpeed(CH, speed);
}
}
// Reprodueix un so processat per un eco definit a sounds.yaml. Si el preset no // Reprodueix un so processat per un eco definit a sounds.yaml. Si el preset no
// existeix o l'engine retorna -1 (cap de canals d'efecte plé), cau a playSound // existeix o l'engine retorna -1 (cap de canals d'efecte plé), cau a playSound
// sec — l'usuari sent el so encara que la cua no s'apliqui. // sec — l'usuari sent el so encara que la cua no s'apliqui.
+6
View File
@@ -85,6 +85,12 @@ class Audio {
// --- Control de sons --- // --- Control de sons ---
void playSound(const std::string& name, Group group = Group::GAME); // Reproduir so puntual per nom (muta globals de Ja) void playSound(const std::string& name, Group group = Group::GAME); // Reproduir so puntual per nom (muta globals de Ja)
void playSound(Ja::Sound* sound, Group group = Group::GAME); // Reproduir so puntual per punter (muta globals de Ja) void playSound(Ja::Sound* sound, Group group = Group::GAME); // Reproduir so puntual per punter (muta globals de Ja)
// Reprodueix un so amb la velocitat (i to) escalats per `speed`:
// 1.0 = normal, 0.95 ≈ -5% (més greu i lent), 1.05 ≈ +5% (més
// agut i ràpid). Mateixa semàntica que `setMusicSpeed`. Útil per a
// variacions subtils que eviten la fatiga d'escoltar el mateix
// sample idèntic (p.ex. obertures de sarcòfag, picks d'ítems).
void playSound(const std::string& name, Group group, float speed);
// Reprodueix un so processat per un efecte definit a data/config/sounds.yaml // Reprodueix un so processat per un efecte definit a data/config/sounds.yaml
// (preset_name busca a SoundEffectsConfig). Si el preset no existeix // (preset_name busca a SoundEffectsConfig). Si el preset no existeix
// o el motor està al cap de canals amb efecte, fa fallback a playSound // o el motor està al cap de canals amb efecte, fa fallback a playSound
+6
View File
@@ -430,6 +430,12 @@ namespace Ja {
return channel; return channel;
} }
void Engine::setChannelSpeed(const int channel, const float ratio) {
if (channel < 0 || channel >= MAX_SIMULTANEOUS_CHANNELS) { return; }
if (channels_[channel].stream == nullptr) { return; }
SDL_SetAudioStreamFrequencyRatio(channels_[channel].stream, ratio);
}
void Engine::pauseChannel(const int channel) { void Engine::pauseChannel(const int channel) {
forEachTargetChannel(channel, [](Channel& ch) { forEachTargetChannel(channel, [](Channel& ch) {
if (ch.state == ChannelState::PLAYING) { if (ch.state == ChannelState::PLAYING) {
+8
View File
@@ -192,6 +192,14 @@ namespace Ja {
// --- So --- // --- So ---
auto playSound(Sound* sound, int loop = 0, int group = 0) -> int; auto playSound(Sound* sound, int loop = 0, int group = 0) -> int;
auto playSoundOnChannel(Sound* sound, int channel, int loop = 0, int group = 0) -> int; auto playSoundOnChannel(Sound* sound, int channel, int loop = 0, int group = 0) -> int;
// Ajusta la velocitat de reproducció d'un canal actiu via
// `SDL_SetAudioStreamFrequencyRatio`. 1.0 = normal. Igual que a
// `setMusicSpeed`, puja/baixa el to junt amb la velocitat
// (efecte "chipmunk"); per a SFX curts arcade és el que volem.
// No-op si el canal no està actiu. Cridar-lo just després de
// `playSound`/`playSoundOnChannel` perquè el ratio cobreixi
// tota la reproducció.
void setChannelSpeed(int channel, float ratio);
// Reproducció amb so processat per un efecte. Retorna el canal // Reproducció amb so processat per un efecte. Retorna el canal
// assignat o -1 si no queden slots d'efecte (MAX_EFFECT_CHANNELS). // assignat o -1 si no queden slots d'efecte (MAX_EFFECT_CHANNELS).
// El sound original només s'usa per consultar el spec/buffer; el // El sound original només s'usa per consultar el spec/buffer; el
+17 -5
View File
@@ -3,6 +3,7 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <format>
#include <string> #include <string>
#include "core/input/key_config.hpp" #include "core/input/key_config.hpp"
@@ -69,15 +70,26 @@ namespace GlobalInputs {
notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off"); notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off");
} }
// Volca el canvas ARGB actual a PNG dins de `screenshots/`. Si ha // Volca el canvas ARGB actual a PNG dins del directori de
// fallat l'escriptura, el handler no mostra notificació (retorn buit). // captures (configurat pel Director). La notificació informa
// del nom i de la ruta absoluta — així l'usuari sap on anar
// a buscar-la encara que el binari s'executi des d'un sitio
// estrany. Si l'escriptura falla, no es mostra notificació.
void onScreenshot() { void onScreenshot() {
const auto* buffer = Screen::get().getCanvasArgb(); const auto* buffer = Screen::get().getCanvasArgb();
const int W = Screen::get().getCanvasWidth(); const int W = Screen::get().getCanvasWidth();
const int H = Screen::get().getCanvasHeight(); const int H = Screen::get().getCanvasHeight();
const std::string FILENAME = Screenshot::save(buffer, W, H); const auto R = Screenshot::save(buffer, W, H);
if (!FILENAME.empty()) { if (!R.filename.empty()) {
Notification::notify(Locale::get().text("input.screenshot") + ' ' + FILENAME); // `make_format_args` retorna un store que conté referències
// als arguments; cal lligar-lo a una lvalue local perquè
// sobrevisqui fins que `vformat` el consumeixi. Passar-lo
// directament a `vformat` és UB en alguns compiladors
// (DR de C++20 / fix de C++23) i ha provocat un segfault
// post-captura en aquest projecte.
const std::string TEMPLATE = Locale::get().text("input.screenshot");
auto args = std::make_format_args(R.filename, R.folder);
Notification::notify(std::vformat(TEMPLATE, args));
} }
} }
+43
View File
@@ -0,0 +1,43 @@
#include "core/rendering/effects/shadow.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp"
namespace Effects::Shadow {
void render(int center_x, int feet_y, float height_factor) {
// Lookup únic al primer ús: la Surface viu a Cache durant tota la
// sessió, així evitem la cerca lineal a cada frame.
static const auto SURFACE = Resource::Cache::get().getSurface(CACHE_KEY);
const int FRAME_COUNT = SURFACE->getWidth() / FRAME_SIZE;
if (FRAME_COUNT <= 0) { return; }
// Mapping a bandes uniformes: factor 0 → últim (gran), factor 1 →
// primer (petit). Per a 3 fotogrames: [0, 1/3) → 2, [1/3, 2/3) → 1,
// [2/3, 1] → 0. Generalitza a qualsevol nombre de fotogrames per
// si afegim mides intermèdies.
const float CLAMPED = std::clamp(height_factor, 0.0F, 1.0F);
int frame_index = static_cast<int>((1.0F - CLAMPED) * static_cast<float>(FRAME_COUNT));
frame_index = std::clamp(frame_index, 0, FRAME_COUNT - 1);
SDL_FRect clip{
static_cast<float>(frame_index * FRAME_SIZE),
0.0F,
static_cast<float>(FRAME_SIZE),
static_cast<float>(FRAME_SIZE),
};
// Ancoratge: vora inferior del fotograma a `feet_y` — així el
// quadrat 16x16 del sprite encaixa exactament al 16x16 inferior
// d'una entitat 16x16/16x32 centrada en `center_x`.
const int X = center_x - (FRAME_SIZE / 2);
const int Y = feet_y - FRAME_SIZE;
SURFACE->render(X, Y, &clip);
}
} // namespace Effects::Shadow
+25
View File
@@ -0,0 +1,25 @@
#pragma once
namespace Effects::Shadow {
// Mida de cada fotograma del sprite compartit de la sombra.
// El sprite és una tira horitzontal de fotogrames de 16x16, ordenats
// del més petit (índex 0) al més gran (últim índex).
constexpr int FRAME_SIZE = 16;
// Clau amb què `Resource::Cache` té carregada la Surface compartida.
// El precarrega el Director al boot.
constexpr const char* CACHE_KEY = "shadow";
// Pinta la sombra al canvas. `height_factor` selecciona el fotograma:
// 0.0 (entitat al terra) → fotograma més gran, 1.0 (pic del salt o
// entitat més alta del terra) → fotograma més petit. La tira es
// divideix en bandes uniformes; valors fora de [0, 1] es clampen.
//
// El fotograma s'ancora al quadrat 16x16 inferior d'una entitat de
// 16x16/16x32 centrada en `center_x` amb peus a `feet_y`: la vora
// inferior del sprite queda exactament a `feet_y`. La posició
// visual de l'oval dins del fotograma la decideix l'artista al GIF.
void render(int center_x, int feet_y, float height_factor);
} // namespace Effects::Shadow
+48 -21
View File
@@ -12,11 +12,52 @@
namespace { namespace {
// Parteix `text` en una llista de línies que caben cadascuna dins // Parteix `text` en una llista de línies que caben cadascuna dins
// `max_width`. Algoritme de projecte_2026: tall per espais, la paraula // Trenca una "paraula" llarga (sense espais) en trossos contigus
// que no cap passa a la línia següent. Les paraules aïllades més // que càpiguen a `max_width`. Si un sol caràcter ja excedeix
// llargues que `max_width` es queden a la seva pròpia línia sense // (font enorme), el deixa sol per garantir progrés. La part final
// trencar-les (cas improbable amb els pretty_names de gamepad retallats // que encara cap queda a `current` per a fusionar-se amb paraules
// a ~25 caràcters). // posteriors via espai.
void hardWrapWord(const std::string& word, int max_width, const Text& font, int kerning, std::vector<std::string>& lines, std::string& current) {
std::string buf;
for (char c : word) {
std::string candidate = buf;
candidate += c;
if (buf.empty() || font.length(candidate, kerning) <= max_width) {
buf = std::move(candidate);
} else {
lines.push_back(buf);
buf = std::string(1, c);
}
}
current = std::move(buf);
}
// Posa la `word` a la `current` line si hi cap (amb separador
// implícit si current no està buida), o fa flush + reintenta a
// línia neta. Si tampoc cap sola, hard-wrap caràcter a caràcter.
void appendWord(const std::string& word, int max_width, const Text& font, int kerning, std::vector<std::string>& lines, std::string& current) {
if (!current.empty()) {
std::string candidate = current;
candidate += ' ';
candidate += word;
if (font.length(candidate, kerning) <= max_width) {
current = std::move(candidate);
return;
}
lines.push_back(current);
current.clear();
}
if (font.length(word, kerning) <= max_width) {
current = word;
} else {
hardWrapWord(word, max_width, font, kerning, lines, current);
}
}
// `max_width`. Tall per espais; les paraules més llargues que
// `max_width` (rutes, URLs...) es trenquen caràcter a caràcter
// — sense això, una caixa que excedís el canvas faria que
// `fillRect` rebés rangs invertits i petés.
auto wrapToWidth(const std::string& text, int max_width, const Text& font, int kerning) -> std::vector<std::string> { auto wrapToWidth(const std::string& text, int max_width, const Text& font, int kerning) -> std::vector<std::string> {
std::vector<std::string> lines; std::vector<std::string> lines;
if (text.empty()) { if (text.empty()) {
@@ -30,26 +71,12 @@ namespace {
const std::size_t SPACE_POS = text.find(' ', i); const std::size_t SPACE_POS = text.find(' ', i);
const bool LAST = (SPACE_POS == std::string::npos); const bool LAST = (SPACE_POS == std::string::npos);
const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i); const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i);
appendWord(WORD, max_width, font, kerning, lines, current);
if (current.empty()) {
current = WORD;
} else {
std::string candidate = current;
candidate += ' ';
candidate += WORD;
if (font.length(candidate, kerning) <= max_width) {
current = std::move(candidate);
} else {
lines.push_back(current);
current = WORD;
}
}
if (LAST) { break; } if (LAST) { break; }
i = SPACE_POS + 1; i = SPACE_POS + 1;
} }
lines.push_back(current); if (!current.empty()) { lines.push_back(current); }
return lines; return lines;
} }
+6
View File
@@ -226,6 +226,12 @@ void Screen::fillRect(int x, int y, int w, int h, std::uint32_t argb) {
const int Y0 = std::max(y, CLIP.y); const int Y0 = std::max(y, CLIP.y);
const int X1 = std::min(x + w, CLIP.x + CLIP.w); const int X1 = std::min(x + w, CLIP.x + CLIP.w);
const int Y1 = std::min(y + h, CLIP.y + CLIP.h); const int Y1 = std::min(y + h, CLIP.y + CLIP.h);
// Bail-out defensiu: si el rang és buit o invertit després del clip,
// `std::fill(first, last)` amb `first > last` és UB i a la pràctica
// entra en bucle infinit (vist en un crash a l'overflow del Notifier
// quan la caixa quedava més ampla que el canvas). El `blendRect`
// d'aquí baix ja és segur perquè usa `for(ix; ix < X1; ...)`.
if (X1 <= X0 || Y1 <= Y0) { return; }
for (int iy = Y0; iy < Y1; ++iy) { for (int iy = Y0; iy < Y1; ++iy) {
std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_); std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_);
std::fill(row + X0, row + X1, argb); std::fill(row + X0, row + X1, argb);
+32 -20
View File
@@ -5,6 +5,7 @@
#include <ctime> #include <ctime>
#include <filesystem> #include <filesystem>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
#include "external/stb_image_write.h" #include "external/stb_image_write.h"
@@ -13,21 +14,18 @@ namespace Screenshot {
namespace { namespace {
// Crea la carpeta `screenshots/` si no existeix i torna la ruta // Estat de mòdul: ruta base on s'escriu la subcarpeta
// completa al fitxer. El paràmetre `filename` rep el nom nu (sense // `screenshots/`. Buida per defecte ⇒ relativa al CWD (fallback
// directori) per poder mostrar-lo a la notificació. // si el Director no ha pogut establir la ruta per plataforma).
auto generateFilePath(std::string& filename) -> std::string { std::string g_base_dir; // NOLINT(*-avoid-non-const-global-variables)
std::filesystem::create_directories("screenshots");
const auto NOW = std::chrono::system_clock::now(); // Construeix la ruta del directori on van les captures:
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW); // `<base>/screenshots/`. Si la base és buida, retorna
const std::tm* tm = std::localtime(&TIME); // "screenshots/" relativa al CWD.
auto screenshotsDir() -> std::string {
char timestamp[20]; if (g_base_dir.empty()) { return "screenshots/"; }
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm); const bool ENDS_WITH_SEP = (g_base_dir.back() == '/' || g_base_dir.back() == '\\');
return g_base_dir + (ENDS_WITH_SEP ? "" : "/") + "screenshots/";
filename = std::string("scr_") + timestamp + ".png";
return "screenshots/" + filename;
} }
// Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write // Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write
@@ -47,14 +45,28 @@ namespace Screenshot {
} // namespace } // namespace
auto save(const std::uint32_t* buffer, int width, int height) -> std::string { void setBaseDir(std::string dir) {
g_base_dir = std::move(dir);
}
auto save(const std::uint32_t* buffer, int width, int height) -> Result {
const std::string FOLDER = screenshotsDir();
std::error_code ec;
std::filesystem::create_directories(FOLDER, ec);
const auto NOW = std::chrono::system_clock::now();
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW);
const std::tm* tm = std::localtime(&TIME);
char timestamp[20];
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm);
const std::string FILENAME = std::string("scr_") + timestamp + ".png";
const std::string FILEPATH = FOLDER + FILENAME;
// Còpia local per no tocar el canvas original durant la conversió. // Còpia local per no tocar el canvas original durant la conversió.
std::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height)); std::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height));
if (!writePng(copy.data(), width, height, FILEPATH)) { return {}; }
std::string filename; return {.filename = FILENAME, .folder = FOLDER};
const std::string FILEPATH = generateFilePath(filename);
return writePng(copy.data(), width, height, FILEPATH) ? filename : "";
} }
} // namespace Screenshot } // namespace Screenshot
+23 -4
View File
@@ -3,11 +3,30 @@
#include <cstdint> #include <cstdint>
#include <string> #include <string>
// Volca un buffer ARGB8888 a un fitxer PNG dins de la carpeta `screenshots/` // Volca un buffer ARGB8888 a un fitxer PNG dins de la subcarpeta
// (relativa al CWD). El nom es genera amb un timestamp i la funció retorna // `screenshots/` del directori configurat amb `setBaseDir`. El nom es
// el nom del fitxer creat, o una cadena buida si l'escriptura ha fallat. // genera amb un timestamp i la funció retorna el basename i la ruta
// del directori on s'ha escrit (o un Result amb `filename` buit si
// l'escriptura ha fallat).
namespace Screenshot { namespace Screenshot {
auto save(const std::uint32_t* buffer, int width, int height) -> std::string; // Configura el directori base on s'escriuran les captures (s'hi
// crea una subcarpeta `screenshots/`). El Director el crida una
// vegada al boot amb `system_folder_` perquè les captures vagin
// al mateix lloc per plataforma que la resta de fitxers generats
// (config.yaml, gamepad_configs.yaml, shaders/, etc.). Si no es
// crida, el fallback és el CWD — comportament antic.
void setBaseDir(std::string dir);
// Resultat d'una captura: el `filename` és el basename del PNG
// (per a notificacions/títol) i el `folder` és la ruta absoluta
// on s'ha guardat (per a poder dir "guardada a {folder}"). Si
// l'escriptura falla, ambdós camps tornen buits.
struct Result {
std::string filename;
std::string folder;
};
auto save(const std::uint32_t* buffer, int width, int height) -> Result;
} // namespace Screenshot } // namespace Screenshot
+13
View File
@@ -18,9 +18,11 @@
#include "core/input/key_config.hpp" #include "core/input/key_config.hpp"
#include "core/input/mouse.hpp" #include "core/input/mouse.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/effects/shadow.hpp"
#include "core/rendering/notifier.hpp" #include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp" #include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/screenshot.hpp"
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "core/rendering/text.hpp" #include "core/rendering/text.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
@@ -162,6 +164,11 @@ Director::Director() {
Resource::Cache::get().loadSurface(ROOM_TEXT_FONT_GIF_KEY, UiConfig::get().roomTextFont().gif); Resource::Cache::get().loadSurface(ROOM_TEXT_FONT_GIF_KEY, UiConfig::get().roomTextFont().gif);
Resource::Cache::get().getSurface(ROOM_TEXT_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui")); Resource::Cache::get().getSurface(ROOM_TEXT_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
// Sprite compartit de la sombra (tira horitzontal de fotogrames de
// 16x16, de petit a gran). El consumeix `Effects::Shadow::render`,
// que el reaprofiten Player i qualsevol entitat futura compatible.
Resource::Cache::get().loadSurface(Effects::Shadow::CACHE_KEY, "data/entities/shadow/shadow.gif");
// Pistes de música del cicle de sala (intro/playing/outro). Es preloden // Pistes de música del cicle de sala (intro/playing/outro). Es preloden
// ací perquè formen part del preload d'assets, com la font i la paleta; // ací perquè formen part del preload d'assets, com la font i la paleta;
// la lògica de quina sona en cada estat viu a `GameScene`. // la lògica de quina sona en cada estat viu a `GameScene`.
@@ -373,6 +380,12 @@ void Director::createSystemFolder() {
if (error) { if (error) {
Log::error("Director", "no s'ha pogut crear {}: {}", system_folder_, error.message()); Log::error("Director", "no s'ha pogut crear {}: {}", system_folder_, error.message());
} }
// Les captures de pantalla viuen sota la mateixa carpeta per
// plataforma. Sense aquesta crida, `Screenshot::save` cauria al
// CWD del procés (raïz del projecte si llances `build/aee_arcade`
// a mà, lloc imprevisible en una release de macOS/Windows).
Screenshot::setBaseDir(system_folder_);
} }
void Director::processEvents() { void Director::processEvents() {
+24 -4
View File
@@ -82,8 +82,15 @@ UiConfig::UiConfig()
menu_cursor_(Defaults::UI::MENU_CURSOR), menu_cursor_(Defaults::UI::MENU_CURSOR),
menu_disabled_(Defaults::UI::MENU_DISABLED), menu_disabled_(Defaults::UI::MENU_DISABLED),
hud_bg_(Defaults::UI::HUD_BG), hud_bg_(Defaults::UI::HUD_BG),
hud_label_(Defaults::UI::HUD_LABEL), hud_p1_label_(Defaults::UI::HUD_P1_LABEL),
hud_value_(Defaults::UI::HUD_VALUE), hud_p1_value_(Defaults::UI::HUD_P1_VALUE),
hud_p1_shadow_(Defaults::UI::HUD_P1_SHADOW),
hud_center_label_(Defaults::UI::HUD_CENTER_LABEL),
hud_center_value_(Defaults::UI::HUD_CENTER_VALUE),
hud_center_shadow_(Defaults::UI::HUD_CENTER_SHADOW),
hud_p2_label_(Defaults::UI::HUD_P2_LABEL),
hud_p2_value_(Defaults::UI::HUD_P2_VALUE),
hud_p2_shadow_(Defaults::UI::HUD_P2_SHADOW),
hud_flash_(Defaults::UI::HUD_FLASH), hud_flash_(Defaults::UI::HUD_FLASH),
room_text_(Defaults::UI::ROOM_TEXT), room_text_(Defaults::UI::ROOM_TEXT),
room_text_outline_(Defaults::UI::ROOM_TEXT_OUTLINE), room_text_outline_(Defaults::UI::ROOM_TEXT_OUTLINE),
@@ -218,13 +225,26 @@ void UiConfig::load(const std::string& file_path) {
readColorIndex(c, "menu_cursor", menu_cursor_); readColorIndex(c, "menu_cursor", menu_cursor_);
readColorIndex(c, "menu_disabled", menu_disabled_); readColorIndex(c, "menu_disabled", menu_disabled_);
readColorIndex(c, "hud_bg", hud_bg_); readColorIndex(c, "hud_bg", hud_bg_);
readColorIndex(c, "hud_label", hud_label_);
readColorIndex(c, "hud_value", hud_value_);
readColorIndex(c, "hud_flash", hud_flash_); readColorIndex(c, "hud_flash", hud_flash_);
readColorIndex(c, "room_text", room_text_); readColorIndex(c, "room_text", room_text_);
readColorIndex(c, "room_text_outline", room_text_outline_); readColorIndex(c, "room_text_outline", room_text_outline_);
} }
// Famílies de color del HUD: blocs `hud_p1`, `hud_center`,
// `hud_p2` amb claus `label`, `value`, `shadow`. Cada bloc i
// cada clau són opcionals — el que manqui cau al default
// compilat a `Defaults::UI`.
auto read_hud_family = [&](const std::string& key, Uint8& label, Uint8& value, Uint8& shadow) {
if (!yaml.contains(key)) { return; }
const auto& f = yaml[key];
readColorIndex(f, "label", label);
readColorIndex(f, "value", value);
readColorIndex(f, "shadow", shadow);
};
read_hud_family("hud_p1", hud_p1_label_, hud_p1_value_, hud_p1_shadow_);
read_hud_family("hud_center", hud_center_label_, hud_center_value_, hud_center_shadow_);
read_hud_family("hud_p2", hud_p2_label_, hud_p2_value_, hud_p2_shadow_);
if (yaml.contains("timings")) { if (yaml.contains("timings")) {
const auto& t = yaml["timings"]; const auto& t = yaml["timings"];
readField(t, "notification_ms", notification_ms_); readField(t, "notification_ms", notification_ms_);
+22 -4
View File
@@ -66,8 +66,19 @@ class UiConfig {
[[nodiscard]] auto menuCursor() const -> Uint8 { return menu_cursor_; } [[nodiscard]] auto menuCursor() const -> Uint8 { return menu_cursor_; }
[[nodiscard]] auto menuDisabled() const -> Uint8 { return menu_disabled_; } [[nodiscard]] auto menuDisabled() const -> Uint8 { return menu_disabled_; }
[[nodiscard]] auto hudBg() const -> Uint8 { return hud_bg_; } [[nodiscard]] auto hudBg() const -> Uint8 { return hud_bg_; }
[[nodiscard]] auto hudLabel() const -> Uint8 { return hud_label_; } // Famílies de color del HUD per bloc (LEFT/CENTER/RIGHT). Cada
[[nodiscard]] auto hudValue() const -> Uint8 { return hud_value_; } // família té label (etiqueta apagada), value (valor destacat) i
// shadow (to fosc per a la sombra-embossing). El flash és comú a
// les tres famílies (groc neó al guanyar punts).
[[nodiscard]] auto hudP1Label() const -> Uint8 { return hud_p1_label_; }
[[nodiscard]] auto hudP1Value() const -> Uint8 { return hud_p1_value_; }
[[nodiscard]] auto hudP1Shadow() const -> Uint8 { return hud_p1_shadow_; }
[[nodiscard]] auto hudCenterLabel() const -> Uint8 { return hud_center_label_; }
[[nodiscard]] auto hudCenterValue() const -> Uint8 { return hud_center_value_; }
[[nodiscard]] auto hudCenterShadow() const -> Uint8 { return hud_center_shadow_; }
[[nodiscard]] auto hudP2Label() const -> Uint8 { return hud_p2_label_; }
[[nodiscard]] auto hudP2Value() const -> Uint8 { return hud_p2_value_; }
[[nodiscard]] auto hudP2Shadow() const -> Uint8 { return hud_p2_shadow_; }
[[nodiscard]] auto hudFlash() const -> Uint8 { return hud_flash_; } [[nodiscard]] auto hudFlash() const -> Uint8 { return hud_flash_; }
[[nodiscard]] auto roomText() const -> Uint8 { return room_text_; } [[nodiscard]] auto roomText() const -> Uint8 { return room_text_; }
[[nodiscard]] auto roomTextOutline() const -> Uint8 { return room_text_outline_; } [[nodiscard]] auto roomTextOutline() const -> Uint8 { return room_text_outline_; }
@@ -209,8 +220,15 @@ class UiConfig {
Uint8 menu_cursor_; Uint8 menu_cursor_;
Uint8 menu_disabled_; Uint8 menu_disabled_;
Uint8 hud_bg_; Uint8 hud_bg_;
Uint8 hud_label_; Uint8 hud_p1_label_;
Uint8 hud_value_; Uint8 hud_p1_value_;
Uint8 hud_p1_shadow_;
Uint8 hud_center_label_;
Uint8 hud_center_value_;
Uint8 hud_center_shadow_;
Uint8 hud_p2_label_;
Uint8 hud_p2_value_;
Uint8 hud_p2_shadow_;
Uint8 hud_flash_; Uint8 hud_flash_;
Uint8 room_text_; Uint8 room_text_;
Uint8 room_text_outline_; Uint8 room_text_outline_;
+4 -2
View File
@@ -739,8 +739,10 @@ void Editor::renderEditorHud(const EntityManager& entities) const {
? "(" + std::to_string(cursor_col_) + "," + std::to_string(cursor_row_) + ")" ? "(" + std::to_string(cursor_col_) + "," + std::to_string(cursor_row_) + ")"
: "(--,--)"; : "(--,--)";
const auto LBL = UiConfig::get().hudLabel(); // El HUD ara té tres famílies (P1/CENTER/P2); l'editor n'agafa la
const auto VAL = UiConfig::get().hudValue(); // del CENTER (ambre/or) per ser neutra i contrastar amb el mapa.
const auto LBL = UiConfig::get().hudCenterLabel();
const auto VAL = UiConfig::get().hudCenterValue();
const auto FLASH = UiConfig::get().hudFlash(); const auto FLASH = UiConfig::get().hudFlash();
const int KERN = UiConfig::get().hudFont().kerning; const int KERN = UiConfig::get().hudFont().kerning;
// Mateixos Y que el HUD del joc per simetria visual: línia 1 = 4, // Mateixos Y que el HUD del joc per simetria visual: línia 1 = 4,
+65 -1
View File
@@ -10,6 +10,7 @@
#include "core/audio/audio.hpp" #include "core/audio/audio.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
#include "game/entities/concrete/whip_attack.hpp"
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp" #include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
@@ -65,6 +66,24 @@ namespace {
// més d'una lane (cas marginal a velocitats típiques, però pot passar // 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 // 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). // 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> { auto findLaneCross(float prev, float delta, float base, float pitch, int max_idx) -> std::optional<float> {
const float NEXT = prev + delta; const float NEXT = prev + delta;
if (delta > 0.0F) { if (delta > 0.0F) {
@@ -87,7 +106,8 @@ Mummy::Mummy(const fkyaml::node& yaml)
: anim_walk_south_(buildAnimation(yaml, "walk_south")), : anim_walk_south_(buildAnimation(yaml, "walk_south")),
anim_walk_north_(buildAnimation(yaml, "walk_north")), anim_walk_north_(buildAnimation(yaml, "walk_north")),
anim_walk_east_(buildAnimation(yaml, "walk_east")), 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"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy");
collides_ = true; collides_ = true;
interactable_ = false; interactable_ = false;
@@ -122,6 +142,19 @@ Mummy::Mummy(const fkyaml::node& yaml)
changeDirection(0.0F, 1.0F); changeDirection(0.0F, 1.0F);
} }
// Estat SPAWN inicial: la mòmia naix en SPAWNING amb l'animació
// `raise` activa (16 frames @ 8fps, no-loop). La duració del SPAWN
// la marca la pròpia animació — `Mummy::update` la tickeja, i quan
// `completed()`, crida `endSpawn()` i restaura la `walk_*` per la
// direcció actual. Passem 0 al countdown del DynamicHazard perquè
// no s'utilitza en aquest cas (el nostre `update` retorna abans).
enterSpawn(0.0F);
anim_ = anim_raise_;
if (anim_.has_value()) {
anim_->reset();
flip_ = anim_->flipMode();
}
// Configuració de l'estat HURT: opcional. Si el bloc no apareix, la // Configuració de l'estat HURT: opcional. Si el bloc no apareix, la
// mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la // mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la
// pot ignorar amb `isWhippable() == false` si volem que no reacció. // pot ignorar amb `isWhippable() == false` si volem que no reacció.
@@ -509,7 +542,19 @@ 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) { void Mummy::onPlayerContact(Player* player) {
// Defensiu: el filtre central a EntityManager::checkPlayerCollisions ja
// salta DynamicHazards en SPAWN, però blindem la crida directa.
if (isSpawning()) { return; }
if (bus_ == nullptr) { return; } if (bus_ == nullptr) { return; }
const int PID = (player != nullptr) ? player->getId() : -1; const int PID = (player != nullptr) ? player->getId() : -1;
bus_->publish(Event{ bus_->publish(Event{
@@ -534,6 +579,10 @@ void Mummy::onPlayerContact(Player* player) {
} }
void Mummy::enterHurt(Direction flee_dir) { void Mummy::enterHurt(Direction flee_dir) {
// Defensiu: el whip no hauria de reaccionar contra una mòmia en SPAWN
// (el filtre de col·lisió la salta), però blindem per si algun camí
// futur crida directament.
if (isSpawning()) { return; }
state_ = State::HURT; state_ = State::HURT;
hurt_remaining_s_ = hurt_config_.duration_s; hurt_remaining_s_ = hurt_config_.duration_s;
hurt_speed_ = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_; hurt_speed_ = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_;
@@ -646,6 +695,21 @@ void Mummy::hurtStep(float delta_time) {
} }
void Mummy::update(float delta_time) { void Mummy::update(float delta_time) {
if (isSpawning()) {
// SPAWN dirigit per animació: la `raise` és no-loop, així que
// `completed()` ens marca el final. Mentre dura, NO cridem
// `DynamicHazard::update`: la base hi té el countdown i ja
// hauria saltat a ACTIVE (passem `enterSpawn(0)`), i el `step()`
// d'IA mouria la mòmia abans de "aixecar-se". En completar,
// tornem a la `walk_*` que toca segons la direcció ja triada
// al ctor (initial_direction de l'IA).
if (anim_.has_value()) { anim_->tick(delta_time); }
if (anim_.has_value() && anim_->completed()) {
endSpawn();
changeDirection(getDirX(), getDirY());
}
return;
}
if (state_ == State::HURT) { if (state_ == State::HURT) {
// Avancem l'animació inline (no podem cridar `Entity::update` ni // Avancem l'animació inline (no podem cridar `Entity::update` ni
// `DynamicHazard::update`: el primer salta una capa virtual i el // `DynamicHazard::update`: el primer salta una capa virtual i el
+12
View File
@@ -64,6 +64,12 @@ class Mummy : public DynamicHazard {
// Contacte amb el jugador: publica `player_hit` amb el dany. // Contacte amb el jugador: publica `player_hit` amb el dany.
void onPlayerContact(Player* player) override; void onPlayerContact(Player* player) override;
// 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`. // Activa l'estat HURT amb la direcció de fuga preferida `flee_dir`.
// Si està bloquejada, prova perpendiculars; si totes estan bloquejades, // Si està bloquejada, prova perpendiculars; si totes estan bloquejades,
// la mòmia queda quieta però en HURT durant tot el timer. // la mòmia queda quieta però en HURT durant tot el timer.
@@ -171,6 +177,12 @@ class Mummy : public DynamicHazard {
AnimationComponent anim_walk_east_; AnimationComponent anim_walk_east_;
AnimationComponent anim_walk_west_; AnimationComponent anim_walk_west_;
// Animació d'aparició (no-loop). Mentre `isSpawning()`, `update()`
// la tickeja sense moure la mòmia; quan `completed()`, la mòmia
// transita a ACTIVE via `endSpawn()` i conmuta a la walk_* que
// toca segons direcció.
AnimationComponent anim_raise_;
// Estat HURT i la seua configuració. `hurt_speed_` és l'efectiva // Estat HURT i la seua configuració. `hurt_speed_` és l'efectiva
// (resultat d'aplicar el `speed` del bloc `hurt:` o, si val 0, la // (resultat d'aplicar el `speed` del bloc `hurt:` o, si val 0, la
// velocitat normal de la mòmia). // velocitat normal de la mòmia).
@@ -1,13 +1,26 @@
#include "game/entities/concrete/sarcophagus.hpp" #include "game/entities/concrete/sarcophagus.hpp"
#include <random>
#include <string> #include <string>
#include <vector> #include <vector>
#include "core/audio/audio.hpp"
#include "core/resources/resource_cache.hpp"
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "utils/defaults.hpp"
namespace { 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 { auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0}; std::vector<int> frames = {0};
float fps = 1.0F; float fps = 1.0F;
@@ -56,6 +69,20 @@ Sarcophagus::Sarcophagus(const fkyaml::node& yaml)
interactable_ = true; interactable_ = true;
readTileSize(yaml, tile_size_w_, tile_size_h_); readTileSize(yaml, tile_size_w_, tile_size_h_);
anim_ = anim_closed_; anim_ = anim_closed_;
// Sons opcionals (bloc `sound:` del YAML). El basename val tant de
// clau al Resource::Cache com de path dins `Defaults::Paths::AUDIO_SOUNDS_DIR`.
// Mateix patró que `mummy.hurt.sound` i `player.jump.sound`.
if (yaml.contains("sound")) {
const auto& s = yaml["sound"];
if (s.contains("opening")) {
opening_sound_key_ = s["opening"].get_value<std::string>();
Resource::Cache::get().loadSound(opening_sound_key_, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + opening_sound_key_);
}
if (s.contains("opening_pitch_spread")) {
opening_pitch_spread_ = s["opening_pitch_spread"].get_value<float>();
}
}
} }
void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) { void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) {
@@ -121,6 +148,30 @@ void Sarcophagus::notifyTilePainted(int col, int row) {
state_ = State::OPENING; state_ = State::OPENING;
anim_ = anim_opening_; anim_ = anim_opening_;
if (anim_.has_value()) { anim_->reset(); } if (anim_.has_value()) { anim_->reset(); }
if (!opening_sound_key_.empty() && Audio::get() != nullptr) {
// Variació de pitch/velocitat per evitar la fatiga d'escoltar
// el mateix sample idèntic a cada obertura. Si l'spread és 0
// (default), reproduïm amb ratio 1.0; en cas contrari sortegem
// uniformement [1 - spread, 1 + spread].
if (opening_pitch_spread_ > 0.0F) {
std::uniform_real_distribution<float> dist(1.0F - opening_pitch_spread_, 1.0F + opening_pitch_spread_);
Audio::get()->playSound(opening_sound_key_, Audio::Group::GAME, dist(rng()));
} else {
Audio::get()->playSound(opening_sound_key_, Audio::Group::GAME);
}
}
}
}
void Sarcophagus::onRemoved() {
// El manager comptabilitza els sarcòfags actius en `total_sarcs_` per
// saber quan ha d'obrir la porta automàticament. Quan el editor
// n'esborra un, cal informar-lo: sense aquesta crida, `total_sarcs_`
// quedaria descompassat i la porta no s'obriria mai. Substitueix el
// `getName() == "sarcophagus"` que el manager feia abans —
// polimorfisme net, no comparació de strings.
if (entity_manager_ != nullptr) {
entity_manager_->onSarcophagusRemoved();
} }
} }
@@ -2,6 +2,7 @@
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <string>
#include <unordered_set> #include <unordered_set>
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
@@ -43,6 +44,7 @@ class Sarcophagus : public Interactable {
void update(float delta_time) override; void update(float delta_time) override;
void notifyTilePainted(int col, int row) override; void notifyTilePainted(int col, int row) override;
void onRemoved() override;
[[nodiscard]] auto getState() const -> State { return state_; } [[nodiscard]] auto getState() const -> State { return state_; }
[[nodiscard]] auto getPerimeterSize() const -> std::size_t { return perimeter_tiles_.size(); } [[nodiscard]] auto getPerimeterSize() const -> std::size_t { return perimeter_tiles_.size(); }
@@ -82,5 +84,12 @@ class Sarcophagus : public Interactable {
AnimationComponent anim_opening_; AnimationComponent anim_opening_;
AnimationComponent anim_open_; AnimationComponent anim_open_;
// Clau (basename del fitxer) al Resource::Cache del so que es dispara
// a la transició CLOSED → OPENING. Buit ⇒ no sona res.
std::string opening_sound_key_;
// Variació uniforme del ratio de reproducció al voltant de 1.0. Cada
// obertura sorteja [1 - spread, 1 + spread]. 0 ⇒ ratio fix a 1.0.
float opening_pitch_spread_{0.0F};
bool opened_event_emitted_{false}; bool opened_event_emitted_{false};
}; };
+27 -28
View File
@@ -262,19 +262,10 @@ void WhipAttack::update(float delta_time) {
namespace { namespace {
auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction { // (El mapping WhipAttack::Direction → Mummy::Direction viu ara a
switch (d) { // mummy.cpp, on s'usa des de `Mummy::onWhipHit`. Aquí només queda el
case WhipAttack::Direction::NORTH: // mapping a Player::Cardinal — usat per al friendly-fire entre
return Mummy::Direction::NORTH; // jugadors, que no és polimòrfic via Entity.)
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 whipDirToPlayerCardinal(WhipAttack::Direction d) -> Player::Cardinal { auto whipDirToPlayerCardinal(WhipAttack::Direction d) -> Player::Cardinal {
switch (d) { switch (d) {
@@ -296,22 +287,24 @@ namespace {
return STATE != Player::State::HURT && STATE != Player::State::EXITED && STATE != Player::State::DISABLED; 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 // Tracta una entitat candidata: si la seva caixa solapa amb
// amb `damage_rect`, la posa a HURT i la marca al set. Extret per // `damage_rect`, delega la decisió a `entity->onWhipHit(attack)`.
// acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar). // Cada entitat decideix què fer (Mummy entra HURT, Torch retorna
void hitMummyIfApplicable( // false sense efecte, RollingRock no override → no-op). El retorn
Entity* e, // 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, const SDL_FRect& damage_rect,
WhipAttack::Direction dir, WhipAttack* attack,
std::unordered_set<void*>& already_hit) { std::unordered_set<void*>& already_hit) {
if (e == nullptr || e->isDead()) { return; } if (entity == nullptr || entity->isDead()) { return; }
if (already_hit.contains(static_cast<void*>(e))) { return; } if (already_hit.contains(static_cast<void*>(entity))) { return; }
auto* mummy = dynamic_cast<Mummy*>(e); const auto BOX = entity->getCollisionRect();
if (mummy == nullptr || !mummy->isWhippable()) { return; }
const auto BOX = mummy->getCollisionRect();
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; } if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
mummy->enterHurt(whipDirToMummyDir(dir)); if (entity->onWhipHit(attack)) {
already_hit.insert(static_cast<void*>(e)); already_hit.insert(static_cast<void*>(entity));
}
} }
void hitPlayerIfApplicable( void hitPlayerIfApplicable(
@@ -336,8 +329,14 @@ namespace {
void WhipAttack::applyDamage() { void WhipAttack::applyDamage() {
if (entity_manager_ != nullptr) { if (entity_manager_ != nullptr) {
for (auto* e : entity_manager_->getEntities()) { // Iterem totes les entitats. La discriminació la fa cada una via
hitMummyIfApplicable(e, damage_rect_, direction_, already_hit_); // `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) { if (players_ != nullptr) {
+25
View File
@@ -28,6 +28,31 @@ void Entity::notifyTilePainted(int /*col*/, int /*row*/) {
// No-op per defecte; només `Sarcophagus` l'usa. // 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 { auto Entity::getCollisionRect() const -> SDL_FRect {
if (!collision_box_.has_value()) { return pixel_rect_; } if (!collision_box_.has_value()) { return pixel_rect_; }
return { return {
+33
View File
@@ -19,6 +19,7 @@ class Player;
class EventBus; class EventBus;
class Surface; class Surface;
class EntityManager; class EntityManager;
class WhipAttack;
// Entitat genèrica del món. Actua com a dada en comú per a tots els actors // 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é // del joc (decoratius, interactables, perills mòbils i estàtics): manté
@@ -86,6 +87,38 @@ class Entity {
// (per marcar perímetre); per defecte no-op. // (per marcar perímetre); per defecte no-op.
virtual void notifyTilePainted(int col, int row); virtual void notifyTilePainted(int col, int row);
// 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.
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 getName() const -> const std::string& { return name_; }
[[nodiscard]] auto getTilePos() const -> TilePos { return tile_; } [[nodiscard]] auto getTilePos() const -> TilePos { return tile_; }
[[nodiscard]] auto getPixelRect() const -> SDL_FRect { return pixel_rect_; } [[nodiscard]] auto getPixelRect() const -> SDL_FRect { return pixel_rect_; }
+14 -9
View File
@@ -90,12 +90,15 @@ void EntityManager::setDoor(Door* door) {
auto EntityManager::removeEntity(Entity* observer) -> bool { auto EntityManager::removeEntity(Entity* observer) -> bool {
if (observer == nullptr) { return false; } if (observer == nullptr) { return false; }
const bool WAS_DOOR = (door_ == observer); const bool WAS_DOOR = (door_ == observer);
const bool WAS_SARC = (observer->getName() == "sarcophagus"); // `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; }); auto it = std::ranges::find_if(entities_, [observer](const auto& up) { return up.get() == observer; });
if (it == entities_.end()) { return false; } if (it == entities_.end()) { return false; }
entities_.erase(it); entities_.erase(it);
if (WAS_DOOR) { door_ = nullptr; } 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 // No tocar `opened_sarcs_` aquí: l'editor congela el joc i no pot
// disparar-se cap esdeveniment derivat. Quan el GameScene faça el // disparar-se cap esdeveniment derivat. Quan el GameScene faça el
// reload posterior, els comptadors es re-deriven d'inici. // reload posterior, els comptadors es re-deriven d'inici.
@@ -127,17 +130,19 @@ void EntityManager::update(float delta_time) {
} }
void EntityManager::notifyPlayerTilePainted(int col, int row) { void EntityManager::notifyPlayerTilePainted(int col, int row) {
// Fan-out polimòrfic a totes les entitats: cada una decideix què fer
// (`Sarcophagus` actualitza el seu perímetre, la resta queden en
// no-op). Cost d'iterar 40 elements amb crida virtual buida ~ 1 µs;
// qui paga el cost guanya simplicitat al callsite.
for (auto& e : entities_) { e->notifyTilePainted(col, row); } for (auto& e : entities_) { e->notifyTilePainted(col, row); }
} }
auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* { auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* {
// Cerca lineal sobre `entities_`: cost menyspreable amb poques // Cerca lineal: només `Hole` (i futurs decals trigger) compleixen la
// desenes d'entitats per sala i una crida per frame des dels // doble condició `triggersOnOverlap` + `isFloorDecal`. Costos típics
// consumidors actius (Player a l'aterratge, Mòmia a la decisió de // (poques desenes d'entitats) menyspreables, una crida per frame des
// direcció). Si esdevé calent, es podria indexar per cel·la del // dels consumidors actius (Player a l'aterratge, Mòmia a la decisió
// tilemap. La doble condició filtra exclusivament decals de sòl que // de direcció).
// volen rebre triggers — avui Hole; en el futur pinchos plans,
// plaques de pressió, charcos.
for (const auto& entity : entities_) { for (const auto& entity : entities_) {
if (!entity->triggersOnOverlap()) { continue; } if (!entity->triggersOnOverlap()) { continue; }
if (!entity->isFloorDecal()) { continue; } if (!entity->isFloorDecal()) { continue; }
+49 -1
View File
@@ -14,6 +14,7 @@ class Entity;
class Sarcophagus; class Sarcophagus;
class Door; class Door;
class Player; class Player;
class DynamicHazard;
// Contenidor i coordinador d'entitats d'un nivell. Responsabilitats: // Contenidor i coordinador d'entitats d'un nivell. Responsabilitats:
// 1. `add` registra una entitat, la connecta al bus i manté propietat. // 1. `add` registra una entitat, la connecta al bus i manté propietat.
@@ -73,7 +74,11 @@ class EntityManager {
static void renderEntity(const Entity& entity); static void renderEntity(const Entity& entity);
// Accés als punters observacionals a les entitats per a consumidors // Accés als punters observacionals a les entitats per a consumidors
// externs que necessiten construir una llista ordenada (z-order). // 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 getEntities() const -> std::vector<Entity*>;
// Comprova contacte del(s) jugador(s) amb entitats `collides()=true`. // Comprova contacte del(s) jugador(s) amb entitats `collides()=true`.
@@ -119,6 +124,23 @@ class EntityManager {
// d'una posició del cursor i el passa. // d'una posició del cursor i el passa.
auto addSpawn(const fkyaml::node& spawn) -> Entity*; auto addSpawn(const fkyaml::node& spawn) -> Entity*;
// Instancia tots els actors que estan en `pending_actors_` (cua que
// `loadFromLevel` omple amb les DynamicHazards declarades al YAML del
// mapa). Cada actor neix en estat SPAWN (per defecte des de la
// plantilla; veure `Mummy::Mummy`) i s'arma immediatament el
// `triggerSpawnExit()` perquè la fase EMERGING corri els seus segons
// de `spawn.duration_s` i transiti a ACTIVE. Vacia la cua. Cridada
// des de GameScene en una subfase concreta de ROOM_INTRO i des de
// `respawnPlayersInstant` (salt debug).
void spawnPendingActors();
// 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,
// p.ex. gotes que cauen, roques que rodolen). Retorna el punter
// observacional o `nullptr` si la plantilla o el factory fallen.
auto spawnActor(const std::string& type, int col, int row) -> DynamicHazard*;
// Reset complet del nivell: destrueix totes les entitats, reseteja // Reset complet del nivell: destrueix totes les entitats, reseteja
// els comptadors interns (sarcòfags oberts, total, exited, flag de // els comptadors interns (sarcòfags oberts, total, exited, flag de
// level_complete) i torna a cridar `loadFromLevel` per regenerar les // level_complete) i torna a cridar `loadFromLevel` per regenerar les
@@ -137,6 +159,7 @@ class EntityManager {
[[nodiscard]] auto getBus() -> EventBus& { return bus_; } [[nodiscard]] auto getBus() -> EventBus& { return bus_; }
[[nodiscard]] auto entityCount() const -> std::size_t { return entities_.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 sarcophagusCount() const -> std::size_t { return total_sarcs_; }
[[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; } [[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; }
[[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; } [[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; }
@@ -148,9 +171,34 @@ class EntityManager {
// sarcs. No-op si la porta ja està oberta o si el nivell no en té. // sarcs. No-op si la porta ja està oberta o si el nivell no en té.
void forceOpenDoor(); 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: private:
EventBus bus_; EventBus bus_;
// 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_; 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)
// i ho buiden. El `fkyaml::node` original es guarda perquè conté la
// posició i qualsevol override del map respecte a la plantilla.
struct PendingActor {
std::string type;
fkyaml::node spawn_node;
};
std::vector<PendingActor> pending_actors_;
Door* door_{nullptr}; Door* door_{nullptr};
std::size_t total_sarcs_{0}; std::size_t total_sarcs_{0};
std::size_t opened_sarcs_{0}; std::size_t opened_sarcs_{0};
+114 -40
View File
@@ -18,6 +18,7 @@
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/entity_loader.hpp" #include "game/entities/entity_loader.hpp"
#include "game/entities/entity_manager.hpp" #include "game/entities/entity_manager.hpp"
#include "game/entities/hazard.hpp"
#include "game/entities/player.hpp" #include "game/entities/player.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
@@ -56,52 +57,56 @@ void EntityManager::renderEntity(const Entity& e) {
} }
void EntityManager::renderAll() { void EntityManager::renderAll() {
// 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 : entities_) { renderEntity(*e); } for (const auto& e : entities_) { renderEntity(*e); }
} }
namespace {
// Filtres "per què aquest jugador no rep contacte d'aquest hazard ara
// mateix": fora de joc, DYING, caient per forat (la transició a mort
// per forat la mana `integrateJump`, no la pot interrompre cap
// hazard), o salt per damunt de l'alçada del cos. Extret a funció
// per acotar la complexitat cognitiva del callsite. Veure la nota a
// `isFallingThroughHole`: si algun dia s'elimina aquest skip, cal
// revisar la cadena DYING(HOLE) → respawn.
auto playerSkipsHazard(const Player& p, int hazard_height_px) -> bool {
if (p.isOutOfPlay() || p.isDying()) { return true; }
if (p.isFallingThroughHole()) { return true; }
return p.isAirborne() && hazard_height_px > 0 && p.getJumpZ() > static_cast<float>(hazard_height_px);
}
auto playerOverlapsEntityBox(const Player& p, const SDL_FRect& box) -> bool {
// Hit-box petita del jugador (`getHitBox`) en lloc de la caja base
// 16×16 — per a "tocar una entitat" volem precisió, no rebot físic.
const auto PBOX = p.getHitBox();
return PBOX.x + PBOX.w > box.x && box.x + box.w > PBOX.x && PBOX.y + PBOX.h > box.y && box.y + box.h > PBOX.y;
}
} // namespace
void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) { void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
if (players.empty()) { return; } if (players.empty()) { return; }
for (const auto& entity : entities_) { // Helper local: aplica el contacte d'una entitat sobre tots els
// Acceptem entitats sòlides (xoc físic) i triggers d'àrea (no // jugadors, amb els filtres per-jugador a `playerSkipsHazard`. Es
// bloquegen però volen rebre `onPlayerContact`). Tanquem el gap // comparteix entre els dos sacs (map_entities_ i actors_).
// latent de FloorTrap/WallTrap (que tenien `collides_=false` i auto apply_contacts = [&players](Entity* entity) {
// mai no es disparaven) i obrim la porta a hazards no-físics
// com `Hole`.
if (!entity->collides() && !entity->triggersOnOverlap()) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
const int E_HEIGHT = entity->getHeight(); const int E_HEIGHT = entity->getHeight();
for (auto* p : players) { for (auto* p : players) {
// Filtrem també DYING: el jugador en cicle de mort no rep if (p == nullptr || playerSkipsHazard(*p, E_HEIGHT)) { continue; }
// contacte amb hazards (un segon toc de mòmia mentre la roda if (playerOverlapsEntityBox(*p, BOX)) { entity->onPlayerContact(p); }
// ja és no-op; cap altra entitat l'ha de fer transicionar).
if (p == nullptr || p->isOutOfPlay() || p->isDying()) { continue; }
// Mentre el jugador cau per un forat, ignorem qualsevol altre
// hazard: la transició a `enterDying()` ja la dispara
// `integrateJump` quan supera `HOLE_FALL_DEPTH`.
//
// NOTA: aquest skip és la defensa que evita que un jugador
// caient per un forat reba contacte d'altres hazards
// "subterranis" durant la caiguda. La cadena de mort per
// forat (`enterDying(HOLE)` → respawn) depén de no ser
// interrompuda per un event `player_hit`/`player_killed`
// mentre `falling_through_hole_=true`. Si el dia que es
// toque aquest skip s'elimina, cal revisar la cadena
// mort/respawn per forat.
if (p->isFallingThroughHole()) { continue; }
// Filtre per altura: si el jugador està en l'aire i està per
// damunt de l'alçada del cos de l'entitat, no hi ha contacte.
// És el mecanisme del salt per esquivar momies, roques, etc.
// Quan height és 0 (entitat sense altura declarada), el filtre
// no es dispara mai i la col·lisió funciona com abans.
if (p->isAirborne() && E_HEIGHT > 0 && p->getJumpZ() > static_cast<float>(E_HEIGHT)) { continue; }
// Contacte actor-actor amb hit-box petita: la caixa base
// 16×16 servia per moure (xoc amb murs), però per a l'event
// "toca una entitat" volem precisió — que la momia mate quan
// els píxels centrals es solapen, no quan els caps es freguen.
const auto PBOX = p->getHitBox();
const bool OVERLAP = PBOX.x + PBOX.w > BOX.x && BOX.x + BOX.w > PBOX.x && PBOX.y + PBOX.h > BOX.y && BOX.y + BOX.h > PBOX.y;
if (OVERLAP) { entity->onPlayerContact(p); }
} }
};
// 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());
} }
} }
@@ -202,6 +207,24 @@ namespace {
return tmpl["anchor"].get_value<std::string>(); return tmpl["anchor"].get_value<std::string>();
} }
// Llig el camp `type:` de la plantilla (la categoria semàntica:
// "decoration", "static_hazard", "interactable", "dynamic_hazard",
// "effect"). Tornarà buit si la plantilla no es pot carregar o no
// declara el camp. S'usa al loader per decidir quin sac rep l'entitat.
auto readTemplateCategory(const std::string& type) -> std::string {
try {
const auto TMPL = EntityLoader::loadTemplate(type);
if (TMPL.contains("type")) { return TMPL["type"].get_value<std::string>(); }
} catch (const std::exception& e) {
// Plantilla absent / parse error: deixem el caller que ho
// gestione (típicament `buildEntity` també fallarà i deixarà
// el seu propi log). Aquí només avisem perquè el silenci
// total amaga problemes de YAML mal formats.
Log::warn("EntityManager", "readTemplateCategory({}) ha fallat: {}", type, e.what());
}
return {};
}
// Coordenades píxel (canvas) d'una cel·la (col, row) del tilemap. El // Coordenades píxel (canvas) d'una cel·la (col, row) del tilemap. El
// grid del tilemap està 16-alineat respecte a `tilemap_origin_*`, que // grid del tilemap està 16-alineat respecte a `tilemap_origin_*`, que
// pot ser negatiu per overscan CPC. // pot ser negatiu per overscan CPC.
@@ -383,6 +406,7 @@ void EntityManager::reload(const std::string& level_yaml_path) {
// se subscriu directament al bus — només publiquen). El bus i les // se subscriu directament al bus — només publiquen). El bus i les
// subscripcions externes (PaintLayer, HUD, GameScene) no es toquen. // subscripcions externes (PaintLayer, HUD, GameScene) no es toquen.
entities_.clear(); entities_.clear();
pending_actors_.clear();
door_ = nullptr; door_ = nullptr;
total_sarcs_ = 0; total_sarcs_ = 0;
opened_sarcs_ = 0; opened_sarcs_ = 0;
@@ -419,8 +443,21 @@ auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* {
void EntityManager::loadFromLevel(const std::string& level_yaml_path) { void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path); const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path);
const int LOADED = static_cast<int>(std::ranges::count_if(SPAWNS, int loaded = 0;
[this](const fkyaml::node& spawn) { return addSpawn(spawn) != nullptr; })); for (const auto& spawn : SPAWNS) {
if (!spawn.contains("type")) { continue; }
const auto TYPE = spawn["type"].get_value<std::string>();
// Spawn diferit: els DynamicHazards (mòmies, roques, futurs
// enemics) no s'instancien aquí. GameScene crida
// `spawnPendingActors()` al final del text-exit de ROOM_INTRO
// (i, defensivament, en salts debug). La instanciació = la
// seua aparició visual.
if (readTemplateCategory(TYPE) == "dynamic_hazard") {
pending_actors_.push_back(PendingActor{.type = TYPE, .spawn_node = spawn});
continue;
}
if (addSpawn(spawn) != nullptr) { ++loaded; }
}
// Broadcast inicial: marca com a "pintats" tots els tiles ocupats per // Broadcast inicial: marca com a "pintats" tots els tiles ocupats per
// decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...). // decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...).
@@ -432,6 +469,8 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
// perquè tots els sarcòfags ja estan registrats al fan-out (la // perquè tots els sarcòfags ja estan registrats al fan-out (la
// subscripció del manager és al ctor, anterior a qualsevol load). // subscripció del manager és al ctor, anterior a qualsevol load).
int decal_broadcast = 0; int decal_broadcast = 0;
// Els decals de sòl (Hole, futurs pinchos plans...) són StaticHazards;
// iterem tot `entities_` i el filtre per `isFloorDecal()` els tria.
for (const auto& entity : entities_) { for (const auto& entity : entities_) {
if (!entity->isFloorDecal()) { continue; } if (!entity->isFloorDecal()) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
@@ -447,5 +486,40 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast); Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast);
} }
Log::info("EntityManager", "{} entitats carregades des de {}", LOADED, 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_`. `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) { observer->onJustSpawned(); }
}
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
// overrides addicionals (orientació inicial, durada de spawn...),
// afegirà una variant amb fkyaml::node pre-construït més endavant.
fkyaml::node spawn;
try {
const std::string YAML_TEXT = "type: " + type + "\nposition: [" + std::to_string(col) + ", " + std::to_string(row) + "]\n";
spawn = fkyaml::node::deserialize(YAML_TEXT.begin(), YAML_TEXT.end());
} catch (const std::exception&) {
Log::error("EntityManager", "spawnActor: no s'ha pogut construir spawn YAML per {}", type);
return nullptr;
}
Entity* observer = addSpawn(spawn);
if (observer == nullptr) { return nullptr; }
// `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);
} }
+65
View File
@@ -1,5 +1,7 @@
#pragma once #pragma once
#include <cstdint>
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
// Base d'entitats perilloses: al contacte amb el jugador, apliquen dany. // Base d'entitats perilloses: al contacte amb el jugador, apliquen dany.
@@ -56,8 +58,20 @@ class StaticHazard : public Hazard {
// Perill dinàmic: es mou. Velocitat expressada en píxels/segon i una // Perill dinàmic: es mou. Velocitat expressada en píxels/segon i una
// direcció (vec2). Les subclasses decideixen la política de moviment // direcció (vec2). Les subclasses decideixen la política de moviment
// (IA corridor_wanderer, línia recta, etc.) sobreescribint `step(dt)`. // (IA corridor_wanderer, línia recta, etc.) sobreescribint `step(dt)`.
//
// Estat SPAWN: tota DynamicHazard pot estar en SPAWNING (visible, sense
// moviment ni dany) fins que algú extern arme el countdown amb
// `triggerSpawnExit()`. Quan el comptador arriba a zero passa a ACTIVE i
// recupera la lògica normal (`step()` per frame). Per defecte arrenca en
// ACTIVE — backward-compat amb subclasses que encara no usen el mecanisme.
// El filtre central de col·lisió a `EntityManager::checkPlayerCollisions`
// salta les entitats en SPAWN: per això el contacte no fa dany ni dispara
// triggers durant aquesta fase.
class DynamicHazard : public Hazard { class DynamicHazard : public Hazard {
public: public:
enum class SpawnState : std::uint8_t { SPAWNING,
ACTIVE };
DynamicHazard() = default; DynamicHazard() = default;
void setSpeed(float px_per_s) { speed_ = px_per_s; } void setSpeed(float px_per_s) { speed_ = px_per_s; }
@@ -70,6 +84,40 @@ class DynamicHazard : public Hazard {
[[nodiscard]] auto getDirX() const -> float { return dir_x_; } [[nodiscard]] auto getDirX() const -> float { return dir_x_; }
[[nodiscard]] auto getDirY() const -> float { return dir_y_; } [[nodiscard]] auto getDirY() const -> float { return dir_y_; }
// Entra en SPAWNING amb una duració determinada. El comptador NO
// descompta fins que algú crida `triggerSpawnExit()` — l'entitat queda
// visible i quieta esperant indefinidament.
void enterSpawn(float duration_s) {
spawn_state_ = SpawnState::SPAWNING;
spawn_duration_s_ = duration_s;
spawn_remaining_s_ = duration_s;
spawn_exit_armed_ = false;
}
// 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_ // Política de moviment per frame. La base desplaça el pixel_rect_
// proporcionalment a velocitat i direcció; les subclasses amb IA la // proporcionalment a velocitat i direcció; les subclasses amb IA la
// sobreescriuen per triar nova direcció a les interseccions o rebotar. // sobreescriuen per triar nova direcció a les interseccions o rebotar.
@@ -79,6 +127,19 @@ class DynamicHazard : public Hazard {
} }
void update(float delta_time) override { void update(float delta_time) override {
if (spawn_state_ == SpawnState::SPAWNING) {
// Sense `step()`: l'entitat es queda quieta al pixel_rect_
// inicial. `Entity::update` tic d'animació igual: si en algun
// moment posem un GIF de spawn aquí ja anirà.
Entity::update(delta_time);
if (spawn_exit_armed_) {
spawn_remaining_s_ -= delta_time;
if (spawn_remaining_s_ <= 0.0F) {
spawn_state_ = SpawnState::ACTIVE;
}
}
return;
}
Entity::update(delta_time); Entity::update(delta_time);
step(delta_time); step(delta_time);
} }
@@ -87,4 +148,8 @@ class DynamicHazard : public Hazard {
float speed_{0.0F}; float speed_{0.0F};
float dir_x_{0.0F}; float dir_x_{0.0F};
float dir_y_{0.0F}; float dir_y_{0.0F};
SpawnState spawn_state_{SpawnState::ACTIVE};
float spawn_duration_s_{0.0F};
float spawn_remaining_s_{0.0F};
bool spawn_exit_armed_{false};
}; };
+15 -31
View File
@@ -13,6 +13,7 @@
#include "core/input/gamepad_manager.hpp" #include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.hpp" #include "core/input/input_state.hpp"
#include "core/rendering/canvas.hpp" #include "core/rendering/canvas.hpp"
#include "core/rendering/effects/shadow.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "core/resources/palette_loader.hpp" #include "core/resources/palette_loader.hpp"
@@ -179,7 +180,6 @@ void Player::readJumpBlock(const fkyaml::node& yaml) {
if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value<float>(); } if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value<float>(); }
if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value<float>(); } if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); } if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
if (j.contains("shadow_y_offset")) { jump_config.shadow_y_offset = j["shadow_y_offset"].get_value<int>(); }
// Sons opcionals: el nom del fitxer (relatiu a Defaults::Paths::AUDIO_SOUNDS_DIR) // Sons opcionals: el nom del fitxer (relatiu a Defaults::Paths::AUDIO_SOUNDS_DIR)
// val tant de clau al Resource::Cache com de basename per a la càrrega. // val tant de clau al Resource::Cache com de basename per a la càrrega.
// Si el camp manca, la clau queda buida i `playSound` no es crida. // Si el camp manca, la clau queda buida i `playSound` no es crida.
@@ -1435,36 +1435,17 @@ void Player::renderShadow() const {
// que `enterDying` netegi el flag. // que `enterDying` netegi el flag.
if (falling_through_hole_) { return; } if (falling_through_hole_) { return; }
// Sombra centrada al sprite, recolzada a la línia dels peus reals // `height_factor`: 0 al terra → fotograma més gran, 1 al pic → més
// (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura // petit. Si el `jump_config` té un divisor zero (cas degenerat) caem
// del salt — al pic queda més estreta, donant la sensació de // a 0 per no propagar nan al renderer.
// "elevació" sense necessitat de coordenades 3D reals. Si el
// jump_config té un divisor zero (cas degenerat), només dibuixem
// la sombra a mida plena per evitar nan.
const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F) const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F)
? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2) ? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2)
: 0.0F; : 0.0F;
const float NORM_Z = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F; const float HEIGHT_FACTOR = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F;
constexpr float MAX_SHRINK = 0.4F; // Fins a un 40% més estreta al pic.
const float SHRINK = 1.0F - (NORM_Z * MAX_SHRINK);
constexpr int SHADOW_H = 3; const auto CENTER_X = static_cast<int>(getPosX() + (static_cast<float>(sprite_w) * 0.5F));
const auto SHADOW_W = static_cast<int>(static_cast<float>(base_size) * SHRINK); const auto FEET_Y = static_cast<int>(getGroundFeetY());
const float CX = getPosX() + (static_cast<float>(sprite_w) * 0.5F); Effects::Shadow::render(CENTER_X, FEET_Y, HEIGHT_FACTOR);
const float GROUND_Y = getGroundFeetY();
const auto SX = static_cast<int>(CX - (static_cast<float>(SHADOW_W) * 0.5F));
// Pugem la sombra `shadow_y_offset` px per compensar la franja
// transparent que el sprite del jugador té sota els peus reals
// (perquè la silueta camine pel mig del passadís en falsa
// perspectiva). Sense aquest ajust, la sombra apareix per sota
// del passadís.
const auto SY = static_cast<int>(GROUND_Y) - SHADOW_H - jump_config.shadow_y_offset;
// ARGB negre amb alpha ~40%. `blendRect` mescla sobre el canvas; així
// la sombra fosqueja el sòl (pintat o no) sense tapar-lo del tot.
constexpr std::uint32_t SHADOW_ARGB = 0x00000000U;
constexpr std::uint8_t SHADOW_ALPHA = 100;
Screen::get().blendRect(SX, SY, SHADOW_W, SHADOW_H, SHADOW_ARGB, SHADOW_ALPHA);
} }
// Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el // Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el
@@ -1589,10 +1570,13 @@ void Player::respawn(float x, float y) {
setPosX(x); setPosX(x);
setPosY(y); setPosY(y);
// Immunitat post-spawn: armem la finestra de gràcia amb la durada del // Immunitat post-spawn: armem la finestra de gràcia amb la durada del
// YAML. Cobreix l'aparició inicial (`spawnFromAir` → `respawn`) i el // YAML. Cobreix el join mid-partida (`joinPlayer` → `spawnFromAir` →
// respawn post-mort (`respawnFromDying` → `respawn`). Si el bloc // `respawn`) i el respawn post-mort (`respawnFromDying` → `respawn`).
// `immunity:` manca al YAML, `duration_ms` és 0 i `setImmunity` és // L'spawn inicial de ROOM_INTRO també hi passa, però `triggerPlayerSpawn`
// no-op — comportament idèntic al d'abans. // crida `setImmunity(0)` immediatament després per cancel·lar-la (els
// enemics ja estan apareixent i el jugador no ha de tindre gràcia).
// Si el bloc `immunity:` manca al YAML, `duration_ms` és 0 i el timer
// queda a 0 — sense gràcia, comportament idèntic al d'abans.
setImmunity(immunity_config.duration_ms); setImmunity(immunity_config.duration_ms);
} }
+4 -5
View File
@@ -331,11 +331,11 @@ class Player : public AnimatedSprite {
// sortida lògica del món. Idempotent: una segona crida reescriu el // sortida lògica del món. Idempotent: una segona crida reescriu el
// timer (no acumula). Visualment, el render alterna la paleta del // timer (no acumula). Visualment, el render alterna la paleta del
// sprite amb una paleta plana cada `immunity_config.blink_period_ms` // sprite amb una paleta plana cada `immunity_config.blink_period_ms`
// ms. Si `duration_ms <= 0` és no-op (manera de cancel·lar // ms. `setImmunity(0)` cancel·la la immunitat activa (sobreescriu el
// explícitament la immunitat seria `setImmunity(0)` — equivalent al // timer a 0) — equivalent al comportament natural quan el timer
// comportament natural quan el timer s'esgota). // s'esgota. Valors negatius es clampejen a 0.
void setImmunity(float duration_ms) { void setImmunity(float duration_ms) {
if (duration_ms > 0.0F) { immunity_remaining_ms_ = duration_ms; } immunity_remaining_ms_ = duration_ms > 0.0F ? duration_ms : 0.0F;
} }
// Cert mentre el jugador té immunitat activa. Comprovat per // Cert mentre el jugador té immunitat activa. Comprovat per
@@ -633,7 +633,6 @@ class Player : public AnimatedSprite {
float impulse_px_per_s{0.0F}; // Velocitat vertical inicial. float impulse_px_per_s{0.0F}; // Velocitat vertical inicial.
float gravity_px_per_s2{0.0F}; // Acceleració descendent constant. float gravity_px_per_s2{0.0F}; // Acceleració descendent constant.
int landing_stun_ms{0}; // Stun en aterrar (no es mou ni salta). int landing_stun_ms{0}; // Stun en aterrar (no es mou ni salta).
int shadow_y_offset{0}; // Px que puja la sombra (compensa transparències al peu del sprite).
std::string sound_key; // Clau al Resource::Cache del so de despegue (buit = silenci). std::string sound_key; // Clau al Resource::Cache del so de despegue (buit = silenci).
std::string land_sound_key; // Clau del so d'aterratge (buit = silenci). std::string land_sound_key; // Clau del so d'aterratge (buit = silenci).
}; };
+145 -154
View File
@@ -31,6 +31,7 @@
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/entity_loader.hpp" #include "game/entities/entity_loader.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/entities/hazard.hpp"
#include "game/entities/player.hpp" #include "game/entities/player.hpp"
#include "game/gameplay_config.hpp" #include "game/gameplay_config.hpp"
#include "game/levels/level_manager.hpp" #include "game/levels/level_manager.hpp"
@@ -622,6 +623,7 @@ void GameScene::enterRoomIntro() {
intro_completed_ = false; intro_completed_ = false;
intro_spawn_elapsed_ms_ = 0; intro_spawn_elapsed_ms_ = 0;
players_spawned_in_intro_.fill(false); players_spawned_in_intro_.fill(false);
pending_actors_spawned_ = false;
// Reset visual: arrenquem el FADE_IN d'INTRO i deixem el d'OUTRO // Reset visual: arrenquem el FADE_IN d'INTRO i deixem el d'OUTRO
// inactiu (es re-armarà a `setState(ROOM_OUTRO)` i, dins d'aquell // inactiu (es re-armarà a `setState(ROOM_OUTRO)` i, dins d'aquell
@@ -674,6 +676,12 @@ void GameScene::respawnPlayersInstant() {
state_ = RoomFsm::State::PLAYING; state_ = RoomFsm::State::PLAYING;
intro_completed_ = true; intro_completed_ = true;
transition_ = {}; transition_ = {};
// El salt debug a sala curt-circuit'a la cinemàtica d'INTRO i, amb
// ella, l'spawn diferit dels DynamicHazards que viu a la fase 4
// d'INTRO. Si no els instanciàvem ací també, les mòmies de la sala
// entrada per editor no apareixerien mai.
entity_manager_.spawnPendingActors();
pending_actors_spawned_ = true;
Log::info("ROOM", "post-editor: spawn instantani, estat -> PLAYING"); Log::info("ROOM", "post-editor: spawn instantani, estat -> PLAYING");
} }
#endif #endif
@@ -701,6 +709,14 @@ void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
// pot canviar de geometria de porta entre cicles. // pot canviar de geometria de porta entre cicles.
players_[slot_index]->setSpawnPoint(x, SPAWN.y); players_[slot_index]->setSpawnPoint(x, SPAWN.y);
players_[slot_index]->spawnFromAir(x, SPAWN.y); players_[slot_index]->spawnFromAir(x, SPAWN.y);
// Spawn inicial sense gràcia: l'aparició coincideix amb el moment
// en què comencen a aparèixer els enemics, i la finestra d'immunitat
// que arma `respawn` deixaria al jugador immune just quan ja hauria
// de poder ser tocat. Cancel·lem el timer recent armat. Els altres
// camins (`joinPlayer` mid-partida i `respawnFromDying`) sí que
// mantenen la immunitat: són situacions on el jugador apareix enmig
// de l'acció ja en curs i necessita la finestra de gràcia.
players_[slot_index]->setImmunity(0.0F);
// Arma el reverb del primer LAND com a part de la cinemàtica // Arma el reverb del primer LAND com a part de la cinemàtica
// d'INTRO. Una sola vegada per spawn aeri: el respawn post-mort // d'INTRO. Una sola vegada per spawn aeri: el respawn post-mort
// (que no passa per ací) cau al camí sec. Si el preset està buit // (que no passa per ací) cau al camí sec. Si el preset està buit
@@ -819,6 +835,15 @@ void GameScene::completeIntro() {
if (intro_completed_) { return; } if (intro_completed_) { return; }
intro_completed_ = true; intro_completed_ = true;
intro_spawn_elapsed_ms_ = 0; intro_spawn_elapsed_ms_ = 0;
// Fallback defensiu: si per qualsevol camí (config de fases amb tots
// els intervals molt curts, salt manual) hem arribat a PLAYING sense
// haver passat per la fase 4 que dispara l'spawn diferit, ho fem ara
// mateix. Sense això, les mòmies pendents quedarien per sempre a
// `pending_actors_` i la sala no tindria enemics.
if (!pending_actors_spawned_) {
entity_manager_.spawnPendingActors();
pending_actors_spawned_ = true;
}
// Spawn dels slots amb delay <= 0 (el cas habitual de P1). Els // Spawn dels slots amb delay <= 0 (el cas habitual de P1). Els
// altres cauen frame a frame dins `updateRoomTransition` a mesura // altres cauen frame a frame dins `updateRoomTransition` a mesura
// que `intro_spawn_elapsed_ms_` supera el seu delay. Així P1 cau // que `intro_spawn_elapsed_ms_` supera el seu delay. Així P1 cau
@@ -855,6 +880,7 @@ void GameScene::setState(RoomFsm::State new_state) {
intro_completed_ = false; intro_completed_ = false;
intro_spawn_elapsed_ms_ = 0; intro_spawn_elapsed_ms_ = 0;
players_spawned_in_intro_.fill(false); players_spawned_in_intro_.fill(false);
pending_actors_spawned_ = false;
} }
auto* audio = Audio::get(); auto* audio = Audio::get();
// PLAYING: arrenca la pista de joc en loop infinit. Netegem el // PLAYING: arrenca la pista de joc en loop infinit. Netegem el
@@ -915,6 +941,23 @@ void GameScene::updateRoomTransition(int delta_ms) {
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms); const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
// ROOM_INTRO: en entrar a la fase 5 (music_tail, immediatament després
// del text-exit) alliberem els DynamicHazards del seu estat SPAWN. Les
// mòmies surten visualment "del terra" just després que el text desapareix
// i abans que caiguin els jugadors — la sala arrenca amb tots els actors
// gairebé alhora (les mòmies amb un petit head-start `spawn.duration_s`).
// ROOM_INTRO, fase 4 = text-exit (el text està desapareixent). Instanciem
// els actors diferits (mòmies) — `EntityManager::spawnPendingActors` els
// crea ja en EMERGING. Visualment, "surten del terra" alhora que el text
// surt de pantalla. Amb spawn.duration_s ~ 1.0 s, queden ACTIVE
// coincidint amb la caiguda dels jugadors a music_tail.
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 4 && !pending_actors_spawned_) {
const std::size_t PENDING = entity_manager_.pendingActorCount();
entity_manager_.spawnPendingActors();
pending_actors_spawned_ = true;
Log::info("ROOM", "INTRO fase 4: spawn diferit de {} actors", PENDING);
}
// OUTRO: arrenquem `outro_fade_` la primera vegada que entrem a la // OUTRO: arrenquem `outro_fade_` la primera vegada que entrem a la
// fase de fade_out (phase 4 del timeline d'OUTRO). Si l'`advance` // fase de fade_out (phase 4 del timeline d'OUTRO). Si l'`advance`
// ha consumit fases buides en aquest mateix frame i ja portem // ha consumit fases buides en aquest mateix frame i ja portem
@@ -968,11 +1011,6 @@ void GameScene::renderRoomFade() {
} }
void GameScene::renderRoomText() { void GameScene::renderRoomText() {
// Orquestrador del rètol de transició de sala. La feina pesada
// (animació de X, animació d'altura del panel i pintat) la fan
// quatre helpers privats; aquesta funció només decideix quin
// text dibuixar i amb quines fases segons l'estat.
//
// Mapatge per estat (només els estats amb text actiu hi entren): // Mapatge per estat (només els estats amb text actiu hi entren):
// ROOM_INTRO: phases 2..4 → enter / hold / exit; dues línies // ROOM_INTRO: phases 2..4 → enter / hold / exit; dues línies
// (piràmide damunt, sala davall) que travessen la // (piràmide damunt, sala davall) que travessen la
@@ -981,82 +1019,35 @@ void GameScene::renderRoomText() {
// oposades ("fase" / "completada"). Phase 3 és el // oposades ("fase" / "completada"). Phase 3 és el
// fade-out i 4 la pausa en negre — cap text en cap // fade-out i 4 la pausa en negre — cap text en cap
// de les dues. // de les dues.
if (!room_text_font_) { return; }
constexpr int KERNING = 0;
// Centre vertical de la zona de joc (canvas sense la HUD). Reemplaça
// l'antic GameCanvas::CENTER_Y per descomptar la franja del marcador
// i tenir el rètol equilibrat sobre el playfield.
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
// Colors del rètol via UiConfig (`data/config/ui.yaml > colors.room_text` i
// `room_text_outline`). Independents dels colors de notificació.
const Uint8 FILL = UiConfig::get().roomText();
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
const int LINE_HEIGHT = room_text_font_->getLineHeight();
const int PHASE = transition_.phase; const int PHASE = transition_.phase;
const int ELAPSED = transition_.elapsed_ms; const int ELAPSED = transition_.elapsed_ms;
if (state_ == RoomFsm::State::ROOM_INTRO && PHASE >= 2 && PHASE <= 4) { if (state_ == RoomFsm::State::ROOM_INTRO && PHASE >= 2 && PHASE <= 4) {
const int DUR_ENTER = transition_config_.intro_text_enter_ms;
const int DUR_EXIT = transition_config_.intro_text_exit_ms;
// Panel primer (queda sota el text gràcies a l'ordre de blits).
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(PHASE, ELAPSED, 2, 3, 4, DUR_ENTER, DUR_EXIT));
const auto& mgr = Levels::LevelManager::get(); const auto& mgr = Levels::LevelManager::get();
const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid()); renderTextScreen(TextScreenSpec{
const std::string LINE_ROOM = RoomFsm::formatIntroRoomLine(mgr.currentRoom()); .line1 = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid()),
// Direccions oposades: piràmide entra per l'esquerra i surt per .line2 = RoomFsm::formatIntroRoomLine(mgr.currentRoom()),
// la dreta; sala fa el contrari. .phase_enter = 2,
const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING), .phase_hold = 3,
.phase_exit = 4,
.dur_enter_ms = transition_config_.intro_text_enter_ms,
.dur_exit_ms = transition_config_.intro_text_exit_ms,
.line_gap_px = transition_config_.intro_text_line_gap_px,
},
PHASE, PHASE,
ELAPSED, ELAPSED);
2,
4,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/false);
const int X_ROOM = computeRoomTextX(room_text_font_->length(LINE_ROOM, KERNING),
PHASE,
ELAPSED,
2,
4,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/true);
// Bloc de dues línies centrat al voltant del PLAY_CENTER_Y. El
// gap (`intro_text_line_gap_px`) es reparteix simètricament:
// mig amunt a la piràmide, mig avall a la sala.
const int GAP = transition_config_.intro_text_line_gap_px;
drawRoomTextLine(LINE_PYRAMID, X_PYRAMID, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
drawRoomTextLine(LINE_ROOM, X_ROOM, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
} else if (state_ == RoomFsm::State::ROOM_OUTRO && PHASE >= 0 && PHASE <= 2) { } else if (state_ == RoomFsm::State::ROOM_OUTRO && PHASE >= 0 && PHASE <= 2) {
const int DUR_ENTER = transition_config_.outro_text_enter_ms; renderTextScreen(TextScreenSpec{
const int DUR_EXIT = transition_config_.outro_text_exit_ms; .line1 = Locale::get().text("game.room_complete_line1"),
drawRoomTextPanel(PLAY_CENTER_Y, .line2 = Locale::get().text("game.room_complete_line2"),
computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT)); .phase_enter = 0,
.phase_hold = 1,
const std::string LINE_1 = Locale::get().text("game.room_complete_line1"); .phase_exit = 2,
const std::string LINE_2 = Locale::get().text("game.room_complete_line2"); .dur_enter_ms = transition_config_.outro_text_enter_ms,
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING), .dur_exit_ms = transition_config_.outro_text_exit_ms,
.line_gap_px = transition_config_.outro_text_line_gap_px,
},
PHASE, PHASE,
ELAPSED, ELAPSED);
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/false);
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
PHASE,
ELAPSED,
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/true);
const int GAP = transition_config_.outro_text_line_gap_px;
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
} }
} }
@@ -1136,6 +1127,51 @@ void GameScene::drawRoomTextLine(const std::string& text, int x, int y, std::uin
outline_color); outline_color);
} }
void GameScene::renderTextScreen(const TextScreenSpec& spec, int phase, int elapsed_ms) const {
// Coreografia comuna als quatre rètols (ROOM_INTRO, ROOM_OUTRO,
// CONTINUE?, GAME OVER): panel translúcid de fons + dues línies
// que entren/surten per costats oposats. Els quatre helpers
// existents (`drawRoomTextPanel`, `computeRoomTextPanelHeight`,
// `computeRoomTextX`, `drawRoomTextLine`) fan la feina pesada;
// aquesta funció només els encadena en el mateix ordre per als
// quatre casos. Una `lineN` buida es salta sense pintar — així
// CONTINUE? amaga la línia del countdown fora del hold sense
// que els callers hagin de duplicar la branca.
if (!room_text_font_) { return; }
constexpr int KERNING = 0;
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
const Uint8 FILL = UiConfig::get().roomText();
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
const int LINE_HEIGHT = room_text_font_->getLineHeight();
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(phase, elapsed_ms, spec.phase_enter, spec.phase_hold, spec.phase_exit, spec.dur_enter_ms, spec.dur_exit_ms));
if (!spec.line1.empty()) {
const int X_1 = computeRoomTextX(room_text_font_->length(spec.line1, KERNING),
phase,
elapsed_ms,
spec.phase_enter,
spec.phase_exit,
spec.dur_enter_ms,
spec.dur_exit_ms,
/*from_right=*/false);
drawRoomTextLine(spec.line1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (spec.line_gap_px / 2), FILL, OUTLINE);
}
if (!spec.line2.empty()) {
const int X_2 = computeRoomTextX(room_text_font_->length(spec.line2, KERNING),
phase,
elapsed_ms,
spec.phase_enter,
spec.phase_exit,
spec.dur_enter_ms,
spec.dur_exit_ms,
/*from_right=*/true);
drawRoomTextLine(spec.line2, X_2, PLAY_CENTER_Y + (spec.line_gap_px / 2), FILL, OUTLINE);
}
}
void GameScene::setGameOverState(GameOverFsm::State new_state) { void GameScene::setGameOverState(GameOverFsm::State new_state) {
if (new_state == game_over_state_) { return; } if (new_state == game_over_state_) { return; }
const auto PREV = game_over_state_; const auto PREV = game_over_state_;
@@ -1272,104 +1308,57 @@ void GameScene::emitCountdownTickIfChanged() {
} }
void GameScene::renderGameOverText() { void GameScene::renderGameOverText() {
if (!room_text_font_) { return; } // Mapatge per estat:
// CONTINUE_PROMPT: phases 0=enter, 1=hold (countdown visible),
// 2=exit. La línia 2 (número) només es pinta
// durant la fase 1 perquè els números no
// travessin la pantalla.
// GAME_OVER: phases 0=hold, 1=exit (sense enter anim — el
// panel ja és amunt del CONTINUE_PROMPT). Phase 2
// (wait) sense text ni panel.
if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT && game_over_state_ != GameOverFsm::State::GAME_OVER) { if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT && game_over_state_ != GameOverFsm::State::GAME_OVER) {
return; return;
} }
constexpr int KERNING = 0;
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
const Uint8 FILL = UiConfig::get().roomText();
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
const int LINE_HEIGHT = room_text_font_->getLineHeight();
const int PHASE = game_over_transition_.phase; const int PHASE = game_over_transition_.phase;
const int ELAPSED = game_over_transition_.elapsed_ms; const int ELAPSED = game_over_transition_.elapsed_ms;
if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) { if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) {
const int DUR_ENTER = game_over_config_.continue_text_enter_ms;
const int DUR_EXIT = game_over_config_.continue_text_exit_ms;
// Phases del CONTINUE_PROMPT: 0=enter, 1=hold, 2=exit. El panel
// segueix la mateixa corba enter/hold/exit que el room text.
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT));
const std::string LINE_1 = Locale::get().text("game.continue_prompt");
const int SECONDS = GameOverFsm::countdownSecondsRemaining( const int SECONDS = GameOverFsm::countdownSecondsRemaining(
game_over_state_, game_over_state_,
game_over_transition_, game_over_transition_,
game_over_config_); game_over_config_);
// Línia 2 només té text durant la fase de hold (countdown). En renderTextScreen(TextScreenSpec{
// les fases d'enter i exit deixem només el "CONTINUE?" perquè .line1 = Locale::get().text("game.continue_prompt"),
// l'animació dels números no quedi rara. .line2 = (PHASE == 1) ? std::to_string(SECONDS) : std::string{},
const std::string LINE_2 = (PHASE == 1) ? std::to_string(SECONDS) : std::string{}; .phase_enter = 0,
.phase_hold = 1,
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING), .phase_exit = 2,
.dur_enter_ms = game_over_config_.continue_text_enter_ms,
.dur_exit_ms = game_over_config_.continue_text_exit_ms,
.line_gap_px = transition_config_.intro_text_line_gap_px,
},
PHASE, PHASE,
ELAPSED, ELAPSED);
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/false);
const int X_2 = computeRoomTextX(LINE_2.empty() ? 0 : room_text_font_->length(LINE_2, KERNING),
PHASE,
ELAPSED,
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/true);
const int GAP = transition_config_.intro_text_line_gap_px;
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
if (!LINE_2.empty()) {
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
}
return; return;
} }
// GAME_OVER: panel sempre amunt durant phase 0/1, decreix en phase 1 // GAME_OVER: phase 2 (wait) sense panel ni text — només temps mort
// (exit). Phase 2 (wait) sense panel ni text — només temps mort // abans del fade. phase_enter=-1 desactiva l'animació d'entrada
// abans del fade. // (el panel ja és amunt heretat del CONTINUE_PROMPT).
const int DUR_HOLD = game_over_config_.game_over_text_hold_ms;
const int DUR_EXIT = game_over_config_.game_over_text_exit_ms;
if (PHASE > 1) { return; } if (PHASE > 1) { return; }
renderTextScreen(TextScreenSpec{
// Panel: phase 0 = ple, phase 1 = decreix. Reaprofitem .line1 = Locale::get().text("game.game_over_line1"),
// computeRoomTextPanelHeight passant un phase_enter "fora de rang" .line2 = Locale::get().text("game.game_over_line2"),
// (-1) perquè la branca d'enter no s'activi mai. .phase_enter = -1,
drawRoomTextPanel(PLAY_CENTER_Y, .phase_hold = 0,
computeRoomTextPanelHeight(PHASE, ELAPSED, /*phase_enter=*/-1, .phase_exit = 1,
/*phase_hold=*/0, .dur_enter_ms = 0,
/*phase_exit=*/1, .dur_exit_ms = game_over_config_.game_over_text_exit_ms,
/*dur_enter=*/0, .line_gap_px = transition_config_.outro_text_line_gap_px,
DUR_EXIT)); },
const std::string LINE_1 = Locale::get().text("game.game_over_line1");
const std::string LINE_2 = Locale::get().text("game.game_over_line2");
// Phase 0 (hold): text quiet al CENTER_X. Phase 1 (exit): el text
// marxa pels costats com el room outro. Reaprofitem computeRoomTextX
// amb phase_enter=-1 (no enter anim) i phase_exit=1.
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
PHASE, PHASE,
ELAPSED, ELAPSED);
/*phase_enter=*/-1,
/*phase_exit=*/1,
/*dur_enter=*/0,
DUR_EXIT,
/*from_right=*/false);
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
PHASE,
ELAPSED,
/*phase_enter=*/-1,
/*phase_exit=*/1,
/*dur_enter=*/0,
DUR_EXIT,
/*from_right=*/true);
const int GAP = transition_config_.outro_text_line_gap_px;
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
(void)DUR_HOLD; // Reservat per a futurs efectes de hold (pulse, etc.).
} }
void GameScene::renderGameOverFade() { void GameScene::renderGameOverFade() {
@@ -1750,6 +1739,8 @@ void GameScene::render() {
}; };
std::vector<Renderable> order; std::vector<Renderable> order;
order.reserve(entity_manager_.entityCount() + players_.size()); order.reserve(entity_manager_.entityCount() + players_.size());
// 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(); const auto ENTITIES = entity_manager_.getEntities();
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) { std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr}; return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
+35
View File
@@ -202,6 +202,14 @@ class GameScene : public Scene {
// i a `setState(ROOM_INTRO)` (camí defensiu no canònic). // i a `setState(ROOM_INTRO)` (camí defensiu no canònic).
std::array<bool, 2> players_spawned_in_intro_{false, false}; std::array<bool, 2> players_spawned_in_intro_{false, false};
// Idempotència de la crida a `entity_manager_.spawnPendingActors()`
// dins d'un cicle d'INTRO. Es dispara una sola vegada quan
// ROOM_INTRO entra a la subfase text-exit (fase 4) i, defensivament,
// a `respawnPlayersInstant` i al fallback de `completeIntro` per a
// camins no canònics. Reset a `enterRoomIntro` i a
// `setState(ROOM_INTRO)`.
bool pending_actors_spawned_{false};
// Vista plana de punters crus — la fa servir Player com a llista // Vista plana de punters crus — la fa servir Player com a llista
// d'obstacles mòbils a la callback de col·lisió. Es construeix un sol // d'obstacles mòbils a la callback de col·lisió. Es construeix un sol
// cop al ctor (els punters són estables perquè `players_` no es // cop al ctor (els punters són estables perquè `players_` no es
@@ -364,6 +372,33 @@ class GameScene : public Scene {
void drawRoomTextPanel(int center_y, int panel_height) const; void drawRoomTextPanel(int center_y, int panel_height) const;
void drawRoomTextLine(const std::string& text, int x, int y, std::uint8_t fill_color, std::uint8_t outline_color) const; void drawRoomTextLine(const std::string& text, int x, int y, std::uint8_t fill_color, std::uint8_t outline_color) const;
// Especificació declarativa d'un rètol de transició de dues línies
// amb panel translúcid. ROOM_INTRO, ROOM_OUTRO, CONTINUE? i GAME OVER
// comparteixen la mateixa coreografia (panel + dues línies en
// direccions oposades) i només divergeixen en el text, els índexs
// de fase i les durades. `phase_enter = -1` desactiva l'animació
// d'entrada (cas GAME_OVER, que apareix amb el panel ja amunt).
// Una `lineN` buida indica que la línia no s'ha de pintar (cas
// CONTINUE? fora del hold, on la línia 2 mostra el countdown).
struct TextScreenSpec {
std::string line1;
std::string line2;
int phase_enter{0};
int phase_hold{0};
int phase_exit{0};
int dur_enter_ms{0};
int dur_exit_ms{0};
int line_gap_px{0};
};
// Orquestrador unificat dels quatre rètols de transició. Pinta el
// panel + dues línies a partir de `spec`, deixant la decisió de
// QUÈ pintar (text, fases, durades) als callers `renderRoomText`
// i `renderGameOverText`. Sense estat propi: la fase i temps
// arriben pels paràmetres perquè la mateixa funció serveix les
// dues sub-FSMs (RoomFsm i GameOverFsm).
void renderTextScreen(const TextScreenSpec& spec, int phase, int elapsed_ms) const;
// Posició inicial dels jugadors derivada de `Map::door_col`. Tots // Posició inicial dels jugadors derivada de `Map::door_col`. Tots
// els jugadors comparteixen Y (centre del corredor exterior superior); // els jugadors comparteixen Y (centre del corredor exterior superior);
// X depèn del nombre actiu: amb un jugador queda centrat sota la // X depèn del nombre actiu: amb un jugador queda centrat sota la
+47 -13
View File
@@ -40,6 +40,25 @@ namespace {
// Separació extra entre camps dins d'una mateixa línia. // Separació extra entre camps dins d'una mateixa línia.
constexpr int FIELD_GAP = 6; // px entre "Vides 3" i "Tres. 00" constexpr int FIELD_GAP = 6; // px entre "Vides 3" i "Tres. 00"
// Desplaçament de l'ombra del text en píxels (sota-dreta). Petit
// perquè la font del HUD és 8 px d'alçada — qualsevol valor més
// gran tapa el següent caràcter.
constexpr int SHADOW_DX = 1;
constexpr int SHADOW_DY = 1;
// Helpers per pintar text amb ombra. Primer la sombra desplaçada,
// després el text principal al damunt — l'ordre garanteix que la
// sombra mai cobreix el text. El kerning queda al default del
// Text (1 px), igual que la resta del HUD.
void drawShadowed(Text& font, int x, int y, const std::string& s, Uint8 color, Uint8 shadow) {
font.writeColored(x + SHADOW_DX, y + SHADOW_DY, s, shadow);
font.writeColored(x, y, s, color);
}
void drawMonoShadowed(Text& font, int x, int y, const std::string& s, Uint8 color, Uint8 shadow, int cell_w) {
font.writeMonoDigits(x + SHADOW_DX, y + SHADOW_DY, s, shadow, cell_w);
font.writeMonoDigits(x, y, s, color, cell_w);
}
// Format zero-padded a 6 dígits per a la puntuació. // Format zero-padded a 6 dígits per a la puntuació.
auto formatScore(int v) -> std::string { auto formatScore(int v) -> std::string {
const int CLAMPED = std::clamp(v, 0, 999999); const int CLAMPED = std::clamp(v, 0, 999999);
@@ -261,6 +280,16 @@ void Hud::renderBlock(BlockId id) const {
void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index, const std::string& label) const { void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index, const std::string& label) const {
const auto& p = players_[static_cast<std::size_t>(player_index)]; const auto& p = players_[static_cast<std::size_t>(player_index)];
// Família de color del bloc: P1 (vermell-crema-vermell-fosc) per al
// jugador 0, P2 (cian-blau-blau-marí) per al jugador 1. Mantenir el
// mateix matís a label/value/shadow dóna jerarquia visual coherent
// entre línies sense empastar (la sombra és un to fosc del mateix
// matís → efecte embossing).
const auto& ui = UiConfig::get();
const Uint8 LBL_C = (player_index == 0) ? ui.hudP1Label() : ui.hudP2Label();
const Uint8 VAL_C = (player_index == 0) ? ui.hudP1Value() : ui.hudP2Value();
const Uint8 SHD_C = (player_index == 0) ? ui.hudP1Shadow() : ui.hudP2Shadow();
// Slot inactiu amb prompt actiu: substituïm el render de zeros pel // Slot inactiu amb prompt actiu: substituïm el render de zeros pel
// missatge "PULSA START / PARA JUGAR" o "ESPERA / PARA JUGAR" segons // missatge "PULSA START / PARA JUGAR" o "ESPERA / PARA JUGAR" segons
// el mode fixat per la GameScene cada frame. El render de zeros // el mode fixat per la GameScene cada frame. El render de zeros
@@ -275,12 +304,12 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
const int L2_W = font_->length(L2); const int L2_W = font_->length(L2);
const int X1 = x + ((w - L1_W) / 2); const int X1 = x + ((w - L1_W) / 2);
const int X2 = x + ((w - L2_W) / 2); const int X2 = x + ((w - L2_W) / 2);
font_->writeColored(X1, y + LINE1_Y, L1, UiConfig::get().hudLabel()); drawShadowed(*font_, X1, y + LINE1_Y, L1, LBL_C, SHD_C);
font_->writeColored(X2, y + LINE2_Y, L2, UiConfig::get().hudValue()); drawShadowed(*font_, X2, y + LINE2_Y, L2, VAL_C, SHD_C);
return; return;
} }
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? UiConfig::get().hudFlash() : UiConfig::get().hudValue(); const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? ui.hudFlash() : VAL_C;
// --- Línia 1: "<label>-000000" centrada --- // --- Línia 1: "<label>-000000" centrada ---
const std::string LBL1 = label + SEPARATOR; const std::string LBL1 = label + SEPARATOR;
@@ -289,9 +318,9 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
const int SC_W = font_->lengthMonoDigits(SCORE, MONO_DIGIT_W); const int SC_W = font_->lengthMonoDigits(SCORE, MONO_DIGIT_W);
const int LINE1_W = LBL_W + SC_W; const int LINE1_W = LBL_W + SC_W;
int xx = x + ((w - LINE1_W) / 2); int xx = x + ((w - LINE1_W) / 2);
font_->writeColored(xx, y + LINE1_Y, LBL1, UiConfig::get().hudLabel()); drawShadowed(*font_, xx, y + LINE1_Y, LBL1, LBL_C, SHD_C);
xx += LBL_W; xx += LBL_W;
font_->writeMonoDigits(xx, y + LINE1_Y, SCORE, SCORE_C, MONO_DIGIT_W); drawMonoShadowed(*font_, xx, y + LINE1_Y, SCORE, SCORE_C, SHD_C, MONO_DIGIT_W);
// --- Línia 2: "<vides>-<n> <tresors>-<n>" centrada --- // --- Línia 2: "<vides>-<n> <tresors>-<n>" centrada ---
const std::string V_LBL = Locale::get().text("hud.lives") + SEPARATOR; const std::string V_LBL = Locale::get().text("hud.lives") + SEPARATOR;
@@ -304,16 +333,21 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
const int T_VW = font_->lengthMonoDigits(T_VAL, MONO_DIGIT_W); const int T_VW = font_->lengthMonoDigits(T_VAL, MONO_DIGIT_W);
const int LINE2_W = V_LW + V_VW + FIELD_GAP + T_LW + T_VW; const int LINE2_W = V_LW + V_VW + FIELD_GAP + T_LW + T_VW;
xx = x + ((w - LINE2_W) / 2); xx = x + ((w - LINE2_W) / 2);
font_->writeColored(xx, y + LINE2_Y, V_LBL, UiConfig::get().hudLabel()); drawShadowed(*font_, xx, y + LINE2_Y, V_LBL, LBL_C, SHD_C);
xx += V_LW; xx += V_LW;
font_->writeMonoDigits(xx, y + LINE2_Y, V_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W); drawMonoShadowed(*font_, xx, y + LINE2_Y, V_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
xx += V_VW + FIELD_GAP; xx += V_VW + FIELD_GAP;
font_->writeColored(xx, y + LINE2_Y, T_LBL, UiConfig::get().hudLabel()); drawShadowed(*font_, xx, y + LINE2_Y, T_LBL, LBL_C, SHD_C);
xx += T_LW; xx += T_LW;
font_->writeMonoDigits(xx, y + LINE2_Y, T_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W); drawMonoShadowed(*font_, xx, y + LINE2_Y, T_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
} }
void Hud::renderIdleCenter(int x, int y, int w) const { void Hud::renderIdleCenter(int x, int y, int w) const {
const auto& ui = UiConfig::get();
const Uint8 LBL_C = ui.hudCenterLabel();
const Uint8 VAL_C = ui.hudCenterValue();
const Uint8 SHD_C = ui.hudCenterShadow();
// --- Línia 1: "<piramide>-<n>" centrada --- // --- Línia 1: "<piramide>-<n>" centrada ---
const std::string PIR_LBL = Locale::get().text("hud.pyramid") + SEPARATOR; const std::string PIR_LBL = Locale::get().text("hud.pyramid") + SEPARATOR;
const std::string PIR_VAL = std::to_string(pyramid_); const std::string PIR_VAL = std::to_string(pyramid_);
@@ -321,9 +355,9 @@ void Hud::renderIdleCenter(int x, int y, int w) const {
const int PV_W = font_->lengthMonoDigits(PIR_VAL, MONO_DIGIT_W); const int PV_W = font_->lengthMonoDigits(PIR_VAL, MONO_DIGIT_W);
const int LINE1_W = PL_W + PV_W; const int LINE1_W = PL_W + PV_W;
int xx = x + ((w - LINE1_W) / 2); int xx = x + ((w - LINE1_W) / 2);
font_->writeColored(xx, y + LINE1_Y, PIR_LBL, UiConfig::get().hudLabel()); drawShadowed(*font_, xx, y + LINE1_Y, PIR_LBL, LBL_C, SHD_C);
xx += PL_W; xx += PL_W;
font_->writeMonoDigits(xx, y + LINE1_Y, PIR_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W); drawMonoShadowed(*font_, xx, y + LINE1_Y, PIR_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
// --- Línia 2: "<sala>-<r>" centrada --- // --- Línia 2: "<sala>-<r>" centrada ---
const std::string SALA_LBL = Locale::get().text("hud.room") + SEPARATOR; const std::string SALA_LBL = Locale::get().text("hud.room") + SEPARATOR;
@@ -332,7 +366,7 @@ void Hud::renderIdleCenter(int x, int y, int w) const {
const int SV_W = font_->lengthMonoDigits(SALA_VAL, MONO_DIGIT_W); const int SV_W = font_->lengthMonoDigits(SALA_VAL, MONO_DIGIT_W);
const int LINE2_W = SL_W + SV_W; const int LINE2_W = SL_W + SV_W;
xx = x + ((w - LINE2_W) / 2); xx = x + ((w - LINE2_W) / 2);
font_->writeColored(xx, y + LINE2_Y, SALA_LBL, UiConfig::get().hudLabel()); drawShadowed(*font_, xx, y + LINE2_Y, SALA_LBL, LBL_C, SHD_C);
xx += SL_W; xx += SL_W;
font_->writeMonoDigits(xx, y + LINE2_Y, SALA_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W); drawMonoShadowed(*font_, xx, y + LINE2_Y, SALA_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
} }
+20 -12
View File
@@ -217,20 +217,28 @@ namespace Defaults {
constexpr Uint8 MENU_DISABLED = 16; // Label en gris quan no aplica. constexpr Uint8 MENU_DISABLED = 16; // Label en gris quan no aplica.
// Índexs de paleta per al marcador de joc (HUD de la GameScene). // Índexs de paleta per al marcador de joc (HUD de la GameScene).
// Arranca reciclant els colors del menú per coherència visual; // Tres "famílies" de color — un parell (label, value) + sombra per
// separant els noms a constants pròpies, tocar-los a posteriori // cada bloc — donen jerarquia visual: P1 càlid (vermell+crema),
// no afecta el menú. // CENTER ambiental (ambre+or), P2 fred (cian+blau pàl·lid). La
constexpr Uint8 HUD_BG = 0; // Fons de cada bloc. // sombra és un to més fosc del mateix matís per donar volum.
constexpr Uint8 HUD_LABEL = 58; // Etiquetes ("P1", "V", "T"). constexpr Uint8 HUD_BG = 0; // Fons de cada bloc.
constexpr Uint8 HUD_VALUE = 187; // Valors (score, vides, tresors). constexpr Uint8 HUD_P1_LABEL = 7; // P1 label: vermell pur (#ff0000).
constexpr Uint8 HUD_FLASH = 237; // Color temporal del valor en flash. constexpr Uint8 HUD_P1_VALUE = 183; // P1 value: crema-préssec (#ffdaaa).
constexpr Uint8 HUD_P1_SHADOW = 2; // P1 ombra: vermell fosc (#480000).
constexpr Uint8 HUD_CENTER_LABEL = 29; // CENTER label: ambre cremat (#b66d00).
constexpr Uint8 HUD_CENTER_VALUE = 55; // CENTER value: or brillant (#ffda00).
constexpr Uint8 HUD_CENTER_SHADOW = 10; // CENTER ombra: marró cremat (#482400).
constexpr Uint8 HUD_P2_LABEL = 248; // P2 label: cian pur (#00ffff).
constexpr Uint8 HUD_P2_VALUE = 253; // P2 value: blau pàl·lid (#b6ffff).
constexpr Uint8 HUD_P2_SHADOW = 64; // P2 ombra: blau marí (#000055).
constexpr Uint8 HUD_FLASH = 63; // Flash (groc neó, comú a totes les famílies).
// Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). // Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO).
// Independent dels colors de notificació perquè el rètol de // Comparteix la família CENTER del HUD: or sobre marró cremat,
// fase és un canal visual diferent. `ROOM_TEXT_OUTLINE` és el // per coherència visual ("Piràmide N / Sala N" és sempre
// contorn que abans estava hardcodejat al `renderRoomText`. // daurat arreu del joc).
constexpr Uint8 ROOM_TEXT = 232; constexpr Uint8 ROOM_TEXT = 55; // Or brillant (= HUD_CENTER_VALUE).
constexpr Uint8 ROOM_TEXT_OUTLINE = 255; constexpr Uint8 ROOM_TEXT_OUTLINE = 10; // Marró cremat (= HUD_CENTER_SHADOW).
// Timings d'animació del HUD. L'entrada són els tres blocs lliscant // Timings d'animació del HUD. L'entrada són els tres blocs lliscant
// a la seua posició; flash i shake acompanyen un `player_hit`. // a la seua posició; flash i shake acompanyen un `player_hit`.
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts { namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner"; constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.40.0"; // Single source of truth — Makefile el grep constexpr const char* VERSION = "0.43.0"; // Single source of truth — Makefile el grep
} // namespace Texts } // namespace Texts
namespace GameCanvas { namespace GameCanvas {
+3
View File
@@ -18,6 +18,9 @@ auto Audio::get() -> Audio* {
void Audio::playSound(const std::string& /*name*/, Audio::Group /*group*/) { void Audio::playSound(const std::string& /*name*/, Audio::Group /*group*/) {
} }
void Audio::playSound(const std::string& /*name*/, Audio::Group /*group*/, float /*speed*/) {
}
namespace Resource { namespace Resource {
auto Cache::get() -> Cache& { auto Cache::get() -> Cache& {
+27 -9
View File
@@ -69,6 +69,16 @@ namespace {
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__) #define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra
// 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) {
for (int i = 0; i < 32 && mummy.isSpawning(); ++i) {
mummy.update(0.5F);
}
}
void testFactory() { void testFactory() {
std::cout << "[T1] factory\n"; std::cout << "[T1] factory\n";
auto& factory = EntityFactory::instance(); auto& factory = EntityFactory::instance();
@@ -156,9 +166,11 @@ namespace {
REQUIRE(sarc->getVisitedCount() == 8); REQUIRE(sarc->getVisitedCount() == 8);
REQUIRE(sarc->getState() == Sarcophagus::State::OPENING); REQUIRE(sarc->getState() == Sarcophagus::State::OPENING);
// Un update amb l'animació `opening` d'1 frame loop=false completa // Tickem prou per consumir l'animació `opening` (4 frames @ 6fps =
// la transició a OPEN. // ~0.67s). Quan completa, la transició OPENING → OPEN es dispara.
sarc->update(0.0F); for (int i = 0; i < 16 && sarc->getState() == Sarcophagus::State::OPENING; ++i) {
sarc->update(0.1F);
}
REQUIRE(sarc->getState() == Sarcophagus::State::OPEN); REQUIRE(sarc->getState() == Sarcophagus::State::OPEN);
} }
@@ -198,12 +210,14 @@ namespace {
forceOpen(s1); forceOpen(s1);
forceOpen(s2); forceOpen(s2);
// Un primer tick: cada sarc transiciona OPENING → OPEN i publica; // Tickem prou per consumir l'animació `opening` dels sarcs (4
// l'handler del manager compta i, en arribar al total, crida // frames @ 6fps) i la de la porta. Cada sarc transiciona
// door->open() — que en aquest mateix tick (la iteració sobre // OPENING → OPEN i publica; l'handler del manager compta i, en
// entities continua) farà transitar la porta OPENING → OPEN perquè // arribar al total, crida `door->open()`, que també necessita
// l'animació és d'1 frame loop=false. // tickar fins acabar la seua animació d'obertura.
manager.update(0.0F); for (int i = 0; i < 20 && door_ptr->getState() != Door::State::OPEN; ++i) {
manager.update(0.1F);
}
REQUIRE(manager.openedSarcophagusCount() == 2); REQUIRE(manager.openedSarcophagusCount() == 2);
REQUIRE(door_ptr->getState() == Door::State::OPEN); REQUIRE(door_ptr->getState() == Door::State::OPEN);
@@ -442,6 +456,7 @@ namespace {
// Mateixa col·locació que testMummyStep: corredor exterior // Mateixa col·locació que testMummyStep: corredor exterior
// superior, lliure de sarcs, prou marge per fugir cap a la dreta. // superior, lliure de sarcs, prou marge per fugir cap a la dreta.
mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F}); mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
exitMummySpawn(mummy);
// Direcció preferida: dreta. Mòmia ha d'entrar en HURT i avançar // Direcció preferida: dreta. Mòmia ha d'entrar en HURT i avançar
// a la dreta (la velocitat de hurt és més alta que la base). // a la dreta (la velocitat de hurt és més alta que la base).
@@ -474,6 +489,7 @@ namespace {
// la paret i fer-la sortir de HURT. // la paret i fer-la sortir de HURT.
const float RIGHT_WALL = static_cast<float>(Map::playfield_x + Map::playfield_w); const float RIGHT_WALL = static_cast<float>(Map::playfield_x + Map::playfield_w);
mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F}); mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
exitMummySpawn(mummy);
// La direcció preferida (RIGHT) està bloquejada al test // La direcció preferida (RIGHT) està bloquejada al test
// d'`is_open`; entrarem amb una perpendicular o quieta. // d'`is_open`; entrarem amb una perpendicular o quieta.
@@ -517,6 +533,7 @@ namespace {
// expiri el timer (60 px/s · 0.6 s = 36 px de runway). // expiri el timer (60 px/s · 0.6 s = 36 px de runway).
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F}); mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
REQUIRE(mummy.getCollisionRect().x == 40.0F); REQUIRE(mummy.getCollisionRect().x == 40.0F);
exitMummySpawn(mummy);
mummy.enterHurt(Mummy::Direction::WEST); mummy.enterHurt(Mummy::Direction::WEST);
REQUIRE(mummy.getState() == Mummy::State::HURT); REQUIRE(mummy.getState() == Mummy::State::HURT);
@@ -571,6 +588,7 @@ namespace {
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16}); mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
// Mateix runway que T18. // Mateix runway que T18.
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F}); mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
exitMummySpawn(mummy);
mummy.enterHurt(Mummy::Direction::WEST); mummy.enterHurt(Mummy::Direction::WEST);
for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) { for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) {
+23 -5
View File
@@ -32,6 +32,17 @@ namespace {
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__) #define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra
// 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) {
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). // Caja de jugador "estàndard" AEE: 16×32 amb cantonada (x, y).
// El damage_rect del whip s'alinearà al feet box (els últims 16 px), // El damage_rect del whip s'alinearà al feet box (els últims 16 px),
// i el draw_rect respectarà els offsets de la taula del disseny. // i el draw_rect respectarà els offsets de la taula del disseny.
@@ -200,12 +211,15 @@ namespace {
// = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap. // = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap.
mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F}); mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F});
auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned))); auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned)));
exitMummySpawn(*mummy_raw);
auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F); auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F);
whip->setWorldContext(&manager, nullptr); whip->setWorldContext(&manager, nullptr);
// 60 ms → frame 1 (active). active_frames: [1, 2]. // frame_ms=80 al YAML → frame 0 viu [0, 80] ms, frame 1 [80, 160] ms.
whip->update(0.06F); // active_frames=[1, 2], així que cal arribar com a mínim a 80 ms
// perquè `isFrameActive()` retorne true i `applyDamage()` corra.
whip->update(0.09F);
REQUIRE(mummy_raw->getState() == Mummy::State::HURT); REQUIRE(mummy_raw->getState() == Mummy::State::HURT);
} }
@@ -258,12 +272,14 @@ namespace {
// = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap. // = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap.
mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F}); mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F});
auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned))); auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned)));
exitMummySpawn(*mummy_raw);
auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F); auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F);
whip->setWorldContext(&manager, nullptr); whip->setWorldContext(&manager, nullptr);
// Primera passada (frame 1, actiu) → mòmia a HURT. // frame_ms=80 → frame 0 [0, 80] ms; cal ≥ 80 ms per entrar al
whip->update(0.06F); // frame 1 (primer dels active_frames=[1, 2]).
whip->update(0.09F);
REQUIRE(mummy_raw->getState() == Mummy::State::HURT); REQUIRE(mummy_raw->getState() == Mummy::State::HURT);
const float HURT_INITIAL = mummy_raw->getHurtRemainingS(); const float HURT_INITIAL = mummy_raw->getHurtRemainingS();
@@ -275,7 +291,9 @@ namespace {
const float HURT_AFTER_MUMMY_TICK = mummy_raw->getHurtRemainingS(); const float HURT_AFTER_MUMMY_TICK = mummy_raw->getHurtRemainingS();
REQUIRE(HURT_AFTER_MUMMY_TICK < HURT_INITIAL); REQUIRE(HURT_AFTER_MUMMY_TICK < HURT_INITIAL);
whip->update(0.05F); // frame 2, encara actiu // Segon tick del whip: acumulat 0.09 + 0.05 = 0.14 s, encara
// dins del frame 1 ([80, 160] ms). already_hit_ ha de bloquejar.
whip->update(0.05F);
REQUIRE(mummy_raw->getHurtRemainingS() == HURT_AFTER_MUMMY_TICK); REQUIRE(mummy_raw->getHurtRemainingS() == HURT_AFTER_MUMMY_TICK);
} }