Compare commits

...
28 Commits
Author SHA1 Message Date
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
JailDesigner 2cbf1bb6e0 chore(version): bump 0.39.0 -> 0.40.0 2026-05-08 23:00:06 +02:00
JailDesigner 8890fa9e1f Merge branch 'feat/player-join-and-game-over' 2026-05-08 22:59:47 +02:00
JailDesigner 23f7cb03a6 chore(data): restaura lives:3 a sam i morcus despres de proves 2026-05-08 22:59:43 +02:00
JailDesigner 486a440668 feat(game): skip de countdown amb JUMP/ATTACK quan numero <= 7 2026-05-08 22:20:39 +02:00
JailDesigner a717411edd feat(audio): musica acelerada en CONTINUE?, game_over.ogg, tick del countdown 2026-05-08 22:06:44 +02:00
JailDesigner 5e862ceadc fix(game): HUD lives desde YAML, prompt P1 en single-P2, countdown 9..0 configurable 2026-05-08 21:14:33 +02:00
JailDesignerandClaude Opus 4.7 78e164fce4 feat(game): CONTINUE? + GAME OVER + fade-to-LOGO (Fase 2)
Sub-FSM nova `GameOverFsm` (CONTINUE_PROMPT → GAME_OVER → FADE_OUT →
DONE) que viu en paral·lel a la `RoomFsm`. Es dispara quan tots els
jugadors queden DISABLED durant PLAYING, mostra el rètol "Continuar?"
amb countdown reusant el panel del room text, i si ningú pulsa START
acaba en "Fi del Joc" + fade negre i sol·licita la transició a LOGO.
Pulsar START durant CONTINUE? cancel·la el countdown i fa join (revive)
del jugador. Després de GAME OVER, el HUD passa a "ESPERA / PER JUGAR"
i el START queda bloquejat fins a la transició d'escena.

Els helpers d'animació del room text (X i altura del panel) ara reben
phase + elapsed_ms per paràmetre per poder ser reaprofitats per les
dues sub-FSMs sense duplicar la lògica visual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:30:37 +02:00
JailDesignerandClaude Opus 4.7 c13b11c501 feat(player): join in progress + DISABLED en agotar vidas (Fase 1)
Habilita la regla 1 de la nova FSM de joc: durant PLAYING, qualsevol
slot DISABLED pot pulsar START per entrar a partida amb spawn aeri al
seu costat de la porta i vides reseteades. Quan un jugador esgota les
vides, `respawnFromDying` el deixa a DISABLED i publica `player_disabled`
al bus; GameScene i HUD reaccionen, deixant el slot disponible per a un
nou join. Afegeix prompts al HUD ("PULSA START / PER JUGAR" o
"ESPERA / PER JUGAR") segons l'estat de la RoomFsm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:04:33 +02:00
JailDesigner 8921b9fd0c chore(version): bump 0.38.0 -> 0.39.0 2026-05-08 13:43:44 +02:00
JailDesigner 1d9fea28fe Merge branch 'feat/gameplay-tweaks'
Tweaks de jugabilitat persistents (gameplay.yaml a la carpeta de
sistema) amb tres flags i submenú "Jugabilitat" al ServiceMenu:
látec travessa sarcs, foc amic, col·lisió entre jugadors.
2026-05-08 13:43:24 +02:00
JailDesigner d0d5cb0e66 fix(player): players_collide també anul·la resolver i landing overlap 2026-05-08 13:40:30 +02:00
JailDesigner e25ccd02fd feat(menu): submenú "Jugabilitat" al menú de servei amb tres toggles
Nou apartat al ServiceMenu (entre Controls i Sistema) amb tres toggles
ON/OFF connectats a GameplayConfig: látec travessa sarcs, foc amic,
col·lisió entre jugadors. Cada canvi crida save() immediatament, així
l'estat persisteix entre sessions sense necessitat de "aplicar".

Locales ca/en: claus `tweaks` + tres `menu.items.*` corresponents.
2026-05-08 13:29:04 +02:00
JailDesigner 3635cebf25 feat(player): cabla flag players.collide_with_each_other
A `collidesWithWorld` saltem la branca actor↔actor quan el flag està
desactivat: els jugadors travessen els altres lliurement però segueixen
topant amb murs i sarcs. A `tryPushSnapBlockers` retornem early per la
mateixa raó: si no hi ha col·lisió entre jugadors, no hi ha ningú a
empentar quan el snap del corredor s'aplica.
2026-05-08 13:26:47 +02:00
JailDesigner cfbcdc236b feat(whip): cabla flag combat.friendly_fire a hitPlayerIfApplicable
Quan el flag està desactivat, el látigo no fa dany a altres jugadors
(l'owner ja quedava exclòs per la guarda existent). Default true:
manté el comportament actual.

Sense canvis als tests del whip — friendly fire ja no es cobreix
allà perquè els stubs de Player no porten estat real (vegeu el comentari
del top de test_whip_attack.cpp).
2026-05-08 13:24:33 +02:00
JailDesigner 7871d3930e feat(whip): cabla flag whip.damage_pierces_walls a computeDamageRect
Quan el flag està actiu (default), l'iteració píxel a píxel ignora
`Map::collides` i només respecta `insidePlayfield`. El mur extern,
doncs, sempre retalla — fora del playfield no hi ha res a colpejar.

Els tests existents passen amb la nova lògica perquè els seus setups
posen el damage_rect a corredors lliures (sense sarcs a la trajectòria),
i el test W2 segueix verificant el retall del playfield.
2026-05-08 13:22:52 +02:00
JailDesigner 932b457725 feat(config): afegeix GameplayConfig persistent amb versionatge defensiu
Singleton paral·lel a UiConfig però escrivible: viu a la carpeta de
sistema (gameplay.yaml). Defaults compilats: friendly_fire,
players_collide i whip_damage_pierces_walls a true. Davant qualsevol
irregularitat (fitxer absent/buit, parse error, version absent o
desconeguda, clau requerida mal tipada o absent), reseteja a defaults
i reescriu el fitxer en lloc de preservar estat parcial.

Cablejat al Director::start després de Options::loadFromFile. Encara
sense consumidors — els flags es cablejaran a WhipAttack i al sistema
de col·lisió de jugadors en commits posteriors.
2026-05-08 13:17:28 +02:00
JailDesigner e9f99a0542 fix(mummy): evita soft-lock per drift sub-pixel a HURT vora del mur 2026-05-08 12:07:18 +02:00
37 changed files with 2092 additions and 190 deletions

No files matched your search

+4
View File
@@ -107,16 +107,19 @@ set(APP_SOURCES
source/utils/log.cpp source/utils/log.cpp
source/utils/utils.cpp source/utils/utils.cpp
source/game/debug_config.cpp source/game/debug_config.cpp
source/game/gameplay_config.cpp
source/game/options.cpp source/game/options.cpp
source/game/options_apply.cpp source/game/options_apply.cpp
source/game/ui/service_menu.cpp source/game/ui/service_menu.cpp
source/game/ui/hud.cpp source/game/ui/hud.cpp
source/game/scenes/placeholder_input.cpp source/game/scenes/placeholder_input.cpp
source/game/scenes/start_input.cpp
source/game/scenes/logo_scene.cpp source/game/scenes/logo_scene.cpp
source/game/scenes/title_scene.cpp source/game/scenes/title_scene.cpp
source/game/scenes/intro_scene.cpp source/game/scenes/intro_scene.cpp
source/game/scenes/game_scene.cpp source/game/scenes/game_scene.cpp
source/game/scenes/room_fsm.cpp source/game/scenes/room_fsm.cpp
source/game/scenes/game_over_fsm.cpp
source/game/entities/player.cpp source/game/entities/player.cpp
source/game/map/map.cpp source/game/map/map.cpp
source/game/map/tilemap.cpp source/game/map/tilemap.cpp
@@ -397,6 +400,7 @@ if(BUILD_TESTS)
tests/audio_cache_stubs.cpp tests/audio_cache_stubs.cpp
source/core/resources/resource_helper.cpp source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp source/core/resources/resource_pack.cpp
source/game/gameplay_config.cpp
source/game/map/map.cpp source/game/map/map.cpp
source/utils/log.cpp source/utils/log.cpp
${ENTITY_SOURCES_BASE} ${ENTITY_SOURCES_BASE}
Binary file not shown.
Binary file not shown.
+8
View File
@@ -40,6 +40,14 @@ ai:
type: corridor_wanderer type: corridor_wanderer
direction_choice: random direction_choice: random
initial_direction: east initial_direction: east
# Estat SPAWN: la mòmia s'instancia visible al pixel_rect_ inicial just
# quan GameScene crida `spawnPendingActors()` (a la subfase text-exit
# de ROOM_INTRO, fase 4). El `triggerSpawnExit` es dispara llavors
# mateix; passats `duration_s` segons transita a ROAMING i empieza a
# deambular. Sense aquest bloc, default 0 ⇒ ACTIVE immediat.
spawn:
duration_s: 1.0
stats: stats:
speed: 30 speed: 30
damage: 1 damage: 1
+18
View File
@@ -38,6 +38,13 @@ hud:
treasures: "Tres" treasures: "Tres"
pyramid: "Piramide" pyramid: "Piramide"
room: "Sala" room: "Sala"
# Prompts mostrats al bloc d'un slot inactiu (en lloc dels zeros)
# quan un jugador encara no ha pulsat START o ha esgotat vides.
# Dues línies per fer-los caber al bloc lateral (~106 px).
join_prompt_can_join_l1: "POLSA START"
join_prompt_can_join_l2: "PER JUGAR"
join_prompt_wait_l1: "ESPERA"
join_prompt_wait_l2: "PER JUGAR"
# Textos de l'escena TITLE. # Textos de l'escena TITLE.
title: title:
@@ -53,6 +60,12 @@ game:
room_intro_room: "Sala {r}" room_intro_room: "Sala {r}"
room_complete_line1: "Fase" room_complete_line1: "Fase"
room_complete_line2: "Completada" room_complete_line2: "Completada"
# Rètol del game over (sub-FSM de la GameScene). El countdown es
# pinta com a segona línia (un enter) durant la fase 1 de
# CONTINUE_PROMPT — només cal traduir el rètol superior.
continue_prompt: "Continuar?"
game_over_line1: "Fi del"
game_over_line2: "Joc"
menu: menu:
titles: titles:
@@ -62,6 +75,7 @@ menu:
controls: "Controls" controls: "Controls"
keyboard: "Teclat" keyboard: "Teclat"
gamepad: "Comandament" gamepad: "Comandament"
tweaks: "Jugabilitat"
system: "Sistema" system: "Sistema"
labels: labels:
player_1: "Jugador 1" player_1: "Jugador 1"
@@ -73,6 +87,7 @@ menu:
controls_submenu: "Controls" controls_submenu: "Controls"
keyboard_submenu: "Teclat" keyboard_submenu: "Teclat"
gamepad_submenu: "Comandament" gamepad_submenu: "Comandament"
tweaks_submenu: "Jugabilitat"
system_submenu: "Sistema" system_submenu: "Sistema"
move_up: "Amunt" move_up: "Amunt"
move_down: "Avall" move_down: "Avall"
@@ -101,6 +116,9 @@ menu:
music_volume: "Volum música" music_volume: "Volum música"
sounds: "Sons" sounds: "Sons"
sounds_volume: "Volum sons" sounds_volume: "Volum sons"
whip_pierces_walls: "Látec travessa sarcs"
friendly_fire: "Foc amic"
players_collide: "Col·lisió entre jugadors"
restart: "Reiniciar" restart: "Reiniciar"
exit_game: "Eixir del joc" exit_game: "Eixir del joc"
values: values:
+18
View File
@@ -38,6 +38,13 @@ hud:
treasures: "Tre" treasures: "Tre"
pyramid: "Pyramid" pyramid: "Pyramid"
room: "Room" room: "Room"
# Prompts shown in an inactive slot's block (instead of zeros) while
# a player hasn't pressed START yet or has run out of lives. Two
# short lines so they fit in the side block (~106 px wide).
join_prompt_can_join_l1: "PRESS START"
join_prompt_can_join_l2: "TO PLAY"
join_prompt_wait_l1: "PLEASE"
join_prompt_wait_l2: "WAIT"
# Texts of the TITLE scene. # Texts of the TITLE scene.
title: title:
@@ -52,6 +59,12 @@ game:
room_intro_room: "Room {r}" room_intro_room: "Room {r}"
room_complete_line1: "Stage" room_complete_line1: "Stage"
room_complete_line2: "Complete" room_complete_line2: "Complete"
# Game over labels (sub-FSM in GameScene). The countdown is drawn as
# a numeric second line during CONTINUE_PROMPT phase 1, so only the
# top label needs translation.
continue_prompt: "Continue?"
game_over_line1: "Game"
game_over_line2: "Over"
menu: menu:
titles: titles:
@@ -61,6 +74,7 @@ menu:
controls: "Controls" controls: "Controls"
keyboard: "Keyboard" keyboard: "Keyboard"
gamepad: "Gamepad" gamepad: "Gamepad"
tweaks: "Gameplay"
system: "System" system: "System"
labels: labels:
player_1: "Player 1" player_1: "Player 1"
@@ -72,6 +86,7 @@ menu:
controls_submenu: "Controls" controls_submenu: "Controls"
keyboard_submenu: "Keyboard" keyboard_submenu: "Keyboard"
gamepad_submenu: "Gamepad" gamepad_submenu: "Gamepad"
tweaks_submenu: "Gameplay"
system_submenu: "System" system_submenu: "System"
move_up: "Up" move_up: "Up"
move_down: "Down" move_down: "Down"
@@ -100,6 +115,9 @@ menu:
music_volume: "Music volume" music_volume: "Music volume"
sounds: "Sounds" sounds: "Sounds"
sounds_volume: "Sound volume" sounds_volume: "Sound volume"
whip_pierces_walls: "Whip pierces sarcs"
friendly_fire: "Friendly fire"
players_collide: "Player collision"
restart: "Restart" restart: "Restart"
exit_game: "Exit game" exit_game: "Exit game"
values: values:
+45
View File
@@ -46,3 +46,48 @@ text_panel:
color: [36, 182, 170] color: [36, 182, 170]
alpha: 128 alpha: 128
height_px: 32 height_px: 32
# Sub-FSM de game over: dispara CONTINUE? quan tots els jugadors
# queden fora de partida durant PLAYING, fa countdown, i si ningú
# pulsa START acaba en GAME OVER + fade-to-black + transició a LOGO.
# Tots els valors són ms (excepte `continue_count_steps`, que és el
# nombre de tics visibles del countdown). Si manca el fitxer o alguna
# clau, GameScene cau als fallbacks compilats (vegeu `GameOverFsm::Config`).
game_over:
# CONTINUE? — animació d'entrada i sortida idèntica al room text.
continue_text_enter_ms: 400
# Hold del countdown: 10 s = (continue_count_start + 1) graons d'1 s.
# La sèrie visible és [continue_count_start, ..., 1, 0]; el "0" es
# manté l'últim segon abans del salt a GAME OVER.
continue_text_hold_ms: 10000
continue_text_exit_ms: 400
continue_count_start: 9
# GAME OVER — apareix sense enter anim (panel ja amunt del CONTINUE?),
# es manté un temps i marxa amb la mateixa exit anim del room text.
game_over_text_hold_ms: 1500
game_over_text_exit_ms: 400
game_over_wait_ms: 400
# Fade final cap a LOGO. `black_hold_ms` és la pausa en negre opac
# abans que el GameScene demani la transició d'escena.
fade_out_ms: 500
black_hold_ms: 500
# Velocitat (frequency ratio) de la música mentre dura el countdown
# de CONTINUE_PROMPT. 1.0 = sense canvi, 1.5 = un 50% més ràpid (i
# amb el to més agut: efecte arcade clàssic). Es restableix a 1.0 al
# sortir del prompt — tant si el jugador reviu com si entra GAME OVER.
continue_music_speed: 1.05
# Pista de música que sona durant l'estat GAME_OVER (sense loop).
# La fase de hold del text es sincronitza amb la durada real de la
# pista, així que el rètol "Fi del Joc" es manté visible mentre dura
# la música; després ve l'exit anim, el wait i el fade a LOGO.
game_over_music_track: "game_over.ogg"
# So que sona cada vegada que apareix un nou número del countdown
# (al primer cop que es mostra `continue_count_start`, a cada canvi,
# i quan apareix el 0 — però NO al saltar a GAME OVER). Buit = silenci.
countdown_sound_key: "countdown.wav"
# Llindar a partir del qual qualsevol botó de salt o atac de
# qualsevol jugador avança al següent número del countdown.
# 7 → skip habilitat mentre el número visible és 7..1 (no a 0).
# -1 = mai. Útil per accelerar la decisió un cop la urgència ja
# és evident al jugador.
skip_below_or_equal: 7
+7
View File
@@ -113,6 +113,13 @@ void Audio::stopMusic() {
} }
} }
// NOLINTNEXTLINE(readability-make-member-function-const)
void Audio::setMusicSpeed(float ratio) {
if (music_enabled_) {
engine_->setMusicSpeed(ratio);
}
}
// Reprodueix un so per nom // Reprodueix un so per nom
// NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_; unique_ptr::operator-> no propaga const // NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_; unique_ptr::operator-> no propaga const
void Audio::playSound(const std::string& name, Group group) { void Audio::playSound(const std::string& name, Group group) {
+5
View File
@@ -76,6 +76,11 @@ class Audio {
void stopMusic(); // Aturar completament la música void stopMusic(); // Aturar completament la música
void fadeOutMusic(int milliseconds); // Fosa de sortida de la música (muta globals de Ja) void fadeOutMusic(int milliseconds); // Fosa de sortida de la música (muta globals de Ja)
void setOnMusicEnded(std::function<void()> callback); // Callback disparat quan la pista actual acaba de drenar (CONV-03) void setOnMusicEnded(std::function<void()> callback); // Callback disparat quan la pista actual acaba de drenar (CONV-03)
// Multiplicador de velocitat de la música actual. 1.0 = normal,
// 1.5 = un 50% més ràpid (efecte "chipmunk" — també puja el to).
// Es reseteja a 1.0 implícitament a cada `playMusic`. No-op si no
// hi ha música activa.
void setMusicSpeed(float ratio);
// --- 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)
+5
View File
@@ -286,6 +286,11 @@ namespace Ja {
} }
} }
void Engine::setMusicSpeed(float ratio) {
if (current_music_ == nullptr || current_music_->stream == nullptr) { return; }
SDL_SetAudioStreamFrequencyRatio(current_music_->stream, ratio);
}
void Engine::pauseMusic() { void Engine::pauseMusic() {
if (current_music_ == nullptr || current_music_->state != MusicState::PLAYING) { return; } if (current_music_ == nullptr || current_music_->state != MusicState::PLAYING) { return; }
+8
View File
@@ -171,6 +171,14 @@ namespace Ja {
void crossfadeMusic(Music* music, int crossfade_ms, int loop = -1); void crossfadeMusic(Music* music, int crossfade_ms, int loop = -1);
[[nodiscard]] auto getMusicState() const -> MusicState; [[nodiscard]] auto getMusicState() const -> MusicState;
auto setMusicVolume(float volume) -> float; auto setMusicVolume(float volume) -> float;
// Multiplicador de velocitat de reproducció de la música actual
// via `SDL_SetAudioStreamFrequencyRatio`. 1.0 = normal, 2.0 =
// doble velocitat. Cal saber que també puja el to (efecte
// "chipmunk") — és el comportament arcade clàssic dels comptes
// enrere. Cada `playMusic` crea un stream nou amb ratio 1.0,
// així que un canvi de track reseteja la velocitat
// implícitament. No-op si no hi ha música activa.
void setMusicSpeed(float ratio);
// Registra un callback que es disparà quan la música actual acabi de // Registra un callback que es disparà quan la música actual acabi de
// drenar naturalment (times == 0 + stream buit). Es crida DESPRÉS de // drenar naturalment (times == 0 + stream buit). Es crida DESPRÉS de
// stopMusic, així que el callback pot invocar playMusic sense córrer. // stopMusic, així que el callback pot invocar playMusic sense córrer.
+9
View File
@@ -29,6 +29,7 @@
#include "core/system/event_buffer.hpp" #include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "game/debug_config.hpp" #include "game/debug_config.hpp"
#include "game/gameplay_config.hpp"
#ifdef _DEBUG #ifdef _DEBUG
#include "game/editor/editor_config.hpp" #include "game/editor/editor_config.hpp"
#endif #endif
@@ -93,6 +94,11 @@ Director::Director() {
// el patró de coffee_crisis_arcade_edition (`gamepad_configs.json`). // el patró de coffee_crisis_arcade_edition (`gamepad_configs.json`).
GamepadBindings::get().load(system_folder_ + "gamepad_configs.yaml"); GamepadBindings::get().load(system_folder_ + "gamepad_configs.yaml");
// Tweaks de jugabilitat (látigo travessa sarcs, friendly fire, col·lisió
// entre jugadors). Fitxer escrivible des del menú de servei (F9). Si
// no existeix, GameplayConfig::load el crea amb els defaults.
GameplayConfig::get().load(system_folder_ + "gameplay.yaml");
// Carrega els presets de PostFX des de shaders/postfx.yaml; si no existeix // Carrega els presets de PostFX des de shaders/postfx.yaml; si no existeix
// es crea amb presets per defecte (off, crt, ntsc). // es crea amb presets per defecte (off, crt, ntsc).
Options::setPostFXFile(system_folder_ + Defaults::Video::POSTFX_FILE); Options::setPostFXFile(system_folder_ + Defaults::Video::POSTFX_FILE);
@@ -162,6 +168,9 @@ Director::Director() {
Resource::Cache::get().loadMusic("room_intro.ogg", Defaults::Paths::MUSIC_ROOM_INTRO); Resource::Cache::get().loadMusic("room_intro.ogg", Defaults::Paths::MUSIC_ROOM_INTRO);
Resource::Cache::get().loadMusic("playing.ogg", Defaults::Paths::MUSIC_PLAYING); Resource::Cache::get().loadMusic("playing.ogg", Defaults::Paths::MUSIC_PLAYING);
Resource::Cache::get().loadMusic("room_outro.ogg", Defaults::Paths::MUSIC_ROOM_OUTRO); Resource::Cache::get().loadMusic("room_outro.ogg", Defaults::Paths::MUSIC_ROOM_OUTRO);
// Pista de game over: pre-load igual que la resta perquè el playMusic
// del moment en què es dispara la sub-FSM no facti disc.
Resource::Cache::get().loadMusic("game_over.ogg", Defaults::Paths::MUSIC_GAME_OVER);
// Música de la TITLE: arrenca al constructor de l'escena i es fa // Música de la TITLE: arrenca al constructor de l'escena i es fa
// fade-out quan l'escena entra a STARTING. // fade-out quan l'escena entra a STARTING.
Resource::Cache::get().loadMusic("title.ogg", Defaults::Paths::MUSIC_TITLE); Resource::Cache::get().loadMusic("title.ogg", Defaults::Paths::MUSIC_TITLE);
+50 -5
View File
@@ -122,6 +122,17 @@ Mummy::Mummy(const fkyaml::node& yaml)
changeDirection(0.0F, 1.0F); changeDirection(0.0F, 1.0F);
} }
// Estat SPAWN inicial: la mòmia naix visible i quieta, esperant que el
// GameScene li armi el comptador via `triggerSpawnExit()` (cridat al
// final de la subfase text-exit de ROOM_INTRO). Si el YAML no declara
// `spawn.duration_s`, default 0 ⇒ al primer update post-trigger passa
// directament a ACTIVE.
float spawn_duration_s = 0.0F;
if (yaml.contains("spawn") && yaml["spawn"].contains("duration_s")) {
spawn_duration_s = yaml["spawn"]["duration_s"].get_value<float>();
}
enterSpawn(spawn_duration_s);
// 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ó.
@@ -222,9 +233,24 @@ void Mummy::step(float delta_time) {
const float DX = dir_x_ * speed_ * delta_time; const float DX = dir_x_ * speed_ * delta_time;
const float DY = dir_y_ * speed_ * delta_time; const float DY = dir_y_ * speed_ * delta_time;
// Si no té direcció (p.ex. YAML sense `initial_direction`), el frame és // Sense direcció: pot ser un setup degenerat (YAML sense
// no-op fins que algú la injecte amb `setDirection`. // `initial_direction`) o el residu d'un HURT que va sortir amb
if (DX == 0.0F && DY == 0.0F) { return; } // `chooseHurtDirection() == NONE` (totes les direccions provades
// bloquejades). En aquest segon cas la mòmia quedaria eternament quieta
// — `setDirection(0,0)` a `enterHurt` no toca `anim_`, així que es
// veuria animant-se sense moure's. Re-decidim com a una intersecció:
// si trobem alguna perpendicular vàlida, la prenem; si no, mitja volta.
// Si tampoc, mantenim (0,0) i tornem a provar el proper frame —
// pràcticament impossible amb la geometria actual, però no enganxa.
if (DX == 0.0F && DY == 0.0F) {
auto pools = collectDirectionPools(speed_ * delta_time, /*avoid_blocked=*/true);
if (!pools.perp.empty()) {
applyChoice(chooseDirection(pools.perp));
} else if (!pools.uturn.empty()) {
applyChoice(chooseDirection(pools.uturn));
}
return;
}
// Branca creuament: si aquest frame travessa la línia central d'un // Branca creuament: si aquest frame travessa la línia central d'un
// corredor perpendicular, amarra la caixa al centre del creuament i // corredor perpendicular, amarra la caixa al centre del creuament i
@@ -495,6 +521,9 @@ void Mummy::applyChoice(Direction dir) {
} }
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{
@@ -519,6 +548,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_;
@@ -612,8 +645,20 @@ void Mummy::hurtStep(float delta_time) {
return; return;
} }
// Topa amb mur o sarc: surt del HURT immediatament. La IA normal // Topa amb mur del playfield: amarra la BOX a la vora exacta abans de
// re-decideix al pròxim step (no fem U-turn ni re-orientació aquí). // transitar a ROAMING. Sense aquest snap, el drift sub-pixel acumulat a
// la cursa (DX = -hurt_speed·dt rarament és enter al joc real, on dt ve
// de SDL_GetTicks) deixaria la BOX a un offset de 0.x px dins de
// l'última cel·la. Com que la BOX és 16×16 i els passadissos també ho
// són, qualsevol drift fa que la cantonada sobresurti cap als sarcs
// adjacents — i el `chooseHurtDirection` del proper fueteig classifica
// les perpendiculars com a bloquejades per micro-overlap, retornant
// NONE i deixant la mòmia eternament quieta. Sarc-collision durant HURT
// és un cas molt menys habitual (la cursa va sempre per corredor recte);
// si arriba a passar, l'snap al playfield no l'arregla però la branca
// de recovery a `step()` el cobreix.
if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); }
state_ = State::ROAMING; state_ = State::ROAMING;
hurt_remaining_s_ = 0.0F; hurt_remaining_s_ = 0.0F;
} }
+29 -8
View File
@@ -9,6 +9,7 @@
#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/player.hpp" #include "game/entities/player.hpp"
#include "game/gameplay_config.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
namespace { namespace {
@@ -174,9 +175,16 @@ void WhipAttack::computeDamageRect(Direction dir, float spawn_x, float spawn_y,
// d'atac, fins a topar amb un mur o sarc, o esgotar `range_px`. // d'atac, fins a topar amb un mur o sarc, o esgotar `range_px`.
// L'amplada perpendicular és `tile_size` (els passadissos del joc // L'amplada perpendicular és `tile_size` (els passadissos del joc
// tenen exactament aquesta mida i l'atac és lateral). // tenen exactament aquesta mida i l'atac és lateral).
//
// Si el tweak `whip.damage_pierces_walls` està actiu, la hitbox
// ignora els sarcòfags (passa a través del bloc complet) però
// continua respectant el `insidePlayfield`: fora de la sala no hi
// ha res a colpejar i ampliar-ho generaria visuals/edge cases
// estranys. El mur extern, doncs, sempre retalla.
const auto TILE = static_cast<float>(Map::tile_size); const auto TILE = static_cast<float>(Map::tile_size);
const int MAX_PX = static_cast<int>(std::max(0.0F, range_px)); const int MAX_PX = static_cast<int>(std::max(0.0F, range_px));
const auto MAX = static_cast<float>(MAX_PX); const auto MAX = static_cast<float>(MAX_PX);
const bool PIERCES = GameplayConfig::get().whipDamagePiercesWalls();
float length = 0.0F; float length = 0.0F;
auto try_probe = [&](float l) -> bool { auto try_probe = [&](float l) -> bool {
@@ -198,7 +206,9 @@ void WhipAttack::computeDamageRect(Direction dir, float spawn_x, float spawn_y,
probe = {.x = spawn_x, .y = spawn_y + (MAX - l), .w = TILE, .h = l}; probe = {.x = spawn_x, .y = spawn_y + (MAX - l), .w = TILE, .h = l};
break; break;
} }
return Map::insidePlayfield(probe) && !Map::collides(probe); if (!Map::insidePlayfield(probe)) { return false; }
if (PIERCES) { return true; }
return !Map::collides(probe);
}; };
for (int i = 1; i <= MAX_PX; ++i) { for (int i = 1; i <= MAX_PX; ++i) {
@@ -289,19 +299,22 @@ namespace {
// Tracta una mòmia candidata: si és whippable i la seua caja solapa // Tracta una mòmia candidata: si és whippable i la seua caja solapa
// amb `damage_rect`, la posa a HURT i la marca al set. Extret per // amb `damage_rect`, la posa a HURT i la marca al set. Extret per
// acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar). // acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar).
// Rep un `DynamicHazard*` (l'iterador d'`applyDamage` ja filtra el
// sac d'actors); només cal el dynamic_cast a Mummy per descartar
// altres hermanes (RollingRock i futurs DynamicHazards no whippables).
void hitMummyIfApplicable( void hitMummyIfApplicable(
Entity* e, DynamicHazard* actor,
const SDL_FRect& damage_rect, const SDL_FRect& damage_rect,
WhipAttack::Direction dir, WhipAttack::Direction dir,
std::unordered_set<void*>& already_hit) { std::unordered_set<void*>& already_hit) {
if (e == nullptr || e->isDead()) { return; } if (actor == nullptr || actor->isDead()) { return; }
if (already_hit.contains(static_cast<void*>(e))) { return; } if (already_hit.contains(static_cast<void*>(actor))) { return; }
auto* mummy = dynamic_cast<Mummy*>(e); auto* mummy = dynamic_cast<Mummy*>(actor);
if (mummy == nullptr || !mummy->isWhippable()) { return; } if (mummy == nullptr || !mummy->isWhippable()) { return; }
const auto BOX = mummy->getCollisionRect(); const auto BOX = mummy->getCollisionRect();
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; } if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
mummy->enterHurt(whipDirToMummyDir(dir)); mummy->enterHurt(whipDirToMummyDir(dir));
already_hit.insert(static_cast<void*>(e)); already_hit.insert(static_cast<void*>(actor));
} }
void hitPlayerIfApplicable( void hitPlayerIfApplicable(
@@ -311,6 +324,10 @@ namespace {
WhipAttack::Direction dir, WhipAttack::Direction dir,
std::unordered_set<void*>& already_hit) { std::unordered_set<void*>& already_hit) {
if (p == owner || !playerIsTargetable(p)) { return; } if (p == owner || !playerIsTargetable(p)) { return; }
// Tweak `combat.friendly_fire`: si està desactivat, els altres
// jugadors són immunes al látigo aliat. L'owner ja queda exclòs
// per la primera guarda; aquest filtre cobreix només cross-player.
if (!GameplayConfig::get().friendlyFire()) { return; }
if (already_hit.contains(static_cast<void*>(p))) { return; } if (already_hit.contains(static_cast<void*>(p))) { return; }
const auto BOX = p->getBaseBox(); const auto BOX = p->getBaseBox();
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; } if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
@@ -322,8 +339,12 @@ namespace {
void WhipAttack::applyDamage() { void WhipAttack::applyDamage() {
if (entity_manager_ != nullptr) { if (entity_manager_ != nullptr) {
for (auto* e : entity_manager_->getEntities()) { // Només els actors poden rebre el fuetejo (decoracions, sarcs,
hitMummyIfApplicable(e, damage_rect_, direction_, already_hit_); // forats... no són hits del whip). Iterem el sac `actors_`
// directament — sense recórrer tot el mapa per descartar-ne la
// major part.
for (auto* actor : entity_manager_->getActors()) {
hitMummyIfApplicable(actor, damage_rect_, direction_, already_hit_);
} }
} }
if (players_ != nullptr) { if (players_ != nullptr) {
+60 -19
View File
@@ -74,7 +74,19 @@ auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
entity->setEventBus(&bus_); entity->setEventBus(&bus_);
entity->setEntityManager(this); entity->setEntityManager(this);
Entity* raw = entity.get(); Entity* raw = entity.get();
entities_.push_back(std::move(entity)); // Enrutament per categoria semàntica: si l'entitat és (o deriva de)
// DynamicHazard va al sac `actors_`, la resta al sac `map_entities_`.
// Així els consumidors que només volen actors itera el sac petit i
// ens estalviem dynamic_casts dispersos al codi (colisió, whip...).
if (dynamic_cast<DynamicHazard*>(entity.get()) != nullptr) {
// Transferim l'ownership al unique_ptr<DynamicHazard> tipat fort.
// El `release()` consumeix el seu valor com a argument del ctor —
// sense crida nua que el linter pugui marcar com a ignorada.
std::unique_ptr<DynamicHazard> hazard{static_cast<DynamicHazard*>(entity.release())};
actors_.push_back(std::move(hazard));
} else {
map_entities_.push_back(std::move(entity));
}
// cppcheck-suppress returnDanglingLifetime // cppcheck-suppress returnDanglingLifetime
return raw; return raw;
} }
@@ -91,9 +103,15 @@ 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"); const bool WAS_SARC = (observer->getName() == "sarcophagus");
auto it = std::ranges::find_if(entities_, [observer](const auto& up) { return up.get() == observer; }); // Busquem en els dos sacs. El sarc i la door sempre viuen a
if (it == entities_.end()) { return false; } // `map_entities_` per disseny; un actor (DynamicHazard) viu a `actors_`.
entities_.erase(it); if (auto it = std::ranges::find_if(map_entities_, [observer](const auto& up) { return up.get() == observer; }); it != map_entities_.end()) {
map_entities_.erase(it);
} else if (auto it_a = std::ranges::find_if(actors_, [observer](const auto& up) { return up.get() == observer; }); it_a != actors_.end()) {
actors_.erase(it_a);
} else {
return false;
}
if (WAS_DOOR) { door_ = nullptr; } if (WAS_DOOR) { door_ = nullptr; }
if (WAS_SARC && total_sarcs_ > 0) { --total_sarcs_; } 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
@@ -107,7 +125,8 @@ auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcoph
sarc->setEventBus(&bus_); sarc->setEventBus(&bus_);
sarc->setEntityManager(this); sarc->setEntityManager(this);
Sarcophagus* raw = sarc.get(); Sarcophagus* raw = sarc.get();
entities_.push_back(std::move(sarc)); // El sarc és Interactable, viu al sac `map_entities_`.
map_entities_.push_back(std::move(sarc));
++total_sarcs_; ++total_sarcs_;
// cppcheck-suppress returnDanglingLifetime // cppcheck-suppress returnDanglingLifetime
return raw; return raw;
@@ -115,30 +134,34 @@ auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcoph
void EntityManager::update(float delta_time) { void EntityManager::update(float delta_time) {
// ATENCIÓ: si en el futur una entitat publica al bus events que // ATENCIÓ: si en el futur una entitat publica al bus events que
// resulten en `entity_manager_.add()` (push_back a `entities_`), açò // resulten en `entity_manager_.add()` (push_back als sacs), açò
// invalidaria l'iterador de la ranged-for. Avui no passa: els spawns // invalidaria l'iterador de la ranged-for. Avui no passa: els spawns
// runtime venen de fora del cicle d'`update` (p.ex. el polvet del // runtime venen de fora del cicle d'`update` (p.ex. el polvet del
// saltador es spawneja durant `Player::tick`, abans d'aquest mètode). // saltador es spawneja durant `Player::tick`, abans d'aquest mètode).
for (auto& e : entities_) { e->update(delta_time); } for (auto& e : map_entities_) { e->update(delta_time); }
for (auto& a : actors_) { a->update(delta_time); }
// Purga d'entitats marcades mortes aquest frame (efectes acabats, // Purga d'entitats marcades mortes aquest frame (efectes acabats,
// p.ex. polvet d'aterratge). Les persistents (sarcs, momies, etc.) // p.ex. polvet d'aterratge). Les persistents (sarcs, momies, etc.)
// mai no es marquen. // mai no es marquen.
std::erase_if(entities_, [](const auto& e) { return e->isDead(); }); std::erase_if(map_entities_, [](const auto& e) { return e->isDead(); });
std::erase_if(actors_, [](const auto& a) { return a->isDead(); });
} }
void EntityManager::notifyPlayerTilePainted(int col, int row) { void EntityManager::notifyPlayerTilePainted(int col, int row) {
for (auto& e : entities_) { e->notifyTilePainted(col, row); } // Fan-out als dos sacs. Avui només Sarcophagus consumeix (viu a
// map_entities_), però els actors també reben la notificació per
// si en el futur volen reaccionar (cost: una crida virtual buida).
for (auto& e : map_entities_) { e->notifyTilePainted(col, row); }
for (auto& a : actors_) { a->notifyTilePainted(col, row); }
} }
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 sobre `map_entities_`: els floor decals trigger són
// desenes d'entitats per sala i una crida per frame des dels // exclusivament StaticHazards (Hole; en el futur pinchos plans,
// consumidors actius (Player a l'aterratge, Mòmia a la decisió de // plaques, charcos). Els actors no qualifiquen com a decals — saltem
// direcció). Si esdevé calent, es podria indexar per cel·la del // directament aquest sac, sense `triggersOnOverlap`/`isFloorDecal` per
// tilemap. La doble condició filtra exclusivament decals de sòl que // a cada un. La doble condició filtra dins de map_entities_.
// volen rebre triggers — avui Hole; en el futur pinchos plans, for (const auto& entity : map_entities_) {
// plaques de pressió, charcos.
for (const auto& entity : entities_) {
if (!entity->triggersOnOverlap()) { continue; } if (!entity->triggersOnOverlap()) { continue; }
if (!entity->isFloorDecal()) { continue; } if (!entity->isFloorDecal()) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
@@ -149,9 +172,27 @@ auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Ent
} }
auto EntityManager::getEntities() const -> std::vector<Entity*> { auto EntityManager::getEntities() const -> std::vector<Entity*> {
// Concatenació map + actors per retro-compat (editor, z-sort debug).
// Els callers que ja saben quin sac volen poden cridar
// `getMapEntities` o `getActors` i estalviar la construcció del temp.
std::vector<Entity*> out; std::vector<Entity*> out;
out.reserve(entities_.size()); out.reserve(map_entities_.size() + actors_.size());
std::ranges::transform(entities_, std::back_inserter(out), [](const auto& e) { return e.get(); }); std::ranges::transform(map_entities_, std::back_inserter(out), [](const auto& e) { return e.get(); });
std::ranges::transform(actors_, std::back_inserter(out), [](const auto& a) -> Entity* { return a.get(); });
return out;
}
auto EntityManager::getMapEntities() const -> std::vector<Entity*> {
std::vector<Entity*> out;
out.reserve(map_entities_.size());
std::ranges::transform(map_entities_, std::back_inserter(out), [](const auto& e) { return e.get(); });
return out;
}
auto EntityManager::getActors() const -> std::vector<DynamicHazard*> {
std::vector<DynamicHazard*> out;
out.reserve(actors_.size());
std::ranges::transform(actors_, std::back_inserter(out), [](const auto& a) { return a.get(); });
return out; return out;
} }
+47 -2
View File
@@ -9,6 +9,7 @@
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/entities/hazard.hpp"
class Entity; class Entity;
class Sarcophagus; class Sarcophagus;
@@ -74,7 +75,13 @@ class EntityManager {
// 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 construir una llista ordenada (z-order).
// `getEntities()` concatena els dos sacs interns (mapa + actors) per
// a consumidors retro-compat (editor, z-sort debug). `getMapEntities`
// i `getActors` exposen cada sac per separat — útil per a iteracions
// que ja saben quin tipus volen i estalvien filtres O(n).
[[nodiscard]] auto getEntities() const -> std::vector<Entity*>; [[nodiscard]] auto getEntities() const -> std::vector<Entity*>;
[[nodiscard]] auto getMapEntities() const -> std::vector<Entity*>;
[[nodiscard]] auto getActors() const -> std::vector<DynamicHazard*>;
// Comprova contacte del(s) jugador(s) amb entitats `collides()=true`. // Comprova contacte del(s) jugador(s) amb entitats `collides()=true`.
// Si la caixa base del jugador solapa amb la caixa de col·lisió de // Si la caixa base del jugador solapa amb la caixa de col·lisió de
@@ -119,6 +126,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
@@ -136,7 +160,8 @@ class EntityManager {
void setExpectedPlayerCount(int n); void setExpectedPlayerCount(int n);
[[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 map_entities_.size() + actors_.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_; }
@@ -150,7 +175,27 @@ class EntityManager {
private: private:
EventBus bus_; EventBus bus_;
std::vector<std::unique_ptr<Entity>> entities_; // Dos sacs lògics:
// `map_entities_` — decoració, static_hazards (Hole, FloorTrap,
// WallTrap) i interactables (Sarcophagus, Door).
// Cargades al loadFromLevel i persistents durant
// tota la sala.
// `actors_` — DynamicHazards (Mummy, RollingRock, futurs
// enemics). Separats per eliminar dynamic_casts
// i preparar el spawn diferit (commit posterior).
// `add()` enruta automàticament segons sigui o no DynamicHazard.
std::vector<std::unique_ptr<Entity>> map_entities_;
std::vector<std::unique_ptr<DynamicHazard>> actors_;
// 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};
+128 -43
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,64 @@ void EntityManager::renderEntity(const Entity& e) {
} }
void EntityManager::renderAll() { void EntityManager::renderAll() {
for (const auto& e : entities_) { renderEntity(*e); } // Sense ordenació per z: l'ordre és el d'inserció dins de cada sac.
// El render del joc real (GameScene) construeix el seu propi vector
// ordenat per `feet_y`; aquest helper queda per al editor i tests.
for (const auto& e : map_entities_) { renderEntity(*e); }
for (const auto& a : actors_) { renderEntity(*a); }
} }
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); }
} }
};
// Sac de mapa: filtre per col·lisió / trigger. Tanquem el gap latent
// de FloorTrap/WallTrap (que tenien `collides_=false` i mai no es
// disparaven) i obrim la porta a hazards no-físics com `Hole`.
for (const auto& entity : map_entities_) {
if (!entity->collides() && !entity->triggersOnOverlap()) { continue; }
apply_contacts(entity.get());
}
// Sac d'actors (DynamicHazards): tots fan dany quan són actius.
// Saltem els que estan en SPAWN: visibles però sense interacció
// durant la fase inicial de ROOM_INTRO i durant futurs spawns en
// runtime. Sense dynamic_cast: el tipus ja és DynamicHazard.
for (const auto& actor : actors_) {
if (actor->isSpawning()) { continue; }
if (!actor->collides() && !actor->triggersOnOverlap()) { continue; }
apply_contacts(actor.get());
} }
} }
@@ -202,6 +215,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.
@@ -382,7 +413,9 @@ void EntityManager::reload(const std::string& level_yaml_path) {
// subscripcions implícites desapareixen amb la seua vida (cap d'elles // subscripcions implícites desapareixen amb la seua vida (cap d'elles
// 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(); map_entities_.clear();
actors_.clear();
pending_actors_.clear();
door_ = nullptr; door_ = nullptr;
total_sarcs_ = 0; total_sarcs_ = 0;
opened_sarcs_ = 0; opened_sarcs_ = 0;
@@ -419,8 +452,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,7 +478,9 @@ 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;
for (const auto& entity : entities_) { // Els decals de sòl (Hole, futurs pinchos plans...) són StaticHazards
// i viuen exclusivament a `map_entities_`. Saltem `actors_` per estalviar.
for (const auto& entity : map_entities_) {
if (!entity->isFloorDecal()) { continue; } if (!entity->isFloorDecal()) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
const auto [COL, ROW] = Map::pixelToTile(BOX.x, BOX.y); const auto [COL, ROW] = Map::pixelToTile(BOX.x, BOX.y);
@@ -447,5 +495,42 @@ 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", "{} map entities + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path);
}
void EntityManager::spawnPendingActors() {
// Itera els pending i els instancia via `addSpawn`. El dispatch d'`add()`
// els enruta a `actors_`. El `triggerSpawnExit()` posterior arma el
// comptador de SPAWN — la mòmia neix ja en EMERGING, sense esperes.
for (const auto& pa : pending_actors_) {
Entity* observer = addSpawn(pa.spawn_node);
if (observer == nullptr) { continue; }
// Per categoria YAML (`type: dynamic_hazard`) sabem que és
// DynamicHazard — el dynamic_cast aquí val zero i evita
// assumptions implícites sobre la jerarquia.
if (auto* dh = dynamic_cast<DynamicHazard*>(observer); dh != nullptr) {
dh->triggerSpawnExit();
}
}
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; }
auto* dh = dynamic_cast<DynamicHazard*>(observer);
if (dh != nullptr) { dh->triggerSpawnExit(); }
return dh;
} }
+46
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,21 @@ 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; }
[[nodiscard]] auto isSpawning() const -> bool { return spawn_state_ == SpawnState::SPAWNING; }
[[nodiscard]] auto getSpawnDurationS() const -> float { return spawn_duration_s_; }
// 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 +108,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 +129,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};
}; };
+42 -5
View File
@@ -22,6 +22,7 @@
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
#include "game/entities/entity_manager.hpp" #include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/gameplay_config.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
@@ -414,6 +415,10 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
// al check actor↔actor més avall via el predicat `isOutOfPlay`. // al check actor↔actor més avall via el predicat `isOutOfPlay`.
if (isOutOfPlay()) { return false; } if (isOutOfPlay()) { return false; }
if (map_.collides(box)) { return true; } if (map_.collides(box)) { return true; }
// Tweak `players.collide_with_each_other`: si està desactivat, els
// jugadors es travessen lliurement. La col·lisió contra el món
// (murs/sarcs) ja s'ha avaluat dalt; saltem només la part actor↔actor.
if (!GameplayConfig::get().playersCollide()) { return false; }
if (all_players_ == nullptr) { return false; } if (all_players_ == nullptr) { return false; }
// Per al contacte actor-actor usem la hit-box petita a banda i banda: // Per al contacte actor-actor usem la hit-box petita a banda i banda:
// projectem la hit-box des de la posició base proposada i la comparem // projectem la hit-box des de la posició base proposada i la comparem
@@ -1027,6 +1032,12 @@ void Player::resolveLandingOverlap() {
// canvi, qualsevol jugador amb el qual solape entra en HURT i // canvi, qualsevol jugador amb el qual solape entra en HURT i
// surt projectat. Així el solapament inicial es resol per moviment // surt projectat. Així el solapament inicial es resol per moviment
// del solapat, no per desplaçament forçat al lloc. // del solapat, no per desplaçament forçat al lloc.
//
// Tweak `players.collide_with_each_other`: si està desactivat, el
// saltador travessa l'altre net en lloc de projectar-lo. Aquest
// camí és independent de `collidesWithWorld`, així que cal aturar-lo
// explícitament aquí.
if (!GameplayConfig::get().playersCollide()) { return; }
if (all_players_ == nullptr) { return; } if (all_players_ == nullptr) { return; }
for (Player* other : *all_players_) { for (Player* other : *all_players_) {
if (other == nullptr || other == this) { continue; } if (other == nullptr || other == this) { continue; }
@@ -1259,11 +1270,30 @@ void Player::applyDyingMovement(float delta_time) {
} }
} }
void Player::resetForJoin() {
lives_remaining_ = death_config.initial_lives;
}
void Player::respawnFromDying() { void Player::respawnFromDying() {
// Vides: decreix sempre. Pot anar a negatius — no hi ha game over. // Vides: decreix una unitat. Si el comptador arriba a 0 el jugador
// surt de partida (DISABLED) i la GameScene/HUD reaccionen via bus
// — això habilita el flux de "join in progress" (l'altre jugador
// pot pulsar START per entrar) i obrirà el camí al CONTINUE? +
// GAME OVER en una fase posterior.
--lives_remaining_; --lives_remaining_;
death_animation_complete_ = false; death_animation_complete_ = false;
respawn_remaining_ms_ = 0.0F; respawn_remaining_ms_ = 0.0F;
if (lives_remaining_ <= 0) {
disable();
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_disabled",
.payload = std::monostate{},
.player_id = id_,
});
}
return;
}
// Cinemàtica idèntica a la d'INTRO de sala: el jugador cau des de // Cinemàtica idèntica a la d'INTRO de sala: el jugador cau des de
// dalt fins al passadís d'spawn (`spawnFromAir` reseteja primer i // dalt fins al passadís d'spawn (`spawnFromAir` reseteja primer i
// després posa AIRBORNE amb l'altura derivada de la geometria). // després posa AIRBORNE amb l'altura derivada de la geometria).
@@ -1559,10 +1589,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);
} }
@@ -1737,6 +1770,10 @@ auto Player::tryPushSnapBlockers(
float (Player::*get_pos)() const, float (Player::*get_pos)() const,
void (Player::*set_pos)(float)) -> bool { void (Player::*set_pos)(float)) -> bool {
if (all_players_ == nullptr) { return false; } if (all_players_ == nullptr) { return false; }
// Si el tweak de col·lisió entre jugadors està desactivat, mai hi
// ha res a empentar — els jugadors són intangibles entre si i el
// snap pot procedir lliurement sense desplaçar ningú.
if (!GameplayConfig::get().playersCollide()) { return false; }
// Blockers = jugadors que ara mateix solapen la nostra hit-box (ja // Blockers = jugadors que ara mateix solapen la nostra hit-box (ja
// amb el snap aplicat). Mateix criteri actor-actor que `collidesWithWorld`. // amb el snap aplicat). Mateix criteri actor-actor que `collidesWithWorld`.
+31 -11
View File
@@ -127,6 +127,11 @@ class Player : public AnimatedSprite {
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
static void loadFrom(const std::string& yaml_path); static void loadFrom(const std::string& yaml_path);
// Vides amb què arrenca un jugador (camp `lives:` del YAML del
// jugador). El consumeix el HUD per inicialitzar el comptador del
// marcador i `Player::resetForJoin` per a re-entrades mid-partida.
[[nodiscard]] static auto getInitialLives() -> int { return death_config.initial_lives; }
Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1); Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1);
// Update en una sola crida: manté l'API anterior per als tests i // Update en una sola crida: manté l'API anterior per als tests i
@@ -296,8 +301,11 @@ class Player : public AnimatedSprite {
// `death_spinning`, posició congelada, input ignorat. Idempotent — // `death_spinning`, posició congelada, input ignorat. Idempotent —
// si ja està DYING, EXITED o DISABLED, és no-op (un segon toc de // si ja està DYING, EXITED o DISABLED, és no-op (un segon toc de
// momia mentre la roda no la reinicia). Quan l'animació acaba i // momia mentre la roda no la reinicia). Quan l'animació acaba i
// `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a // `respawn_delay_ms` expira, `respawnFromDying()` decrementa
// GROUNDED i `lives_remaining_` decreix en 1 (pot anar a negatius). // `lives_remaining_`: si encara queden vides, fa `spawnFromAir`
// al spawn point; si arriba a 0, fa `disable()` i publica al bus
// `player_disabled` perquè GameScene/HUD reaccionin (slot fora de
// partida, esperant un nou START per re-entrar via "join in progress").
void enterDying(DeathCause cause = DeathCause::GENERIC); void enterDying(DeathCause cause = DeathCause::GENERIC);
// Coordenades del spawn de respawn (cantó superior-esquerra del // Coordenades del spawn de respawn (cantó superior-esquerra del
@@ -309,9 +317,11 @@ class Player : public AnimatedSprite {
spawn_y_ = y; spawn_y_ = y;
} }
// Comptador d'intents (estil Mario, NO HP). Decreix a cada respawn; // Comptador d'intents (estil Mario, NO HP). Decreix a cada respawn
// no es clampa a 0 — permet anar a negatius perquè avui no hi ha // post-mort. En arribar a 0 el jugador passa a DISABLED i publica
// game over. Públic per a HUD i tests. // `player_disabled` al bus; pot tornar a partida via "join in
// progress" (GameScene::joinPlayer crida `resetForJoin`). Públic
// per a HUD i tests.
[[nodiscard]] auto getLivesRemaining() const -> int { return lives_remaining_; } [[nodiscard]] auto getLivesRemaining() const -> int { return lives_remaining_; }
// Activa la immunitat per `duration_ms` ms. Mentre `isImmune()` és // Activa la immunitat per `duration_ms` ms. Mentre `isImmune()` és
@@ -321,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
@@ -342,6 +352,14 @@ class Player : public AnimatedSprite {
// sala usa `disable()` + `spawnFromAir()`. // sala usa `disable()` + `spawnFromAir()`.
void respawn(float x, float y); void respawn(float x, float y);
// Restaura el comptador de vides al valor inicial configurat
// (`death_config.initial_lives`). Cridat per la GameScene quan un
// slot inactiu pulsa START per entrar mid-partida (regla 6 de la
// FSM de joc): el jugador entra amb les vides plenes. La resta de
// l'estat (posició, animació, flags) la reseteja `spawnFromAir`
// que GameScene crida tot seguit.
void resetForJoin();
// Posa el jugador en el slot inactiu (DISABLED). Idempotent: la // Posa el jugador en el slot inactiu (DISABLED). Idempotent: la
// segona crida és no-op. Mateix patró que `exit()` per simetria. // segona crida és no-op. Mateix patró que `exit()` per simetria.
// No publica res al bus — el GameScene decideix quan disparar el // No publica res al bus — el GameScene decideix quan disparar el
@@ -829,8 +847,10 @@ class Player : public AnimatedSprite {
// Comptador d'intents (estil Mario). Inicialitzat a `death_config. // Comptador d'intents (estil Mario). Inicialitzat a `death_config.
// initial_lives` al ctor; decreix una vegada per cada respawn // initial_lives` al ctor; decreix una vegada per cada respawn
// post-mort (no per respawns de canvi de sala). Pot anar a negatius: // post-mort (no per respawns de canvi de sala). En arribar a 0,
// avui no hi ha game over. // `respawnFromDying` posa el jugador a DISABLED i publica
// `player_disabled` al bus. `resetForJoin()` el restaura al valor
// inicial quan el jugador re-entra mid-partida.
int lives_remaining_{0}; int lives_remaining_{0};
// Coordenades del spawn de respawn (cantó superior-esquerra del // Coordenades del spawn de respawn (cantó superior-esquerra del
+163
View File
@@ -0,0 +1,163 @@
#include "game/gameplay_config.hpp"
#include <exception>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace {
auto readFile(const std::string& path) -> std::string {
std::ifstream file(path);
if (!file) { return {}; }
std::stringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}
// Llig una subclau booleana obligatòria (`section.key`). Retorna
// false si la secció o la clau no hi són o el tipus no és bool —
// qualsevol d'aquests casos és una irregularitat que dispara
// reescriptura del fitxer.
auto readRequiredBool(const fkyaml::node& root, const char* section, const char* key, bool& out)
-> bool {
if (!root.contains(section)) { return false; }
const auto& sec = root[section];
if (!sec.contains(key)) { return false; }
try {
out = sec[key].get_value<bool>();
return true;
} catch (const std::exception&) {
return false;
}
}
// Escriu el fitxer amb els valors actuals + comentaris d'ajuda.
// Format estable perquè l'usuari pot editar-lo a mà.
void writeFile(const std::string& path,
int version,
bool whip_pierces,
bool friendly_fire,
bool players_collide) {
std::ofstream file(path);
if (!file) {
Log::error("gameplay.yaml", "no s'ha pogut escriure {}", path);
return;
}
file << "# gameplay.yaml — tweaks de jugabilitat persistents.\n";
file << "# Edita des del menú de servei (F9 → Tweaks) o a mà.\n";
file << "# Si esborres el fitxer (o aquesta capçalera de versió), es regenerarà\n";
file << "# amb els defaults a la propera arrencada.\n";
file << "\n";
file << "version: " << version << "\n";
file << "\n";
file << "whip:\n";
file << " damage_pierces_walls: " << (whip_pierces ? "true" : "false") << "\n";
file << "\n";
file << "combat:\n";
file << " friendly_fire: " << (friendly_fire ? "true" : "false") << "\n";
file << "\n";
file << "players:\n";
file << " collide_with_each_other: " << (players_collide ? "true" : "false") << "\n";
}
} // namespace
auto GameplayConfig::get() -> GameplayConfig& {
static GameplayConfig instance_;
return instance_;
}
void GameplayConfig::load(const std::string& file_path) {
file_path_ = file_path;
std::error_code error;
const bool EXISTS = std::filesystem::exists(file_path, error);
const auto CONTENT = EXISTS ? readFile(file_path) : std::string{};
// Tots els valors van a temporaris; només ens els quedem si la
// validació completa passa. Així una validació parcial (p. ex. dues
// claus bones i una de dolenta) no deixa l'estat a mig camí.
bool whip_pierces = whip_damage_pierces_walls_;
bool ff = friendly_fire_;
bool players_collide = players_collide_;
auto rewrite_with_defaults = [&](const char* reason) {
// Resetem els camps als defaults compilats abans de reescriure.
// Necessari si load() es crida diverses vegades amb fitxers
// distints; el primer pot haver mutat l'estat.
whip_damage_pierces_walls_ = true;
friendly_fire_ = true;
players_collide_ = true;
writeFile(file_path,
CURRENT_VERSION,
whip_damage_pierces_walls_,
friendly_fire_,
players_collide_);
Log::info("gameplay.yaml", "{} — reescrit a {} amb defaults", reason, file_path);
};
if (!EXISTS) {
rewrite_with_defaults("fitxer absent");
return;
}
if (CONTENT.empty()) {
rewrite_with_defaults("fitxer buit");
return;
}
fkyaml::node yaml;
try {
yaml = fkyaml::node::deserialize(CONTENT);
} catch (const std::exception& e) {
rewrite_with_defaults((std::string("parse error: ") + e.what()).c_str());
return;
}
// `version` ha de ser un enter present i igual a CURRENT_VERSION.
// Qualsevol altra cosa (absent, tipus no enter, valor diferent) és
// una irregularitat: el fitxer ve d'una versió incompatible o ha
// estat manipulat de manera inesperada.
if (!yaml.contains("version")) {
rewrite_with_defaults("version absent");
return;
}
int version = 0;
try {
version = yaml["version"].get_value<int>();
} catch (const std::exception&) {
rewrite_with_defaults("version no és enter");
return;
}
if (version != CURRENT_VERSION) {
rewrite_with_defaults("version desconeguda");
return;
}
if (!readRequiredBool(yaml, "whip", "damage_pierces_walls", whip_pierces) || !readRequiredBool(yaml, "combat", "friendly_fire", ff) || !readRequiredBool(yaml, "players", "collide_with_each_other", players_collide)) {
rewrite_with_defaults("clau requerida absent o de tipus incorrecte");
return;
}
// Tot validat: ens quedem els valors llegits.
whip_damage_pierces_walls_ = whip_pierces;
friendly_fire_ = ff;
players_collide_ = players_collide;
}
void GameplayConfig::save() const {
if (file_path_.empty()) {
// Sense path establert: cap consumidor s'ha registrat encara.
// Pot passar en tests que no carreguen el fitxer; silenciem el cas.
return;
}
writeFile(file_path_,
CURRENT_VERSION,
whip_damage_pierces_walls_,
friendly_fire_,
players_collide_);
}
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include <string>
// Tweaks de jugabilitat persistents per usuari. Carregat de
// `gameplay.yaml` dins la carpeta de sistema (vegeu
// `Director::createSystemFolder` per la ubicació real per plataforma).
//
// Diferent d'UiConfig (data/config/ui.yaml, només lectura): aquest fitxer
// és escrivible i el ServiceMenu en modifica els valors. Els defaults
// compilats són els que veuen els nous usuaris fins que toquen el menú.
//
// Política d'integritat: davant qualsevol irregularitat (fitxer absent,
// parse error, `version` absent o diferent, clau requerida absent o de
// tipus incorrecte), `load()` reseteja a defaults i reescriu el fitxer.
// No es preserven fitxers parcialment vàlids — així evitem dragar
// versions antigues incoherents quan l'esquema evolucioni.
class GameplayConfig {
public:
// Esquema actual del fitxer. Incrementar quan canviï l'estructura
// (renomenar/treure claus o canviar semàntica). Els fitxers d'usuari
// amb una `version` distinta es reescriuen amb els defaults nous.
// Afegir noves claus opcionals no requereix bump si els defaults
// compilats són equivalents al comportament anterior.
static constexpr int CURRENT_VERSION = 1;
static auto get() -> GameplayConfig&;
GameplayConfig(const GameplayConfig&) = delete;
GameplayConfig(GameplayConfig&&) = delete;
auto operator=(const GameplayConfig&) -> GameplayConfig& = delete;
auto operator=(GameplayConfig&&) -> GameplayConfig& = delete;
// Llig el fitxer i guarda la ruta per a `save()`. Si no existeix,
// l'escriu amb els defaults compilats. Idempotent: pot cridar-se
// múltiples vegades (l'última crida fixa el path actiu).
void load(const std::string& file_path);
// Persisteix l'estat actual al path passat a `load()`. Cridat pel
// ServiceMenu a cada canvi de toggle. No-op si `load()` no s'ha
// cridat encara (sense path no podem escriure enlloc útil).
void save() const;
// Tweaks. Defaults: tots true.
//
// - whip_damage_pierces_walls: el dany del látigo travessa sarcòfags
// (NO el mur extern del playfield, que retalla igualment per
// evitar que el rect surti de la sala visible).
// - friendly_fire: un latigazo pot impactar l'altre jugador.
// - players_collide: els jugadors col·lisionen físicament entre si
// en lloc de travessar-se.
[[nodiscard]] auto whipDamagePiercesWalls() const -> bool { return whip_damage_pierces_walls_; }
[[nodiscard]] auto friendlyFire() const -> bool { return friendly_fire_; }
[[nodiscard]] auto playersCollide() const -> bool { return players_collide_; }
void setWhipDamagePiercesWalls(bool v) { whip_damage_pierces_walls_ = v; }
void setFriendlyFire(bool v) { friendly_fire_ = v; }
void setPlayersCollide(bool v) { players_collide_ = v; }
private:
GameplayConfig() = default;
~GameplayConfig() = default;
// Ruta absoluta al fitxer persistit. Buida fins que algú crida
// `load()` per primera vegada; mentre estigui buida, `save()` és no-op.
std::string file_path_;
bool whip_damage_pierces_walls_{true};
bool friendly_fire_{true};
bool players_collide_{true};
};
+167
View File
@@ -0,0 +1,167 @@
#include "game/scenes/game_over_fsm.hpp"
#include <algorithm>
#include <array>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace {
// Llegeix un sub-camp sencer d'un node YAML. Si manca, retorna el
// fallback. Mateix patró que `RoomFsm::loadConfig`.
auto readChildInt(const fkyaml::node& parent, const char* key, int fallback) -> int {
if (!parent.contains(key)) { return fallback; }
return parent[key].get_value<int>();
}
} // namespace
namespace GameOverFsm {
auto loadConfig(const std::string& yaml_path) -> Config {
Config cfg{};
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
Log::warn("GameOverFsm", "{} absent — uso fallbacks compilats", yaml_path);
return cfg;
}
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
if (yaml.contains("game_over")) {
const auto& n = yaml["game_over"];
cfg.continue_text_enter_ms = readChildInt(n, "continue_text_enter_ms", cfg.continue_text_enter_ms);
cfg.continue_text_hold_ms = readChildInt(n, "continue_text_hold_ms", cfg.continue_text_hold_ms);
cfg.continue_text_exit_ms = readChildInt(n, "continue_text_exit_ms", cfg.continue_text_exit_ms);
cfg.continue_count_start = readChildInt(n, "continue_count_start", cfg.continue_count_start);
cfg.game_over_text_hold_ms = readChildInt(n, "game_over_text_hold_ms", cfg.game_over_text_hold_ms);
cfg.game_over_text_exit_ms = readChildInt(n, "game_over_text_exit_ms", cfg.game_over_text_exit_ms);
cfg.game_over_wait_ms = readChildInt(n, "game_over_wait_ms", cfg.game_over_wait_ms);
cfg.fade_out_ms = readChildInt(n, "fade_out_ms", cfg.fade_out_ms);
cfg.black_hold_ms = readChildInt(n, "black_hold_ms", cfg.black_hold_ms);
if (n.contains("continue_music_speed")) {
cfg.continue_music_speed = n["continue_music_speed"].get_value<float>();
}
if (n.contains("game_over_music_track")) {
cfg.game_over_music_track = n["game_over_music_track"].get_value<std::string>();
}
if (n.contains("countdown_sound_key")) {
cfg.countdown_sound_key = n["countdown_sound_key"].get_value<std::string>();
}
cfg.skip_below_or_equal = readChildInt(n, "skip_below_or_equal", cfg.skip_below_or_equal);
}
Log::info("GameOverFsm", "timings carregats des de {} (continue hold {}ms, fade {}ms)", yaml_path, cfg.continue_text_hold_ms, cfg.fade_out_ms);
return cfg;
}
auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State {
if (current == State::OFF || current == State::DONE) { return current; }
t.elapsed_ms += delta_ms;
if (current == State::CONTINUE_PROMPT) {
const std::array<int, 3> DURS = {
cfg.continue_text_enter_ms,
cfg.continue_text_hold_ms,
cfg.continue_text_exit_ms,
};
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase];
// Si acabem la fase 1 (hold) sense cancel·lació, saltem
// directament a GAME_OVER — el panel queda amunt i no
// es reprodueix l'exit anim. La fase 2 (exit) només
// s'executa si el GameScene l'ha forçada amb
// `cancelContinuePrompt`.
if (t.phase == 1) {
return State::GAME_OVER;
}
++t.phase;
}
if (t.phase >= static_cast<int>(DURS.size())) { return State::OFF; }
return current;
}
if (current == State::GAME_OVER) {
const std::array<int, 3> DURS = {
cfg.game_over_text_hold_ms,
cfg.game_over_text_exit_ms,
cfg.game_over_wait_ms,
};
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase];
++t.phase;
}
if (t.phase >= static_cast<int>(DURS.size())) { return State::FADE_OUT; }
return current;
}
// FADE_OUT
const std::array<int, 2> DURS = {
cfg.fade_out_ms,
cfg.black_hold_ms,
};
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase];
++t.phase;
}
if (t.phase >= static_cast<int>(DURS.size())) { return State::DONE; }
return current;
}
void cancelContinuePrompt(State current, Transition& t) {
if (current != State::CONTINUE_PROMPT) { return; }
if (t.phase >= 2) { return; } // ja en exit anim, no-op
t.phase = 2;
t.elapsed_ms = 0;
}
void skipToNextCountdown(State current, Transition& t, const Config& cfg) {
if (current != State::CONTINUE_PROMPT) { return; }
if (t.phase != 1) { return; }
const int START = std::max(0, cfg.continue_count_start);
const int STEP_COUNT = START + 1;
const int STEP_MS = std::max(1, cfg.continue_text_hold_ms / STEP_COUNT);
const int CONSUMED = t.elapsed_ms / STEP_MS;
const int CURRENT = std::clamp(START - CONSUMED, 0, START);
// No saltem si ja som al 0: el següent "número" és la transició
// a GAME_OVER, que `advance` ja gestiona quan el hold s'esgota.
if (CURRENT <= 0) { return; }
t.elapsed_ms = (CONSUMED + 1) * STEP_MS;
}
auto countdownSecondsRemaining(State current, const Transition& t, const Config& cfg) -> int {
if (current != State::CONTINUE_PROMPT) { return 0; }
if (t.phase != 1) { return 0; }
// La sèrie és [start, start-1, ..., 1, 0]: en total `start+1`
// graons. El primer número visible és `start` i l'últim 0 es
// manté durant el seu graó abans que la fase 1 acabi i la FSM
// salti a GAME_OVER. Si `start == 0` només es mostra "0"
// durant tot el hold.
const int START = std::max(0, cfg.continue_count_start);
const int STEP_COUNT = START + 1;
const int STEP_MS = std::max(1, cfg.continue_text_hold_ms / STEP_COUNT);
const int CONSUMED = t.elapsed_ms / STEP_MS;
return std::clamp(START - CONSUMED, 0, START);
}
auto stateName(State s) -> const char* {
switch (s) {
case State::OFF:
return "OFF";
case State::CONTINUE_PROMPT:
return "CONTINUE_PROMPT";
case State::GAME_OVER:
return "GAME_OVER";
case State::FADE_OUT:
return "FADE_OUT";
case State::DONE:
return "DONE";
}
return "?";
}
} // namespace GameOverFsm
+150
View File
@@ -0,0 +1,150 @@
#pragma once
#include <cstdint>
#include <string>
// FSM secundària de la GameScene que governa el flux "tots els jugadors
// fora de partida" — CONTINUE? amb countdown → GAME OVER → fade-to-black
// → transició a LOGO. Viu separada de la `RoomFsm` (que gestiona
// intro/outro de cada sala) perquè els seus disparadors són ortogonals
// i la composició entre les dues queda al GameScene.
//
// Lògica pura: no toca SDL ni Audio, només temporitzadors. La part
// visual (text + panel + fade) viu al GameScene reaprofitant la mateixa
// infraestructura del room text.
namespace GameOverFsm {
// Estats canònics. Phase indices per estat:
// OFF — joc normal; el GameScene transita a
// CONTINUE_PROMPT quan detecta 0 jugadors actius.
// CONTINUE_PROMPT — phases: 0=text_enter, 1=text_hold (countdown
// visible), 2=text_exit (només si l'usuari
// cancel·la pulsant START). Si la fase 1 acaba
// sense cancel·lació → salt a GAME_OVER amb el
// panel encara amunt (sense exit anim).
// GAME_OVER — phases: 0=text_hold (apareix sense enter
// anim, panel ja amunt), 1=text_exit, 2=wait
// (otro tiempo más abans del fade).
// FADE_OUT — phases: 0=fade, 1=black_hold (perquè el
// negre quedi un instant abans de cedir el
// pas a la LogoScene).
// DONE — sentinel: el caller (GameScene) ha de
// demanar la transició cap a LOGO.
enum class State : std::uint8_t {
OFF,
CONTINUE_PROMPT,
GAME_OVER,
FADE_OUT,
DONE,
};
// Sub-fase i temps acumulat dins de l'estat actual. Es reseteja a
// {0, 0} quan es canvia d'estat (`GameScene::setGameOverState`).
struct Transition {
int phase{0};
int elapsed_ms{0};
};
// Timings llegits de `data/scenes/game/game.yaml` (secció
// `game_over:`). Si el fitxer no porta cap d'aquests camps, els
// valors per defecte coincideixen amb els del .yaml versionat.
struct Config {
// CONTINUE? — animació d'entrada/sortida idèntica al room text.
int continue_text_enter_ms{500};
// Hold = duració total del countdown. Es divideix en
// (continue_count_start + 1) graons iguals; el cas canònic és
// 10000 ms / 10 graons d'1 s cadascun.
int continue_text_hold_ms{10000};
int continue_text_exit_ms{500};
// Primer número visible al countdown. La sèrie és
// [continue_count_start, ..., 1, 0] — sempre acaba en 0 i el
// valor 0 es manté visible durant l'últim graó abans del salt
// a GAME_OVER. 9 → "9, 8, ..., 0" (10 graons).
int continue_count_start{9};
// GAME OVER — apareix sense enter anim (el panel ja és amunt
// del CONTINUE?) i s'esvaeix amb la mateixa exit anim que el
// room text. `wait_ms` és el "otro tiempo más" abans del fade.
int game_over_text_hold_ms{2000};
int game_over_text_exit_ms{500};
int game_over_wait_ms{500};
// Fade final cap a LOGO. `black_hold_ms` és el temps amb el
// canvas negre opac abans que el GameScene demani la transició.
int fade_out_ms{500};
int black_hold_ms{500};
// Multiplicador de velocitat de la música mentre dura el
// CONTINUE_PROMPT. 1.0 = sense canvi, >1 = més ràpid (i amb el
// to més agut: és l'efecte arcade clàssic dels comptes enrere).
// Es restableix a 1.0 al sortir del prompt (revive o GAME_OVER).
float continue_music_speed{1.5F};
// Pista de música que sona durant l'estat GAME_OVER (sense loop).
// Si la pista no està carregada al Resource::Cache, s'omet la
// reproducció i la fase manté els timings configurats. La durada
// de la pista determina implícitament `game_over_text_hold_ms`
// (vegeu `GameScene::setGameOverState`).
std::string game_over_music_track{"game_over.ogg"};
// So que sona cada vegada que apareix un nou número del
// countdown (al primer cop que es mostra el `start`, cada
// canvi de número, i quan apareix el 0). NO sona en la
// transició posterior cap a GAME_OVER. Buit = sense so.
std::string countdown_sound_key{"countdown.wav"};
// Llindar a partir del qual qualsevol botó de salt o atac (de
// qualsevol jugador) salta al següent número del countdown.
// Exemple: 7 → mentre el número visible és 7, 6, 5, ..., 1
// (no a 0, que ja no té "següent número") un botó d'acció
// avança al següent. Si el valor és més gran que `start` el
// skip queda desactivat efectivament. -1 = mai.
int skip_below_or_equal{7};
};
// Llegeix la configuració des del YAML. Si el fitxer manca o falta
// alguna clau, els camps no presents conserven els seus fallbacks.
[[nodiscard]] auto loadConfig(const std::string& yaml_path) -> Config;
// Avança la transició consumint `delta_ms` des del temps real.
// Retorna l'estat resultant. Si retorna un estat diferent, el
// caller ha de resetejar la `Transition` a {0, 0} abans del següent
// frame. Funció pura sobre l'argument.
//
// Particularitats:
// - CONTINUE_PROMPT phase 1 (hold) arribar a la fi → GAME_OVER
// (NO reprodueix exit anim; el caller ja té el panel amunt).
// - CONTINUE_PROMPT phase 2 (exit) arribar a la fi → OFF (cas
// "cancel·lació via START": el GameScene ha forçat la fase 2
// amb `cancelContinuePrompt`).
// - GAME_OVER phase 2 (wait) arribar a la fi → FADE_OUT.
// - FADE_OUT phase 1 (black_hold) arribar a la fi → DONE.
// - DONE i OFF no avancen ni canvien d'estat (sumiders).
[[nodiscard]] auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State;
// Salta a la fase 2 (text_exit) del CONTINUE_PROMPT. No fa res si
// l'estat no és CONTINUE_PROMPT o ja som a la fase 2 (idempotent).
// Disparat pel GameScene quan un jugador pulsa START dins del
// countdown — el join real també el fa GameScene; aquesta funció
// només dispara la sortida visual del rètol.
void cancelContinuePrompt(State current, Transition& t);
// Quants segons "queden" del countdown de CONTINUE?. Retorna un
// enter en el rang [0, continue_count_steps] perquè es renderitzi
// com a segona línia del rètol durant la fase 1 (hold). Fora de
// CONTINUE_PROMPT phase 1, retorna 0 (no s'hauria de pintar).
[[nodiscard]] auto countdownSecondsRemaining(State current, const Transition& t, const Config& cfg) -> int;
// Salta al següent número del countdown (decrementa el valor
// visible en una unitat). Només actua dins de CONTINUE_PROMPT
// phase 1 i quan el valor actual és > 0 — al 0 ja no hi ha
// "següent número", la fase ha d'esgotar-se de manera natural i
// el caller es recolza en `advance` per al salt a GAME_OVER. La
// GameScene comprova també el llindar `skip_below_or_equal`
// abans de cridar; aquesta funció, per simplicitat, no en sap.
void skipToNextCountdown(State current, Transition& t, const Config& cfg);
// Nom textual per a logs i tests.
[[nodiscard]] auto stateName(State s) -> const char*;
} // namespace GameOverFsm
+506 -32
View File
@@ -31,10 +31,14 @@
#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/levels/level_manager.hpp" #include "game/levels/level_manager.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "game/scenes/scene_flow.hpp"
#include "game/scenes/start_input.hpp"
#include "game/ui/hud.hpp" #include "game/ui/hud.hpp"
#include "game/ui/service_menu.hpp" #include "game/ui/service_menu.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
@@ -108,6 +112,11 @@ namespace {
auto* a = ps[0]; auto* a = ps[0];
auto* b = ps[1]; auto* b = ps[1];
if (a == nullptr || b == nullptr) { return; } if (a == nullptr || b == nullptr) { return; }
// Tweak `players.collide_with_each_other`: amb el flag desactivat
// saltem TOT el resolver — head-on, push i bounce aeri queden
// anul·lats. La col·lisió contra el món continua intacta perquè
// viu a `Player::collidesWithWorld`/`tryMove`, no aquí.
if (!GameplayConfig::get().playersCollide()) { return; }
const bool A_AIR = a->isAirborne(); const bool A_AIR = a->isAirborne();
const bool B_AIR = b->isAirborne(); const bool B_AIR = b->isAirborne();
@@ -262,7 +271,7 @@ GameScene::GameScene(Config config)
const bool USE_P1 = (config_.starting_players == DebugConfig::StartingPlayers::P1 || config_.starting_players == DebugConfig::StartingPlayers::BOTH); const bool USE_P1 = (config_.starting_players == DebugConfig::StartingPlayers::P1 || config_.starting_players == DebugConfig::StartingPlayers::BOTH);
const bool USE_P2 = (config_.starting_players == DebugConfig::StartingPlayers::P2 || config_.starting_players == DebugConfig::StartingPlayers::BOTH); const bool USE_P2 = (config_.starting_players == DebugConfig::StartingPlayers::P2 || config_.starting_players == DebugConfig::StartingPlayers::BOTH);
players_starts_active_ = {USE_P1, USE_P2}; players_active_ = {USE_P1, USE_P2};
// Carreguem assets per als DOS jugadors sempre, encara que algun no // Carreguem assets per als DOS jugadors sempre, encara que algun no
// arranque actiu: l'slot existeix igualment al vector com a DISABLED // arranque actiu: l'slot existeix igualment al vector com a DISABLED
@@ -305,6 +314,13 @@ GameScene::GameScene(Config config)
entity_manager_.getBus().subscribe("player_killed", [this](const Event& ev) { entity_manager_.getBus().subscribe("player_killed", [this](const Event& ev) {
dispatchPlayerKilled(ev.player_id); dispatchPlayerKilled(ev.player_id);
}); });
// `player_disabled` el publica el Player al final del cicle DYING
// quan `lives_remaining_` arriba a 0. La feina viu a
// `onPlayerDisabled` per evitar pujar la complexitat del ctor; el
// HUD ja s'apunta al mateix event per banda.
entity_manager_.getBus().subscribe("player_disabled", [this](const Event& ev) {
onPlayerDisabled(ev.player_id);
});
// Polvet: precarreguem el surface del Dust una sola vegada perquè // Polvet: precarreguem el surface del Dust una sola vegada perquè
// els spawns runtime de l'entitat no facin disc al primer salt. // els spawns runtime de l'entitat no facin disc al primer salt.
@@ -427,7 +443,7 @@ GameScene::GameScene(Config config)
// tots els jugadors *actius* han travessat la porta. Els slots DISABLED // tots els jugadors *actius* han travessat la porta. Els slots DISABLED
// permanents (single-player) NO compten — d'altra manera la sala mai // permanents (single-player) NO compten — d'altra manera la sala mai
// es completaria, perquè un slot DISABLED no pot eixir per la porta. // es completaria, perquè un slot DISABLED no pot eixir per la porta.
const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true); const int ACTIVE_COUNT = std::ranges::count(players_active_, true);
entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT); entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT);
// El snap també empeny lateralment si l'altre jugador ocupa el carril: // El snap també empeny lateralment si l'altre jugador ocupa el carril:
// passem el mateix factor perquè vaja al mateix ritme que el resolver. // passem el mateix factor perquè vaja al mateix ritme que el resolver.
@@ -441,10 +457,17 @@ GameScene::GameScene(Config config)
auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY); auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY);
auto hud_font = std::make_shared<Text>(hud_font_surface, UiConfig::get().hudFont().fnt); auto hud_font = std::make_shared<Text>(hud_font_surface, UiConfig::get().hudFont().fnt);
hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning); hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning);
hud_ = std::make_unique<Hud>(entity_manager_.getBus(), hud_font); // Vides inicials del HUD = `initial_lives` del YAML del jugador
// Si hi ha P2 actiu, activem el seu bloc (per defecte el HUD naix amb // (`Player::loadFrom` ja ha omplert `death_config` abans d'aquí).
// només P1). Mentre els events del bus no porten `player_id`, els hits // Sense això, el marcador es queda fixe a 3 i el YAML perd efecte.
// segueixen aplicant-se a P1 (veure TODO a Hud::onPlayerHit). hud_ = std::make_unique<Hud>(entity_manager_.getBus(), hud_font, Player::getInitialLives());
// Activem el bloc HUD per als slots que arranquen en partida. Els
// inactius queden mostrant el prompt "POLSA START / PER JUGAR" via
// `refreshJoinPrompt` cada frame. Tots dos s'haurien de poder
// activar (cas BOTH) o un dels dos (cas P1 o P2 sols) — el HUD
// naix amb tots dos slots inactius i és aquesta crida la que dóna
// l'estat real.
if (USE_P1) { hud_->activatePlayer(1); }
if (USE_P2) { hud_->activatePlayer(2); } if (USE_P2) { hud_->activatePlayer(2); }
// Sincronització inicial dels comptadors de piràmide/sala del HUD amb // Sincronització inicial dels comptadors de piràmide/sala del HUD amb
// el LevelManager (després de cada `loadRoom` també es refresca; ací // el LevelManager (després de cada `loadRoom` també es refresca; ací
@@ -479,6 +502,12 @@ GameScene::GameScene(Config config)
// Timings i corbes de la FSM de sala. Llegits del YAML una sola // Timings i corbes de la FSM de sala. Llegits del YAML una sola
// vegada; canvis requereixen reiniciar el joc. // vegada; canvis requereixen reiniciar el joc.
transition_config_ = RoomFsm::loadConfig(Defaults::GameScene::GAME_YAML); transition_config_ = RoomFsm::loadConfig(Defaults::GameScene::GAME_YAML);
// Timings de la sub-FSM de game over (CONTINUE? + GAME OVER + fade
// final). Comparteix YAML amb la RoomFsm però viu en una secció
// pròpia (`game_over:`).
game_over_config_ = GameOverFsm::loadConfig(Defaults::GameScene::GAME_YAML);
preloadGameOverAssets();
// Cinemàtica d'entrada de la primera sala: arrenca la pista // Cinemàtica d'entrada de la primera sala: arrenca la pista
// `room_intro.ogg` i arma el callback de fi que farà spawn + salt // `room_intro.ogg` i arma el callback de fi que farà spawn + salt
@@ -488,7 +517,18 @@ GameScene::GameScene(Config config)
enterRoomIntro(); enterRoomIntro();
} }
GameScene::~GameScene() = default; GameScene::~GameScene() {
// Aturem qualsevol música que estigués sonant al sortir de la
// GameScene. Això cobreix tres camins: (a) transició natural a
// LOGO al final del game over (l'única audio activa pot ser la
// pista `game_over.ogg`, que en aquell moment ja s'ha drenat);
// (b) tancament directe via service menu o ALT+F4; (c) qualsevol
// futura sortida no-natural. La LogoScene té la seva pròpia
// música i no s'ha de mesclar amb la del joc.
if (auto* audio = Audio::get(); audio != nullptr) {
audio->stopMusic();
}
}
auto GameScene::computeSpawn() -> PlayerSpawn { auto GameScene::computeSpawn() -> PlayerSpawn {
// Centre de la columna del passadís en píxels canvas. La caixa de // Centre de la columna del passadís en píxels canvas. La caixa de
@@ -536,7 +576,7 @@ void GameScene::loadRoom(const std::string& yaml_path) {
for (auto& p : players_) { p->setMapInfo(INFO); } for (auto& p : players_) { p->setMapInfo(INFO); }
// Tornem a fixar el comptador de "esperats" amb només els slots // Tornem a fixar el comptador de "esperats" amb només els slots
// actius — un slot DISABLED permanent (single-player) no compta. // actius — un slot DISABLED permanent (single-player) no compta.
const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true); const int ACTIVE_COUNT = std::ranges::count(players_active_, true);
entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT); entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT);
// Tornem tots els slots a DISABLED. Els actius els reactivarà el // Tornem tots els slots a DISABLED. Els actius els reactivarà el
@@ -583,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
@@ -623,10 +664,10 @@ void GameScene::respawnPlayersInstant() {
// mateix patró de `triggerPlayerSpawn` però cridem `respawn(x, y)` // mateix patró de `triggerPlayerSpawn` però cridem `respawn(x, y)`
// (teleport instantani) en lloc de `spawnFromAir(x, y)`. // (teleport instantani) en lloc de `spawnFromAir(x, y)`.
const auto SPAWN = computeSpawn(); const auto SPAWN = computeSpawn();
const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]); const bool BOTH_ACTIVE = (players_active_[0] && players_active_[1]);
const float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size); const float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size);
for (std::size_t i = 0; i < players_.size(); ++i) { for (std::size_t i = 0; i < players_.size(); ++i) {
if (!players_starts_active_[i]) { continue; } if (!players_active_[i]) { continue; }
float x = SINGLE_X; float x = SINGLE_X;
if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; } if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
players_[i]->setSpawnPoint(x, SPAWN.y); players_[i]->setSpawnPoint(x, SPAWN.y);
@@ -635,20 +676,26 @@ 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
void GameScene::triggerPlayerSpawn(std::size_t slot_index) { void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
if (slot_index >= players_.size()) { return; } if (slot_index >= players_.size()) { return; }
if (!players_starts_active_[slot_index]) { return; } if (!players_active_[slot_index]) { return; }
if (players_spawned_in_intro_[slot_index]) { return; } if (players_spawned_in_intro_[slot_index]) { return; }
players_spawned_in_intro_[slot_index] = true; players_spawned_in_intro_[slot_index] = true;
// Recalcula spawn per si door_col canvia entre sales (avui no, però // Recalcula spawn per si door_col canvia entre sales (avui no, però
// la lògica ja queda preparada per a l'itinerari). // la lògica ja queda preparada per a l'itinerari).
const auto SPAWN = computeSpawn(); const auto SPAWN = computeSpawn();
const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]); const bool BOTH_ACTIVE = (players_active_[0] && players_active_[1]);
// Mode single-player: l'únic actiu queda centrat sota la porta. Els // Mode single-player: l'únic actiu queda centrat sota la porta. Els
// valors de SPAWN.p1_x/p2_x estan fets per al cas BOTH (offset ±tile); // valors de SPAWN.p1_x/p2_x estan fets per al cas BOTH (offset ±tile);
// amb un sol actiu, el reposicionem al centre exacte. // amb un sol actiu, el reposicionem al centre exacte.
@@ -662,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
@@ -679,10 +734,116 @@ void GameScene::dispatchPlayerKilled(int player_id) {
if (IT != players_.end()) { (*IT)->enterDying(); } if (IT != players_.end()) { (*IT)->enterDying(); }
} }
void GameScene::onPlayerDisabled(int player_id) {
Log::info("PLAYER DISABLED", "player_id={}", player_id);
if (player_id < 0 || player_id >= static_cast<int>(players_active_.size())) { return; }
players_active_[player_id] = false;
}
void GameScene::preloadGameOverAssets() const {
// Pre-load del so del countdown: el dispara `updateGameOverFlow`
// cada vegada que canvia el número visible de CONTINUE? (incloent
// l'aparició del 0 — però NO la transició a GAME_OVER). Sense
// pre-load, la primera reproducció faria disc amb el countdown ja
// en marxa.
const auto& key = game_over_config_.countdown_sound_key;
if (key.empty()) { return; }
Resource::Cache::get().loadSound(
key,
std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + key);
}
void GameScene::handleJoinInput() {
// El "join in progress" es processa durant PLAYING (regla 1) i,
// com a cas especial, durant CONTINUE_PROMPT — ahí un START reviu
// el jugador i cancel·la el countdown. A partir de GAME_OVER el
// START queda bloquejat (regla 5: ja no es pot pulsar start).
const bool IN_PLAYING = (state_ == RoomFsm::State::PLAYING);
const bool IN_CONTINUE = (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT);
const bool IN_GAME_OVER = (game_over_state_ != GameOverFsm::State::OFF && game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT);
if (IN_GAME_OVER) { return; }
if (!IN_PLAYING && !IN_CONTINUE) { return; }
for (std::size_t i = 0; i < players_.size(); ++i) {
if (players_active_[i]) { continue; }
if (!players_[i]->isDisabled()) { continue; }
if (!Input::Detect::pressedStart(static_cast<int>(i))) { continue; }
joinPlayer(i);
// Si veníem del countdown de CONTINUE?, disparem la sortida
// visual del rètol. El jugador ja apareix en escena per la
// crida prèvia a `joinPlayer`; el text marxa amb la mateixa
// exit anim que el room text.
if (IN_CONTINUE) {
GameOverFsm::cancelContinuePrompt(game_over_state_, game_over_transition_);
}
}
}
void GameScene::joinPlayer(std::size_t slot_index) {
if (slot_index >= players_.size()) { return; }
// Spawn lateral SEMPRE: cada jugador entra pel seu costat de la
// porta de sortida. Mai el centre (en single-player la cinemàtica
// inicial sí que centra el slot actiu, però en un join mid-partida
// volem el costat per coherència visual amb la regla 2).
const auto SPAWN = computeSpawn();
const float X = (slot_index == 0) ? SPAWN.p1_x : SPAWN.p2_x;
players_[slot_index]->setSpawnPoint(X, SPAWN.y);
players_[slot_index]->resetForJoin();
players_[slot_index]->spawnFromAir(X, SPAWN.y);
players_active_[slot_index] = true;
if (hud_) { hud_->activatePlayer(static_cast<int>(slot_index) + 1); }
Log::info("ROOM", "join mid-game P{} -> spawn aerial at ({}, {})", slot_index + 1, X, SPAWN.y);
}
auto GameScene::activePlayerCount() const -> int {
int n = 0;
for (const auto& p : players_) {
if (!p) { continue; }
if (!p->isOutOfPlay() || p->isDying()) { ++n; }
}
return n;
}
void GameScene::refreshJoinPrompt() {
if (!hud_) { return; }
// El game over substitueix la decisió purament basada en RoomFsm:
// durant CONTINUE_PROMPT els jugadors encara poden entrar (revive),
// a partir de GAME_OVER ja no, així que mostrem WAIT (o NONE) per
// indicar que el START ja no farà res fins a la transició a LOGO.
Hud::JoinPromptMode mode_inactive = Hud::JoinPromptMode::NONE;
if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) {
mode_inactive = Hud::JoinPromptMode::CAN_JOIN;
} else if (game_over_state_ != GameOverFsm::State::OFF) {
mode_inactive = Hud::JoinPromptMode::WAIT;
} else {
switch (state_) {
case RoomFsm::State::PLAYING:
mode_inactive = Hud::JoinPromptMode::CAN_JOIN;
break;
case RoomFsm::State::ROOM_INTRO:
case RoomFsm::State::ROOM_OUTRO:
mode_inactive = Hud::JoinPromptMode::WAIT;
break;
}
}
for (std::size_t i = 0; i < players_.size(); ++i) {
const auto MODE = players_active_[i] ? Hud::JoinPromptMode::NONE : mode_inactive;
hud_->setJoinPrompt(static_cast<int>(i) + 1, MODE);
}
}
void GameScene::completeIntro() { 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
@@ -694,9 +855,9 @@ void GameScene::completeIntro() {
void GameScene::spawnDuePlayers() { void GameScene::spawnDuePlayers() {
if (!intro_completed_) { return; } if (!intro_completed_) { return; }
const bool BOTH_ACTIVE = players_starts_active_[0] && players_starts_active_[1]; const bool BOTH_ACTIVE = players_active_[0] && players_active_[1];
for (std::size_t i = 0; i < players_starts_active_.size(); ++i) { for (std::size_t i = 0; i < players_active_.size(); ++i) {
if (!players_starts_active_[i]) { continue; } if (!players_active_[i]) { continue; }
if (players_spawned_in_intro_[i]) { continue; } if (players_spawned_in_intro_[i]) { continue; }
// Single-player: el slot actiu (P1 o P2) sempre fa servir // Single-player: el slot actiu (P1 o P2) sempre fa servir
// `player_spawn_delay_ms`. Mode BOTH: P1 fa player_spawn_delay_ms, // `player_spawn_delay_ms`. Mode BOTH: P1 fa player_spawn_delay_ms,
@@ -719,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
@@ -779,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
@@ -858,12 +1037,14 @@ void GameScene::renderRoomText() {
const Uint8 OUTLINE = UiConfig::get().roomTextOutline(); const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
const int LINE_HEIGHT = room_text_font_->getLineHeight(); const int LINE_HEIGHT = room_text_font_->getLineHeight();
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) { const int PHASE = transition_.phase;
const int ELAPSED = transition_.elapsed_ms;
if (state_ == RoomFsm::State::ROOM_INTRO && PHASE >= 2 && PHASE <= 4) {
const int DUR_ENTER = transition_config_.intro_text_enter_ms; const int DUR_ENTER = transition_config_.intro_text_enter_ms;
const int DUR_EXIT = transition_config_.intro_text_exit_ms; const int DUR_EXIT = transition_config_.intro_text_exit_ms;
// Panel primer (queda sota el text gràcies a l'ordre de blits). // Panel primer (queda sota el text gràcies a l'ordre de blits).
drawRoomTextPanel(PLAY_CENTER_Y, drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(2, 3, 4, DUR_ENTER, DUR_EXIT)); 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()); const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid());
@@ -871,12 +1052,16 @@ void GameScene::renderRoomText() {
// Direccions oposades: piràmide entra per l'esquerra i surt per // Direccions oposades: piràmide entra per l'esquerra i surt per
// la dreta; sala fa el contrari. // la dreta; sala fa el contrari.
const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING), const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING),
PHASE,
ELAPSED,
2, 2,
4, 4,
DUR_ENTER, DUR_ENTER,
DUR_EXIT, DUR_EXIT,
/*from_right=*/false); /*from_right=*/false);
const int X_ROOM = computeRoomTextX(room_text_font_->length(LINE_ROOM, KERNING), const int X_ROOM = computeRoomTextX(room_text_font_->length(LINE_ROOM, KERNING),
PHASE,
ELAPSED,
2, 2,
4, 4,
DUR_ENTER, DUR_ENTER,
@@ -888,21 +1073,25 @@ void GameScene::renderRoomText() {
const int GAP = transition_config_.intro_text_line_gap_px; 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_PYRAMID, X_PYRAMID, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
drawRoomTextLine(LINE_ROOM, X_ROOM, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE); drawRoomTextLine(LINE_ROOM, X_ROOM, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
} else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) { } else if (state_ == RoomFsm::State::ROOM_OUTRO && PHASE >= 0 && PHASE <= 2) {
const int DUR_ENTER = transition_config_.outro_text_enter_ms; const int DUR_ENTER = transition_config_.outro_text_enter_ms;
const int DUR_EXIT = transition_config_.outro_text_exit_ms; const int DUR_EXIT = transition_config_.outro_text_exit_ms;
drawRoomTextPanel(PLAY_CENTER_Y, drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(0, 1, 2, DUR_ENTER, DUR_EXIT)); computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT));
const std::string LINE_1 = Locale::get().text("game.room_complete_line1"); const std::string LINE_1 = Locale::get().text("game.room_complete_line1");
const std::string LINE_2 = Locale::get().text("game.room_complete_line2"); const std::string LINE_2 = Locale::get().text("game.room_complete_line2");
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING), const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
PHASE,
ELAPSED,
0, 0,
2, 2,
DUR_ENTER, DUR_ENTER,
DUR_EXIT, DUR_EXIT,
/*from_right=*/false); /*from_right=*/false);
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING), const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
PHASE,
ELAPSED,
0, 0,
2, 2,
DUR_ENTER, DUR_ENTER,
@@ -914,7 +1103,7 @@ void GameScene::renderRoomText() {
} }
} }
auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int { auto GameScene::computeRoomTextX(int width, int phase, int elapsed_ms, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int {
// X animada d'una línia: entra des de fora de pantalla, s'atura al // X animada d'una línia: entra des de fora de pantalla, s'atura al
// CENTER_X durant el hold (fora del rang d'aquesta funció — el caller // CENTER_X durant el hold (fora del rang d'aquesta funció — el caller
// garanteix que cau a la branca return final) i surt pel costat // garanteix que cau a la branca return final) i surt pel costat
@@ -924,16 +1113,16 @@ auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int
const int OFF_RIGHT = GameCanvas::WIDTH; const int OFF_RIGHT = GameCanvas::WIDTH;
const int OFF_ENTER = from_right ? OFF_RIGHT : OFF_LEFT; const int OFF_ENTER = from_right ? OFF_RIGHT : OFF_LEFT;
const int OFF_EXIT = from_right ? OFF_LEFT : OFF_RIGHT; const int OFF_EXIT = from_right ? OFF_LEFT : OFF_RIGHT;
if (transition_.phase == phase_enter) { if (phase == phase_enter) {
const float T = (dur_enter > 0) const float T = (dur_enter > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F) ? std::clamp(static_cast<float>(elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
: 1.0F; : 1.0F;
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear; const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
return Easing::lerpInt(OFF_ENTER, GameCanvas::CENTER_X, CURVE(T)); return Easing::lerpInt(OFF_ENTER, GameCanvas::CENTER_X, CURVE(T));
} }
if (transition_.phase == phase_exit) { if (phase == phase_exit) {
const float T = (dur_exit > 0) const float T = (dur_exit > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F) ? std::clamp(static_cast<float>(elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
: 1.0F; : 1.0F;
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear; const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
return Easing::lerpInt(GameCanvas::CENTER_X, OFF_EXIT, CURVE(T)); return Easing::lerpInt(GameCanvas::CENTER_X, OFF_EXIT, CURVE(T));
@@ -941,23 +1130,23 @@ auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int
return GameCanvas::CENTER_X; return GameCanvas::CENTER_X;
} }
auto GameScene::computeRoomTextPanelHeight(int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int { auto GameScene::computeRoomTextPanelHeight(int phase, int elapsed_ms, int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int {
// Altura animada del panel translúcid sota el rètol. Creix de 0 a // Altura animada del panel translúcid sota el rètol. Creix de 0 a
// `text_panel_height_px` amb `text_enter_curve` durant l'enter, es // `text_panel_height_px` amb `text_enter_curve` durant l'enter, es
// manté plena durant el hold i decreix amb `text_exit_curve` // manté plena durant el hold i decreix amb `text_exit_curve`
// durant l'exit. Fora d'aquestes tres fases retorna 0. // durant l'exit. Fora d'aquestes tres fases retorna 0.
const int MAX = transition_config_.text_panel_height_px; const int MAX = transition_config_.text_panel_height_px;
if (transition_.phase == phase_enter) { if (phase == phase_enter) {
const float T = (dur_enter > 0) const float T = (dur_enter > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F) ? std::clamp(static_cast<float>(elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
: 1.0F; : 1.0F;
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear; const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
return Easing::lerpInt(0, MAX, CURVE(T)); return Easing::lerpInt(0, MAX, CURVE(T));
} }
if (transition_.phase == phase_hold) { return MAX; } if (phase == phase_hold) { return MAX; }
if (transition_.phase == phase_exit) { if (phase == phase_exit) {
const float T = (dur_exit > 0) const float T = (dur_exit > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F) ? std::clamp(static_cast<float>(elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
: 1.0F; : 1.0F;
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear; const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
return Easing::lerpInt(MAX, 0, CURVE(T)); return Easing::lerpInt(MAX, 0, CURVE(T));
@@ -990,6 +1179,250 @@ void GameScene::drawRoomTextLine(const std::string& text, int x, int y, std::uin
outline_color); outline_color);
} }
void GameScene::setGameOverState(GameOverFsm::State new_state) {
if (new_state == game_over_state_) { return; }
const auto PREV = game_over_state_;
Log::info("GAMEOVER", "{} -> {}", GameOverFsm::stateName(PREV), GameOverFsm::stateName(new_state));
game_over_state_ = new_state;
game_over_transition_ = {};
auto* audio = Audio::get();
// Sortida de CONTINUE_PROMPT cap a OFF (revive): reinici de la
// música de joc des del principi a velocitat normal. Reaprofitem
// el track gravat a `config_.music_track` (sempre `playing.ogg`
// amb loop infinit). Si la sortida és cap a GAME_OVER, la branca
// de més avall ja talla la música i posa `game_over.ogg` — aquí
// saltem perquè el stop+play seria redundant.
if (PREV == GameOverFsm::State::CONTINUE_PROMPT && new_state == GameOverFsm::State::OFF && audio != nullptr) {
audio->stopMusic();
audio->playMusic(config_.music_track, -1);
audio->setMusicSpeed(1.0F);
}
if (new_state == GameOverFsm::State::CONTINUE_PROMPT && audio != nullptr) {
// Acceleració arcade: stop+play perquè la pista arrenqui des
// del principi al ritme accelerat (sense aquest reinici, el
// canvi de velocitat sobre la posició actual queda poc
// perceptible amb ratios subtils com 1.05). El to també puja:
// és l'efecte clàssic dels comptes enrere.
audio->stopMusic();
audio->playMusic(config_.music_track, -1);
audio->setMusicSpeed(game_over_config_.continue_music_speed);
}
// Reset del tracker del so del countdown cada vegada que entrem
// a CONTINUE_PROMPT, perquè la primera aparició del valor inicial
// (`continue_count_start`) dispari el "tick". Fora de
// CONTINUE_PROMPT no s'usa.
if (new_state == GameOverFsm::State::CONTINUE_PROMPT) {
last_countdown_value_ = -1;
}
if (new_state == GameOverFsm::State::GAME_OVER && audio != nullptr) {
// Talla la música actual i arrenca la pista de GAME OVER sense
// loop. La fase de hold es redimensiona a la durada real de la
// pista perquè el text es mantingui visible mentre dura la
// música; l'exit anim, el wait i el fade arrenquen quan la
// música ja s'ha drenat.
audio->stopMusic();
const auto& track = game_over_config_.game_over_music_track;
if (!track.empty()) {
audio->playMusic(track, 0);
const int DUR_MS = Audio::getMusicDurationMs(track);
if (DUR_MS > 0) {
game_over_config_.game_over_text_hold_ms = DUR_MS;
Log::info("GAMEOVER", "{}: durada {} ms (text_hold sincronitzat)", track, DUR_MS);
}
}
}
if (new_state == GameOverFsm::State::FADE_OUT) {
// Arma el fade en el moment que entrem a la fase de fade-out.
// Mateix patró que `outro_fade_started_` per a OUTRO de sala.
game_over_fade_.begin(game_over_config_.fade_out_ms,
Transitions::FadeToBlack::Direction::FADE_OUT);
game_over_fade_started_ = true;
}
}
void GameScene::updateGameOverFlow(int delta_ms) {
// Tick del fade visual: és no-op si encara no s'ha armat (begin
// sense duration → tick=no-op gràcies al clamp intern).
game_over_fade_.tick(delta_ms);
// Detecció d'entrada a CONTINUE_PROMPT: tots els jugadors fora de
// partida durant PLAYING. Només es dispara una vegada — `setGameOverState`
// és idempotent davant del mateix estat. Es vol que la transició
// sigui exclusiva de PLAYING perquè un join mid-INTRO/OUTRO ja no
// és possible (vegeu `handleJoinInput`); permetent CONTINUE? fora
// de PLAYING obriríem races amb la cinemàtica de sala.
if (game_over_state_ == GameOverFsm::State::OFF && state_ == RoomFsm::State::PLAYING && activePlayerCount() == 0) {
setGameOverState(GameOverFsm::State::CONTINUE_PROMPT);
return;
}
if (game_over_state_ == GameOverFsm::State::OFF) { return; }
const auto NEXT = GameOverFsm::advance(game_over_state_, game_over_transition_, game_over_config_, delta_ms);
if (NEXT != game_over_state_) {
setGameOverState(NEXT);
// En arribar a DONE, demanem la transició a LOGO. La construcció
// de la nova escena la fa el Director llegint `exit_request_`.
if (NEXT == GameOverFsm::State::DONE) {
exit_request_ = ExitRequest{.next = SceneId::LOGO, .params = LogoParams{}};
Log::info("GAMEOVER", "DONE -> requesting LOGO scene");
}
}
// Skip i tick del so del countdown viuen en helpers per acotar
// la complexitat cognitiva d'aquesta funció. Skip primer perquè
// el tick del so soni amb el valor nou si la pulsació ha avançat.
handleCountdownSkip();
emitCountdownTickIfChanged();
}
void GameScene::handleCountdownSkip() {
if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT) { return; }
if (game_over_transition_.phase != 1) { return; }
const int CURRENT = GameOverFsm::countdownSecondsRemaining(
game_over_state_,
game_over_transition_,
game_over_config_);
if (CURRENT <= 0) { return; }
if (CURRENT > game_over_config_.skip_below_or_equal) { return; }
const bool ANY_ACTION = Input::Detect::pressedActionButton(0) || Input::Detect::pressedActionButton(1);
if (!ANY_ACTION) { return; }
GameOverFsm::skipToNextCountdown(
game_over_state_,
game_over_transition_,
game_over_config_);
}
void GameScene::emitCountdownTickIfChanged() {
if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT) { return; }
if (game_over_transition_.phase != 1) { return; }
const int CURRENT = GameOverFsm::countdownSecondsRemaining(
game_over_state_,
game_over_transition_,
game_over_config_);
if (CURRENT == last_countdown_value_) { return; }
last_countdown_value_ = CURRENT;
const auto& key = game_over_config_.countdown_sound_key;
if (key.empty()) { return; }
if (auto* audio = Audio::get(); audio != nullptr) {
audio->playSound(key, Audio::Group::INTERFACE);
}
}
void GameScene::renderGameOverText() {
if (!room_text_font_) { return; }
if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT && game_over_state_ != GameOverFsm::State::GAME_OVER) {
return;
}
constexpr int KERNING = 0;
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
const Uint8 FILL = UiConfig::get().roomText();
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
const int LINE_HEIGHT = room_text_font_->getLineHeight();
const int PHASE = game_over_transition_.phase;
const int ELAPSED = game_over_transition_.elapsed_ms;
if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) {
const int DUR_ENTER = game_over_config_.continue_text_enter_ms;
const int DUR_EXIT = game_over_config_.continue_text_exit_ms;
// Phases del CONTINUE_PROMPT: 0=enter, 1=hold, 2=exit. El panel
// segueix la mateixa corba enter/hold/exit que el room text.
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(PHASE, ELAPSED, 0, 1, 2, DUR_ENTER, DUR_EXIT));
const std::string LINE_1 = Locale::get().text("game.continue_prompt");
const int SECONDS = GameOverFsm::countdownSecondsRemaining(
game_over_state_,
game_over_transition_,
game_over_config_);
// Línia 2 només té text durant la fase de hold (countdown). En
// les fases d'enter i exit deixem només el "CONTINUE?" perquè
// l'animació dels números no quedi rara.
const std::string LINE_2 = (PHASE == 1) ? std::to_string(SECONDS) : std::string{};
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
PHASE,
ELAPSED,
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/false);
const int X_2 = computeRoomTextX(LINE_2.empty() ? 0 : room_text_font_->length(LINE_2, KERNING),
PHASE,
ELAPSED,
0,
2,
DUR_ENTER,
DUR_EXIT,
/*from_right=*/true);
const int GAP = transition_config_.intro_text_line_gap_px;
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
if (!LINE_2.empty()) {
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
}
return;
}
// GAME_OVER: panel sempre amunt durant phase 0/1, decreix en phase 1
// (exit). Phase 2 (wait) sense panel ni text — només temps mort
// abans del fade.
const int DUR_HOLD = game_over_config_.game_over_text_hold_ms;
const int DUR_EXIT = game_over_config_.game_over_text_exit_ms;
if (PHASE > 1) { return; }
// Panel: phase 0 = ple, phase 1 = decreix. Reaprofitem
// computeRoomTextPanelHeight passant un phase_enter "fora de rang"
// (-1) perquè la branca d'enter no s'activi mai.
drawRoomTextPanel(PLAY_CENTER_Y,
computeRoomTextPanelHeight(PHASE, ELAPSED, /*phase_enter=*/-1,
/*phase_hold=*/0,
/*phase_exit=*/1,
/*dur_enter=*/0,
DUR_EXIT));
const std::string LINE_1 = Locale::get().text("game.game_over_line1");
const std::string LINE_2 = Locale::get().text("game.game_over_line2");
// Phase 0 (hold): text quiet al CENTER_X. Phase 1 (exit): el text
// marxa pels costats com el room outro. Reaprofitem computeRoomTextX
// amb phase_enter=-1 (no enter anim) i phase_exit=1.
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
PHASE,
ELAPSED,
/*phase_enter=*/-1,
/*phase_exit=*/1,
/*dur_enter=*/0,
DUR_EXIT,
/*from_right=*/false);
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
PHASE,
ELAPSED,
/*phase_enter=*/-1,
/*phase_exit=*/1,
/*dur_enter=*/0,
DUR_EXIT,
/*from_right=*/true);
const int GAP = transition_config_.outro_text_line_gap_px;
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
(void)DUR_HOLD; // Reservat per a futurs efectes de hold (pulse, etc.).
}
void GameScene::renderGameOverFade() {
// Sense `begin` previ, `tick` és no-op i `render` pinta alpha=0
// (vegeu `Transitions::FadeToBlack`). Així el callsite és
// incondicional sense malbaratar pixel ops fora del flux.
if (!game_over_fade_started_) { return; }
game_over_fade_.render();
}
void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) { void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) {
// Mida del sprite de Dust llegida del seu YAML. Hardcoded per // Mida del sprite de Dust llegida del seu YAML. Hardcoded per
// simplificar — si canvia, només cal tocar dust.yaml + aquesta // simplificar — si canvia, només cal tocar dust.yaml + aquesta
@@ -1162,6 +1595,12 @@ void GameScene::update(int delta_ms) {
// arrenque ja sigui efectiu en el mateix frame. // arrenque ja sigui efectiu en el mateix frame.
updateRoomTransition(delta_ms); updateRoomTransition(delta_ms);
// Sub-FSM de game over (CONTINUE? + GAME OVER + fade-to-LOGO).
// També en temps real: el countdown de CONTINUE? no es congela
// amb la pausa de gameplay. La detecció de "tots fora de partida"
// s'arma dins quan estem a PLAYING.
updateGameOverFlow(delta_ms);
#ifdef _DEBUG #ifdef _DEBUG
// Toggle de l'editor: només si no hi ha cap transició de pausa ni // Toggle de l'editor: només si no hi ha cap transició de pausa ni
// pausa consolidada. L'editor mateix no consumeix la tecla — la // pausa consolidada. L'editor mateix no consumeix la tecla — la
@@ -1213,6 +1652,20 @@ void GameScene::update(int delta_ms) {
} }
#endif #endif
// Join in progress (regla 1): un slot DISABLED pot entrar a partida
// pulsant START durant PLAYING. Es processa abans del bloc de món
// per que el spawn aeri ja sigui efectiu en aquest mateix frame.
// Gateat per pausa/menú/editor: no es vol que un START accidental
// mentre l'usuari navega per un menú trigueri un join.
#ifdef _DEBUG
const bool EDITOR_OPEN = editor_.active();
#else
constexpr bool EDITOR_OPEN = false;
#endif
if (pause_.state == PauseState::IDLE && !MENU_FROZEN && !EDITOR_OPEN) {
handleJoinInput();
}
GlobalInputs::update(delta_ms); GlobalInputs::update(delta_ms);
if (GlobalInputs::wantsQuit()) { wants_quit_ = true; } if (GlobalInputs::wantsQuit()) { wants_quit_ = true; }
@@ -1273,6 +1726,11 @@ void GameScene::update(int delta_ms) {
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow())); RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()));
Notifier::get().update(delta_ms); Notifier::get().update(delta_ms);
// Refresc del prompt al HUD per a slots inactius. Es fa al final per
// recollir els canvis d'aquest frame (joins, deaths, transició de
// RoomFsm) abans del proper render.
refreshJoinPrompt();
} }
void GameScene::render() { void GameScene::render() {
@@ -1335,10 +1793,18 @@ 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());
const auto ENTITIES = entity_manager_.getEntities(); // Mergem els dos sacs (map + actors) i els jugadors en un sol
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) { // vector temporal — el `stable_sort` per `feet_y` posterior fa el
// z-order interleaving entre els tres orígens sense importar el
// sac d'on venen.
const auto MAP = entity_manager_.getMapEntities();
std::ranges::transform(MAP, std::back_inserter(order), [](Entity* e) {
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr}; return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
}); });
const auto ACTORS = entity_manager_.getActors();
std::ranges::transform(ACTORS, std::back_inserter(order), [](DynamicHazard* a) -> Renderable {
return Renderable{.feet_y = a->getFeetY(), .entity = a, .player = nullptr};
});
// Tots els jugadors entren al z-sort. Els fora de joc fan no-op a // Tots els jugadors entren al z-sort. Els fora de joc fan no-op a
// `render()`; els actius (inclosos AIRBORNE durant l'spawn aeri) // `render()`; els actius (inclosos AIRBORNE durant l'spawn aeri)
// es pinten amb el seu feet_y normal. La sombra que afecta la // es pinten amb el seu feet_y normal. La sombra que afecta la
@@ -1371,6 +1837,14 @@ void GameScene::render() {
// Text de la transició de sala (entrada o sortida): per damunt del // Text de la transició de sala (entrada o sortida): per damunt del
// HUD perquè és UI de transició i ha de ser sempre llegible. // HUD perquè és UI de transició i ha de ser sempre llegible.
renderRoomText(); renderRoomText();
// Rètol de game over (CONTINUE? + countdown / GAME OVER) — comparteix
// panel i font amb el room text però viu en una sub-FSM independent.
renderGameOverText();
// Fade-to-black final del game over: tapeix tot el canvas (HUD
// inclòs) durant la fase FADE_OUT i el black hold posterior.
renderGameOverFade();
} }
#ifdef _DEBUG #ifdef _DEBUG
+123 -18
View File
@@ -13,6 +13,7 @@
#include "game/entities/entity_manager.hpp" #include "game/entities/entity_manager.hpp"
#include "game/map/paint_layer.hpp" #include "game/map/paint_layer.hpp"
#include "game/map/tilemap.hpp" #include "game/map/tilemap.hpp"
#include "game/scenes/game_over_fsm.hpp"
#include "game/scenes/room_fsm.hpp" #include "game/scenes/room_fsm.hpp"
#ifdef _DEBUG #ifdef _DEBUG
@@ -95,17 +96,19 @@ class GameScene : public Scene {
// Vector des del principi: AEE suportarà multijugador local; cada Player // Vector des del principi: AEE suportarà multijugador local; cada Player
// té els seus propis Controls i entra al vector amb una crida extra. // té els seus propis Controls i entra al vector amb una crida extra.
// Sempre conté **dos** entrades (P1 a [0], P2 a [1]) tant en mode BOTH // Sempre conté **dos** entrades (P1 a [0], P2 a [1]) tant en mode BOTH
// com en single-player: l'slot inactiu es queda en estat DISABLED de // com en single-player: l'slot inactiu es queda en estat DISABLED i
// manera permanent, deixant el camí preparat per al "join in progress" // pot entrar mid-partida via "join in progress" (`handleJoinInput`).
// futur (el segon jugador podrà entrar a meitat de partida sense
// recrear estructures). Veure `players_starts_active_`.
std::vector<std::unique_ptr<Player>> players_; std::vector<std::unique_ptr<Player>> players_;
// Quins slots juguen aquesta partida segons `config_.starting_players`. // Quins slots estan en partida ARA MATEIX (jugant o DYING). Mutable
// Index = id del jugador (0 = P1, 1 = P2). Determina a qui aplica el // durant la sala: el ctor el sembra des de `config_.starting_players`,
// spawn aeri al final de la fase 0 d'INTRO; un slot amb `false` es // `joinPlayer` el posa a true quan un slot inactiu pulsa START, i la
// queda DISABLED tot el cicle de sala. // subscripció a `player_disabled` el torna a false quan un slot
std::array<bool, 2> players_starts_active_{false, false}; // esgota vides. `loadRoom` recalcula `setExpectedPlayerCount` a
// partir d'aquesta vista — un slot DISABLED al final d'una sala no
// entra automàticament a la següent (el jugador ha de re-pulsar
// START si vol re-entrar).
std::array<bool, 2> players_active_{false, false};
// Cinemàtica de spawn d'un slot concret. Posa el slot indicat en // Cinemàtica de spawn d'un slot concret. Posa el slot indicat en
// AIRBORNE des d'una altura fora de pantalla. Cridat per // AIRBORNE des d'una altura fora de pantalla. Cridat per
@@ -130,6 +133,19 @@ class GameScene : public Scene {
// Extret a un mètode per acotar la complexitat cognitiva del ctor. // Extret a un mètode per acotar la complexitat cognitiva del ctor.
void dispatchPlayerKilled(int player_id); void dispatchPlayerKilled(int player_id);
// Handler de l'event `player_disabled` (un slot acaba d'esgotar
// vides). Apaga `players_active_[id]` perquè el `loadRoom` següent
// no compti aquest slot i perquè el "join in progress" del frame
// següent torni a oferir-li `setJoinPrompt(CAN_JOIN)`. Extret a
// mètode per acotar la complexitat cognitiva del subscriber/ctor.
void onPlayerDisabled(int player_id);
// Pre-load dels recursos d'àudio que s'usen mid-partida i no els
// toca el Director (so del countdown, etc.). Si la clau és buida
// o ja està al cache, és no-op. Extret a mètode per acotar la
// complexitat cognitiva del ctor.
void preloadGameOverAssets() const;
// Itera els slots actius i, si el delay configurat ha expirat des // Itera els slots actius i, si el delay configurat ha expirat des
// del callback de fi de música d'INTRO, dispara `triggerPlayerSpawn`. // del callback de fi de música d'INTRO, dispara `triggerPlayerSpawn`.
// Cridada cada frame durant la fase d'spawn escalonat (entre el // Cridada cada frame durant la fase d'spawn escalonat (entre el
@@ -137,6 +153,33 @@ class GameScene : public Scene {
// caigut tots o si encara no estem dins la finestra (intro_completed_). // caigut tots o si encara no estem dins la finestra (intro_completed_).
void spawnDuePlayers(); void spawnDuePlayers();
// Atén el "join in progress" (regla 1 de la FSM de joc): si un slot
// està DISABLED i el jugador corresponent pulsa START dins de PLAYING,
// dispara `joinPlayer`. La detecció de tecla/botó la fa
// `Input::detect::pressedStart`. Ignorat fora de PLAYING (durant
// INTRO/OUTRO el slot inactiu mostra "ESPERA / PARA JUGAR" al HUD).
void handleJoinInput();
// Materialitza l'entrada d'un slot inactiu: spawn aeri al costat
// corresponent de la porta (`computeSpawn().pX_x`), reset de vides
// al valor inicial via `Player::resetForJoin`, marcatge de
// `players_active_[i]=true` i activació del bloc del HUD. La
// cinemàtica reaprofita `spawnFromAir` (mateixa caiguda visual que
// l'INTRO de sala i el respawn post-mort).
void joinPlayer(std::size_t slot_index);
// Recompte de jugadors "en partida" (no DISABLED ni EXITED, o bé
// DYING — DYING encara compta com a jugant fins que es resol). Hook
// exposat per a la futura sub-FSM CONTINUE? + GAME OVER, que vigila
// la transició a 0 jugadors actius. No s'usa dins de la Fase 1.
[[nodiscard]] auto activePlayerCount() const -> int;
// Refresca el prompt mostrat al bloc HUD de cada slot inactiu. Es
// crida cada frame des d'`update()`. La decisió viu ací (que coneix
// `state_` de la RoomFsm); el HUD només pinta. Per a slots actius,
// posa el prompt a NONE per ser defensiu davant de canvis d'estat.
void refreshJoinPrompt();
// Sentinel que marca el moment "música d'INTRO acabada" dins del // Sentinel que marca el moment "música d'INTRO acabada" dins del
// timeline. Ja no ve d'un callback d'àudio: el setja el GameScene // timeline. Ja no ve d'un callback d'àudio: el setja el GameScene
// quan la FSM ha consumit l'última fase d'INTRO (la `music_tail`, // quan la FSM ha consumit l'última fase d'INTRO (la `music_tail`,
@@ -159,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
@@ -255,15 +306,69 @@ class GameScene : public Scene {
void renderRoomFade(); void renderRoomFade();
void renderRoomText(); void renderRoomText();
// Helpers de `renderRoomText`. Tots consulten `transition_` i // Configuració, estat i transició de la sub-FSM de game over
// `transition_config_` directament a través de `this` — així les // (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la
// signatures es queden netes i la funció pública s'orquestra amb // RoomFsm via `GameOverFsm::loadConfig`.
// poques línies. Tres tasques separades: GameOverFsm::Config game_over_config_{};
// - X animada d'una línia (entra per un costat, surt per l'altre). GameOverFsm::State game_over_state_{GameOverFsm::State::OFF};
// - Altura animada del panel translúcid (creix, manté, decreix). GameOverFsm::Transition game_over_transition_{};
// - Pintat efectiu del panel i d'una línia de text.
[[nodiscard]] auto computeRoomTextX(int width, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int; // Fade final cap a LOGO. Independent del `outro_fade_` perquè el
[[nodiscard]] auto computeRoomTextPanelHeight(int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int; // game over passa per damunt de la RoomFsm i pot disparar-se durant
// PLAYING sense haver d'arrencar un OUTRO de sala. Comença a la
// fase 0 de FADE_OUT i es manté pintant alpha=255 durant el
// `black_hold` final (mateix patró que `outro_fade_`).
Transitions::FadeToBlack game_over_fade_;
bool game_over_fade_started_{false};
// Últim valor del countdown reproduït amb so. -1 com a sentinel
// perquè la primera detecció del valor inicial (`continue_count_start`)
// dispari el "tick". Es reseteja a -1 cada vegada que entrem a
// CONTINUE_PROMPT (vegeu `setGameOverState`).
int last_countdown_value_{-1};
// Avança la sub-FSM de game over consumint `delta_ms`. Detecta la
// transició de "tots els jugadors fora de partida" durant PLAYING
// (entrada a CONTINUE_PROMPT), arma el fade de FADE_OUT i, en
// arribar a DONE, demana la transició a `LOGO`.
void updateGameOverFlow(int delta_ms);
// Substitueix `setState` per a la sub-FSM de game over: log
// d'entrada/sortida i reset de la `Transition`. També arma el
// `game_over_fade_` quan entrem a FADE_OUT, en analogia amb
// `outro_fade_`.
void setGameOverState(GameOverFsm::State new_state);
// Pintat del rètol de game over (CONTINUE? + countdown durant
// CONTINUE_PROMPT; "GAME OVER" durant GAME_OVER). Reaprofita el
// panel translúcid i la font del room text.
void renderGameOverText();
// Pintat del fade negre final (FADE_OUT i black hold). Cridat
// després del HUD perquè cobreixi tota la pantalla, inclòs el
// marcador.
void renderGameOverFade();
// Comprova si dins de CONTINUE_PROMPT phase 1 el número visible
// està al rang de skip i, si un jugador pulsa salt o atac, fa
// saltar la fase al següent número via `GameOverFsm::skipToNextCountdown`.
// Extret a mètode separat per acotar la complexitat cognitiva
// d'`updateGameOverFlow`.
void handleCountdownSkip();
// Detecta canvis de número del countdown i dispara el so
// configurat un cop per cada nou valor. També extret per
// mantenir `updateGameOverFlow` per sota del llindar de tidy.
void emitCountdownTickIfChanged();
// Helpers d'animació del rètol de transició. Reutilitzats pel
// rètol de sala (`renderRoomText`) i pel de game over
// (`renderGameOverText`). Reben `phase` i `elapsed_ms` per
// paràmetre perquè la mateixa lògica visual serveix les dues
// sub-FSMs sense saber a quina pertanyen — només es lliguen al
// `transition_config_` per a corbes i geometria del panel.
[[nodiscard]] auto computeRoomTextX(int width, int phase, int elapsed_ms, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int;
[[nodiscard]] auto computeRoomTextPanelHeight(int phase, int elapsed_ms, int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int;
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;
+59
View File
@@ -0,0 +1,59 @@
#include "game/scenes/start_input.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include "core/input/gamepad_bindings.hpp"
#include "core/input/gamepad_manager.hpp"
#include "core/system/event_buffer.hpp"
#include "game/options.hpp"
#include "utils/defaults.hpp"
namespace Input::Detect {
auto pressedStart(int player_id) -> bool {
const SDL_Scancode KB = (player_id == 0) ? Defaults::KeysGame::START : Defaults::KeysGameP2::START;
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0;
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
: Options::controls().gamepad_p2;
const int START_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::START);
return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) {
if (ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == KB) { return true; }
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID && static_cast<int>(ev.gbutton.button) == START_BTN) {
return true;
}
return false;
});
}
auto pressedActionButton(int player_id) -> bool {
// Teclat: les bindings actives venen d'`Options::controls()` (les
// que el menú de servei pot reescriure), no de `Defaults` cru —
// així respectem el remapping de l'usuari igual que fa el Player.
const auto& kb = (player_id == 0) ? Options::controls().keyboard_p1
: Options::controls().keyboard_p2;
const SDL_Scancode KB_JUMP = kb.jump;
const SDL_Scancode KB_ATTACK = kb.attack;
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0;
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
: Options::controls().gamepad_p2;
const int JUMP_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::JUMP);
const int ATTACK_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::ATTACK);
return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) {
if (ev.type == SDL_EVENT_KEY_DOWN && (ev.key.scancode == KB_JUMP || ev.key.scancode == KB_ATTACK)) {
return true;
}
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID) {
const int BTN = static_cast<int>(ev.gbutton.button);
if (BTN == JUMP_BTN || BTN == ATTACK_BTN) { return true; }
}
return false;
});
}
} // namespace Input::Detect
+28
View File
@@ -0,0 +1,28 @@
#pragma once
// Helper compartit per detectar pulsacions del botó START dins del frame
// actual per a un jugador concret. Originalment vivia al namespace anònim
// de `title_scene.cpp` perquè era l'únic consumidor; ara la GameScene
// també l'usa per al "join in progress" mid-partida (regla 1 de la FSM
// del jugador), així que extraiem la funció a un namespace propi per
// evitar duplicar la pila d'input (Defaults::Keys + GamepadManager +
// Options + GamepadBindings + EventBuffer) en dos llocs.
namespace Input::Detect {
// Cert si dins dels events SDL d'aquest frame hi ha una pulsació de
// START per al jugador `player_id` (0 = P1, 1 = P2). Combina events
// de teclat (scancode hardcoded a `Defaults::KeysGame[P2]::START`) i
// de gamepad (botó remapejable via `GamepadBindings`, lligat al pad
// assignat al jugador via `Options::controls()`).
[[nodiscard]] auto pressedStart(int player_id) -> bool;
// Cert si dins dels events SDL d'aquest frame el jugador
// `player_id` (0 = P1, 1 = P2) ha pulsat el botó de salt o el
// d'atac (qualsevol dels dos). Usat per la GameScene per acceptar
// qualsevol "botó d'acció" com a senyal de skip del countdown de
// CONTINUE? un cop el número visible cau a un llindar configurable.
// Mateix patró que `pressedStart`: combina teclat (Defaults::Keys)
// i gamepad (GamepadBindings).
[[nodiscard]] auto pressedActionButton(int player_id) -> bool;
} // namespace Input::Detect
+3 -27
View File
@@ -8,8 +8,6 @@
#include <utility> #include <utility>
#include "core/audio/audio.hpp" #include "core/audio/audio.hpp"
#include "core/input/gamepad_bindings.hpp"
#include "core/input/gamepad_manager.hpp"
#include "core/input/global_inputs.hpp" #include "core/input/global_inputs.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/notifier.hpp" #include "core/rendering/notifier.hpp"
@@ -18,11 +16,10 @@
#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"
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "game/scenes/scene_flow.hpp" #include "game/scenes/scene_flow.hpp"
#include "utils/defaults.hpp" #include "game/scenes/start_input.hpp"
#include "utils/defines.hpp" #include "utils/defines.hpp"
#include "utils/easing.hpp" #include "utils/easing.hpp"
#include "utils/log.hpp" #include "utils/log.hpp"
@@ -33,27 +30,6 @@ namespace {
// docs/audit-core-rendering.md). // docs/audit-core-rendering.md).
const char* const SCENE_NAME = "TITLE"; const char* const SCENE_NAME = "TITLE";
// Detecta una pulsació de START dins el frame actual per al jugador
// indicat. Combina events de teclat (scancode hardcoded a Defaults::
// KeysGame[P2]) i events de gamepad (botó remapejable via
// GamepadBindings, lligat al pad assignat al jugador via Options).
auto pressedStart(int player_id) -> bool {
const SDL_Scancode KB = (player_id == 0) ? Defaults::KeysGame::START : Defaults::KeysGameP2::START;
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0;
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
: Options::controls().gamepad_p2;
const int START_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::START);
return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) {
if (ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == KB) { return true; }
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID && static_cast<int>(ev.gbutton.button) == START_BTN) {
return true;
}
return false;
});
}
// Reproducció del so de START. Centralitzat ací per disparar-lo des // Reproducció del so de START. Centralitzat ací per disparar-lo des
// dels punts on una pulsació té efecte real (entrada a STARTING o // dels punts on una pulsació té efecte real (entrada a STARTING o
// upgrade a BOTH); l'attract timeout no l'ha de tocar. // upgrade a BOTH); l'attract timeout no l'ha de tocar.
@@ -139,8 +115,8 @@ void TitleScene::update(int delta_ms) {
} }
} }
const bool P1_START = pressedStart(0); const bool P1_START = Input::Detect::pressedStart(0);
const bool P2_START = pressedStart(1); const bool P2_START = Input::Detect::pressedStart(1);
switch (phase_) { switch (phase_) {
case Phase::IDLE: case Phase::IDLE:
+52 -6
View File
@@ -69,9 +69,10 @@ namespace {
} // namespace } // namespace
Hud::Hud(EventBus& bus, std::shared_ptr<Text> font) Hud::Hud(EventBus& bus, std::shared_ptr<Text> font, int initial_lives)
: bus_(bus), : bus_(bus),
font_(std::move(font)) { font_(std::move(font)),
initial_lives_(initial_lives) {
// Geometria fixa dels tres blocs. Sumen exactament GameCanvas::WIDTH. // Geometria fixa dels tres blocs. Sumen exactament GameCanvas::WIDTH.
// La durada de l'animació d'entrada ve de data/config/ui.yaml via UiConfig. // La durada de l'animació d'entrada ve de data/config/ui.yaml via UiConfig.
const int ENTRY_MS = UiConfig::get().hudEntryMs(); const int ENTRY_MS = UiConfig::get().hudEntryMs();
@@ -103,10 +104,17 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font)
.entry_offset_y = 0, .entry_offset_y = 0,
}; };
// Per defecte, P1 actiu (sempre n'hi ha al menys un); P2 inactiu fins // Naixem amb tots dos slots inactius. El GameScene crida
// que un segon Player s'uneix mitjançant `activatePlayer(2)`. // `activatePlayer(...)` per als que arrenquen en partida segons
players_[0].active = true; // `starting_players` (un, l'altre, o tots dos). Els no activats
// mostraran el prompt "POLSA START / PER JUGAR" via `setJoinPrompt`
// fins que el jugador faci join. Les vides s'inicialitzen al valor
// del YAML del jugador — sense això, canviar `lives:` no es
// reflectiria al marcador.
players_[0].active = false;
players_[0].lives = initial_lives_;
players_[1].active = false; players_[1].active = false;
players_[1].lives = initial_lives_;
// Subscripcions al bus de l'EntityManager. De moment només `player_hit` // Subscripcions al bus de l'EntityManager. De moment només `player_hit`
// afecta el HUD visualment: decrementa vides, dispara flash i shake al // afecta el HUD visualment: decrementa vides, dispara flash i shake al
@@ -136,6 +144,18 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font)
Log::info("HUD", "level_complete (mode Win pendent)"); Log::info("HUD", "level_complete (mode Win pendent)");
})); }));
// `player_disabled`: el jugador acaba d'esgotar les seves vides al
// final del cicle DYING. Tornem el bloc al render inactiu — el
// prompt textual el fixa GameScene cada frame via `setJoinPrompt`,
// així que ací només cal apagar `active`. Els camps lives/score/
// treasures es preserven per si en una versió futura volem mostrar
// el resultat final abans de tornar a zero.
subs_.push_back(bus_.subscribe("player_disabled", [this](const Event& ev) {
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
players_[IDX].active = false;
Log::info("HUD", "player_disabled p{}", IDX + 1);
}));
Log::info("HUD", "init: 3 blocks ready (entry {} ms)", ENTRY_MS); Log::info("HUD", "init: 3 blocks ready (entry {} ms)", ENTRY_MS);
} }
@@ -164,9 +184,15 @@ void Hud::activatePlayer(int player_one_based) {
if (player_one_based < 1 || player_one_based > 2) { return; } if (player_one_based < 1 || player_one_based > 2) { return; }
auto& p = players_[static_cast<std::size_t>(player_one_based - 1)]; auto& p = players_[static_cast<std::size_t>(player_one_based - 1)];
p.active = true; p.active = true;
p.lives = DEFAULT_LIVES; p.lives = initial_lives_;
p.score = 0; p.score = 0;
p.treasures = 0; p.treasures = 0;
p.join_prompt = JoinPromptMode::NONE;
}
void Hud::setJoinPrompt(int player_one_based, JoinPromptMode mode) {
if (player_one_based < 1 || player_one_based > 2) { return; }
players_[static_cast<std::size_t>(player_one_based - 1)].join_prompt = mode;
} }
auto Hud::computeOffset(const Block& b) -> SDL_FPoint { auto Hud::computeOffset(const Block& b) -> SDL_FPoint {
@@ -234,6 +260,26 @@ 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)];
// Slot inactiu amb prompt actiu: substituïm el render de zeros pel
// missatge "PULSA START / PARA JUGAR" o "ESPERA / PARA JUGAR" segons
// el mode fixat per la GameScene cada frame. El render de zeros
// queda com a fallback (mode NONE o slot actiu).
if (!p.active && p.join_prompt != JoinPromptMode::NONE) {
const bool CAN_JOIN = (p.join_prompt == JoinPromptMode::CAN_JOIN);
const std::string L1 = Locale::get().text(
CAN_JOIN ? "hud.join_prompt_can_join_l1" : "hud.join_prompt_wait_l1");
const std::string L2 = Locale::get().text(
CAN_JOIN ? "hud.join_prompt_can_join_l2" : "hud.join_prompt_wait_l2");
const int L1_W = font_->length(L1);
const int L2_W = font_->length(L2);
const int X1 = x + ((w - L1_W) / 2);
const int X2 = x + ((w - L2_W) / 2);
font_->writeColored(X1, y + LINE1_Y, L1, UiConfig::get().hudLabel());
font_->writeColored(X2, y + LINE2_Y, L2, UiConfig::get().hudValue());
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) ? UiConfig::get().hudFlash() : UiConfig::get().hudValue();
// --- Línia 1: "<label>-000000" centrada --- // --- Línia 1: "<label>-000000" centrada ---
+39 -8
View File
@@ -41,7 +41,25 @@ class Hud {
WIN, WIN,
EMPTY }; EMPTY };
Hud(EventBus& bus, std::shared_ptr<Text> font); // Prompt mostrat al bloc d'un slot inactiu (en lloc dels zeros). El
// GameScene fixa aquest valor cada frame segons el `RoomFsm::State`
// perquè el missatge sigui coherent amb si el jugador pot pulsar
// START per entrar ara mateix o si ha d'esperar al canvi de fase.
// - NONE → render històric (zeros). Per a slots actius o per
// quan el GameScene no vol ensenyar prompt.
// - CAN_JOIN → "PULSA START / PARA JUGAR". Slot inactiu en PLAYING.
// - WAIT → "ESPERA / PARA JUGAR". Slot inactiu durant
// ROOM_INTRO/ROOM_OUTRO i, en fases futures, després
// de GAME OVER.
enum class JoinPromptMode : std::uint8_t { NONE,
CAN_JOIN,
WAIT };
// `initial_lives` es la cantidad de vides amb què s'activa un slot
// (P1 al ctor i, més tard, els que arriben via "join in progress").
// Ve del YAML del jugador (`Player::death_config.initial_lives`),
// així el marcador segueix automàticament la configuració del joc.
Hud(EventBus& bus, std::shared_ptr<Text> font, int initial_lives);
~Hud(); ~Hud();
Hud(const Hud&) = delete; Hud(const Hud&) = delete;
@@ -71,6 +89,14 @@ class Hud {
// arrenca amb les vides per defecte. // arrenca amb les vides per defecte.
void activatePlayer(int player_one_based); void activatePlayer(int player_one_based);
// Fixa el prompt visible al bloc d'un slot inactiu (1-based: 1 → P1,
// 2 → P2). Cridat des de `GameScene::update()` cada frame perquè el
// missatge depèn de l'estat de la `RoomFsm` (vegeu enum
// `JoinPromptMode`). Sobre un slot actiu el prompt no es renderitza
// — el camp existeix per simetria i per evitar branques al caller.
// Idempotent: cridar amb el mateix mode és no-op.
void setJoinPrompt(int player_one_based, JoinPromptMode mode);
// Geometria pública perquè la GameScene puga calcular si el HUD // Geometria pública perquè la GameScene puga calcular si el HUD
// s'està movent encara (p. ex. retardar l'spawn fins que estiga al // s'està movent encara (p. ex. retardar l'spawn fins que estiga al
// seu lloc) — opcional, no obligatori. // seu lloc) — opcional, no obligatori.
@@ -89,10 +115,6 @@ class Hud {
static constexpr int LINE1_Y = 4; static constexpr int LINE1_Y = 4;
static constexpr int LINE2_Y = 13; static constexpr int LINE2_Y = 13;
// Vides inicials per jugador (placeholder fins que GameScene ho
// configure des del Config del nivell).
static constexpr int DEFAULT_LIVES = 3;
struct Block { struct Block {
Mode mode{Mode::IDLE}; Mode mode{Mode::IDLE};
// Coordenades de pantalla on viu el bloc (constants durant la // Coordenades de pantalla on viu el bloc (constants durant la
@@ -121,12 +143,17 @@ class Hud {
}; };
struct PlayerStats { struct PlayerStats {
int lives{DEFAULT_LIVES}; int lives{0};
int score{0}; int score{0};
int treasures{0}; int treasures{0};
// `false` → el bloc mostra `000000`/`0`/`00` (jugador inactiu). // `false` → el bloc mostra el prompt actiu (`join_prompt`) o
// Sempre es renderitza per mantenir el layout fix. // bé zeros si el prompt és NONE. Sempre es renderitza per
// mantenir el layout fix.
bool active{false}; bool active{false};
// Missatge a mostrar quan `active==false`. El GameScene el
// refresca cada frame segons l'estat de la RoomFsm (vegeu
// `Hud::setJoinPrompt`).
JoinPromptMode join_prompt{JoinPromptMode::NONE};
}; };
// Pinta el bloc indicat amb el seu fons + el contingut del seu mode // Pinta el bloc indicat amb el seu fons + el contingut del seu mode
@@ -152,4 +179,8 @@ class Hud {
std::array<PlayerStats, 2> players_; std::array<PlayerStats, 2> players_;
int pyramid_{1}; int pyramid_{1};
int room_{1}; int room_{1};
// Vides amb què s'arrenca un slot (`activatePlayer`). Cachejat al
// ctor des de `Player::death_config.initial_lives` per no llegir el
// singleton estàtic en el camí calent del bus de events.
int initial_lives_{0};
}; };
+39
View File
@@ -17,6 +17,7 @@
#include "core/rendering/text.hpp" #include "core/rendering/text.hpp"
#include "core/system/event_buffer.hpp" #include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "game/gameplay_config.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "game/options_apply.hpp" #include "game/options_apply.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
@@ -692,6 +693,8 @@ auto ServiceMenu::buildRoot() -> Page {
[]() { ServiceMenu::get().pushPage(buildAudio()); })); []() { ServiceMenu::get().pushPage(buildAudio()); }));
page.items.push_back(makeSubmenu("menu.items.controls_submenu", page.items.push_back(makeSubmenu("menu.items.controls_submenu",
[]() { ServiceMenu::get().pushPage(buildControls()); })); []() { ServiceMenu::get().pushPage(buildControls()); }));
page.items.push_back(makeSubmenu("menu.items.tweaks_submenu",
[]() { ServiceMenu::get().pushPage(buildTweaks()); }));
page.items.push_back(makeSubmenu("menu.items.system_submenu", page.items.push_back(makeSubmenu("menu.items.system_submenu",
[]() { ServiceMenu::get().pushPage(buildSystem()); })); []() { ServiceMenu::get().pushPage(buildSystem()); }));
return page; return page;
@@ -1155,6 +1158,42 @@ void ServiceMenu::startDefineButtons(SDL_Gamepad* pad, const std::string& name,
define_buttons_.enable(pad, name, path); define_buttons_.enable(pad, name, path);
} }
auto ServiceMenu::buildTweaks() -> Page {
// Submenú "Tweaks" — flags de jugabilitat persistits a `gameplay.yaml`
// dins la carpeta de sistema. Cada toggle muta `GameplayConfig` i
// n'invoca `save()` immediatament: l'usuari veu el canvi reflectit al
// joc sense esperar a un "aplicar" i, si tanca el joc, torna en el
// mateix estat. Tres toggles ON/OFF, sense estat addicional.
Page page;
page.title_key = "menu.titles.tweaks";
page.items.push_back(makeToggle(
"menu.items.whip_pierces_walls",
[]() { return GameplayConfig::get().whipDamagePiercesWalls(); },
[](bool v) {
GameplayConfig::get().setWhipDamagePiercesWalls(v);
GameplayConfig::get().save();
}));
page.items.push_back(makeToggle(
"menu.items.friendly_fire",
[]() { return GameplayConfig::get().friendlyFire(); },
[](bool v) {
GameplayConfig::get().setFriendlyFire(v);
GameplayConfig::get().save();
}));
page.items.push_back(makeToggle(
"menu.items.players_collide",
[]() { return GameplayConfig::get().playersCollide(); },
[](bool v) {
GameplayConfig::get().setPlayersCollide(v);
GameplayConfig::get().save();
}));
return page;
}
auto ServiceMenu::buildSystem() -> Page { auto ServiceMenu::buildSystem() -> Page {
Page page; Page page;
page.title_key = "menu.titles.system"; page.title_key = "menu.titles.system";
+1
View File
@@ -253,6 +253,7 @@ class ServiceMenu {
[[nodiscard]] static auto buildKeyboardControls() -> Page; [[nodiscard]] static auto buildKeyboardControls() -> Page;
[[nodiscard]] static auto buildGamepadControls() -> Page; [[nodiscard]] static auto buildGamepadControls() -> Page;
[[nodiscard]] static auto buildSystem() -> Page; [[nodiscard]] static auto buildSystem() -> Page;
[[nodiscard]] static auto buildTweaks() -> Page;
// Helpers privats d'items de vídeo: aïllen la lògica de // Helpers privats d'items de vídeo: aïllen la lògica de
// `shader_type` i `preset` fora de `buildVideo` perquè no exploti la // `shader_type` i `preset` fora de `buildVideo` perquè no exploti la
+1
View File
@@ -508,6 +508,7 @@ namespace Defaults {
constexpr const char* MUSIC_PLAYING = "data/audio/music/playing.ogg"; constexpr const char* MUSIC_PLAYING = "data/audio/music/playing.ogg";
constexpr const char* MUSIC_ROOM_INTRO = "data/audio/music/room_intro.ogg"; constexpr const char* MUSIC_ROOM_INTRO = "data/audio/music/room_intro.ogg";
constexpr const char* MUSIC_ROOM_OUTRO = "data/audio/music/room_outro.ogg"; constexpr const char* MUSIC_ROOM_OUTRO = "data/audio/music/room_outro.ogg";
constexpr const char* MUSIC_GAME_OVER = "data/audio/music/game_over.ogg";
// Gràfics de logo / title pre-carregats pel Director. // Gràfics de logo / title pre-carregats pel Director.
constexpr const char* LOGO_MINI_GIF = "data/scenes/logo/logo_jailgames_mini.gif"; constexpr const char* LOGO_MINI_GIF = "data/scenes/logo/logo_jailgames_mini.gif";
+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.38.0"; // Single source of truth — Makefile el grep constexpr const char* VERSION = "0.41.0"; // Single source of truth — Makefile el grep
} // namespace Texts } // namespace Texts
namespace GameCanvas { namespace GameCanvas {
+109
View File
@@ -69,6 +69,15 @@ namespace {
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__) #define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra).
// Equival al que fa GameScene al final del text-exit de ROOM_INTRO: arma
// el comptador i consumeix la durada amb un update. Tests que operin
// directament sobre HURT/ROAMING l'han de cridar abans.
void exitMummySpawn(Mummy& mummy) {
mummy.triggerSpawnExit();
mummy.update(mummy.getSpawnDurationS() + 0.001F);
}
void testFactory() { void testFactory() {
std::cout << "[T1] factory\n"; std::cout << "[T1] factory\n";
auto& factory = EntityFactory::instance(); auto& factory = EntityFactory::instance();
@@ -442,6 +451,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 +484,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.
@@ -496,6 +507,101 @@ namespace {
REQUIRE(mummy.getState() == Mummy::State::ROAMING); REQUIRE(mummy.getState() == Mummy::State::ROAMING);
} }
void testMummyHurtSnapsAtPlayfieldWall() {
std::cout << "[T18] mummy HURT amarra a la vora del playfield en topar\n";
// Regressió: amb dt no-exacte (joc real → SDL_GetTicks ms) el drift
// sub-pixel de la cursa HURT deixava la BOX a 0.x px dins de l'última
// cel·la quan topava amb el mur. Com que la BOX 16×16 cabe exacta al
// corredor 16×16, qualsevol drift fa que la cantonada perpendicular
// sobresurti cap als sarcs adjacents — i el `chooseHurtDirection` del
// proper fueteig classifica les perpendiculars com a bloquejades per
// micro-overlap, retornant NONE i deixant la mòmia eternament quieta
// amb l'animació de córrer activa. El fix és snap a la vora exacta
// dins de `hurtStep` abans de transitar a ROAMING.
Map::loadFrom("data/maps/pyramid.yaml");
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
// Caixa real del joc (YAML): collision 16×16 als peus del sprite 16×32.
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
// Comencem prou a prop del mur perquè la cursa hi arribi abans que
// 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});
REQUIRE(mummy.getCollisionRect().x == 40.0F);
exitMummySpawn(mummy);
mummy.enterHurt(Mummy::Direction::WEST);
REQUIRE(mummy.getState() == Mummy::State::HURT);
REQUIRE(mummy.getDirX() == -1.0F);
// Avancem amb dt no-divisor de 1 (joc real) fins que topa el mur.
for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) {
mummy.update(0.0173F);
}
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
// El fix garanteix snap exacte a playfield_x = 24.
REQUIRE(mummy.getCollisionRect().x == static_cast<float>(Map::playfield_x));
}
void testMummyRoamingRecoversFromZeroDir() {
std::cout << "[T19] mummy ROAMING amb dir=(0,0) re-decideix la propera step()\n";
// Xarxa de seguretat: si per qualsevol via la mòmia entra a ROAMING
// amb dir_x_/dir_y_ a zero, el `step()` anterior feia early return i
// es quedava quieta per sempre. Ara re-decideix com a una intersecció
// si troba alguna direcció vàlida.
Map::loadFrom("data/maps/pyramid.yaml");
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
// Posicionem-la a un creuament aliniat (interior, lluny de murs i
// sarcs problemàtics): vertical x=88 ∩ horitzontal y=80.
mummy.setPixelRect({.x = 88.0F, .y = 64.0F, .w = 16.0F, .h = 32.0F});
// Forcem dir=(0,0) sense passar per HURT — simula l'estat patològic.
mummy.setDirection(0.0F, 0.0F);
REQUIRE(mummy.getDirX() == 0.0F);
REQUIRE(mummy.getDirY() == 0.0F);
// Un sol step amb dt típic ha d'assignar alguna direcció no-zero.
mummy.step(0.0167F);
const bool RECOVERED = mummy.getDirX() != 0.0F || mummy.getDirY() != 0.0F;
REQUIRE(RECOVERED);
}
void testMummyHurtCornerNoSoftLock() {
std::cout << "[T20] mummy HURT contra cantonada + re-whip no fa soft-lock\n";
// Integració: cursa HURT cap a una cantonada del playfield, després
// fueteig immediat al mateix lloc. Amb el bug, la mòmia quedava amb
// dir=(0,0) i no es movia per molts fueteigs que rebés. Amb el fix
// (snap a hurtStep + recovery a step), o bé `chooseHurtDirection`
// troba una direcció lliure des de la posició aliniada, o bé el
// proper step la rescata. En cap cas pot quedar permanentment quieta.
Map::loadFrom("data/maps/pyramid.yaml");
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
Mummy mummy(tmpl);
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
// Mateix runway que T18.
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
exitMummySpawn(mummy);
mummy.enterHurt(Mummy::Direction::WEST);
for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) {
mummy.update(0.0173F);
}
// Fueteig immediat: enterHurt al moment exacte de quedar al mur.
mummy.enterHurt(Mummy::Direction::WEST);
// Després d'una desena de frames, la mòmia s'ha de moure ja sigui via
// la fugida HURT (perpendicular trobada) o via recovery a ROAMING.
const auto BEFORE = mummy.getPixelRect();
for (int i = 0; i < 60; ++i) { mummy.update(0.0173F); }
const auto AFTER = mummy.getPixelRect();
const float DX_TOTAL = AFTER.x - BEFORE.x;
const float DY_TOTAL = AFTER.y - BEFORE.y;
REQUIRE((DX_TOTAL != 0.0F) || (DY_TOTAL != 0.0F));
}
void testMummyWhippableFlag() { void testMummyWhippableFlag() {
std::cout << "[T17] mummy YAML llig `whippable` correctament\n"; std::cout << "[T17] mummy YAML llig `whippable` correctament\n";
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy"); fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
@@ -584,6 +690,9 @@ auto main() -> int {
testMummyStep(); testMummyStep();
testMummyHurtState(); testMummyHurtState();
testMummyHurtAgainstWall(); testMummyHurtAgainstWall();
testMummyHurtSnapsAtPlayfieldWall();
testMummyRoamingRecoversFromZeroDir();
testMummyHurtCornerNoSoftLock();
testMummyWhippableFlag(); testMummyWhippableFlag();
testTorchAnimation(); testTorchAnimation();
testExitedAggregator(); testExitedAggregator();
+20 -5
View File
@@ -32,6 +32,14 @@ namespace {
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__) #define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra). Els
// tests del whip necessiten que la mòmia estigui ACTIVE per reaccionar
// al fueteig — sense això, `enterHurt` torna early per `isSpawning()`.
void exitMummySpawn(Mummy& mummy) {
mummy.triggerSpawnExit();
mummy.update(mummy.getSpawnDurationS() + 0.001F);
}
// 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 +208,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 +269,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 +288,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);
} }