Compare commits

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

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

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

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

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

Canvis:

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

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

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

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

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

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

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

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

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

Comportament idèntic, tests verds. Aquest commit prepara el següent:
amb el polimorfisme madurat, el sac dual ja no aporta valor i podem
tornar a un únic `entities_`.
2026-05-11 13:01:26 +02:00
JailDesigner e8418f89e0 chore(version): bump 0.40.0 -> 0.41.0 2026-05-11 11:31:24 +02:00
JailDesigner 9ca09484a6 Merge branch 'feat/dynamic-hazard-spawn-state'
Spawn on-demand de DynamicHazards via dos sacs a EntityManager:
les mòmies (i futurs hazards mòbils) ara s'instancien just quan
GameScene les "spawneja", no abans. Cinemàtica ROOM_INTRO neta:
mapa buit durant el text, mòmies surten emergint al text-exit
(fase 4, durada 1.0 s), jugadors cauen del cel a continuació.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:43:38 +02:00
JailDesigner b7811180ba feat(game): spawn inicial de ROOM_INTRO sense immunitat 2026-05-11 07:53:56 +02:00
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
JailDesigner 0d58da536c chore(version): bump 0.37.0 -> 0.38.0 2026-05-08 11:16:22 +02:00
JailDesigner 583acb9053 fix(config): rutes per plataforma a createSystemFolder 2026-05-08 11:15:15 +02:00
107 changed files with 5777 additions and 1481 deletions

No files matched your search

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

After

Width:  |  Height:  |  Size: 805 B

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

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 303 B

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

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

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.6 KiB

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

After

Width:  |  Height:  |  Size: 98 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

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

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

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

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+19 -2
View File
@@ -14,7 +14,7 @@ input:
shader_off: "Shaders desactivats" shader_off: "Shaders desactivats"
vsync_on: "VSync activat" vsync_on: "VSync activat"
vsync_off: "VSync desactivat" vsync_off: "VSync desactivat"
screenshot: "Captura" screenshot: "Imatge {} guardada a {}"
shader: "Shader" shader: "Shader"
preset: "Preset" preset: "Preset"
scaling_disabled: "Escalat sense" scaling_disabled: "Escalat sense"
@@ -23,7 +23,6 @@ input:
scaling_overscan: "Escalat overscan" scaling_overscan: "Escalat overscan"
scaling_integer: "Escalat enter" scaling_integer: "Escalat enter"
press_again_exit: "Prem una altra volta per eixir" press_again_exit: "Prem una altra volta per eixir"
paused: "Pausa"
editor_on: "Editor ON" editor_on: "Editor ON"
editor_off: "Editor OFF" editor_off: "Editor OFF"
editor_save_error: "Editor: error guardant el mapa" editor_save_error: "Editor: error guardant el mapa"
@@ -38,6 +37,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 +59,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 +74,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 +86,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 +115,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:
+19 -2
View File
@@ -14,7 +14,7 @@ input:
shader_off: "Shaders off" shader_off: "Shaders off"
vsync_on: "VSync on" vsync_on: "VSync on"
vsync_off: "VSync off" vsync_off: "VSync off"
screenshot: "Screenshot" screenshot: "Image {} saved at {}"
shader: "Shader" shader: "Shader"
preset: "Preset" preset: "Preset"
scaling_disabled: "Scaling off" scaling_disabled: "Scaling off"
@@ -23,7 +23,6 @@ input:
scaling_overscan: "Scaling overscan" scaling_overscan: "Scaling overscan"
scaling_integer: "Scaling integer" scaling_integer: "Scaling integer"
press_again_exit: "Press again to exit" press_again_exit: "Press again to exit"
paused: "Paused"
editor_on: "Editor ON" editor_on: "Editor ON"
editor_off: "Editor OFF" editor_off: "Editor OFF"
editor_save_error: "Editor: map save failed" editor_save_error: "Editor: map save failed"
@@ -38,6 +37,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 +58,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 +73,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 +85,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 +114,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:
+33 -4
View File
@@ -10,11 +10,38 @@
# la cel·la buida (transparent), útil per caselles que ocuparà un actor. # la cel·la buida (transparent), útil per caselles que ocuparà un actor.
pyramid: 1 pyramid: 1
room: 1 room: 2
tileset: data/tilesets/pyramid_1.gif tileset: data/tilesets/pyramid_1.gif
tile_size: 16 tile_size: 16
origin: [-8, 0] origin: [-8, 0]
# Banc de proves del mode `additive` d'il·luminació. La sala es veu
# "normal" (level 1.0, el GIF intacte fora del cercle de les torxes)
# i només les antorxes hi sumen un halo càlid suau. Player i mummies
# desactivats explícitament perquè aquí no volem cap halo al voltant
# d'actors — el toc cromàtic és només l'antorxa. La corba additiva
# (intensitats baixes a propòsit perquè additive satura ràpid) viu a
# `gradient_additive:` de data/config/lighting.yaml.
lighting:
mode: additive
level: 1.0
emitters:
torches:
radius: 20
color: [255, 170, 60]
# Valors default del flicker explicitats per anar-los ajustant
# in-situ. Coincideixen amb els del global a data/config/lighting.yaml;
# baixar wave_frequency_hz fa el "palpitar" més lent, baixar les
# ratios fa l'oscil·lació més subtil. Qualsevol camp es pot
# eliminar i caurà al valor del global.
flicker_params:
wave_amplitude_ratio: 0.04
wave_frequency_hz: 3.0
noise_amplitude_ratio: 0.025
noise_frequency_hz: 3.0
players: false
mummies: false
# Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl # Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl
# pintable. Un índex per jugador perquè a multijugador local el recorregut # pintable. Un índex per jugador perquè a multijugador local el recorregut
# es distingeix a simple vista. Provisional fins que l'art del pintat # es distingeix a simple vista. Provisional fins que l'art del pintat
@@ -82,6 +109,8 @@ data:
# d'obertura (262/263). La momia al corredor central. L'antorxa al costat # d'obertura (262/263). La momia al corredor central. L'antorxa al costat
# superior esquerre per verificar render d'una entitat decorativa. # superior esquerre per verificar render d'una entitat decorativa.
entities: entities:
- type: torch
position: [4, 2]
- type: torch - type: torch
position: [8, 2] position: [8, 2]
- type: torch - type: torch
@@ -128,8 +157,8 @@ entities:
sarc_position: [2, 4] sarc_position: [2, 4]
- type: sarcophagus - type: sarcophagus
sarc_position: [3, 4] sarc_position: [3, 4]
- type: torch
position: [4, 2]
- type: door - type: door
- type: mummy - type: mummy
position: [10, 11] position: [10, 7]
- type: mummy
position: [7, 9]
+56 -13
View File
@@ -10,11 +10,38 @@
# la cel·la buida (transparent), útil per caselles que ocuparà un actor. # la cel·la buida (transparent), útil per caselles que ocuparà un actor.
pyramid: 1 pyramid: 1
room: 2 room: 1
tileset: data/tilesets/pyramid_1.gif tileset: data/tilesets/pyramid_1.gif
tile_size: 16 tile_size: 16
origin: [-8, 0] origin: [-8, 0]
# Il·luminació de la sala. Bloc opcional; si manca, la sala queda
# totalment il·luminada (1.0) i tots els tipus d'entitat emeten amb els
# valors declarats al seu YAML de tipus (comportament històric).
#
# level → atenuació base de la sala (0..1). 1.0 = res
# d'efecte, 0.0 = negre fora del radi dels emissors.
# El gradient dels emissors viu a
# data/config/lighting.yaml.
# emitters.<tipus> → override per tipus d'entitat. Tres formes:
# false → apaga el tipus
# true → defaults del tipus
# { radius: N, flicker: B } → camps presents
# sobreescriuen el
# default; els
# absents cauen al
# default del tipus.
# Plural per llegir-se natural ("totes les momies de la sala"). Per a
# casos particulars, un spawn pot dur el seu propi `light:` (mateixes
# formes) que té prioritat sobre l'override de sala. La forma antiga
# (`light_level:` plà a l'arrel) també segueix sent vàlida per
# retrocompatibilitat.
lighting:
level: 0.15
emitters:
players: true
mummies: false
# Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl # Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl
# pintable. Un índex per jugador perquè a multijugador local el recorregut # pintable. Un índex per jugador perquè a multijugador local el recorregut
# es distingeix a simple vista. Provisional fins que l'art del pintat # es distingeix a simple vista. Provisional fins que l'art del pintat
@@ -82,16 +109,12 @@ data:
# d'obertura (262/263). La momia al corredor central. L'antorxa al costat # d'obertura (262/263). La momia al corredor central. L'antorxa al costat
# superior esquerre per verificar render d'una entitat decorativa. # superior esquerre per verificar render d'una entitat decorativa.
entities: entities:
- type: torch
position: [4, 2]
- type: torch - type: torch
position: [8, 2] position: [8, 2]
- type: torch - type: torch
position: [12, 2] position: [12, 2]
- type: torch - type: torch
position: [16, 2] position: [16, 2]
- type: sarcophagus
sarc_position: [0, 0]
- type: sarcophagus - type: sarcophagus
sarc_position: [1, 0] sarc_position: [1, 0]
- type: sarcophagus - type: sarcophagus
@@ -118,8 +141,6 @@ entities:
sarc_position: [0, 3] sarc_position: [0, 3]
- type: sarcophagus - type: sarcophagus
sarc_position: [1, 3] sarc_position: [1, 3]
- type: sarcophagus
sarc_position: [2, 3]
- type: sarcophagus - type: sarcophagus
sarc_position: [3, 3] sarc_position: [3, 3]
- type: sarcophagus - type: sarcophagus
@@ -130,11 +151,33 @@ entities:
sarc_position: [2, 4] sarc_position: [2, 4]
- type: sarcophagus - type: sarcophagus
sarc_position: [3, 4] sarc_position: [3, 4]
- type: torch
position: [4, 2]
- type: door - type: door
# Sense `position`: la porta sempre viu al mur trasser. La columna - type: column
# transitable la dicta `geometry.door_col`; el render i la caixa position: [3, 4]
# de col·lisió es deriven d'aquesta dada al loadFromLevel. frame: 2
- type: stone
position: [5, 4]
frame: 16
- type: stone
position: [4, 4]
frame: 19
- type: column
position: [13, 10]
frame: 4
- type: stone
position: [12, 10]
frame: 13
- type: stone
position: [11, 10]
frame: 23
- type: hole
position: [5, 9]
- type: hole
position: [9, 11]
- type: stone
position: [10, 10]
frame: 18
- type: mummy - type: mummy
position: [10, 7] position: [10, 13]
- type: mummy
position: [7, 9]
+51 -3
View File
@@ -1,6 +1,6 @@
# Configuració de la GameScene: timings i corbes de la FSM de sala # Configuració de la GameScene: timings i corbes de la FSM de sala
# (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria # (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria
# i font del text es reutilitzen del tema UI (data/config/ui.yaml > pause + # i font del text es reutilitzen del tema UI (data/config/ui.yaml >
# notification). Si manca el fitxer o alguna clau, GameScene cau als # notification). Si manca el fitxer o alguna clau, GameScene cau als
# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp. # fallbacks compilats; veure `readSceneConfig` a game_scene.cpp.
@@ -43,6 +43,54 @@ text:
# text i decreix amb l'exit_curve. `color` són tres canals RGB (0..255); # text i decreix amb l'exit_curve. `color` són tres canals RGB (0..255);
# `alpha` és la transparència del blend (0 = invisible, 255 = opac). # `alpha` és la transparència del blend (0 = invisible, 255 = opac).
text_panel: text_panel:
color: [36, 182, 170] # Violeta cerimonial: contrasta amb l'or del text (room_text), no
alpha: 128 # compite amb el blau del bloc P2 del HUD, i deixa la sorra de les
# sales respirar al darrere.
color: [72, 36, 85]
alpha: 160
height_px: 32 height_px: 32
# Sub-FSM de game over: dispara CONTINUE? quan tots els jugadors
# 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
+23
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) {
@@ -129,6 +136,22 @@ void Audio::playSound(Ja::Sound* sound, Group group) {
} }
} }
// Variant amb velocitat (i to) escalats. Apliquem el ratio al canal
// just retornat per `playSound`: així el `SDL_AudioStream` recent creat
// processa tot el sample amb el ratio des del primer pull del callback.
// Si l'engine torna -1 (sense canal lliure) o el so no existeix, no fem
// la crida al ratio — sense efectes col·laterals.
// NOLINTNEXTLINE(readability-make-member-function-const)
void Audio::playSound(const std::string& name, Group group, float speed) {
if (!sound_enabled_) { return; }
auto* sound = AudioResource::getSound(name);
if (sound == nullptr) { return; }
const int CH = engine_->playSound(sound, 0, static_cast<int>(group));
if (CH >= 0 && speed != 1.0F) {
engine_->setChannelSpeed(CH, speed);
}
}
// Reprodueix un so processat per un eco definit a sounds.yaml. Si el preset no // Reprodueix un so processat per un eco definit a sounds.yaml. Si el preset no
// existeix o l'engine retorna -1 (cap de canals d'efecte plé), cau a playSound // existeix o l'engine retorna -1 (cap de canals d'efecte plé), cau a playSound
// sec — l'usuari sent el so encara que la cua no s'apliqui. // sec — l'usuari sent el so encara que la cua no s'apliqui.
+11
View File
@@ -76,10 +76,21 @@ 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)
void playSound(Ja::Sound* sound, Group group = Group::GAME); // Reproduir so puntual per punter (muta globals de Ja) void playSound(Ja::Sound* sound, Group group = Group::GAME); // Reproduir so puntual per punter (muta globals de Ja)
// Reprodueix un so amb la velocitat (i to) escalats per `speed`:
// 1.0 = normal, 0.95 ≈ -5% (més greu i lent), 1.05 ≈ +5% (més
// agut i ràpid). Mateixa semàntica que `setMusicSpeed`. Útil per a
// variacions subtils que eviten la fatiga d'escoltar el mateix
// sample idèntic (p.ex. obertures de sarcòfag, picks d'ítems).
void playSound(const std::string& name, Group group, float speed);
// Reprodueix un so processat per un efecte definit a data/config/sounds.yaml // Reprodueix un so processat per un efecte definit a data/config/sounds.yaml
// (preset_name busca a SoundEffectsConfig). Si el preset no existeix // (preset_name busca a SoundEffectsConfig). Si el preset no existeix
// o el motor està al cap de canals amb efecte, fa fallback a playSound // o el motor està al cap de canals amb efecte, fa fallback a playSound
+11
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; }
@@ -425,6 +430,12 @@ namespace Ja {
return channel; return channel;
} }
void Engine::setChannelSpeed(const int channel, const float ratio) {
if (channel < 0 || channel >= MAX_SIMULTANEOUS_CHANNELS) { return; }
if (channels_[channel].stream == nullptr) { return; }
SDL_SetAudioStreamFrequencyRatio(channels_[channel].stream, ratio);
}
void Engine::pauseChannel(const int channel) { void Engine::pauseChannel(const int channel) {
forEachTargetChannel(channel, [](Channel& ch) { forEachTargetChannel(channel, [](Channel& ch) {
if (ch.state == ChannelState::PLAYING) { if (ch.state == ChannelState::PLAYING) {
+16
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.
@@ -184,6 +192,14 @@ namespace Ja {
// --- So --- // --- So ---
auto playSound(Sound* sound, int loop = 0, int group = 0) -> int; auto playSound(Sound* sound, int loop = 0, int group = 0) -> int;
auto playSoundOnChannel(Sound* sound, int channel, int loop = 0, int group = 0) -> int; auto playSoundOnChannel(Sound* sound, int channel, int loop = 0, int group = 0) -> int;
// Ajusta la velocitat de reproducció d'un canal actiu via
// `SDL_SetAudioStreamFrequencyRatio`. 1.0 = normal. Igual que a
// `setMusicSpeed`, puja/baixa el to junt amb la velocitat
// (efecte "chipmunk"); per a SFX curts arcade és el que volem.
// No-op si el canal no està actiu. Cridar-lo just després de
// `playSound`/`playSoundOnChannel` perquè el ratio cobreixi
// tota la reproducció.
void setChannelSpeed(int channel, float ratio);
// Reproducció amb so processat per un efecte. Retorna el canal // Reproducció amb so processat per un efecte. Retorna el canal
// assignat o -1 si no queden slots d'efecte (MAX_EFFECT_CHANNELS). // assignat o -1 si no queden slots d'efecte (MAX_EFFECT_CHANNELS).
// El sound original només s'usa per consultar el spec/buffer; el // El sound original només s'usa per consultar el spec/buffer; el
+17 -5
View File
@@ -3,6 +3,7 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <format>
#include <string> #include <string>
#include "core/input/key_config.hpp" #include "core/input/key_config.hpp"
@@ -69,15 +70,26 @@ namespace GlobalInputs {
notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off"); notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off");
} }
// Volca el canvas ARGB actual a PNG dins de `screenshots/`. Si ha // Volca el canvas ARGB actual a PNG dins del directori de
// fallat l'escriptura, el handler no mostra notificació (retorn buit). // captures (configurat pel Director). La notificació informa
// del nom i de la ruta absoluta — així l'usuari sap on anar
// a buscar-la encara que el binari s'executi des d'un sitio
// estrany. Si l'escriptura falla, no es mostra notificació.
void onScreenshot() { void onScreenshot() {
const auto* buffer = Screen::get().getCanvasArgb(); const auto* buffer = Screen::get().getCanvasArgb();
const int W = Screen::get().getCanvasWidth(); const int W = Screen::get().getCanvasWidth();
const int H = Screen::get().getCanvasHeight(); const int H = Screen::get().getCanvasHeight();
const std::string FILENAME = Screenshot::save(buffer, W, H); const auto R = Screenshot::save(buffer, W, H);
if (!FILENAME.empty()) { if (!R.filename.empty()) {
Notification::notify(Locale::get().text("input.screenshot") + ' ' + FILENAME); // `make_format_args` retorna un store que conté referències
// als arguments; cal lligar-lo a una lvalue local perquè
// sobrevisqui fins que `vformat` el consumeixi. Passar-lo
// directament a `vformat` és UB en alguns compiladors
// (DR de C++20 / fix de C++23) i ha provocat un segfault
// post-captura en aquest projecte.
const std::string TEMPLATE = Locale::get().text("input.screenshot");
auto args = std::make_format_args(R.filename, R.folder);
Notification::notify(std::vformat(TEMPLATE, args));
} }
} }
+43
View File
@@ -0,0 +1,43 @@
#include "core/rendering/effects/shadow.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp"
namespace Effects::Shadow {
void render(int center_x, int feet_y, float height_factor) {
// Lookup únic al primer ús: la Surface viu a Cache durant tota la
// sessió, així evitem la cerca lineal a cada frame.
static const auto SURFACE = Resource::Cache::get().getSurface(CACHE_KEY);
const int FRAME_COUNT = SURFACE->getWidth() / FRAME_SIZE;
if (FRAME_COUNT <= 0) { return; }
// Mapping a bandes uniformes: factor 0 → últim (gran), factor 1 →
// primer (petit). Per a 3 fotogrames: [0, 1/3) → 2, [1/3, 2/3) → 1,
// [2/3, 1] → 0. Generalitza a qualsevol nombre de fotogrames per
// si afegim mides intermèdies.
const float CLAMPED = std::clamp(height_factor, 0.0F, 1.0F);
int frame_index = static_cast<int>((1.0F - CLAMPED) * static_cast<float>(FRAME_COUNT));
frame_index = std::clamp(frame_index, 0, FRAME_COUNT - 1);
SDL_FRect clip{
static_cast<float>(frame_index * FRAME_SIZE),
0.0F,
static_cast<float>(FRAME_SIZE),
static_cast<float>(FRAME_SIZE),
};
// Ancoratge: vora inferior del fotograma a `feet_y` — així el
// quadrat 16x16 del sprite encaixa exactament al 16x16 inferior
// d'una entitat 16x16/16x32 centrada en `center_x`.
const int X = center_x - (FRAME_SIZE / 2);
const int Y = feet_y - FRAME_SIZE;
SURFACE->render(X, Y, &clip);
}
} // namespace Effects::Shadow
+25
View File
@@ -0,0 +1,25 @@
#pragma once
namespace Effects::Shadow {
// Mida de cada fotograma del sprite compartit de la sombra.
// El sprite és una tira horitzontal de fotogrames de 16x16, ordenats
// del més petit (índex 0) al més gran (últim índex).
constexpr int FRAME_SIZE = 16;
// Clau amb què `Resource::Cache` té carregada la Surface compartida.
// El precarrega el Director al boot.
constexpr const char* CACHE_KEY = "shadow";
// Pinta la sombra al canvas. `height_factor` selecciona el fotograma:
// 0.0 (entitat al terra) → fotograma més gran, 1.0 (pic del salt o
// entitat més alta del terra) → fotograma més petit. La tira es
// divideix en bandes uniformes; valors fora de [0, 1] es clampen.
//
// El fotograma s'ancora al quadrat 16x16 inferior d'una entitat de
// 16x16/16x32 centrada en `center_x` amb peus a `feet_y`: la vora
// inferior del sprite queda exactament a `feet_y`. La posició
// visual de l'oval dins del fotograma la decideix l'artista al GIF.
void render(int center_x, int feet_y, float height_factor);
} // namespace Effects::Shadow
+376
View File
@@ -0,0 +1,376 @@
#include "core/rendering/lighting.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <numbers>
#include <vector>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp"
namespace Lighting {
namespace {
// Buffer de luminància compartit entre frames. Es redimensiona
// només si canvia la mida del canvas (mai en un run normal: el
// canvas del joc és 320×240 fix). El reusem perquè una `apply`
// allotjant cada frame seria un cost evitable.
auto luminanceBuffer() -> std::vector<std::uint8_t>& {
static std::vector<std::uint8_t> buf_;
return buf_;
}
// Corba activa del mode subtractive. loadConfig() la reemplaça
// si troba el bloc `gradient:` al YAML.
auto activeSubtractiveConfig() -> GradientConfig& {
static GradientConfig cfg_{
.inner_ratio = Defaults::Lighting::INNER_RATIO,
.mid_ratio = Defaults::Lighting::MID_RATIO,
.inner_intensity = Defaults::Lighting::INNER_INTENSITY,
.mid_intensity = Defaults::Lighting::MID_INTENSITY,
.outer_intensity = Defaults::Lighting::OUTER_INTENSITY,
};
return cfg_;
}
// Corba activa del mode additive. Defaults baixos a propòsit:
// en additive la intensitat és l'alfa del color afegit, així
// que valors petits (0.05..0.15) donen el "halo subtil" que
// sol voler-se. loadConfig() la reemplaça si troba el bloc
// `gradient_additive:` al YAML.
auto activeAdditiveConfig() -> GradientConfig& {
static GradientConfig cfg_{
.inner_ratio = Defaults::Lighting::INNER_RATIO_ADDITIVE,
.mid_ratio = Defaults::Lighting::MID_RATIO_ADDITIVE,
.inner_intensity = Defaults::Lighting::INNER_INTENSITY_ADDITIVE,
.mid_intensity = Defaults::Lighting::MID_INTENSITY_ADDITIVE,
.outer_intensity = Defaults::Lighting::OUTER_INTENSITY_ADDITIVE,
};
return cfg_;
}
auto activeFlicker() -> FlickerConfig& {
static FlickerConfig fc_{
.wave_amplitude_ratio = Defaults::Lighting::FLICKER_WAVE_AMPLITUDE_RATIO,
.wave_frequency_hz = Defaults::Lighting::FLICKER_WAVE_FREQUENCY_HZ,
.noise_amplitude_ratio = Defaults::Lighting::FLICKER_NOISE_AMPLITUDE_RATIO,
.noise_frequency_hz = Defaults::Lighting::FLICKER_NOISE_FREQUENCY_HZ,
};
return fc_;
}
// Modula el radi base d'un emissor amb la suma de dues sinusoides
// (`wave` + `noise`) i una fase derivada de `seed`. La seed és un
// identificador estable de l'emissor (no canvia amb la posició) —
// així el patró no "deriva" quan l'emissor es mou. El factor
// ~φ (golden ratio) dispersa les fases per a seeds consecutives.
// Les amplituds són ràtios sobre el radi base, multiplicades aquí
// → mateix percentatge d'oscil·lació per a qualsevol mida.
auto computeFlickerRadius(int base_radius, int seed, float t_s, const FlickerConfig& fc) -> int {
constexpr float TWO_PI = 2.0F * std::numbers::pi_v<float>;
constexpr float GOLDEN_FRACTION = 0.6180339F;
const float PHASE = static_cast<float>(seed) * GOLDEN_FRACTION;
const float W1 = std::sin((TWO_PI * fc.wave_frequency_hz * t_s) + PHASE);
const float W2 = std::sin((TWO_PI * fc.noise_frequency_hz * t_s) + (PHASE * 2.7F));
const auto R_F = static_cast<float>(base_radius);
const float DELTA = (fc.wave_amplitude_ratio * R_F * W1) + (fc.noise_amplitude_ratio * R_F * W2);
const int R = base_radius + static_cast<int>(std::lround(DELTA));
return std::max(1, R);
}
// Quantitza un factor 0..1 a 0..255. `clamp` per defensar-nos de
// YAMLs que entreguen valors fora de rang sense forçar la mà.
auto toByte(float v) -> std::uint8_t {
const float CLAMPED = std::clamp(v, 0.0F, 1.0F);
return static_cast<std::uint8_t>(std::lround(CLAMPED * 255.0F));
}
void readField(const fkyaml::node& node, const char* key, float& dst) {
if (node.contains(key)) { dst = node[key].get_value<float>(); }
}
// Pre-calcula els 3 quadrats de radi i les intensitats que farem
// servir dins del bucle per píxel. Es resol una sola vegada per
// emissor i s'evita arrastrar 6 paràmetres a `rasterizeEmitter`.
struct EmitterRings {
int radius_sq{0};
int mid_sq{0};
int inner_sq{0};
std::uint8_t inner{0};
std::uint8_t mid{0};
std::uint8_t outer{0};
};
auto computeRings(int radius, const GradientConfig& cfg) -> EmitterRings {
const auto R_F = static_cast<float>(radius);
const float R_INNER = R_F * cfg.inner_ratio;
const float R_MID = R_F * cfg.mid_ratio;
return EmitterRings{
.radius_sq = radius * radius,
.mid_sq = static_cast<int>(R_MID * R_MID),
.inner_sq = static_cast<int>(R_INNER * R_INNER),
.inner = toByte(cfg.inner_intensity),
.mid = toByte(cfg.mid_intensity),
.outer = toByte(cfg.outer_intensity),
};
}
// Itera la bounding box de l'emissor clipada al canvas i actualitza
// la fila de luminància corresponent amb `max(actual, ring)`. Es
// crida un cop per emissor, així extreure-la deixa `apply` simple.
void rasterizeEmitter(const Emitter& e, const EmitterRings& rings, std::uint8_t* lum, int width, int height) {
const int R = e.radius;
const int CX = static_cast<int>(e.cx);
const int CY = static_cast<int>(e.cy);
const int X0 = std::max(0, CX - R);
const int Y0 = std::max(0, CY - R);
const int X1 = std::min(width - 1, CX + R);
const int Y1 = std::min(height - 1, CY + R);
for (int y = Y0; y <= Y1; ++y) {
const int DY = y - CY;
const int DY_SQ = DY * DY;
if (DY_SQ > rings.radius_sq) { continue; }
std::uint8_t* row = lum + (static_cast<std::ptrdiff_t>(y) * width);
for (int x = X0; x <= X1; ++x) {
const int DX = x - CX;
const int DIST_SQ = (DX * DX) + DY_SQ;
if (DIST_SQ > rings.radius_sq) { continue; }
std::uint8_t intensity = rings.outer;
if (DIST_SQ <= rings.inner_sq) {
intensity = rings.inner;
} else if (DIST_SQ <= rings.mid_sq) {
intensity = rings.mid;
}
row[x] = std::max(intensity, row[x]);
}
}
}
// Pasada final del mode subtractive: pixel = pixel * lum / 255
// per canal. El canal alpha es preserva (la resta del pipeline
// assumeix opacitat total al canvas; veure Rendering::OPAQUE_ALPHA).
void modulateCanvas(std::uint32_t* canvas, const std::uint8_t* lum, int width, int height) {
for (int y = 0; y < height; ++y) {
std::uint32_t* px = canvas + (static_cast<std::ptrdiff_t>(y) * width);
const std::uint8_t* lrow = lum + (static_cast<std::ptrdiff_t>(y) * width);
for (int x = 0; x < width; ++x) {
const std::uint8_t L = lrow[x];
if (L == 255) { continue; } // píxel intacte.
const std::uint32_t SRC = px[x];
const std::uint8_t SR = (SRC >> 16) & 0xFF;
const std::uint8_t SG = (SRC >> 8) & 0xFF;
const std::uint8_t SB = SRC & 0xFF;
const auto R = static_cast<std::uint8_t>((SR * L) / 255);
const auto G = static_cast<std::uint8_t>((SG * L) / 255);
const auto B = static_cast<std::uint8_t>((SB * L) / 255);
px[x] = (SRC & 0xFF000000U) |
(static_cast<std::uint32_t>(R) << 16) |
(static_cast<std::uint32_t>(G) << 8) |
static_cast<std::uint32_t>(B);
}
}
}
// Atenuació ambient del mode additive: aplica `pixel × level` per
// canvas abans del bucle d'emissors. Si `level == 255` (sala a
// 1.0) salta com a no-op — el cas comú quan només volem el tint
// dels emissors sense oscurir la sala.
void darkenCanvas(std::uint32_t* canvas, int width, int height, std::uint8_t level) {
if (level == 255) { return; }
for (int y = 0; y < height; ++y) {
std::uint32_t* px = canvas + (static_cast<std::ptrdiff_t>(y) * width);
for (int x = 0; x < width; ++x) {
const std::uint32_t SRC = px[x];
const std::uint8_t SR = (SRC >> 16) & 0xFF;
const std::uint8_t SG = (SRC >> 8) & 0xFF;
const std::uint8_t SB = SRC & 0xFF;
const auto R = static_cast<std::uint8_t>((SR * level) / 255);
const auto G = static_cast<std::uint8_t>((SG * level) / 255);
const auto B = static_cast<std::uint8_t>((SB * level) / 255);
px[x] = (SRC & 0xFF000000U) |
(static_cast<std::uint32_t>(R) << 16) |
(static_cast<std::uint32_t>(G) << 8) |
static_cast<std::uint32_t>(B);
}
}
}
// Pasada del mode additive per a un emissor: suma `color × intensity`
// a cada píxel cobert pel cercle, saturant a 255 per canal. A
// diferència del subtractive, NO usa buffer intermig (no cal max())
// perquè la suma és commutativa; emissors solapats acumulen
// contribucions de manera natural. Si dues llums de colors diferents
// es solapen, el resultat tendeix al blanc en la zona de coincidència
// (suma física): el solape s'aclareix més que un sol emissor.
void rasterizeEmitterAdditive(const Emitter& e, const EmitterRings& rings, std::uint32_t* canvas, int width, int height) {
const int R = e.radius;
const int CX = static_cast<int>(e.cx);
const int CY = static_cast<int>(e.cy);
const int X0 = std::max(0, CX - R);
const int Y0 = std::max(0, CY - R);
const int X1 = std::min(width - 1, CX + R);
const int Y1 = std::min(height - 1, CY + R);
for (int y = Y0; y <= Y1; ++y) {
const int DY = y - CY;
const int DY_SQ = DY * DY;
if (DY_SQ > rings.radius_sq) { continue; }
std::uint32_t* row = canvas + (static_cast<std::ptrdiff_t>(y) * width);
for (int x = X0; x <= X1; ++x) {
const int DX = x - CX;
const int DIST_SQ = (DX * DX) + DY_SQ;
if (DIST_SQ > rings.radius_sq) { continue; }
std::uint8_t intensity = rings.outer;
if (DIST_SQ <= rings.inner_sq) {
intensity = rings.inner;
} else if (DIST_SQ <= rings.mid_sq) {
intensity = rings.mid;
}
// contrib_channel = emitter_color_channel × intensity / 255
// (intensity ja és 0..255 quantitzat a `toByte`).
const int CR = (e.color.r * intensity) / 255;
const int CG = (e.color.g * intensity) / 255;
const int CB = (e.color.b * intensity) / 255;
const std::uint32_t SRC = row[x];
const int SR = static_cast<int>((SRC >> 16) & 0xFF);
const int SG = static_cast<int>((SRC >> 8) & 0xFF);
const int SB = static_cast<int>(SRC & 0xFF);
const auto NR = static_cast<std::uint32_t>(std::min(255, SR + CR));
const auto NG = static_cast<std::uint32_t>(std::min(255, SG + CG));
const auto NB = static_cast<std::uint32_t>(std::min(255, SB + CB));
row[x] = (SRC & 0xFF000000U) | (NR << 16) | (NG << 8) | NB;
}
}
}
} // namespace
auto config() -> const GradientConfig& {
return activeSubtractiveConfig();
}
auto flickerConfig() -> const FlickerConfig& {
return activeFlicker();
}
namespace {
// Llegeix els 5 camps de `GradientConfig` d'un node YAML — qualsevol
// camp absent conserva el valor compilat dels defaults. Compartit
// entre els blocs `gradient:` i `gradient_additive:`.
void readGradient(const fkyaml::node& g, GradientConfig& cfg) {
readField(g, "inner_ratio", cfg.inner_ratio);
readField(g, "mid_ratio", cfg.mid_ratio);
readField(g, "inner_intensity", cfg.inner_intensity);
readField(g, "mid_intensity", cfg.mid_intensity);
readField(g, "outer_intensity", cfg.outer_intensity);
}
} // namespace
void loadConfig(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
Log::warn("Lighting", "no s'ha pogut obrir {} — s'usaran els defaults compilats", yaml_path);
return;
}
try {
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
if (yaml.contains("gradient")) {
readGradient(yaml["gradient"], activeSubtractiveConfig());
}
if (yaml.contains("gradient_additive")) {
readGradient(yaml["gradient_additive"], activeAdditiveConfig());
}
if (yaml.contains("flicker")) {
const auto& f = yaml["flicker"];
auto& fc = activeFlicker();
readField(f, "wave_amplitude_ratio", fc.wave_amplitude_ratio);
readField(f, "wave_frequency_hz", fc.wave_frequency_hz);
readField(f, "noise_amplitude_ratio", fc.noise_amplitude_ratio);
readField(f, "noise_frequency_hz", fc.noise_frequency_hz);
}
} catch (const std::exception& ex) {
Log::warn("Lighting", "error parsejant {}: {} — s'usaran els defaults compilats", yaml_path, ex.what());
}
}
namespace {
// Helper: emissor "patxat" amb el radi efectiu (post-flicker) per
// a aquest frame. Mantenim l'`Emitter` del caller intacte i la
// resta de camps (color, seed, flicker_params, ...) propagats.
auto withEffectiveRadius(const Emitter& e, int r_eff) -> Emitter {
return Emitter{
.cx = e.cx,
.cy = e.cy,
.radius = r_eff,
.seed = e.seed,
.flicker = e.flicker,
.color = e.color,
.flicker_params = e.flicker_params,
};
}
// Camí del mode subtractive (històric): omple un buffer de
// luminància amb `max(room, emitter_intensity)` i finalment
// multiplica el canvas per ell. Color de l'emissor ignorat.
void applySubtractive(std::uint32_t* canvas, int width, int height, std::uint8_t room, std::span<const Emitter> emitters) {
auto& lum = luminanceBuffer();
const std::size_t TOTAL = static_cast<std::size_t>(width) * static_cast<std::size_t>(height);
if (lum.size() != TOTAL) { lum.resize(TOTAL); }
std::ranges::fill(lum, room);
const auto& cfg = activeSubtractiveConfig();
const float T_S = static_cast<float>(SDL_GetTicks()) / 1000.0F;
for (const auto& e : emitters) {
if (e.radius <= 0) { continue; }
const int R_EFF = e.flicker ? computeFlickerRadius(e.radius, e.seed, T_S, e.flicker_params) : e.radius;
const auto RINGS = computeRings(R_EFF, cfg);
const Emitter EFF = withEffectiveRadius(e, R_EFF);
rasterizeEmitter(EFF, RINGS, lum.data(), width, height);
}
modulateCanvas(canvas, lum.data(), width, height);
}
// Camí del mode additive: atenua el canvas per `room` (si <255)
// i després suma `color × intensity` per emissor directament al
// canvas, sense buffer intermig.
void applyAdditive(std::uint32_t* canvas, int width, int height, std::uint8_t room, std::span<const Emitter> emitters) {
darkenCanvas(canvas, width, height, room);
const auto& cfg = activeAdditiveConfig();
const float T_S = static_cast<float>(SDL_GetTicks()) / 1000.0F;
for (const auto& e : emitters) {
if (e.radius <= 0) { continue; }
const int R_EFF = e.flicker ? computeFlickerRadius(e.radius, e.seed, T_S, e.flicker_params) : e.radius;
const auto RINGS = computeRings(R_EFF, cfg);
const Emitter EFF = withEffectiveRadius(e, R_EFF);
rasterizeEmitterAdditive(EFF, RINGS, canvas, width, height);
}
}
} // namespace
void apply(std::uint32_t* canvas, int width, int height, Mode mode, float room_light, std::span<const Emitter> emitters) {
if (canvas == nullptr || width <= 0 || height <= 0) { return; }
// Atajo per a tots dos modes: sala totalment "neutra" + cap
// emissor → la pasada no canviaria res al canvas.
// subtractive: room=1.0 i sense emissors → pixel * 1.0 global.
// additive: room=1.0 i sense emissors → cap aportació.
const std::uint8_t ROOM = toByte(room_light);
if (ROOM == 255 && emitters.empty()) { return; }
if (mode == Mode::ADDITIVE) {
applyAdditive(canvas, width, height, ROOM, emitters);
} else {
applySubtractive(canvas, width, height, ROOM, emitters);
}
}
} // namespace Lighting
+150
View File
@@ -0,0 +1,150 @@
#pragma once
#include <cstdint>
#include <span>
#include <string>
// Sistema d'il·luminació CPU. Treballa sobre el mateix canvas ARGB del
// joc (320×240) que tot el pipeline de render: després que GameScene
// haja pintat tilemap, paint layer, sombres i actors, una pasada final
// modifica cada píxel del canvas en funció de la "llum de la sala" i
// dels cercles emissors al voltant de certes entitats.
//
// Dos modes seleccionables per la sala (`lighting.mode` al YAML):
//
// SUBTRACTIVE (mode històric, default si no es declara mode):
// out = src × max(room_light, max_emitter_intensity_at_pixel)
// Els emissors "trenquen" una capa fosca multiplicativa. Tot el
// trip cromàtic dels emissors s'ignora — només la intensitat
// compta. Pensat per a sales fosques (cripta, pirámide).
//
// ADDITIVE (mode nou):
// out = saturate( (src × room_light) + Σ (emitter_color × intensity) )
// El canvas s'enfosqueix opcionalment per `room_light` (atenuació
// ambient) i els emissors hi sumen color cap a sobre. Les intensitats
// dels anells es llegeixen com a "alfa" del color afegit (0..1).
// Pensat per a sales clares on les antorxes aporten halo cromàtic
// sense restar contrast — o per a sales lleument fosques (0.85, p.ex.)
// on el halo de l'antorxa es veu càlid en lloc de "blanquinós".
//
// Forma del cercle de cada emissor (radial, 3 anells configurables al
// fitxer data/config/lighting.yaml; veure `GradientConfig`):
// 0..inner_ratio del radi → inner_intensity
// inner_ratio..mid_ratio → mid_intensity
// mid_ratio..1.0 → outer_intensity
//
// La corba viu en dos blocs separats al YAML perquè la mateixa escala
// 0..1 té significats molt diferents per mode:
// `gradient:` → corba del mode subtractive (1.0 = "no fosqueix")
// `gradient_additive:` → corba del mode additive (1.0 = "afegeix
// el color sencer i satura"). Es vol molt més
// baixa per a no inundar la sala de color.
// Cada mode llegeix la seva pròpia corba — no es comparteixen valors.
//
// Combinació entre emissors:
// SUBTRACTIVE → per a cada píxel es pren el màxim de la intensitat
// aportada per cada emissor que el solapa (no se sumen intensitats).
// ADDITIVE → per a cada píxel se sumen totes les contribucions
// (`color × intensity`), saturant a 255 per canal. Dues llums de
// colors diferents que solapen produeixen la suma natural (orange +
// blue → near-white) — comportament físic.
namespace Lighting {
// Mode de composició. La sala el selecciona via `lighting.mode:
// additive | subtractive` (default subtractive si manca, retrocompat).
enum class Mode : std::uint8_t {
SUBTRACTIVE,
ADDITIVE,
};
// Color emès. Default blanc (sense modificació quan s'usa en mode
// additive). En mode subtractive s'ignora — la pasada multiplicativa
// només té un canal (intensitat). El YAML accepta `color: [r, g, b]`
// a qualsevol nivell de la cadena de resolució (tipus, sala, spawn).
struct Color {
std::uint8_t r{255};
std::uint8_t g{255};
std::uint8_t b{255};
};
// Forma del gradient. Compartida per tots els emissors per evitar
// duplicar la corba a cada YAML d'entitat. Càrrega via loadConfig.
struct GradientConfig {
float inner_ratio{0.8F};
float mid_ratio{0.9F};
float inner_intensity{1.0F};
float mid_intensity{0.9F};
float outer_intensity{0.8F};
};
// Paràmetres del flicker per a emissors amb `flicker=true`. La
// forma final del radi és la suma d'una sinusoide "respiració"
// (wave_*) i una sinusoide secundària de freqüència més alta
// (noise_*) que aporta la sensació d'inquietud. Les fases es
// deriven de la seed estable de cada emissor — sense estat per
// emissor. Les amplituds són **ràtios** sobre el radi base (p.ex.
// 0.05 = 5% del radi): així una torxa de radi 40 i una de radi 24
// tenen el mateix percentatge d'oscil·lació visual, no els mateixos
// píxels (que es notarien més en la petita).
struct FlickerConfig {
float wave_amplitude_ratio{0.05F};
float wave_frequency_hz{3.0F};
float noise_amplitude_ratio{0.025F};
float noise_frequency_hz{7.0F};
};
// Posició al canvas (en píxels, mateixa unitat que la resta del
// motor) i radi efectiu de l'emissor. El centre és el del sprite
// visible: és l'entitat qui sap on cau (veure `Entity::getLightCenter`).
// `flicker` activa la modulació del radi per frame; `seed` és un
// identificador estable per emissor (no canvia amb la posició) del
// qual es deriva la fase del flicker — així els emissors mòbils
// mantenen un patró propi que no "deriva" pel moviment. `color`
// només té efecte en mode additive.
struct Emitter {
float cx{0.0F};
float cy{0.0F};
int radius{0};
int seed{0};
bool flicker{false};
Color color{};
// Paràmetres del flicker resolts per a aquest emissor (combinació
// global + override del tipus/sala/spawn). Només té sentit si
// `flicker == true`; en cas contrari s'ignoren. Per defecte
// s'inicialitza amb els defaults compilats — el caller (game_scene)
// hi posa el resolt correcte.
FlickerConfig flicker_params{};
};
// Llegeix `yaml_path` (data/config/lighting.yaml) i pobla la
// configuració global. Qualsevol clau absent cau als Defaults::Lighting.
// No es crida més d'una vegada en un run normal; tornar-la a cridar
// simplement reemplaça la configuració activa.
void loadConfig(const std::string& yaml_path);
// Configuració activa (per a debug/inspectors). Retorna sempre la
// corba subtractive — la corba additive viu en paral·lel al cpp i,
// si algun dia es necessita inspeccionar des de fora, caldrà afegir
// una variant `configAdditive()`. Inicialitzada amb els
// Defaults::Lighting fins que loadConfig() es crida.
[[nodiscard]] auto config() -> const GradientConfig&;
// Defaults globals del flicker (data/config/lighting.yaml, bloc
// `flicker:`). Els callers del construct path d'entitats el llegeixen
// per inicialitzar la `FlickerConfig` de cada emissor, i aplicar a
// sobre els overrides per tipus/sala/spawn.
[[nodiscard]] auto flickerConfig() -> const FlickerConfig&;
// Aplica la pasada d'il·luminació al canvas in-place. `mode`
// selecciona la composició (vegeu el comentari de la capçalera).
// `room_light` és l'atenuació ambient de la sala (0..1, 1 = sense
// atenuació): en subtractive és el "terra" de la intensitat fora
// dels emissors; en additive és la multiplicació prèvia del canvas
// abans de sumar els emissors (es pot deixar a 1.0 per "només tint",
// o baixar per combinar oscuriment ambient + halo cromàtic). El
// span d'emissors pot ser buit. La funció no fa cap allocació si
// es crida amb la mateixa mida de canvas múltiples cops: manté un
// buffer intern reutilitzable (només l'usa el mode subtractive).
void apply(std::uint32_t* canvas, int width, int height, Mode mode, float room_light, std::span<const Emitter> emitters);
} // namespace Lighting
+48 -21
View File
@@ -12,11 +12,52 @@
namespace { namespace {
// Parteix `text` en una llista de línies que caben cadascuna dins // Parteix `text` en una llista de línies que caben cadascuna dins
// `max_width`. Algoritme de projecte_2026: tall per espais, la paraula // Trenca una "paraula" llarga (sense espais) en trossos contigus
// que no cap passa a la línia següent. Les paraules aïllades més // que càpiguen a `max_width`. Si un sol caràcter ja excedeix
// llargues que `max_width` es queden a la seva pròpia línia sense // (font enorme), el deixa sol per garantir progrés. La part final
// trencar-les (cas improbable amb els pretty_names de gamepad retallats // que encara cap queda a `current` per a fusionar-se amb paraules
// a ~25 caràcters). // posteriors via espai.
void hardWrapWord(const std::string& word, int max_width, const Text& font, int kerning, std::vector<std::string>& lines, std::string& current) {
std::string buf;
for (char c : word) {
std::string candidate = buf;
candidate += c;
if (buf.empty() || font.length(candidate, kerning) <= max_width) {
buf = std::move(candidate);
} else {
lines.push_back(buf);
buf = std::string(1, c);
}
}
current = std::move(buf);
}
// Posa la `word` a la `current` line si hi cap (amb separador
// implícit si current no està buida), o fa flush + reintenta a
// línia neta. Si tampoc cap sola, hard-wrap caràcter a caràcter.
void appendWord(const std::string& word, int max_width, const Text& font, int kerning, std::vector<std::string>& lines, std::string& current) {
if (!current.empty()) {
std::string candidate = current;
candidate += ' ';
candidate += word;
if (font.length(candidate, kerning) <= max_width) {
current = std::move(candidate);
return;
}
lines.push_back(current);
current.clear();
}
if (font.length(word, kerning) <= max_width) {
current = word;
} else {
hardWrapWord(word, max_width, font, kerning, lines, current);
}
}
// `max_width`. Tall per espais; les paraules més llargues que
// `max_width` (rutes, URLs...) es trenquen caràcter a caràcter
// — sense això, una caixa que excedís el canvas faria que
// `fillRect` rebés rangs invertits i petés.
auto wrapToWidth(const std::string& text, int max_width, const Text& font, int kerning) -> std::vector<std::string> { auto wrapToWidth(const std::string& text, int max_width, const Text& font, int kerning) -> std::vector<std::string> {
std::vector<std::string> lines; std::vector<std::string> lines;
if (text.empty()) { if (text.empty()) {
@@ -30,26 +71,12 @@ namespace {
const std::size_t SPACE_POS = text.find(' ', i); const std::size_t SPACE_POS = text.find(' ', i);
const bool LAST = (SPACE_POS == std::string::npos); const bool LAST = (SPACE_POS == std::string::npos);
const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i); const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i);
appendWord(WORD, max_width, font, kerning, lines, current);
if (current.empty()) {
current = WORD;
} else {
std::string candidate = current;
candidate += ' ';
candidate += WORD;
if (font.length(candidate, kerning) <= max_width) {
current = std::move(candidate);
} else {
lines.push_back(current);
current = WORD;
}
}
if (LAST) { break; } if (LAST) { break; }
i = SPACE_POS + 1; i = SPACE_POS + 1;
} }
lines.push_back(current); if (!current.empty()) { lines.push_back(current); }
return lines; return lines;
} }
+6
View File
@@ -226,6 +226,12 @@ void Screen::fillRect(int x, int y, int w, int h, std::uint32_t argb) {
const int Y0 = std::max(y, CLIP.y); const int Y0 = std::max(y, CLIP.y);
const int X1 = std::min(x + w, CLIP.x + CLIP.w); const int X1 = std::min(x + w, CLIP.x + CLIP.w);
const int Y1 = std::min(y + h, CLIP.y + CLIP.h); const int Y1 = std::min(y + h, CLIP.y + CLIP.h);
// Bail-out defensiu: si el rang és buit o invertit després del clip,
// `std::fill(first, last)` amb `first > last` és UB i a la pràctica
// entra en bucle infinit (vist en un crash a l'overflow del Notifier
// quan la caixa quedava més ampla que el canvas). El `blendRect`
// d'aquí baix ja és segur perquè usa `for(ix; ix < X1; ...)`.
if (X1 <= X0 || Y1 <= Y0) { return; }
for (int iy = Y0; iy < Y1; ++iy) { for (int iy = Y0; iy < Y1; ++iy) {
std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_); std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_);
std::fill(row + X0, row + X1, argb); std::fill(row + X0, row + X1, argb);
+1 -1
View File
@@ -72,7 +72,7 @@ class Screen {
// Alpha-blend sobre el canvas: `alpha=0` no fa res, `alpha=255` equival // Alpha-blend sobre el canvas: `alpha=0` no fa res, `alpha=255` equival
// a fillRect. El color entra com a ARGB little-endian; `alpha` és el // a fillRect. El color entra com a ARGB little-endian; `alpha` és el
// factor de mescla amb el que ja hi ha al canvas. Pensat per a overlays // factor de mescla amb el que ja hi ha al canvas. Pensat per a overlays
// (menú, pauses) que volen veure's a través el joc atenuat. // (menú de servei) que volen veure's a través el joc atenuat.
void blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha); void blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha);
// Hook invocat al principi de `present()`, abans de pujar els píxels al // Hook invocat al principi de `present()`, abans de pujar els píxels al
// backend. Serveix per a pintar overlays que han de quedar per damunt // backend. Serveix per a pintar overlays que han de quedar per damunt
+32 -20
View File
@@ -5,6 +5,7 @@
#include <ctime> #include <ctime>
#include <filesystem> #include <filesystem>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
#include "external/stb_image_write.h" #include "external/stb_image_write.h"
@@ -13,21 +14,18 @@ namespace Screenshot {
namespace { namespace {
// Crea la carpeta `screenshots/` si no existeix i torna la ruta // Estat de mòdul: ruta base on s'escriu la subcarpeta
// completa al fitxer. El paràmetre `filename` rep el nom nu (sense // `screenshots/`. Buida per defecte ⇒ relativa al CWD (fallback
// directori) per poder mostrar-lo a la notificació. // si el Director no ha pogut establir la ruta per plataforma).
auto generateFilePath(std::string& filename) -> std::string { std::string g_base_dir; // NOLINT(*-avoid-non-const-global-variables)
std::filesystem::create_directories("screenshots");
const auto NOW = std::chrono::system_clock::now(); // Construeix la ruta del directori on van les captures:
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW); // `<base>/screenshots/`. Si la base és buida, retorna
const std::tm* tm = std::localtime(&TIME); // "screenshots/" relativa al CWD.
auto screenshotsDir() -> std::string {
char timestamp[20]; if (g_base_dir.empty()) { return "screenshots/"; }
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm); const bool ENDS_WITH_SEP = (g_base_dir.back() == '/' || g_base_dir.back() == '\\');
return g_base_dir + (ENDS_WITH_SEP ? "" : "/") + "screenshots/";
filename = std::string("scr_") + timestamp + ".png";
return "screenshots/" + filename;
} }
// Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write // Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write
@@ -47,14 +45,28 @@ namespace Screenshot {
} // namespace } // namespace
auto save(const std::uint32_t* buffer, int width, int height) -> std::string { void setBaseDir(std::string dir) {
g_base_dir = std::move(dir);
}
auto save(const std::uint32_t* buffer, int width, int height) -> Result {
const std::string FOLDER = screenshotsDir();
std::error_code ec;
std::filesystem::create_directories(FOLDER, ec);
const auto NOW = std::chrono::system_clock::now();
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW);
const std::tm* tm = std::localtime(&TIME);
char timestamp[20];
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm);
const std::string FILENAME = std::string("scr_") + timestamp + ".png";
const std::string FILEPATH = FOLDER + FILENAME;
// Còpia local per no tocar el canvas original durant la conversió. // Còpia local per no tocar el canvas original durant la conversió.
std::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height)); std::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height));
if (!writePng(copy.data(), width, height, FILEPATH)) { return {}; }
std::string filename; return {.filename = FILENAME, .folder = FOLDER};
const std::string FILEPATH = generateFilePath(filename);
return writePng(copy.data(), width, height, FILEPATH) ? filename : "";
} }
} // namespace Screenshot } // namespace Screenshot
+23 -4
View File
@@ -3,11 +3,30 @@
#include <cstdint> #include <cstdint>
#include <string> #include <string>
// Volca un buffer ARGB8888 a un fitxer PNG dins de la carpeta `screenshots/` // Volca un buffer ARGB8888 a un fitxer PNG dins de la subcarpeta
// (relativa al CWD). El nom es genera amb un timestamp i la funció retorna // `screenshots/` del directori configurat amb `setBaseDir`. El nom es
// el nom del fitxer creat, o una cadena buida si l'escriptura ha fallat. // genera amb un timestamp i la funció retorna el basename i la ruta
// del directori on s'ha escrit (o un Result amb `filename` buit si
// l'escriptura ha fallat).
namespace Screenshot { namespace Screenshot {
auto save(const std::uint32_t* buffer, int width, int height) -> std::string; // Configura el directori base on s'escriuran les captures (s'hi
// crea una subcarpeta `screenshots/`). El Director el crida una
// vegada al boot amb `system_folder_` perquè les captures vagin
// al mateix lloc per plataforma que la resta de fitxers generats
// (config.yaml, gamepad_configs.yaml, shaders/, etc.). Si no es
// crida, el fallback és el CWD — comportament antic.
void setBaseDir(std::string dir);
// Resultat d'una captura: el `filename` és el basename del PNG
// (per a notificacions/títol) i el `folder` és la ruta absoluta
// on s'ha guardat (per a poder dir "guardada a {folder}"). Si
// l'escriptura falla, ambdós camps tornen buits.
struct Result {
std::string filename;
std::string folder;
};
auto save(const std::uint32_t* buffer, int width, int height) -> Result;
} // namespace Screenshot } // namespace Screenshot
+46 -6
View File
@@ -18,9 +18,12 @@
#include "core/input/key_config.hpp" #include "core/input/key_config.hpp"
#include "core/input/mouse.hpp" #include "core/input/mouse.hpp"
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/effects/shadow.hpp"
#include "core/rendering/lighting.hpp"
#include "core/rendering/notifier.hpp" #include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp" #include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/screenshot.hpp"
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "core/rendering/text.hpp" #include "core/rendering/text.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
@@ -29,6 +32,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
@@ -69,8 +73,9 @@ Director::Director() {
std::exit(1); std::exit(1);
} }
// Prepara la carpeta de sistema (~/.config/jailgames/aee_arcade/) i carrega // Prepara la carpeta de sistema (per plataforma: %APPDATA% a Windows,
// les opcions; si no existeix config.yaml el crea amb els defaults. // ~/Library/Application Support a macOS, ~/.config a Linux) i carrega les
// opcions; si no existeix config.yaml el crea amb els defaults.
createSystemFolder(); createSystemFolder();
// Gamepad: descobreix mandos presents silenciosament. Cal fer-ho ABANS // Gamepad: descobreix mandos presents silenciosament. Cal fer-ho ABANS
@@ -92,6 +97,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);
@@ -122,6 +132,10 @@ Director::Director() {
// Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a // Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a
// data/config/ui.yaml cau al valor compilat a Defaults::UI. // data/config/ui.yaml cau al valor compilat a Defaults::UI.
UiConfig::get().load(Defaults::Paths::UI_YAML); UiConfig::get().load(Defaults::Paths::UI_YAML);
// Forma del gradient d'il·luminació (3 anells radials). El radi
// per emissor i el `light_level` per sala viuen als YAMLs propis;
// ací només la corba compartida per a tots els emissors.
Lighting::loadConfig(Defaults::Paths::LIGHTING_YAML);
// Configuració específica de les escenes TITLE i LOGO. Es carreguen // Configuració específica de les escenes TITLE i LOGO. Es carreguen
// additivament sobre el mateix UiConfig: cada fitxer només conté la // additivament sobre el mateix UiConfig: cada fitxer només conté la
// seua subclau (`title:` o `logo:`), i les claus absents a la base // seua subclau (`title:` o `logo:`), i les claus absents a la base
@@ -155,12 +169,20 @@ Director::Director() {
Resource::Cache::get().loadSurface(ROOM_TEXT_FONT_GIF_KEY, UiConfig::get().roomTextFont().gif); Resource::Cache::get().loadSurface(ROOM_TEXT_FONT_GIF_KEY, UiConfig::get().roomTextFont().gif);
Resource::Cache::get().getSurface(ROOM_TEXT_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui")); Resource::Cache::get().getSurface(ROOM_TEXT_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
// Sprite compartit de la sombra (tira horitzontal de fotogrames de
// 16x16, de petit a gran). El consumeix `Effects::Shadow::render`,
// que el reaprofiten Player i qualsevol entitat futura compatible.
Resource::Cache::get().loadSurface(Effects::Shadow::CACHE_KEY, "data/entities/shadow/shadow.gif");
// Pistes de música del cicle de sala (intro/playing/outro). Es preloden // Pistes de música del cicle de sala (intro/playing/outro). Es preloden
// ací perquè formen part del preload d'assets, com la font i la paleta; // ací perquè formen part del preload d'assets, com la font i la paleta;
// la lògica de quina sona en cada estat viu a `GameScene`. // la lògica de quina sona en cada estat viu a `GameScene`.
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);
@@ -331,17 +353,29 @@ Director::~Director() {
} }
void Director::createSystemFolder() { void Director::createSystemFolder() {
// Volem la config a $XDG_CONFIG_HOME/jailgames/aee_arcade/ (defecte // Carpeta de configuració per plataforma:
// ~/.config/jailgames/aee_arcade/). SDL_GetPrefPath apunta a XDG_DATA_HOME // - Windows: %APPDATA%\jailgames\aee_arcade
// (.local/share), que és el lloc per a dades, no per a configuració. // - macOS: ~/Library/Application Support/jailgames/aee_arcade
const char* xdg = std::getenv("XDG_CONFIG_HOME"); // - Linux: $XDG_CONFIG_HOME/jailgames/aee_arcade (defecte
// ~/.config/jailgames/aee_arcade)
// No fem servir SDL_GetPrefPath: a Linux apunta a XDG_DATA_HOME
// (~/.local/share), que és per a dades, no per a configuració.
std::string base; std::string base;
#ifdef _WIN32
const char* appdata = std::getenv("APPDATA");
base = (appdata != nullptr && *appdata != '\0') ? std::string(appdata) : ".";
#elif defined(__APPLE__)
const char* home = std::getenv("HOME");
base = (home != nullptr) ? std::string(home) + "/Library/Application Support" : ".";
#else
const char* xdg = std::getenv("XDG_CONFIG_HOME");
if (xdg != nullptr && *xdg != '\0') { if (xdg != nullptr && *xdg != '\0') {
base = xdg; base = xdg;
} else { } else {
const char* home = std::getenv("HOME"); const char* home = std::getenv("HOME");
base = (home != nullptr) ? std::string(home) + "/.config" : "."; base = (home != nullptr) ? std::string(home) + "/.config" : ".";
} }
#endif
system_folder_ = base + "/jailgames/aee_arcade/"; system_folder_ = base + "/jailgames/aee_arcade/";
std::error_code error; std::error_code error;
@@ -351,6 +385,12 @@ void Director::createSystemFolder() {
if (error) { if (error) {
Log::error("Director", "no s'ha pogut crear {}: {}", system_folder_, error.message()); Log::error("Director", "no s'ha pogut crear {}: {}", system_folder_, error.message());
} }
// Les captures de pantalla viuen sota la mateixa carpeta per
// plataforma. Sense aquesta crida, `Screenshot::save` cauria al
// CWD del procés (raïz del projecte si llances `build/aee_arcade`
// a mà, lloc imprevisible en una release de macOS/Windows).
Screenshot::setBaseDir(system_folder_);
} }
void Director::processEvents() { void Director::processEvents() {
+28 -16
View File
@@ -82,8 +82,15 @@ UiConfig::UiConfig()
menu_cursor_(Defaults::UI::MENU_CURSOR), menu_cursor_(Defaults::UI::MENU_CURSOR),
menu_disabled_(Defaults::UI::MENU_DISABLED), menu_disabled_(Defaults::UI::MENU_DISABLED),
hud_bg_(Defaults::UI::HUD_BG), hud_bg_(Defaults::UI::HUD_BG),
hud_label_(Defaults::UI::HUD_LABEL), hud_p1_label_(Defaults::UI::HUD_P1_LABEL),
hud_value_(Defaults::UI::HUD_VALUE), hud_p1_value_(Defaults::UI::HUD_P1_VALUE),
hud_p1_shadow_(Defaults::UI::HUD_P1_SHADOW),
hud_center_label_(Defaults::UI::HUD_CENTER_LABEL),
hud_center_value_(Defaults::UI::HUD_CENTER_VALUE),
hud_center_shadow_(Defaults::UI::HUD_CENTER_SHADOW),
hud_p2_label_(Defaults::UI::HUD_P2_LABEL),
hud_p2_value_(Defaults::UI::HUD_P2_VALUE),
hud_p2_shadow_(Defaults::UI::HUD_P2_SHADOW),
hud_flash_(Defaults::UI::HUD_FLASH), hud_flash_(Defaults::UI::HUD_FLASH),
room_text_(Defaults::UI::ROOM_TEXT), room_text_(Defaults::UI::ROOM_TEXT),
room_text_outline_(Defaults::UI::ROOM_TEXT_OUTLINE), room_text_outline_(Defaults::UI::ROOM_TEXT_OUTLINE),
@@ -92,10 +99,8 @@ UiConfig::UiConfig()
hud_entry_ms_(Defaults::UI::HUD_ENTRY_MS), hud_entry_ms_(Defaults::UI::HUD_ENTRY_MS),
hud_flash_ms_(Defaults::UI::HUD_FLASH_MS), hud_flash_ms_(Defaults::UI::HUD_FLASH_MS),
hud_shake_ms_(Defaults::UI::HUD_SHAKE_MS), hud_shake_ms_(Defaults::UI::HUD_SHAKE_MS),
pause_enter_ms_(Defaults::UI::PAUSE_ENTER_MS), hud_blink_cycle_ms_(Defaults::UI::HUD_BLINK_CYCLE_MS),
pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS), hud_blink_visible_ratio_(Defaults::UI::HUD_BLINK_VISIBLE_RATIO),
pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)),
pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)),
title_duration_ms_(Defaults::UI::Title::DURATION_MS), title_duration_ms_(Defaults::UI::Title::DURATION_MS),
title_bg_color_(Defaults::UI::Title::BG_COLOR), title_bg_color_(Defaults::UI::Title::BG_COLOR),
title_text_color_(Defaults::UI::Title::TEXT_COLOR), title_text_color_(Defaults::UI::Title::TEXT_COLOR),
@@ -218,13 +223,26 @@ void UiConfig::load(const std::string& file_path) {
readColorIndex(c, "menu_cursor", menu_cursor_); readColorIndex(c, "menu_cursor", menu_cursor_);
readColorIndex(c, "menu_disabled", menu_disabled_); readColorIndex(c, "menu_disabled", menu_disabled_);
readColorIndex(c, "hud_bg", hud_bg_); readColorIndex(c, "hud_bg", hud_bg_);
readColorIndex(c, "hud_label", hud_label_);
readColorIndex(c, "hud_value", hud_value_);
readColorIndex(c, "hud_flash", hud_flash_); readColorIndex(c, "hud_flash", hud_flash_);
readColorIndex(c, "room_text", room_text_); readColorIndex(c, "room_text", room_text_);
readColorIndex(c, "room_text_outline", room_text_outline_); readColorIndex(c, "room_text_outline", room_text_outline_);
} }
// Famílies de color del HUD: blocs `hud_p1`, `hud_center`,
// `hud_p2` amb claus `label`, `value`, `shadow`. Cada bloc i
// cada clau són opcionals — el que manqui cau al default
// compilat a `Defaults::UI`.
auto read_hud_family = [&](const std::string& key, Uint8& label, Uint8& value, Uint8& shadow) {
if (!yaml.contains(key)) { return; }
const auto& f = yaml[key];
readColorIndex(f, "label", label);
readColorIndex(f, "value", value);
readColorIndex(f, "shadow", shadow);
};
read_hud_family("hud_p1", hud_p1_label_, hud_p1_value_, hud_p1_shadow_);
read_hud_family("hud_center", hud_center_label_, hud_center_value_, hud_center_shadow_);
read_hud_family("hud_p2", hud_p2_label_, hud_p2_value_, hud_p2_shadow_);
if (yaml.contains("timings")) { if (yaml.contains("timings")) {
const auto& t = yaml["timings"]; const auto& t = yaml["timings"];
readField(t, "notification_ms", notification_ms_); readField(t, "notification_ms", notification_ms_);
@@ -247,14 +265,8 @@ void UiConfig::load(const std::string& file_path) {
readField(h, "entry_ms", hud_entry_ms_); readField(h, "entry_ms", hud_entry_ms_);
readField(h, "flash_ms", hud_flash_ms_); readField(h, "flash_ms", hud_flash_ms_);
readField(h, "shake_ms", hud_shake_ms_); readField(h, "shake_ms", hud_shake_ms_);
} readField(h, "blink_cycle_ms", hud_blink_cycle_ms_);
readField(h, "blink_visible_ratio", hud_blink_visible_ratio_);
if (yaml.contains("pause")) {
const auto& p = yaml["pause"];
readField(p, "enter_ms", pause_enter_ms_);
readField(p, "exit_ms", pause_exit_ms_);
readCurve(p, "enter_curve", pause_enter_curve_);
readCurve(p, "exit_curve", pause_exit_curve_);
} }
if (yaml.contains("sounds")) { if (yaml.contains("sounds")) {
+26 -15
View File
@@ -66,8 +66,19 @@ class UiConfig {
[[nodiscard]] auto menuCursor() const -> Uint8 { return menu_cursor_; } [[nodiscard]] auto menuCursor() const -> Uint8 { return menu_cursor_; }
[[nodiscard]] auto menuDisabled() const -> Uint8 { return menu_disabled_; } [[nodiscard]] auto menuDisabled() const -> Uint8 { return menu_disabled_; }
[[nodiscard]] auto hudBg() const -> Uint8 { return hud_bg_; } [[nodiscard]] auto hudBg() const -> Uint8 { return hud_bg_; }
[[nodiscard]] auto hudLabel() const -> Uint8 { return hud_label_; } // Famílies de color del HUD per bloc (LEFT/CENTER/RIGHT). Cada
[[nodiscard]] auto hudValue() const -> Uint8 { return hud_value_; } // família té label (etiqueta apagada), value (valor destacat) i
// shadow (to fosc per a la sombra-embossing). El flash és comú a
// les tres famílies (groc neó al guanyar punts).
[[nodiscard]] auto hudP1Label() const -> Uint8 { return hud_p1_label_; }
[[nodiscard]] auto hudP1Value() const -> Uint8 { return hud_p1_value_; }
[[nodiscard]] auto hudP1Shadow() const -> Uint8 { return hud_p1_shadow_; }
[[nodiscard]] auto hudCenterLabel() const -> Uint8 { return hud_center_label_; }
[[nodiscard]] auto hudCenterValue() const -> Uint8 { return hud_center_value_; }
[[nodiscard]] auto hudCenterShadow() const -> Uint8 { return hud_center_shadow_; }
[[nodiscard]] auto hudP2Label() const -> Uint8 { return hud_p2_label_; }
[[nodiscard]] auto hudP2Value() const -> Uint8 { return hud_p2_value_; }
[[nodiscard]] auto hudP2Shadow() const -> Uint8 { return hud_p2_shadow_; }
[[nodiscard]] auto hudFlash() const -> Uint8 { return hud_flash_; } [[nodiscard]] auto hudFlash() const -> Uint8 { return hud_flash_; }
[[nodiscard]] auto roomText() const -> Uint8 { return room_text_; } [[nodiscard]] auto roomText() const -> Uint8 { return room_text_; }
[[nodiscard]] auto roomTextOutline() const -> Uint8 { return room_text_outline_; } [[nodiscard]] auto roomTextOutline() const -> Uint8 { return room_text_outline_; }
@@ -78,8 +89,8 @@ class UiConfig {
[[nodiscard]] auto hudEntryMs() const -> int { return hud_entry_ms_; } [[nodiscard]] auto hudEntryMs() const -> int { return hud_entry_ms_; }
[[nodiscard]] auto hudFlashMs() const -> int { return hud_flash_ms_; } [[nodiscard]] auto hudFlashMs() const -> int { return hud_flash_ms_; }
[[nodiscard]] auto hudShakeMs() const -> int { return hud_shake_ms_; } [[nodiscard]] auto hudShakeMs() const -> int { return hud_shake_ms_; }
[[nodiscard]] auto pauseEnterMs() const -> int { return pause_enter_ms_; } [[nodiscard]] auto hudBlinkCycleMs() const -> int { return hud_blink_cycle_ms_; }
[[nodiscard]] auto pauseExitMs() const -> int { return pause_exit_ms_; } [[nodiscard]] auto hudBlinkVisibleRatio() const -> float { return hud_blink_visible_ratio_; }
// Escena TITLE (placeholder visual fins que tinguem artwork). // Escena TITLE (placeholder visual fins que tinguem artwork).
struct TitleHeatHaze { struct TitleHeatHaze {
@@ -158,11 +169,6 @@ class UiConfig {
[[nodiscard]] auto logoTimeline() const -> const LogoTimeline& { return logo_timeline_; } [[nodiscard]] auto logoTimeline() const -> const LogoTimeline& { return logo_timeline_; }
// Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa
// Easing::linear com a fallback.
[[nodiscard]] auto pauseEnterCurve() const -> CurveFn { return pause_enter_curve_; }
[[nodiscard]] auto pauseExitCurve() const -> CurveFn { return pause_exit_curve_; }
// Resol un nom de corba a la seua funció. Pública per permetre a // Resol un nom de corba a la seua funció. Pública per permetre a
// futurs camps d'UI triar corbes pel mateix mecanisme (ex.: HUD // futurs camps d'UI triar corbes pel mateix mecanisme (ex.: HUD
// entry, notifications slide...). Si el nom no està registrat, // entry, notifications slide...). Si el nom no està registrat,
@@ -209,8 +215,15 @@ class UiConfig {
Uint8 menu_cursor_; Uint8 menu_cursor_;
Uint8 menu_disabled_; Uint8 menu_disabled_;
Uint8 hud_bg_; Uint8 hud_bg_;
Uint8 hud_label_; Uint8 hud_p1_label_;
Uint8 hud_value_; Uint8 hud_p1_value_;
Uint8 hud_p1_shadow_;
Uint8 hud_center_label_;
Uint8 hud_center_value_;
Uint8 hud_center_shadow_;
Uint8 hud_p2_label_;
Uint8 hud_p2_value_;
Uint8 hud_p2_shadow_;
Uint8 hud_flash_; Uint8 hud_flash_;
Uint8 room_text_; Uint8 room_text_;
Uint8 room_text_outline_; Uint8 room_text_outline_;
@@ -220,10 +233,8 @@ class UiConfig {
int hud_entry_ms_; int hud_entry_ms_;
int hud_flash_ms_; int hud_flash_ms_;
int hud_shake_ms_; int hud_shake_ms_;
int pause_enter_ms_; int hud_blink_cycle_ms_;
int pause_exit_ms_; float hud_blink_visible_ratio_;
CurveFn pause_enter_curve_;
CurveFn pause_exit_curve_;
int title_duration_ms_; int title_duration_ms_;
Uint8 title_bg_color_; Uint8 title_bg_color_;
+4 -3
View File
@@ -5,9 +5,10 @@
#include <string> #include <string>
// Configuració personal de desenvolupament, carregada de `debug.yaml` dins la // Configuració personal de desenvolupament, carregada de `debug.yaml` dins la
// carpeta de sistema (`$XDG_CONFIG_HOME/jailgames/aee_arcade/debug.yaml`). No es // carpeta de sistema del joc (vegeu `Director::createSystemFolder` per la
// versiona: cada dev el crea a la seua màquina. Si no existeix, s'aplica el // ubicació real per plataforma). No es versiona: cada dev el crea a la seua
// valor per defecte (GAME) sense warning de nivell error. // màquina. Si no existeix, s'aplica el valor per defecte (GAME) sense warning
// de nivell error.
namespace DebugConfig { namespace DebugConfig {
enum class StartScene : std::uint8_t { LOGO, enum class StartScene : std::uint8_t { LOGO,
+197 -7
View File
@@ -176,6 +176,23 @@ namespace {
return {PX, PY}; return {PX, PY};
} }
// Compta sub-sprites del atlas i decideix si el template demana
// sub-picker de frames. Extret de `ensureEditableEntities` per acotar
// la complexitat cognitiva del callsite. Per a strips d'1 frame
// (la majoria del catàleg) retorna (1, false).
struct AtlasVariants {
int frame_count;
bool has_variants;
};
auto computeAtlasVariants(const Surface& surface, int frame_w, int frame_h, const fkyaml::node& tmpl) -> AtlasVariants {
const int COLS = (frame_w > 0) ? (surface.getWidth() / frame_w) : 1;
const int ROWS = (frame_h > 0) ? (surface.getHeight() / frame_h) : 1;
const int FRAME_COUNT = std::max(1, COLS * ROWS);
const bool FLAG = tmpl.contains("editable_variants") && tmpl["editable_variants"].get_value<bool>();
return {.frame_count = FRAME_COUNT, .has_variants = FLAG && FRAME_COUNT > 1};
}
} // namespace } // namespace
void Editor::enter() { void Editor::enter() {
@@ -190,6 +207,10 @@ void Editor::enter() {
picker_ = Picker::NONE; picker_ = Picker::NONE;
brush_tile_ = -1; brush_tile_ = -1;
brush_entity_.clear(); brush_entity_.clear();
brush_entity_frame_ = -1;
entity_frame_picker_source_ = nullptr;
entity_frame_picker_hover_ = -1;
entity_frame_picker_scroll_ = 0;
hand_selected_idx_ = -1; hand_selected_idx_ = -1;
hand_hover_idx_ = -1; hand_hover_idx_ = -1;
ensureEditableEntities(); ensureEditableEntities();
@@ -256,7 +277,15 @@ void Editor::ensureEditableEntities() {
} }
if (surface == nullptr) { continue; } if (surface == nullptr) { continue; }
editable_entities_.push_back({.name = name, .sprite = surface, .frame_w = FRAME_W, .frame_h = FRAME_H}); const auto [FRAME_COUNT, HAS_VARIANTS] = computeAtlasVariants(*surface, FRAME_W, FRAME_H, tmpl);
editable_entities_.push_back({
.name = name,
.sprite = surface,
.frame_w = FRAME_W,
.frame_h = FRAME_H,
.frame_count = FRAME_COUNT,
.has_variants = HAS_VARIANTS,
});
} }
} }
@@ -301,6 +330,13 @@ namespace {
s.col = (static_cast<int>(R.x) - Map::tilemap_origin_x) / Map::tile_size; s.col = (static_cast<int>(R.x) - Map::tilemap_origin_x) / Map::tile_size;
s.row = (static_cast<int>(R.y) - Map::tilemap_origin_y) / Map::tile_size; s.row = (static_cast<int>(R.y) - Map::tilemap_origin_y) / Map::tile_size;
} }
// Variant del atlas: si l'entitat té un AnimationComponent
// d'un sol frame, és una entitat amb variant escollida
// (debris, columna). Persistim l'índex perquè la pròxima
// càrrega restauri la mateixa cara del sub-sprite.
if (const auto& anim = e->getAnimation(); anim.has_value() && anim->frameCount() == 1) {
s.frame = anim->currentFrame();
}
} }
out.push_back(std::move(s)); out.push_back(std::move(s));
} }
@@ -374,6 +410,7 @@ auto Editor::applyHotkey(int scancode) -> bool {
// tracte per a la seva acció de confirmar-eixida. // tracte per a la seva acció de confirmar-eixida.
if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) { if (picker_ != Picker::NONE && scancode == SDL_SCANCODE_ESCAPE) {
picker_ = Picker::NONE; picker_ = Picker::NONE;
entity_frame_picker_source_ = nullptr;
return true; return true;
} }
return false; return false;
@@ -426,6 +463,8 @@ void Editor::update(int /*delta_ms*/, Tilemap& tilemap, EntityManager& entities)
updateTilePicker(tilemap, CX, CY); updateTilePicker(tilemap, CX, CY);
} else if (picker_ == Picker::ENTITY) { } else if (picker_ == Picker::ENTITY) {
updateEntityPicker(CX, CY); updateEntityPicker(CX, CY);
} else if (picker_ == Picker::ENTITY_FRAME) {
updateEntityFramePicker(CX, CY);
} else { } else {
switch (mode_) { switch (mode_) {
case Mode::TILE: case Mode::TILE:
@@ -514,7 +553,9 @@ namespace {
// Composa el spawn YAML per al brush actual. Retorna empty si el snap // Composa el spawn YAML per al brush actual. Retorna empty si el snap
// no és vàlid (fora de rejilla, solapament, o duplicat de porta). // no és vàlid (fora de rejilla, solapament, o duplicat de porta).
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing) -> std::string { // `frame` (-1 = no emetre) afegeix el camp `frame: N` per a entitats
// amb variants estàtiques (debris, columna).
auto buildSpawnYaml(const std::string& brush, int cursor_col, int cursor_row, const std::vector<Entity*>& existing, int frame) -> std::string {
std::ostringstream y; std::ostringstream y;
y << "type: " << brush << '\n'; y << "type: " << brush << '\n';
if (brush == "sarcophagus") { if (brush == "sarcophagus") {
@@ -530,6 +571,7 @@ namespace {
const auto DEST = tileCellRect(cursor_col, cursor_row, Map::tile_size); const auto DEST = tileCellRect(cursor_col, cursor_row, Map::tile_size);
if (cellOccupied(existing, DEST)) { return {}; } if (cellOccupied(existing, DEST)) { return {}; }
y << "position: [" << cursor_col << ", " << cursor_row << "]\n"; y << "position: [" << cursor_col << ", " << cursor_row << "]\n";
if (frame >= 0) { y << "frame: " << frame << '\n'; }
return y.str(); return y.str();
} }
@@ -546,6 +588,7 @@ void Editor::updateEntityBrush(EntityManager& entities, int /*canvas_x*/, int /*
// H (mà): manté la simetria amb el comportament del brush // H (mà): manté la simetria amb el comportament del brush
// de tiles. // de tiles.
brush_entity_.clear(); brush_entity_.clear();
brush_entity_frame_ = -1;
} }
} }
} }
@@ -665,7 +708,14 @@ auto Editor::tryMoveSelected(const Tilemap& tilemap, EntityManager& entities) co
filtered.reserve(list.size() - 1); filtered.reserve(list.size() - 1);
std::ranges::copy_if(list, std::back_inserter(filtered), [target](const Entity* e) { return e != target; }); std::ranges::copy_if(list, std::back_inserter(filtered), [target](const Entity* e) { return e != target; });
const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered); // Si l'entitat moguda té una variant fixada (AnimationComponent d'un
// sol frame, convenció per a debris/columnes), la preservem al spawn
// reconstruït — així moure no perd la cara escollida.
int frame = -1;
if (const auto& anim = target->getAnimation(); anim.has_value() && anim->frameCount() == 1) {
frame = anim->currentFrame();
}
const auto STR = buildSpawnYaml(TYPE, cursor_col_, cursor_row_, filtered, frame);
if (STR.empty()) { return false; } if (STR.empty()) { return false; }
if (!entities.removeEntity(target)) { return false; } if (!entities.removeEntity(target)) { return false; }
try { try {
@@ -739,8 +789,10 @@ void Editor::renderEditorHud(const EntityManager& entities) const {
? "(" + std::to_string(cursor_col_) + "," + std::to_string(cursor_row_) + ")" ? "(" + std::to_string(cursor_col_) + "," + std::to_string(cursor_row_) + ")"
: "(--,--)"; : "(--,--)";
const auto LBL = UiConfig::get().hudLabel(); // El HUD ara té tres famílies (P1/CENTER/P2); l'editor n'agafa la
const auto VAL = UiConfig::get().hudValue(); // del CENTER (ambre/or) per ser neutra i contrastar amb el mapa.
const auto LBL = UiConfig::get().hudCenterLabel();
const auto VAL = UiConfig::get().hudCenterValue();
const auto FLASH = UiConfig::get().hudFlash(); const auto FLASH = UiConfig::get().hudFlash();
const int KERN = UiConfig::get().hudFont().kerning; const int KERN = UiConfig::get().hudFont().kerning;
// Mateixos Y que el HUD del joc per simetria visual: línia 1 = 4, // Mateixos Y que el HUD del joc per simetria visual: línia 1 = 4,
@@ -804,7 +856,7 @@ void Editor::renderEntityFrame(const Entity& e, std::uint32_t argb) {
auto Editor::tryPlaceEntity(EntityManager& entities) -> bool { auto Editor::tryPlaceEntity(EntityManager& entities) -> bool {
if (brush_entity_.empty() || cursor_col_ < 0 || cursor_row_ < 0) { return false; } if (brush_entity_.empty() || cursor_col_ < 0 || cursor_row_ < 0) { return false; }
const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities()); const auto STR = buildSpawnYaml(brush_entity_, cursor_col_, cursor_row_, entities.getEntities(), brush_entity_frame_);
if (STR.empty()) { return false; } if (STR.empty()) { return false; }
try { try {
auto node = fkyaml::node::deserialize(STR.begin(), STR.end()); auto node = fkyaml::node::deserialize(STR.begin(), STR.end());
@@ -838,13 +890,25 @@ void Editor::updateEntityPicker(int canvas_x, int canvas_y) {
entity_picker_scroll_ = std::clamp(entity_picker_scroll_, 0, LAY.max_scroll); entity_picker_scroll_ = std::clamp(entity_picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) { } else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (entity_picker_hover_ >= 0 && entity_picker_hover_ < static_cast<int>(editable_entities_.size())) { if (entity_picker_hover_ >= 0 && entity_picker_hover_ < static_cast<int>(editable_entities_.size())) {
brush_entity_ = editable_entities_[entity_picker_hover_].name; const auto& sel = editable_entities_[entity_picker_hover_];
if (sel.has_variants) {
// Entitat amb variants: encara no fixem brush. Obrim
// el sub-picker de frames; el brush es fixarà només
// quan l'usuari trii un frame concret allà.
entity_frame_picker_source_ = &sel;
entity_frame_picker_scroll_ = 0;
entity_frame_picker_hover_ = -1;
picker_ = Picker::ENTITY_FRAME;
} else {
brush_entity_ = sel.name;
brush_entity_frame_ = -1;
mode_ = Mode::ENTITY; mode_ = Mode::ENTITY;
picker_ = Picker::NONE; picker_ = Picker::NONE;
} }
} }
} }
} }
}
void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const { void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const {
const auto LAY = layoutEntityPicker(static_cast<int>(editable_entities_.size())); const auto LAY = layoutEntityPicker(static_cast<int>(editable_entities_.size()));
@@ -894,6 +958,106 @@ void Editor::renderEntityPicker(const Tilemap& /*tilemap*/) const {
screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER); screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER);
} }
void Editor::updateEntityFramePicker(int canvas_x, int canvas_y) {
// Si la font ha desaparegut (no hauria de passar mentre el picker
// estigui obert, però defensem el cas), tanquem el sub-picker en lloc
// de fer crash.
if (entity_frame_picker_source_ == nullptr) {
picker_ = Picker::ENTITY;
return;
}
const auto LAY = layoutEntityPicker(entity_frame_picker_source_->frame_count);
entity_frame_picker_hover_ = -1;
const int INNER_X = LAY.x + PICKER_BORDER;
const int INNER_Y = LAY.y + PICKER_BORDER;
const int RX = canvas_x - INNER_X;
const int RY = canvas_y - INNER_Y;
if (RX >= 0 && RY >= 0 && LAY.pitch > 0 && LAY.cols > 0) {
const int VC = RX / LAY.pitch;
const int VR = RY / LAY.pitch;
const bool IN_SPACING_X = (RX % LAY.pitch) >= ENTITY_CELL;
const bool IN_SPACING_Y = (RY % LAY.pitch) >= ENTITY_CELL;
if (!IN_SPACING_X && !IN_SPACING_Y && VC < LAY.cols && VR < LAY.rows_visible) {
const int IDX = ((entity_frame_picker_scroll_ + VR) * LAY.cols) + VC;
if (IDX < LAY.total_items) { entity_frame_picker_hover_ = IDX; }
}
}
for (const auto& ev : EventBuffer::events) {
if (ev.type == SDL_EVENT_MOUSE_WHEEL) {
entity_frame_picker_scroll_ -= static_cast<int>(ev.wheel.y);
entity_frame_picker_scroll_ = std::clamp(entity_frame_picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (entity_frame_picker_hover_ >= 0) {
brush_entity_ = entity_frame_picker_source_->name;
brush_entity_frame_ = entity_frame_picker_hover_;
mode_ = Mode::ENTITY;
picker_ = Picker::NONE;
}
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_RIGHT) {
// Botó dret: tanca el sub-picker i torna al picker d'entitats
// sense fixar brush. Coherent amb la convenció de "right =
// cancel·la l'acció actual" dels altres modes.
picker_ = Picker::ENTITY;
entity_frame_picker_source_ = nullptr;
}
}
}
void Editor::renderEntityFramePicker() const {
if (entity_frame_picker_source_ == nullptr) { return; }
const auto& src = *entity_frame_picker_source_;
const auto LAY = layoutEntityPicker(src.frame_count);
if (LAY.cols <= 0 || LAY.rows_visible <= 0) { return; }
auto& screen = Screen::get();
const auto& cfg = EditorConfig::get();
screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, cfg.pickerBgArgb());
// Cols del atlas (sub-sprites per fila): es dedueix de la mida del
// surface i del frame, igual que fa `EntityManager::renderEntity`.
const int ATLAS_COLS = (src.frame_w > 0) ? (src.sprite->getWidth() / src.frame_w) : 1;
const int ATLAS_COLS_SAFE = (ATLAS_COLS > 0) ? ATLAS_COLS : 1;
for (int vr = 0; vr < LAY.rows_visible; ++vr) {
const int LOGICAL_ROW = entity_frame_picker_scroll_ + vr;
for (int vc = 0; vc < LAY.cols; ++vc) {
const int IDX = (LOGICAL_ROW * LAY.cols) + vc;
if (IDX >= LAY.total_items) { break; }
const auto [PX, PY] = entityCellOrigin(LAY, vc, vr);
const int OX = PX + ((ENTITY_CELL - src.frame_w) / 2);
const int OY = PY + ((ENTITY_CELL - src.frame_h) / 2);
const int A_COL = IDX % ATLAS_COLS_SAFE;
const int A_ROW = IDX / ATLAS_COLS_SAFE;
const SDL_FRect SRC{
.x = static_cast<float>(A_COL * src.frame_w),
.y = static_cast<float>(A_ROW * src.frame_h),
.w = static_cast<float>(src.frame_w),
.h = static_cast<float>(src.frame_h),
};
src.sprite->render(OX, OY, &SRC, SDL_FLIP_NONE);
}
}
if (entity_frame_picker_hover_ >= 0) {
const int VR = (entity_frame_picker_hover_ / LAY.cols) - entity_frame_picker_scroll_;
const int VC = entity_frame_picker_hover_ % LAY.cols;
if (VR >= 0 && VR < LAY.rows_visible) {
const auto [PX, PY] = entityCellOrigin(LAY, VC, VR);
const auto C = cfg.pickerHoverArgb();
screen.fillRect(PX, PY, ENTITY_CELL, 1, C);
screen.fillRect(PX, PY + ENTITY_CELL - 1, ENTITY_CELL, 1, C);
screen.fillRect(PX, PY, 1, ENTITY_CELL, C);
screen.fillRect(PX + ENTITY_CELL - 1, PY, 1, ENTITY_CELL, C);
}
}
const auto BORDER = cfg.borderArgb();
screen.fillRect(LAY.x, LAY.y, LAY.w, 1, BORDER);
screen.fillRect(LAY.x, LAY.y + LAY.h - 1, LAY.w, 1, BORDER);
screen.fillRect(LAY.x, LAY.y, 1, LAY.h, BORDER);
screen.fillRect(LAY.x + LAY.w - 1, LAY.y, 1, LAY.h, BORDER);
}
void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) { void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
const auto LAY = layoutPicker(tilemap); const auto LAY = layoutPicker(tilemap);
tile_picker_hover_ = -1; tile_picker_hover_ = -1;
@@ -928,6 +1092,28 @@ void Editor::updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y
} }
} }
void Editor::renderEntityBrushPreview(const Tilemap& tilemap) const {
auto it = std::ranges::find_if(editable_entities_, [this](const auto& e) { return e.name == brush_entity_; });
if (it == editable_entities_.end() || it->sprite == nullptr) { return; }
const int FRAME = (brush_entity_frame_ >= 0) ? brush_entity_frame_ : 0;
const int ATLAS_COLS = (it->frame_w > 0) ? (it->sprite->getWidth() / it->frame_w) : 1;
const int ATLAS_COLS_SAFE = (ATLAS_COLS > 0) ? ATLAS_COLS : 1;
const int A_COL = FRAME % ATLAS_COLS_SAFE;
const int A_ROW = FRAME / ATLAS_COLS_SAFE;
const SDL_FRect SRC{
.x = static_cast<float>(A_COL * it->frame_w),
.y = static_cast<float>(A_ROW * it->frame_h),
.w = static_cast<float>(it->frame_w),
.h = static_cast<float>(it->frame_h),
};
const int TS = tilemap.getTileSize();
const int TILE_PX = tilemap.getOriginX() + (cursor_col_ * TS);
const int TILE_PY = tilemap.getOriginY() + (cursor_row_ * TS);
const int DST_X = TILE_PX + ((TS - it->frame_w) / 2);
const int DST_Y = TILE_PY + TS - it->frame_h;
it->sprite->render(DST_X, DST_Y, &SRC, SDL_FLIP_NONE);
}
void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const { void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const {
if (!active_) { return; } if (!active_) { return; }
auto& screen = Screen::get(); auto& screen = Screen::get();
@@ -938,11 +1124,15 @@ void Editor::render(const Tilemap& tilemap, const EntityManager& entities) const
renderTilePicker(tilemap); renderTilePicker(tilemap);
} else if (picker_ == Picker::ENTITY) { } else if (picker_ == Picker::ENTITY) {
renderEntityPicker(tilemap); renderEntityPicker(tilemap);
} else if (picker_ == Picker::ENTITY_FRAME) {
renderEntityFramePicker();
} else if (mode_ == Mode::TILE && brush_tile_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) { } else if (mode_ == Mode::TILE && brush_tile_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
// Preview del brush sota el cursor (només quan el picker és tancat). // Preview del brush sota el cursor (només quan el picker és tancat).
const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize()); const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize());
const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize()); const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize());
tilemap.renderTilePreview(PX, PY, brush_tile_); tilemap.renderTilePreview(PX, PY, brush_tile_);
} else if (mode_ == Mode::ENTITY && !brush_entity_.empty() && cursor_col_ >= 0 && cursor_row_ >= 0) {
renderEntityBrushPreview(tilemap);
} else if (mode_ == Mode::HAND && picker_ == Picker::NONE) { } else if (mode_ == Mode::HAND && picker_ == Picker::NONE) {
// Marcs d'entitat: hover (tenue) i selecció (brillant). El // Marcs d'entitat: hover (tenue) i selecció (brillant). El
// pickerHoverArgb el reusem com a "selecció brillant"; el // pickerHoverArgb el reusem com a "selecció brillant"; el
+37 -7
View File
@@ -78,9 +78,13 @@ class Editor {
HAND }; HAND };
// Quin picker està obert (si n'hi ha). Mútuament excloents. // Quin picker està obert (si n'hi ha). Mútuament excloents.
// - ENTITY_FRAME: sub-picker que apareix després de clicar una entitat
// amb variants al picker principal. Mostra tots els sub-sprites del
// atlas (índex frame) per a triar quina cara representa la instància.
enum class Picker : std::uint8_t { NONE, enum class Picker : std::uint8_t { NONE,
TILE, TILE,
ENTITY }; ENTITY,
ENTITY_FRAME };
// Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`. // Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`.
// Es crida al principi d'`update` perquè altres handlers no vegin // Es crida al principi d'`update` perquè altres handlers no vegin
@@ -100,6 +104,7 @@ class Editor {
void updateHand(const Tilemap& tilemap, EntityManager& entities, int canvas_x, int canvas_y); void updateHand(const Tilemap& tilemap, EntityManager& entities, int canvas_x, int canvas_y);
void updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y); void updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y);
void updateEntityPicker(int canvas_x, int canvas_y); void updateEntityPicker(int canvas_x, int canvas_y);
void updateEntityFramePicker(int canvas_x, int canvas_y);
// Intent de col·locació segons brush_entity_ a la cel·la del cursor. // Intent de col·locació segons brush_entity_ a la cel·la del cursor.
// Construeix un node spawn sintètic, fa el snap concret per tipus // Construeix un node spawn sintètic, fa el snap concret per tipus
@@ -138,6 +143,13 @@ class Editor {
void renderTilePicker(const Tilemap& tilemap) const; void renderTilePicker(const Tilemap& tilemap) const;
void renderEntityPicker(const Tilemap& tilemap) const; void renderEntityPicker(const Tilemap& tilemap) const;
void renderEntityFramePicker() const;
// Preview del sprite de l'entitat seleccionada com a brush, snapped al
// tile sota el cursor. Anchor "feet": base del sprite a la base inferior
// del tile, centrat horitzontalment — així stone cau exactament i column
// sobresurt cap amunt, coincidint amb el resultat de col·locar.
void renderEntityBrushPreview(const Tilemap& tilemap) const;
bool active_{false}; bool active_{false};
Mode mode_{Mode::TILE}; Mode mode_{Mode::TILE};
@@ -153,21 +165,22 @@ class Editor {
int tile_picker_scroll_{0}; int tile_picker_scroll_{0};
int tile_picker_hover_{-1}; int tile_picker_hover_{-1};
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
std::string brush_entity_;
// Estat de l'EntityPicker.
int entity_picker_scroll_{0};
int entity_picker_hover_{-1}; // Índex a la llista de noms.
// Entrada editable del picker. Cada item recorda el nom (clau de // Entrada editable del picker. Cada item recorda el nom (clau de
// factory + YAML), el surface del sprite (frame 0 com a icona) i la // factory + YAML), el surface del sprite (frame 0 com a icona) i la
// mida de cada frame al GIF — necessària per clipar el primer frame // mida de cada frame al GIF — necessària per clipar el primer frame
// quan el GIF és un strip multi-frame. // quan el GIF és un strip multi-frame.
// - `frame_count` és el nombre total de sub-sprites del atlas
// (cols × rows segons les dimensions del surface i del frame).
// - `has_variants` és `true` quan el template declara `editable_variants:
// true` i hi ha més d'un frame: aleshores clicar al picker obre el
// sub-picker de frames en lloc de fixar el brush directament.
struct EditableEntity { struct EditableEntity {
std::string name; std::string name;
std::shared_ptr<Surface> sprite; std::shared_ptr<Surface> sprite;
int frame_w{0}; int frame_w{0};
int frame_h{0}; int frame_h{0};
int frame_count{1};
bool has_variants{false};
}; };
// Construïda lazy al primer enter() i preservada entre sessions: // Construïda lazy al primer enter() i preservada entre sessions:
@@ -178,6 +191,23 @@ class Editor {
std::vector<EditableEntity> editable_entities_; std::vector<EditableEntity> editable_entities_;
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
std::string brush_entity_;
// Frame escollit per al brush actiu (-1 = no aplicable: entitat sense
// variants o brush encara per fixar). El emet el sub-picker en clicar
// un frame; `tryPlaceEntity` el passa al spawn YAML com a `frame: N`.
int brush_entity_frame_{-1};
// Estat de l'EntityPicker.
int entity_picker_scroll_{0};
int entity_picker_hover_{-1}; // Índex a la llista de noms.
// Estat del sub-picker de frames. `entity_frame_picker_source_` apunta
// a la entitat activa (vista al EntityPicker) per a la qual el
// sub-picker mostra les variants. Quan el sub-picker es tanca sense
// selecció, ho deixem a nullptr i `brush_entity_frame_ = -1`.
int entity_frame_picker_scroll_{0};
int entity_frame_picker_hover_{-1};
const EditableEntity* entity_frame_picker_source_{nullptr};
// Font compartida amb el Notifier/HUD: el surface el carrega Director // Font compartida amb el Notifier/HUD: el surface el carrega Director
// a l'arrencada, ací només prenem la referència del cache. shared_ptr // a l'arrencada, ací només prenem la referència del cache. shared_ptr
// perquè `Text` accepta el surface per shared_ptr i així no copiem. // perquè `Text` accepta el surface per shared_ptr i així no copiem.
+119 -7
View File
@@ -1,14 +1,87 @@
#include "game/entities/animation_component.hpp" #include "game/entities/animation_component.hpp"
#include <cmath> #include <cmath>
#include <stdexcept>
#include <utility> #include <utility>
AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool loop) namespace {
: AnimationComponent(std::move(frames), fps, loop, false, false) {}
AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool loop, bool flip_h, bool flip_v) // Quan el caller no especifica temps, considerem un frame "estàtic" de
// 1s. El valor és arbitrari: amb 1 frame el component mai no avança,
// així que el speed concret és inert. Amb N frames és el fallback que
// assumeix la factory (mateix valor que el default del format antic).
constexpr float K_DEFAULT_FRAME_SECONDS = 1.0F;
// Si el YAML declara temps <= 0 per a algun frame, el saturem a aquest
// mínim per a evitar bucles infinits a `tick` (que avancen frame
// mentre `accumulator >= speeds[i]`). 1 ms és prou petit per a no
// alterar mai una temporització real i prou gran per a no col·lapsar.
constexpr float K_MIN_FRAME_SECONDS = 0.001F;
auto sanitizeSpeed(float seconds) -> float {
return seconds > 0.0F ? seconds : K_MIN_FRAME_SECONDS;
}
// Expandeix una llista de temps a la mida de `frame_count`:
// - mida = N: 1:1.
// - mida < N: emplena la resta amb l'últim valor (o el default si
// la llista era buida).
// - mida > N: talla a N.
// Tots els valors es passen per `sanitizeSpeed`.
auto fitSpeeds(std::vector<float> speeds, std::size_t frame_count) -> std::vector<float> {
if (frame_count == 0) { return {}; }
if (speeds.empty()) {
return std::vector<float>(frame_count, K_DEFAULT_FRAME_SECONDS);
}
// cppcheck-suppress useStlAlgorithm
for (auto& s : speeds) { s = sanitizeSpeed(s); }
if (speeds.size() == frame_count) { return speeds; }
if (speeds.size() > frame_count) {
speeds.resize(frame_count);
return speeds;
}
const float TAIL = speeds.back();
speeds.resize(frame_count, TAIL);
return speeds;
}
// Llig `animations.<key>.frames` com a llista d'enters. Garanteix
// almenys 1 frame (fallback a [0]) per a poder construir sempre un
// component vàlid.
auto parseFrames(const fkyaml::node& anim) -> std::vector<int> {
std::vector<int> frames;
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
return frames;
}
// Llig `animations.<key>.frame_ms` (escalar o llista) i retorna els
// temps en segons. Si és escalar, retorna una llista uniforme de
// `frame_count` elements; si és llista, copia tal qual (l'ajust de
// mida el farà `fitSpeeds` al constructor).
auto parseFrameMs(const fkyaml::node& anim, std::size_t frame_count) -> std::vector<float> {
std::vector<float> speeds_secs;
const auto& fm = anim["frame_ms"];
if (fm.is_sequence()) {
for (const auto& v : fm) {
// cppcheck-suppress useStlAlgorithm
speeds_secs.push_back(static_cast<float>(v.get_value<int>()) / 1000.0F);
}
} else {
const float SECS = static_cast<float>(fm.get_value<int>()) / 1000.0F;
speeds_secs.assign(frame_count, SECS);
}
return speeds_secs;
}
} // namespace
AnimationComponent::AnimationComponent(std::vector<int> frames, std::vector<float> speeds, bool loop, bool flip_h, bool flip_v)
: frames_(std::move(frames)), : frames_(std::move(frames)),
fps_(fps > 0.0F ? fps : 1.0F), speeds_(fitSpeeds(std::move(speeds), frames_.size())),
loop_(loop), loop_(loop),
flip_h_(flip_h), flip_h_(flip_h),
flip_v_(flip_v) { flip_v_(flip_v) {
@@ -19,13 +92,52 @@ AnimationComponent::AnimationComponent(std::vector<int> frames, float fps, bool
if (frames_.size() <= 1 && !loop_) { completed_ = true; } if (frames_.size() <= 1 && !loop_) { completed_ = true; }
} }
auto AnimationComponent::uniform(std::vector<int> frames, float frame_ms, bool loop) -> AnimationComponent {
return uniform(std::move(frames), frame_ms, loop, false, false);
}
auto AnimationComponent::uniform(std::vector<int> frames, float frame_ms, bool loop, bool flip_h, bool flip_v) -> AnimationComponent {
const float SECS = sanitizeSpeed(frame_ms / 1000.0F);
std::vector<float> speeds(frames.size(), SECS);
return {std::move(frames), std::move(speeds), loop, flip_h, flip_v};
}
auto AnimationComponent::fromYaml(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
// Defaults conservadors per a quan el bloc `animations.<key>` no
// existeix: 1 frame loopant a 1s — visualment estàtic, semàntica neutra.
std::vector<int> frames = {0};
std::vector<float> speeds_secs; // buit ⇒ fitSpeeds emplenarà amb default
bool loop = true;
bool flip_h = false;
bool flip_v = false;
if (!yaml.contains("animations") || !yaml["animations"].contains(key)) {
return {std::move(frames), std::move(speeds_secs), loop, flip_h, flip_v};
}
const auto& anim = yaml["animations"][key];
// L'antic format `fps: N` ja no és suportat. Si apareix, peguem hard
// perquè la migració sigui visible (no silenciosa). El missatge inclou
// la clau d'animació per a facilitar localitzar el YAML problemàtic.
if (anim.contains("fps")) {
throw std::runtime_error("AnimationComponent: 'fps:' is no longer supported, use 'frame_ms:' instead (animation '" + key + "')");
}
if (anim.contains("frames")) { frames = parseFrames(anim); }
if (anim.contains("frame_ms")) { speeds_secs = parseFrameMs(anim, frames.size()); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
if (anim.contains("flip_h")) { flip_h = anim["flip_h"].get_value<bool>(); }
if (anim.contains("flip_v")) { flip_v = anim["flip_v"].get_value<bool>(); }
return {std::move(frames), std::move(speeds_secs), loop, flip_h, flip_v};
}
void AnimationComponent::tick(float delta_time) { void AnimationComponent::tick(float delta_time) {
if (frames_.size() <= 1 || completed_) { return; } if (frames_.size() <= 1 || completed_) { return; }
accumulator_ += delta_time; accumulator_ += delta_time;
const float FRAME_TIME = 1.0F / fps_; while (accumulator_ >= speeds_[static_cast<std::size_t>(current_)]) {
while (accumulator_ >= FRAME_TIME) { accumulator_ -= speeds_[static_cast<std::size_t>(current_)];
accumulator_ -= FRAME_TIME;
++current_; ++current_;
if (static_cast<std::size_t>(current_) >= frames_.size()) { if (static_cast<std::size_t>(current_) >= frames_.size()) {
if (loop_) { if (loop_) {
+37 -8
View File
@@ -3,8 +3,11 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <cstddef> #include <cstddef>
#include <string>
#include <vector> #include <vector>
#include "external/fkyaml_node.hpp"
// Component reutilitzable d'animació: qualsevol entitat que necessiti // Component reutilitzable d'animació: qualsevol entitat que necessiti
// reproduir una seqüència de frames l'instancia. Es manté com a camp // reproduir una seqüència de frames l'instancia. Es manté com a camp
// opcional dins d'Entity (veure `entity.hpp`) i NO hereta de cap classe // opcional dins d'Entity (veure `entity.hpp`) i NO hereta de cap classe
@@ -22,16 +25,41 @@
// canvien d'animació, sense decidir-lo per direcció. Així una mòmia que va // canvien d'animació, sense decidir-lo per direcció. Així una mòmia que va
// cap a l'oest reprodueix `walk_west` (frames de l'est + `flip_h: true`) // cap a l'oest reprodueix `walk_west` (frames de l'est + `flip_h: true`)
// sense que el codi de la mòmia toqui flip directament. // sense que el codi de la mòmia toqui flip directament.
//
// El temps es modela com a `speeds_[i]` segons per frame (per-frame), no com
// a `fps` uniforme. Això permet expressar holds al YAML via `frame_ms: [..]`
// (llista) sense haver de repetir el mateix índex de frame. Per a casos
// uniformes la factory omple `speeds_` amb el mateix valor a tot el vector.
class AnimationComponent { class AnimationComponent {
public: public:
AnimationComponent() = default; AnimationComponent() = default;
AnimationComponent(std::vector<int> frames, float fps, bool loop);
AnimationComponent(std::vector<int> frames, float fps, bool loop, bool flip_h, bool flip_v); // Factory canònica: llig el bloc `animations.<key>` del YAML.
//
// Camps llegits:
// - `frames` : llista d'índexs sencers (obligatori per animar).
// - `frame_ms` : escalar ms (uniforme) o llista de ms (per-frame).
// Default 1000 ms (animació estàtica).
// - `loop` : bool (default true).
// - `flip_h` : bool (default false).
// - `flip_v` : bool (default false).
//
// Si la clau no existeix retorna un component "estàtic" (1 frame, loop
// true). Si el YAML conté `fps:`, llença `std::runtime_error`: el
// format antic ja no és suportat — cal migrar a `frame_ms:`.
static auto fromYaml(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent;
// Helper uniforme: construeix un component on tots els frames duren
// `frame_ms` mil·lisegons. Útil per a usos programàtics (tests,
// imposició d'un frame estàtic des del loader de nivell). Per al cas
// típic guiat per YAML, usar `fromYaml`.
static auto uniform(std::vector<int> frames, float frame_ms, bool loop) -> AnimationComponent;
static auto uniform(std::vector<int> frames, float frame_ms, bool loop, bool flip_h, bool flip_v) -> AnimationComponent;
// Avanç del temps. Si `frames_` està buit o té un sol element, no fa // Avanç del temps. Si `frames_` està buit o té un sol element, no fa
// res. En mode loop, `current_frame_` rebota a 0 en passar del final; // res. En mode loop, `current_` rebota a 0 en passar del final; en
// en mode no-loop, es queda al darrer frame i `completed()` passa a // mode no-loop, es queda al darrer frame i `completed()` passa a true.
// true. // Cada frame consumeix `speeds_[current_]` segons.
void tick(float delta_time); void tick(float delta_time);
// Torna al primer frame i reinicia l'acumulador. Útil en transicions // Torna al primer frame i reinicia l'acumulador. Útil en transicions
@@ -39,8 +67,7 @@ class AnimationComponent {
void reset(); void reset();
// Índex actual dins `frames_`. Si `frames_` està buit retorna 0 com // Índex actual dins `frames_`. Si `frames_` està buit retorna 0 com
// a fallback tolerant (el loader ja ha emès warning si hi havia // a fallback tolerant.
// frames fora de rang; aquí preferim no crashejar).
[[nodiscard]] auto currentFrame() const -> int; [[nodiscard]] auto currentFrame() const -> int;
[[nodiscard]] auto frameCount() const -> std::size_t { return frames_.size(); } [[nodiscard]] auto frameCount() const -> std::size_t { return frames_.size(); }
@@ -55,8 +82,10 @@ class AnimationComponent {
[[nodiscard]] auto flipMode() const -> SDL_FlipMode; [[nodiscard]] auto flipMode() const -> SDL_FlipMode;
private: private:
AnimationComponent(std::vector<int> frames, std::vector<float> speeds, bool loop, bool flip_h, bool flip_v);
std::vector<int> frames_; std::vector<int> frames_;
float fps_{1.0F}; std::vector<float> speeds_; // segons per frame; size == frames_.size()
bool loop_{true}; bool loop_{true};
bool flip_h_{false}; bool flip_h_{false};
bool flip_v_{false}; bool flip_v_{false};
+4 -33
View File
@@ -1,45 +1,16 @@
#include "game/entities/concrete/door.hpp" #include "game/entities/concrete/door.hpp"
#include <string> #include <string>
#include <vector>
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp" #include "game/entities/player.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
namespace {
// Carrega una animació d'un bloc concret (closed/opening/open) amb
// fallbacks tolerants. Si la clau no existeix, retorna una animació
// d'un sol frame loopant: no fa estat invàlid, simplement no hi ha res
// per reproduir.
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = true;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace
Door::Door(const fkyaml::node& yaml) Door::Door(const fkyaml::node& yaml)
: anim_closed_(buildAnimation(yaml, "closed")), : anim_closed_(AnimationComponent::fromYaml(yaml, "closed")),
anim_opening_(buildAnimation(yaml, "opening")), anim_opening_(AnimationComponent::fromYaml(yaml, "opening")),
anim_open_(buildAnimation(yaml, "open")) { anim_open_(AnimationComponent::fromYaml(yaml, "open")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("door"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("door");
collides_ = true; collides_ = true;
interactable_ = true; interactable_ = true;
@@ -99,7 +70,7 @@ void Door::open() {
// placeholders defineixen `loop: false` explícitament al YAML, així // placeholders defineixen `loop: false` explícitament al YAML, així
// que aquest fallback només actua si algú dissenya malament. // que aquest fallback només actua si algú dissenya malament.
if (anim_.has_value() && anim_->frameCount() <= 1 && anim_->loops()) { if (anim_.has_value() && anim_->frameCount() <= 1 && anim_->loops()) {
anim_ = AnimationComponent({0}, 6.0F, false); anim_ = AnimationComponent::uniform({0}, 167.0F, false);
} }
} }
+2 -32
View File
@@ -1,42 +1,12 @@
#include "game/entities/concrete/dust.hpp" #include "game/entities/concrete/dust.hpp"
#include <string> #include <string>
#include <vector>
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
namespace {
// Llig una animació opcional del bloc `animations` del YAML, amb
// valors per defecte segurs (1 frame, fps=1, sense loop). Si la
// clau no existeix, retornem una animació "ja completada" — així,
// un Dust spawnejat amb una variant inexistent al YAML mor al
// pròxim frame sense petar.
auto buildAnim(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = false;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace
Dust::Dust(const fkyaml::node& yaml) Dust::Dust(const fkyaml::node& yaml)
: anim_jump_(buildAnim(yaml, "jump")), : anim_jump_(AnimationComponent::fromYaml(yaml, "jump")),
anim_land_(buildAnim(yaml, "land")) { anim_land_(AnimationComponent::fromYaml(yaml, "land")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("dust"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("dust");
collides_ = false; collides_ = false;
interactable_ = false; interactable_ = false;
+106 -39
View File
@@ -10,6 +10,7 @@
#include "core/audio/audio.hpp" #include "core/audio/audio.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
#include "game/entities/concrete/whip_attack.hpp"
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp" #include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
@@ -28,36 +29,6 @@ namespace {
return k_rng_; return k_rng_;
} }
// Llegeix un bloc d'animació del YAML amb fallbacks tolerants (mateix
// patró que Door/Sarcophagus). Si la clau no apareix, retorna una
// animació d'1 frame loopant, neutra — la momia es podrà moure però
// visualment es quedarà estàtica. Llegeix `flip_h`/`flip_v` (default
// false) i els passa al component perquè el motor sàpiga aplicar-los
// al sprite quan aquesta animació estigui activa.
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 8.0F;
bool loop = true;
bool flip_h = false;
bool flip_v = false;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
if (anim.contains("flip_h")) { flip_h = anim["flip_h"].get_value<bool>(); }
if (anim.contains("flip_v")) { flip_v = anim["flip_v"].get_value<bool>(); }
}
return {frames, fps, loop, flip_h, flip_v};
}
// Busca si el segment [prev, prev+delta] travessa una lane del patró // Busca si el segment [prev, prev+delta] travessa una lane del patró
// `base + k·pitch` (k ∈ [0..max_idx]). Retorna el delta que cal aplicar // `base + k·pitch` (k ∈ [0..max_idx]). Retorna el delta que cal aplicar
// per arribar exactament a la primera línia travessada. Iterem en el // per arribar exactament a la primera línia travessada. Iterem en el
@@ -65,6 +36,24 @@ namespace {
// més d'una lane (cas marginal a velocitats típiques, però pot passar // més d'una lane (cas marginal a velocitats típiques, però pot passar
// amb dt grans). Comparació estricta a un costat: no dispara quan la // amb dt grans). Comparació estricta a un costat: no dispara quan la
// caixa ja es troba alineada a la lane (hi està després del snap). // caixa ja es troba alineada a la lane (hi està després del snap).
// Mapatge WhipAttack::Direction → Mummy::Direction. Viuen amb noms i
// valors paral·lels però són dos enums distints (cada classe modela el
// seu propi domini). Conversió 1-a-1; `NONE` no apareix com a possible
// sortida del whip, però la deixem per coherència defensiva.
auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction {
switch (d) {
case WhipAttack::Direction::NORTH:
return Mummy::Direction::NORTH;
case WhipAttack::Direction::SOUTH:
return Mummy::Direction::SOUTH;
case WhipAttack::Direction::EAST:
return Mummy::Direction::EAST;
case WhipAttack::Direction::WEST:
return Mummy::Direction::WEST;
}
return Mummy::Direction::NONE;
}
auto findLaneCross(float prev, float delta, float base, float pitch, int max_idx) -> std::optional<float> { auto findLaneCross(float prev, float delta, float base, float pitch, int max_idx) -> std::optional<float> {
const float NEXT = prev + delta; const float NEXT = prev + delta;
if (delta > 0.0F) { if (delta > 0.0F) {
@@ -84,10 +73,11 @@ namespace {
} // namespace } // namespace
Mummy::Mummy(const fkyaml::node& yaml) Mummy::Mummy(const fkyaml::node& yaml)
: anim_walk_south_(buildAnimation(yaml, "walk_south")), : anim_walk_south_(AnimationComponent::fromYaml(yaml, "walk_south")),
anim_walk_north_(buildAnimation(yaml, "walk_north")), anim_walk_north_(AnimationComponent::fromYaml(yaml, "walk_north")),
anim_walk_east_(buildAnimation(yaml, "walk_east")), anim_walk_east_(AnimationComponent::fromYaml(yaml, "walk_east")),
anim_walk_west_(buildAnimation(yaml, "walk_west")) { anim_walk_west_(AnimationComponent::fromYaml(yaml, "walk_west")),
anim_raise_(AnimationComponent::fromYaml(yaml, "raise")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy");
collides_ = true; collides_ = true;
interactable_ = false; interactable_ = false;
@@ -122,6 +112,11 @@ Mummy::Mummy(const fkyaml::node& yaml)
changeDirection(0.0F, 1.0F); changeDirection(0.0F, 1.0F);
} }
// L'estat SPAWNING amb l'animació `raise` s'arma a `onJustSpawned`,
// no al ctor. Així el manager pot instanciar la mòmia sense disparar
// la cinemàtica (cas del mode editor) i veure-la ja en ROAMING amb
// la `walk_*` triada per `changeDirection` més amunt.
// Configuració de l'estat HURT: opcional. Si el bloc no apareix, la // 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ó.
@@ -147,6 +142,20 @@ Mummy::Mummy(const fkyaml::node& yaml)
} }
} }
void Mummy::onJustSpawned() {
// Cinemàtica d'aparició: la mòmia "s'aixeca" reproduint `raise`
// (16 frames no-loop). El countdown del DynamicHazard NO s'utilitza
// (passem 0): `Mummy::update` controla la transició a ACTIVE per
// `anim_->completed()`. Cap crida a la base perquè el `triggerSpawnExit`
// del DynamicHazard arma aquest mateix countdown que aquí no toca.
enterSpawn(0.0F);
anim_ = anim_raise_;
if (anim_.has_value()) {
anim_->reset();
flip_ = anim_->flipMode();
}
}
void Mummy::changeDirection(float dx, float dy) { void Mummy::changeDirection(float dx, float dy) {
setDirection(dx, dy); setDirection(dx, dy);
// Una animació per cada cardinal — el flip viu al YAML // Una animació per cada cardinal — el flip viu al YAML
@@ -222,9 +231,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
@@ -494,7 +518,19 @@ void Mummy::applyChoice(Direction dir) {
} }
} }
auto Mummy::onWhipHit(WhipAttack* attack) -> bool {
// Defensiu: el filtre `shouldReceiveContact` ja salta DynamicHazards en
// SPAWN, però blindem aquesta crida si arribara per un camí no central.
if (isSpawning()) { return false; }
if (!whippable_ || attack == nullptr) { return false; }
enterHurt(whipDirToMummyDir(attack->getDirection()));
return true;
}
void Mummy::onPlayerContact(Player* player) { void Mummy::onPlayerContact(Player* player) {
// Defensiu: el filtre central a EntityManager::checkPlayerCollisions ja
// salta DynamicHazards en SPAWN, però blindem la crida directa.
if (isSpawning()) { return; }
if (bus_ == nullptr) { return; } if (bus_ == nullptr) { return; }
const int PID = (player != nullptr) ? player->getId() : -1; const int PID = (player != nullptr) ? player->getId() : -1;
bus_->publish(Event{ bus_->publish(Event{
@@ -519,6 +555,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,13 +652,40 @@ 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;
} }
void Mummy::update(float delta_time) { void Mummy::update(float delta_time) {
if (isSpawning()) {
// SPAWN dirigit per animació: la `raise` és no-loop, així que
// `completed()` ens marca el final. Mentre dura, NO cridem
// `DynamicHazard::update`: la base hi té el countdown i ja
// hauria saltat a ACTIVE (passem `enterSpawn(0)`), i el `step()`
// d'IA mouria la mòmia abans de "aixecar-se". En completar,
// tornem a la `walk_*` que toca segons la direcció ja triada
// al ctor (initial_direction de l'IA).
if (anim_.has_value()) { anim_->tick(delta_time); }
if (anim_.has_value() && anim_->completed()) {
endSpawn();
changeDirection(getDirX(), getDirY());
}
return;
}
if (state_ == State::HURT) { if (state_ == State::HURT) {
// Avancem l'animació inline (no podem cridar `Entity::update` ni // Avancem l'animació inline (no podem cridar `Entity::update` ni
// `DynamicHazard::update`: el primer salta una capa virtual i el // `DynamicHazard::update`: el primer salta una capa virtual i el
+18
View File
@@ -61,9 +61,21 @@ class Mummy : public DynamicHazard {
// En estat ROAMING delega a `step()` com sempre via la base. // En estat ROAMING delega a `step()` com sempre via la base.
void update(float delta_time) override; void update(float delta_time) override;
// Inicia la fase SPAWNING amb l'animació `raise`. Es crida al
// spawnPendingActors() del joc, no al ctor: així l'editor (que
// materialitza les entitats sense aquest hook) veu la mòmia ja en
// ROAMING visible, sense quedar-se "soterrada".
void onJustSpawned() override;
// Contacte amb el jugador: publica `player_hit` amb el dany. // Contacte amb el jugador: publica `player_hit` amb el dany.
void onPlayerContact(Player* player) override; void onPlayerContact(Player* player) override;
// Contacte amb el fueteig: si la mòmia és whippable, entra a HURT amb
// la direcció derivada del whip. Retorna true si efectivament ha
// rebut dany — el WhipAttack ho usa per al `already_hit_`. Substitueix
// el `dynamic_cast<Mummy*>` que feia el whip abans per discriminar.
auto onWhipHit(WhipAttack* attack) -> bool override;
// Activa l'estat HURT amb la direcció de fuga preferida `flee_dir`. // Activa l'estat HURT amb la direcció de fuga preferida `flee_dir`.
// Si està bloquejada, prova perpendiculars; si totes estan bloquejades, // Si està bloquejada, prova perpendiculars; si totes estan bloquejades,
// la mòmia queda quieta però en HURT durant tot el timer. // la mòmia queda quieta però en HURT durant tot el timer.
@@ -171,6 +183,12 @@ class Mummy : public DynamicHazard {
AnimationComponent anim_walk_east_; AnimationComponent anim_walk_east_;
AnimationComponent anim_walk_west_; AnimationComponent anim_walk_west_;
// Animació d'aparició (no-loop). Mentre `isSpawning()`, `update()`
// la tickeja sense moure la mòmia; quan `completed()`, la mòmia
// transita a ACTIVE via `endSpawn()` i conmuta a la walk_* que
// toca segons direcció.
AnimationComponent anim_raise_;
// Estat HURT i la seua configuració. `hurt_speed_` és l'efectiva // Estat HURT i la seua configuració. `hurt_speed_` és l'efectiva
// (resultat d'aplicar el `speed` del bloc `hurt:` o, si val 0, la // (resultat d'aplicar el `speed` del bloc `hurt:` o, si val 0, la
// velocitat normal de la mòmia). // velocitat normal de la mòmia).
+89 -34
View File
@@ -1,31 +1,23 @@
#include "game/entities/concrete/sarcophagus.hpp" #include "game/entities/concrete/sarcophagus.hpp"
#include <random>
#include <string> #include <string>
#include <vector>
#include "core/audio/audio.hpp"
#include "core/resources/resource_cache.hpp"
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "utils/defaults.hpp"
namespace { namespace {
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent { // RNG local al mòdul, mateix patró que `mummy.cpp`: llavor fixa per
std::vector<int> frames = {0}; // facilitar la depuració. Si algun dia volem aleatoritat real entre
float fps = 1.0F; // partides, prou amb llegir-la de std::random_device.
bool loop = true; auto rng() -> std::mt19937& {
if (yaml.contains("animations") && yaml["animations"].contains(key)) { static std::mt19937 k_rng_{0xCAFEU};
const auto& anim = yaml["animations"][key]; return k_rng_;
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
} }
// Llig `tile_size: [w, h]` del YAML. Fallback [1,1] si no existeix. // Llig `tile_size: [w, h]` del YAML. Fallback [1,1] si no existeix.
@@ -48,14 +40,37 @@ Sarcophagus::Sarcophagus(const fkyaml::node& yaml, int anchor_col, int anchor_ro
} }
Sarcophagus::Sarcophagus(const fkyaml::node& yaml) Sarcophagus::Sarcophagus(const fkyaml::node& yaml)
: anim_closed_(buildAnimation(yaml, "closed")), : anim_closed_(AnimationComponent::fromYaml(yaml, "closed")),
anim_opening_(buildAnimation(yaml, "opening")), anim_opening_(AnimationComponent::fromYaml(yaml, "opening")),
anim_open_(buildAnimation(yaml, "open")) { anim_open_(AnimationComponent::fromYaml(yaml, "open")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("sarcophagus"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("sarcophagus");
collides_ = true; collides_ = true;
interactable_ = true; interactable_ = true;
readTileSize(yaml, tile_size_w_, tile_size_h_); readTileSize(yaml, tile_size_w_, tile_size_h_);
anim_ = anim_closed_; anim_ = anim_closed_;
// Sons opcionals (bloc `sound:` del YAML). El basename val tant de
// clau al Resource::Cache com de path dins `Defaults::Paths::AUDIO_SOUNDS_DIR`.
// Mateix patró que `mummy.hurt.sound` i `player.jump.sound`.
if (yaml.contains("sound")) {
const auto& s = yaml["sound"];
if (s.contains("opening")) {
opening_sound_key_ = s["opening"].get_value<std::string>();
Resource::Cache::get().loadSound(opening_sound_key_, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + opening_sound_key_);
}
if (s.contains("opening_pitch_spread")) {
opening_pitch_spread_ = s["opening_pitch_spread"].get_value<float>();
}
}
// Bloc `reward:` opcional — la recompensa que rebrà el jugador que
// tanqui el sarcòfag. Si no hi és, els camps queden a 0 i el GameScene
// simplement no aplica res al consumir `sarcophagus_opened`.
if (yaml.contains("reward")) {
const auto& r = yaml["reward"];
if (r.contains("points")) { points_reward_ = r["points"].get_value<int>(); }
if (r.contains("treasures")) { treasures_reward_ = r["treasures"].get_value<int>(); }
}
} }
void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) { void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) {
@@ -70,14 +85,16 @@ void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate&
void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) { void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) {
perimeter_tiles_.clear(); perimeter_tiles_.clear();
// El perímetre ortogonal d'un rectangle (w,h) de tiles són les files // El perímetre d'un rectangle (w,h) de tiles són les files de tiles
// de tiles immediatament N, S, E, O. No s'hi inclouen les cantonades // immediatament adjacents: N, S, E, O *més* les quatre cantonades
// diagonals (tile NO, NE, SO, SE), perquè el jugador fa passes pels // diagonals (NO, NE, SO, SE). En la pràctica el jugador trepitja
// quatre costats i no pot "tallar" per una diagonal: això garanteix // aquestes cantonades en doblar les cantonades del sarcòfag, així que
// que el recorregut completa només si passa per cada costat. // si no s'hi compten, el cerco no es pot tancar visualment encara
// que es completi a nivell de codi (o, pitjor, s'obre sense que el
// jugador hagi clos realment el contorn).
// //
// - Fila Nord: (col, row-1) per col ∈ [col0, col0+w-1] // - Fila Nord: (col, row-1) per col ∈ [col0-1, col0+w] (inclou NO i NE)
// - Fila Sud: (col, row+h) per col ∈ [col0, col0+w-1] // - Fila Sud: (col, row+h) per col ∈ [col0-1, col0+w] (inclou SO i SE)
// - Columna O: (col0-1, row) per row ∈ [row0, row0+h-1] // - Columna O: (col0-1, row) per row ∈ [row0, row0+h-1]
// - Columna E: (col0+w, row) per row ∈ [row0, row0+h-1] // - Columna E: (col0+w, row) per row ∈ [row0, row0+h-1]
@@ -86,12 +103,12 @@ void Sarcophagus::computePerimeter(const BlockedPredicate& is_blocked) {
perimeter_tiles_.insert({col, row}); perimeter_tiles_.insert({col, row});
}; };
// Nord i Sud // Nord i Sud (incloent les quatre cantonades diagonals)
for (int dc = 0; dc < tile_size_w_; ++dc) { for (int dc = -1; dc <= tile_size_w_; ++dc) {
maybe_insert(anchor_col_ + dc, anchor_row_ - 1); maybe_insert(anchor_col_ + dc, anchor_row_ - 1);
maybe_insert(anchor_col_ + dc, anchor_row_ + tile_size_h_); maybe_insert(anchor_col_ + dc, anchor_row_ + tile_size_h_);
} }
// Oest i Est // Oest i Est (les cantonades ja les ha posat el bucle N/S)
for (int dr = 0; dr < tile_size_h_; ++dr) { for (int dr = 0; dr < tile_size_h_; ++dr) {
maybe_insert(anchor_col_ - 1, anchor_row_ + dr); maybe_insert(anchor_col_ - 1, anchor_row_ + dr);
maybe_insert(anchor_col_ + tile_size_w_, anchor_row_ + dr); maybe_insert(anchor_col_ + tile_size_w_, anchor_row_ + dr);
@@ -106,21 +123,59 @@ void Sarcophagus::update(float delta_time) {
anim_ = anim_open_; anim_ = anim_open_;
if (anim_.has_value()) { anim_->reset(); } if (anim_.has_value()) { anim_->reset(); }
if (!opened_event_emitted_ && bus_ != nullptr) { if (!opened_event_emitted_ && bus_ != nullptr) {
bus_->publish("sarcophagus_opened", static_cast<Entity*>(this)); // Publica amb `player_id = last_closer_player_id_` perquè el
// GameScene pugui atribuir la recompensa al jugador correcte.
// El payload manté el punter al sarc perquè el consumidor
// pugui llegir `getPointsReward()` i `getTreasuresReward()`.
bus_->publish(Event{
.name = "sarcophagus_opened",
.payload = static_cast<Entity*>(this),
.player_id = last_closer_player_id_,
});
opened_event_emitted_ = true; opened_event_emitted_ = true;
} }
} }
} }
void Sarcophagus::notifyTilePainted(int col, int row) { void Sarcophagus::notifyTilePainted(int col, int row, int player_id) {
if (state_ != State::CLOSED) { return; } if (state_ != State::CLOSED) { return; }
const TileKey KEY{.col = col, .row = row}; const TileKey KEY{.col = col, .row = row};
if (perimeter_tiles_.find(KEY) == perimeter_tiles_.end()) { return; } if (perimeter_tiles_.find(KEY) == perimeter_tiles_.end()) { return; }
visited_.insert(KEY); visited_.insert(KEY);
if (visited_.size() >= perimeter_tiles_.size()) { if (visited_.size() >= perimeter_tiles_.size()) {
// Atribució: el jugador que pinta el tile que tanca el cercle es
// queda amb la recompensa. Si el broadcast inicial de càrrega
// arribara a tancar un sarc (no hauria de passar perquè els tiles
// d'aquest broadcast són decals/murs, no de corredor), `player_id`
// seria -1 i el GameScene saltarà el repartiment.
last_closer_player_id_ = player_id;
state_ = State::OPENING; state_ = State::OPENING;
anim_ = anim_opening_; anim_ = anim_opening_;
if (anim_.has_value()) { anim_->reset(); } if (anim_.has_value()) { anim_->reset(); }
if (!opening_sound_key_.empty() && Audio::get() != nullptr) {
// Variació de pitch/velocitat per evitar la fatiga d'escoltar
// el mateix sample idèntic a cada obertura. Si l'spread és 0
// (default), reproduïm amb ratio 1.0; en cas contrari sortegem
// uniformement [1 - spread, 1 + spread].
if (opening_pitch_spread_ > 0.0F) {
std::uniform_real_distribution<float> dist(1.0F - opening_pitch_spread_, 1.0F + opening_pitch_spread_);
Audio::get()->playSound(opening_sound_key_, Audio::Group::GAME, dist(rng()));
} else {
Audio::get()->playSound(opening_sound_key_, Audio::Group::GAME);
}
}
}
}
void Sarcophagus::onRemoved() {
// El manager comptabilitza els sarcòfags actius en `total_sarcs_` per
// saber quan ha d'obrir la porta automàticament. Quan el editor
// n'esborra un, cal informar-lo: sense aquesta crida, `total_sarcs_`
// quedaria descompassat i la porta no s'obriria mai. Substitueix el
// `getName() == "sarcophagus"` que el manager feia abans —
// polimorfisme net, no comparació de strings.
if (entity_manager_ != nullptr) {
entity_manager_->onSarcophagusRemoved();
} }
} }
+36 -2
View File
@@ -2,6 +2,7 @@
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <string>
#include <unordered_set> #include <unordered_set>
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
@@ -17,7 +18,10 @@
// El càlcul de `perimeter_tiles_` es fa al ctor a partir de la posició del // El càlcul de `perimeter_tiles_` es fa al ctor a partir de la posició del
// sarcòfag a la graella del Map (`anchor_col`, `anchor_row`) i de les // sarcòfag a la graella del Map (`anchor_col`, `anchor_row`) i de les
// dimensions en tiles (`tile_size_w_`, `tile_size_h_`, llegits del YAML // dimensions en tiles (`tile_size_w_`, `tile_size_h_`, llegits del YAML
// plantilla). Es recorren els tiles adjacents per N/S/E/O; els que // plantilla). S'inclouen els tiles adjacents per N/S/E/O i també les
// quatre cantonades diagonals (NO/NE/SO/SE), perquè el jugador les
// trepitja en doblar les cantonades del sarcòfag i, sense comptar-les,
// el cerco s'obriria abans d'haver-se tancat visualment. Els tiles que
// cauen dins d'un altre sarcòfag es descarten (predicat opcional). Si no // cauen dins d'un altre sarcòfag es descarten (predicat opcional). Si no
// s'aporta el predicat, s'inclouen tots. // s'aporta el predicat, s'inclouen tots.
class Sarcophagus : public Interactable { class Sarcophagus : public Interactable {
@@ -42,7 +46,8 @@ class Sarcophagus : public Interactable {
void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked); void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked);
void update(float delta_time) override; void update(float delta_time) override;
void notifyTilePainted(int col, int row) override; void notifyTilePainted(int col, int row, int player_id) override;
void onRemoved() override;
[[nodiscard]] auto getState() const -> State { return state_; } [[nodiscard]] auto getState() const -> State { return state_; }
[[nodiscard]] auto getPerimeterSize() const -> std::size_t { return perimeter_tiles_.size(); } [[nodiscard]] auto getPerimeterSize() const -> std::size_t { return perimeter_tiles_.size(); }
@@ -53,6 +58,17 @@ class Sarcophagus : public Interactable {
[[nodiscard]] auto getTileWidth() const -> int { return tile_size_w_; } [[nodiscard]] auto getTileWidth() const -> int { return tile_size_w_; }
[[nodiscard]] auto getTileHeight() const -> int { return tile_size_h_; } [[nodiscard]] auto getTileHeight() const -> int { return tile_size_h_; }
// Recompensa que es donarà al jugador que pinta el tile que tanca el
// perímetre (el `last_closer_player_id_`). Llegits del YAML del sarc;
// 0/0 si el bloc `reward:` no existeix. El GameScene escolta
// `sarcophagus_opened` i aplica aquests valors al Player corresponent.
[[nodiscard]] auto getPointsReward() const -> int { return points_reward_; }
[[nodiscard]] auto getTreasuresReward() const -> int { return treasures_reward_; }
// Jugador atribuit (qui pinta el tile que dispara CLOSED→OPENING). -1
// si el sarcòfag s'ha tancat per pintades sense atribució (p.ex. el
// broadcast inicial de càrrega) — cas que el GameScene ignora.
[[nodiscard]] auto getLastCloserPlayerId() const -> int { return last_closer_player_id_; }
private: private:
struct TileKey { struct TileKey {
int col; int col;
@@ -82,5 +98,23 @@ class Sarcophagus : public Interactable {
AnimationComponent anim_opening_; AnimationComponent anim_opening_;
AnimationComponent anim_open_; AnimationComponent anim_open_;
// Clau (basename del fitxer) al Resource::Cache del so que es dispara
// a la transició CLOSED → OPENING. Buit ⇒ no sona res.
std::string opening_sound_key_;
// Variació uniforme del ratio de reproducció al voltant de 1.0. Cada
// obertura sorteja [1 - spread, 1 + spread]. 0 ⇒ ratio fix a 1.0.
float opening_pitch_spread_{0.0F};
// Recompenses llegides del YAML (bloc `reward:`). 0/0 si el bloc no
// hi és — el GameScene salta el pas de repartiment quan són 0.
int points_reward_{0};
int treasures_reward_{0};
// Jugador que ha pintat el tile que tanca el perímetre. Es fixa una
// sola vegada, en la transició CLOSED→OPENING dins de notifyTilePainted.
// -1 mentre està a CLOSED, o si la pintada que el tanca no porta
// atribució (player_id == -1 al broadcast inicial).
int last_closer_player_id_{-1};
bool opened_event_emitted_{false}; bool opened_event_emitted_{false};
}; };
@@ -0,0 +1,27 @@
#include <string>
#include "external/fkyaml_node.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/solid_decoration.hpp"
// Murs decoratius estàtics que comparteixen el mateix codi C++ però es
// registren amb claus de factory diferents per a que el YAML els pugui
// referenciar pel seu tipus i el editor els pugui distingir al picker.
// La selecció del sub-sprite (frame del atlas) viatja al spawn YAML
// (`frame: N`); l'aplica `placeGeneric` via `Entity::setAnimation`. Aquí no
// cal cap lògica específica per a la variant.
namespace {
class StaticSolid : public SolidDecoration {
public:
explicit StaticSolid(const fkyaml::node& yaml) {
if (yaml.contains("name")) {
name_ = yaml["name"].get_value<std::string>();
}
}
};
} // namespace
REGISTER_ENTITY_AS("stone", StaticSolid, Stone)
REGISTER_ENTITY_AS("column", StaticSolid, Column)
+1 -28
View File
@@ -1,41 +1,14 @@
#include "game/entities/concrete/torch.hpp" #include "game/entities/concrete/torch.hpp"
#include <string> #include <string>
#include <vector>
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
namespace {
// Ajut: extreu l'animació `idle` del bloc `animations` del YAML si existeix,
// amb valors per defecte que sempre produïxen una animació vàlida (1 frame).
auto buildIdleAnimation(const fkyaml::node& yaml) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = true;
if (yaml.contains("animations") && yaml["animations"].contains("idle")) {
const auto& anim = yaml["animations"]["idle"];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace
Torch::Torch(const fkyaml::node& yaml) { Torch::Torch(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("torch"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("torch");
collides_ = false; collides_ = false;
interactable_ = false; interactable_ = false;
anim_ = buildIdleAnimation(yaml); anim_ = AnimationComponent::fromYaml(yaml, "idle");
} }
REGISTER_ENTITY("torch", Torch) REGISTER_ENTITY("torch", Torch)
+51 -61
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 {
@@ -32,43 +33,19 @@ namespace {
return sprite; return sprite;
} }
// Construeix l'`AnimationComponent` del swing a partir del bloc // Llig el camp `active_frames` del bloc `animations.swing` — específic
// `animations.swing`. Convenció igual que la resta d'entitats: // del látigo: els índexs llistats són els frames on la hitbox aplica
// - `frames` : llista d'índexs dins l'spritesheet (obligatori). // dany (wind-up i recovery no danyen). La resta de l'animació la
// - `frame_ms` : durada uniforme per frame (ms). Alternativament `fps`. // construeix `AnimationComponent::fromYaml`.
// - `loop` : per defecte true; al látigo el volem false (mor en void readActiveFrames(const fkyaml::node& yaml, std::vector<int>& active_out) {
// completar). if (!yaml.contains("animations") || !yaml["animations"].contains("swing")) { return; }
auto buildSwingAnim(const fkyaml::node& yaml, std::vector<int>& active_out)
-> AnimationComponent {
std::vector<int> frames = {0};
float fps = 8.0F;
bool loop = false;
if (yaml.contains("animations") && yaml["animations"].contains("swing")) {
const auto& sw = yaml["animations"]["swing"]; const auto& sw = yaml["animations"]["swing"];
if (sw.contains("frames")) { if (!sw.contains("active_frames")) { return; }
frames.clear();
for (const auto& f : sw["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (sw.contains("frame_ms")) {
const auto MS = static_cast<float>(sw["frame_ms"].get_value<int>());
if (MS > 0.0F) { fps = 1000.0F / MS; }
} else if (sw.contains("fps")) {
fps = sw["fps"].get_value<float>();
}
if (sw.contains("loop")) { loop = sw["loop"].get_value<bool>(); }
if (sw.contains("active_frames")) {
for (const auto& f : sw["active_frames"]) { for (const auto& f : sw["active_frames"]) {
// cppcheck-suppress useStlAlgorithm // cppcheck-suppress useStlAlgorithm
active_out.push_back(f.get_value<int>()); active_out.push_back(f.get_value<int>());
} }
} }
}
return {frames, fps, loop};
}
} // namespace } // namespace
@@ -85,7 +62,8 @@ WhipAttack::WhipAttack(const fkyaml::node& yaml) {
if (s.contains("west")) { sprite_west_ = readDirSprite(s["west"]); } if (s.contains("west")) { sprite_west_ = readDirSprite(s["west"]); }
} }
anim_swing_ = buildSwingAnim(yaml, active_frames_); anim_swing_ = AnimationComponent::fromYaml(yaml, "swing");
readActiveFrames(yaml, active_frames_);
anim_ = anim_swing_; anim_ = anim_swing_;
} }
@@ -174,9 +152,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 +183,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) {
@@ -252,19 +239,10 @@ void WhipAttack::update(float delta_time) {
namespace { namespace {
auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction { // (El mapping WhipAttack::Direction → Mummy::Direction viu ara a
switch (d) { // mummy.cpp, on s'usa des de `Mummy::onWhipHit`. Aquí només queda el
case WhipAttack::Direction::NORTH: // mapping a Player::Cardinal — usat per al friendly-fire entre
return Mummy::Direction::NORTH; // jugadors, que no és polimòrfic via Entity.)
case WhipAttack::Direction::SOUTH:
return Mummy::Direction::SOUTH;
case WhipAttack::Direction::EAST:
return Mummy::Direction::EAST;
case WhipAttack::Direction::WEST:
return Mummy::Direction::WEST;
}
return Mummy::Direction::NONE;
}
auto whipDirToPlayerCardinal(WhipAttack::Direction d) -> Player::Cardinal { auto whipDirToPlayerCardinal(WhipAttack::Direction d) -> Player::Cardinal {
switch (d) { switch (d) {
@@ -286,22 +264,24 @@ namespace {
return STATE != Player::State::HURT && STATE != Player::State::EXITED && STATE != Player::State::DISABLED; return STATE != Player::State::HURT && STATE != Player::State::EXITED && STATE != Player::State::DISABLED;
} }
// Tracta una mòmia candidata: si és whippable i la seua caja solapa // Tracta una entitat candidata: si la seva caixa solapa amb
// amb `damage_rect`, la posa a HURT i la marca al set. Extret per // `damage_rect`, delega la decisió a `entity->onWhipHit(attack)`.
// acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar). // Cada entitat decideix què fer (Mummy entra HURT, Torch retorna
void hitMummyIfApplicable( // false sense efecte, RollingRock no override → no-op). El retorn
Entity* e, // true marca el set `already_hit` per evitar segons impactes dins del
// mateix swing. Polimorfisme net — sense dynamic_cast.
void hitEntityIfApplicable(
Entity* entity,
const SDL_FRect& damage_rect, const SDL_FRect& damage_rect,
WhipAttack::Direction dir, WhipAttack* attack,
std::unordered_set<void*>& already_hit) { std::unordered_set<void*>& already_hit) {
if (e == nullptr || e->isDead()) { return; } if (entity == nullptr || entity->isDead()) { return; }
if (already_hit.contains(static_cast<void*>(e))) { return; } if (already_hit.contains(static_cast<void*>(entity))) { return; }
auto* mummy = dynamic_cast<Mummy*>(e); const auto BOX = entity->getCollisionRect();
if (mummy == nullptr || !mummy->isWhippable()) { return; }
const auto BOX = mummy->getCollisionRect();
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; } if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
mummy->enterHurt(whipDirToMummyDir(dir)); if (entity->onWhipHit(attack)) {
already_hit.insert(static_cast<void*>(e)); already_hit.insert(static_cast<void*>(entity));
}
} }
void hitPlayerIfApplicable( void hitPlayerIfApplicable(
@@ -311,6 +291,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 +306,14 @@ namespace {
void WhipAttack::applyDamage() { void WhipAttack::applyDamage() {
if (entity_manager_ != nullptr) { if (entity_manager_ != nullptr) {
for (auto* e : entity_manager_->getEntities()) { // Iterem totes les entitats. La discriminació la fa cada una via
hitMummyIfApplicable(e, damage_rect_, direction_, already_hit_); // `onWhipHit`: Mummy entra HURT si és whippable, la resta retorna
// false (no-op). El cost d'iterar antorxes/sarcs/forats és una
// crida virtual buida cada una — menyspreable comparat amb la
// claredat del callsite. Si demà un sarc vol trencar-se al
// fueteig, només cal override de `onWhipHit` allà.
for (auto* entity : entity_manager_->getEntities()) {
hitEntityIfApplicable(entity, damage_rect_, this, already_hit_);
} }
} }
if (players_ != nullptr) { if (players_ != nullptr) {
+37 -1
View File
@@ -24,10 +24,35 @@ void Entity::onPlayerContact(Player* /*player*/) {
// No-op per defecte. // No-op per defecte.
} }
void Entity::notifyTilePainted(int /*col*/, int /*row*/) { void Entity::notifyTilePainted(int /*col*/, int /*row*/, int /*player_id*/) {
// No-op per defecte; només `Sarcophagus` l'usa. // No-op per defecte; només `Sarcophagus` l'usa.
} }
auto Entity::onWhipHit(WhipAttack* /*attack*/) -> bool {
// No-op per defecte; les entitats no-whippables (Torch, Hole, Sarc,
// Door, RollingRock...) retornen `false`. `Mummy` n'és l'override
// efectiu. El caller usa el retorn per marcar `already_hit_` —
// entitats que no han rebut dany NO bloquegen futurs frames del whip.
return false;
}
void Entity::onJustSpawned() {
// No-op per defecte. `DynamicHazard` override per disparar el seu
// `triggerSpawnExit` (anima la fase EMERGING just després de néixer).
}
void Entity::onRemoved() {
// No-op per defecte. `Sarcophagus` override per decrementar el
// comptador `total_sarcs_` del manager.
}
auto Entity::shouldReceiveContact() const -> bool {
// Default: l'entitat rep contacte si és sòlida (`collides_`) o si és
// un trigger d'àrea (`triggers_on_overlap_`). `DynamicHazard` afegeix
// el filtre de SPAWN — mentre està emergint, ignora contactes.
return collides_ || triggers_on_overlap_;
}
auto Entity::getCollisionRect() const -> SDL_FRect { auto Entity::getCollisionRect() const -> SDL_FRect {
if (!collision_box_.has_value()) { return pixel_rect_; } if (!collision_box_.has_value()) { return pixel_rect_; }
return { return {
@@ -38,6 +63,17 @@ auto Entity::getCollisionRect() const -> SDL_FRect {
}; };
} }
auto Entity::getLightCenter() const -> SDL_FPoint {
// Centre del rect de render + offset configurat al YAML. Per a
// entitats que es desplacen visualment sobre la seva posició lògica
// (p.ex. el Player saltant), la subclasse override aquest mètode
// per restar el desplaçament — i ha d'incloure l'offset igualment.
return {
.x = pixel_rect_.x + (pixel_rect_.w * 0.5F) + static_cast<float>(light_offset_x_),
.y = pixel_rect_.y + (pixel_rect_.h * 0.5F) + static_cast<float>(light_offset_y_),
};
}
auto Entity::getFeetY() const -> float { auto Entity::getFeetY() const -> float {
// Decal de sòl: sentinel mínim per quedar al principi del Y-sort // Decal de sòl: sentinel mínim per quedar al principi del Y-sort
// ascendent de `GameScene` (stable_sort) i així pintar-se ABANS de // ascendent de `GameScene` (stable_sort) i així pintar-se ABANS de
+144 -2
View File
@@ -2,10 +2,12 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <cstdint>
#include <memory> #include <memory>
#include <optional> #include <optional>
#include <string> #include <string>
#include "core/rendering/lighting.hpp"
#include "game/entities/animation_component.hpp" #include "game/entities/animation_component.hpp"
// Forward-declaracions: mantenim aquest header lleuger. `Player` viu a // Forward-declaracions: mantenim aquest header lleuger. `Player` viu a
@@ -19,6 +21,7 @@ class Player;
class EventBus; class EventBus;
class Surface; class Surface;
class EntityManager; class EntityManager;
class WhipAttack;
// Entitat genèrica del món. Actua com a dada en comú per a tots els actors // Entitat genèrica del món. Actua com a dada en comú per a tots els actors
// del joc (decoratius, interactables, perills mòbils i estàtics): manté // del joc (decoratius, interactables, perills mòbils i estàtics): manté
@@ -83,8 +86,47 @@ class Entity {
// Hook que el `EntityManager` invoca per a cada entitat cada vegada // Hook que el `EntityManager` invoca per a cada entitat cada vegada
// que un tile es pinta (via PaintLayer, com a conseqüència d'un // que un tile es pinta (via PaintLayer, com a conseqüència d'un
// jugador trepitjant un sòl pintable). Només `Sarcophagus` ho consumeix // jugador trepitjant un sòl pintable). Només `Sarcophagus` ho consumeix
// (per marcar perímetre); per defecte no-op. // (per marcar perímetre); per defecte no-op. `player_id` és l'id del
virtual void notifyTilePainted(int col, int row); // jugador que ha pintat el tile, o -1 si la pintada ve del broadcast
// inicial de càrrega de nivell (decals + murs, sense atribució). Els
// consumidors han de filtrar per `player_id >= 0` si necessiten
// atribució.
virtual void notifyTilePainted(int col, int row, int player_id);
// L'entitat ha rebut un fueteig amb solapament. Es passa el punter al
// `WhipAttack` perquè la subclasse pugui llegir-ne direcció o atributs
// (mateix patró que `onPlayerContact(Player*)`). Retorna `true` si
// efectivament ha aplicat dany — el caller en marca el set
// `already_hit_` per evitar segons impactes dins del mateix swing.
// Default: no fa res, retorna `false`. Override a `Mummy` (whippable)
// i a futurs enemics whippables. `RollingRock` no override → roca
// immune al látigo per defecte.
virtual auto onWhipHit(WhipAttack* attack) -> bool;
// Hook just després de néixer al món. El manager el crida quan
// `addSpawn` / `spawnActor` / `spawnPendingActors` instancien una
// entitat. La base no fa res; `DynamicHazard` override per disparar
// el seu `triggerSpawnExit` (anima la fase EMERGING). Substitueix el
// `dynamic_cast<DynamicHazard*>(observer)->triggerSpawnExit()` que el
// manager feia abans — ara el dispatch és polimòrfic. Entitats que
// requereixen una fase d'aparició (mòmia amb animació `raise`)
// l'inicien aquí, NO al ctor: així `materializePendingActorsStatic`
// (editor) les salta i queden visibles directament.
virtual void onJustSpawned();
// Hook just abans que el manager esborri l'entitat (via
// `removeEntity`, típicament al editor). Permet a subclasses
// notificar al manager o publicar events per mantenir invariants
// (p.ex. `Sarcophagus` decrementa `total_sarcs_`). Default no-op.
virtual void onRemoved();
// Decisió polimòrfica de si aquesta entitat ha de rebre el callback
// `onPlayerContact` aquest frame. La base permet contacte si `collides_`
// o `triggers_on_overlap_` estan posats. Subclasses poden afegir
// condicions transitòries (p.ex. `DynamicHazard` retorna `false` mentre
// està en SPAWN). Centralitza el filtre que abans vivia al manager
// amb dynamic_casts/isSpawning explícits.
[[nodiscard]] virtual auto shouldReceiveContact() const -> bool;
[[nodiscard]] auto getName() const -> const std::string& { return name_; } [[nodiscard]] auto getName() const -> const std::string& { return name_; }
[[nodiscard]] auto getTilePos() const -> TilePos { return tile_; } [[nodiscard]] auto getTilePos() const -> TilePos { return tile_; }
@@ -92,6 +134,15 @@ class Entity {
[[nodiscard]] auto collides() const -> bool { return collides_; } [[nodiscard]] auto collides() const -> bool { return collides_; }
[[nodiscard]] auto interactable() const -> bool { return interactable_; } [[nodiscard]] auto interactable() const -> bool { return interactable_; }
// Marca d'entitat que ha de bloquejar el moviment del jugador (com un
// mur). `collides_=true` només garanteix que l'entitat reb el callback
// `onPlayerContact` quan el jugador la toca, no que el seu rect sigui
// sòlid: dynamic hazards (mòmies, roques) tenen `collides_=true` però
// no han de bloquejar el pas — el contacte resol el dany i la roca es
// mou independentment. Per a "murs passius" (sarcs, columnes, debris)
// posem aquest flag a true i `Map::collides` els tracta com a obstacle.
[[nodiscard]] auto blocksMovement() const -> bool { return blocks_movement_; }
// Trigger d'àrea: l'entitat NO bloqueja el moviment (`collides_=false`) // Trigger d'àrea: l'entitat NO bloqueja el moviment (`collides_=false`)
// però sí vol rebre `onPlayerContact` quan el jugador la solapa. Pensat // però sí vol rebre `onPlayerContact` quan el jugador la solapa. Pensat
// per a hazards "no físics" (forats al sòl, picos retràctils, plaques // per a hazards "no físics" (forats al sòl, picos retràctils, plaques
@@ -154,6 +205,14 @@ class Entity {
// Entitats sense animació retornen `nullopt` → frame 0 per defecte. // Entitats sense animació retornen `nullopt` → frame 0 per defecte.
[[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; } [[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; }
// Sobreescriu l'`AnimationComponent` actual. L'usa el loader del nivell
// per aplicar el camp opcional `frame: N` del spawn a entitats amb
// variants estàtiques (debris, columnes, etc.): el spawn fixa una
// animació de 1 frame al índex escollit i el render del manager clipa
// el sub-sprite corresponent. També és útil per a tests que volen
// controlar el frame mostrat sense passar pel YAML.
void setAnimation(AnimationComponent anim) { anim_ = std::move(anim); }
// Alçada del cos de l'entitat en píxels. La consulten els filtres // Alçada del cos de l'entitat en píxels. La consulten els filtres
// de col·lisió per altura: si un jugador en l'aire passa per damunt // de col·lisió per altura: si un jugador en l'aire passa per damunt
// d'aquest valor (jump_z_ > height), no col·lisiona amb l'entitat — // d'aquest valor (jump_z_ > height), no col·lisiona amb l'entitat —
@@ -196,6 +255,74 @@ class Entity {
[[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; } [[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; }
void setFlip(SDL_FlipMode flip) { flip_ = flip; } void setFlip(SDL_FlipMode flip) { flip_ = flip; }
// Radi de la llum emesa per aquesta entitat (en píxels del canvas).
// 0 = no emet (cas habitual). El bloc opcional `light: { radius: N }`
// del YAML de la plantilla el pobla via `EntityManager::buildEntity`.
[[nodiscard]] auto getLightRadius() const -> int { return light_radius_; }
void setLightRadius(int r) { light_radius_ = r; }
// Activa la modulació "tipus foc" del radi a la pasada d'il·luminació
// (per a antorxes, fogueres, etc.). El bloc opcional
// `light: { flicker: true }` del YAML el pobla. Per defecte false:
// la llum queda perfectament estable (cas player, mòmia).
[[nodiscard]] auto getLightFlicker() const -> bool { return light_flicker_; }
void setLightFlicker(bool f) { light_flicker_ = f; }
// Color emès en mode additive (RGB 0..255 per canal). Default blanc:
// en mode subtractive aquest valor s'ignora completament; en mode
// additive és el tint que l'emissor afegeix al canvas. La cadena de
// resolució (tipus → sala → spawn) el pobla via `setLightColor`.
[[nodiscard]] auto getLightColorR() const -> std::uint8_t { return light_color_r_; }
[[nodiscard]] auto getLightColorG() const -> std::uint8_t { return light_color_g_; }
[[nodiscard]] auto getLightColorB() const -> std::uint8_t { return light_color_b_; }
void setLightColor(std::uint8_t r, std::uint8_t g, std::uint8_t b) {
light_color_r_ = r;
light_color_g_ = g;
light_color_b_ = b;
}
// Desplaçament en píxels del centre del cercle de llum respecte el
// centre visual del sprite (que és el que retornaria per defecte
// `getLightCenter`). Útil quan el "punt de llum" iconogràfic no
// coincideix amb el centre del bounding box — per exemple, la flama
// d'una antorxa està a la part de dalt del sprite i interessa pujar
// l'emissor 3-4 px per a que el cercle de llum surti de la flama,
// no del peu. Y creix cap avall al canvas, així que `y: -3` puja
// la llum 3 píxels. Cadena de resolució idèntica al color: tipus →
// sala → spawn.
[[nodiscard]] auto getLightOffsetX() const -> int { return light_offset_x_; }
[[nodiscard]] auto getLightOffsetY() const -> int { return light_offset_y_; }
void setLightOffset(int x, int y) {
light_offset_x_ = x;
light_offset_y_ = y;
}
// Paràmetres del flicker resolts per a aquest emissor (combinació
// del global de `data/config/lighting.yaml` + overrides del tipus,
// sala i spawn). Només té efecte si `light_flicker_` és true. El
// construct path li passa una còpia ja resolta — el raster (lighting.cpp)
// només la llegeix. Es publica per camps perquè el bloc YAML
// `flicker_params:` permet sobreescriure qualsevol subconjunt.
[[nodiscard]] auto getFlickerParams() const -> const Lighting::FlickerConfig& { return flicker_params_; }
void setFlickerParams(const Lighting::FlickerConfig& fp) { flicker_params_ = fp; }
// Identificador estable per al flicker. Cada Entity rep un valor
// únic a la construcció (contador estàtic) i el conserva fins a la
// mort — així el patró del flicker no "deriva" quan l'entitat es
// mou. Si l'entitat respawneja, la nova instància tindrà una seed
// nova; com que el cicle visual ja queda trencat per la mort, no és
// un problema.
[[nodiscard]] auto getLightSeed() const -> int { return light_seed_; }
// Centre de la llum en píxels del canvas. Per defecte és el centre
// del `pixel_rect_` (caixa de render) més l'offset configurat al
// YAML (`light.offset: [x, y]`). Virtual perquè entitats amb
// desplaçament visual sobre la posició lògica (p.ex. el Player en
// l'aire, que rebaixa visualment `pos_.y` amb `jump_z_`) puguin
// retornar el centre que realment es veu — l'override n'és
// responsable d'incloure l'offset (com fa `Player::getLightCenter`).
[[nodiscard]] virtual auto getLightCenter() const -> SDL_FPoint;
protected: protected:
std::string name_; std::string name_;
TilePos tile_{}; TilePos tile_{};
@@ -207,7 +334,22 @@ class Entity {
bool interactable_{false}; bool interactable_{false};
bool triggers_on_overlap_{false}; bool triggers_on_overlap_{false};
bool is_floor_decal_{false}; bool is_floor_decal_{false};
bool blocks_movement_{false};
int height_{0}; int height_{0};
int light_radius_{0};
// Comptador global d'instàncies; cada Entity nova s'emporta el
// valor incrementat com a seed propi. Estàtic perquè no calgui
// estat extern (sense risc de threads: les entitats es construeixen
// sempre des del fil del joc).
static inline int s_next_light_seed{0};
int light_seed_{++s_next_light_seed};
bool light_flicker_{false};
std::uint8_t light_color_r_{255};
std::uint8_t light_color_g_{255};
std::uint8_t light_color_b_{255};
int light_offset_x_{0};
int light_offset_y_{0};
Lighting::FlickerConfig flicker_params_{};
bool dead_{false}; bool dead_{false};
SDL_FlipMode flip_{SDL_FLIP_NONE}; SDL_FlipMode flip_{SDL_FLIP_NONE};
EventBus* bus_{nullptr}; EventBus* bus_{nullptr};
+41 -12
View File
@@ -7,6 +7,7 @@
#include "game/entities/concrete/door.hpp" #include "game/entities/concrete/door.hpp"
#include "game/entities/concrete/sarcophagus.hpp" #include "game/entities/concrete/sarcophagus.hpp"
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
#include "game/map/map.hpp"
// NOTA: `loadFromLevel` viu a `entity_manager_loadlevel.cpp` per evitar // NOTA: `loadFromLevel` viu a `entity_manager_loadlevel.cpp` per evitar
// arrossegar `Resource::Cache`, `Surface` i les seues dependències (GIF, // arrossegar `Resource::Cache`, `Surface` i les seues dependències (GIF,
@@ -30,7 +31,7 @@ EntityManager::EntityManager() {
// els sarcòfags puguin actualitzar el seu `visited_`. // els sarcòfags puguin actualitzar el seu `visited_`.
tile_sub_id_ = bus_.subscribe("tile_painted", [this](const Event& ev) { tile_sub_id_ = bus_.subscribe("tile_painted", [this](const Event& ev) {
const auto* tile = std::get_if<TilePos>(&ev.payload); const auto* tile = std::get_if<TilePos>(&ev.payload);
if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row); } if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row, ev.player_id); }
}); });
// Subscripció a `player_exited` (publicat per Door en travessar la // Subscripció a `player_exited` (publicat per Door en travessar la
@@ -51,12 +52,23 @@ EntityManager::EntityManager() {
}); });
} }
}); });
// Connectar amb el check global de col·lisió del Map: les entitats
// sòlides genèriques (debris, columnes, futurs murs) viuen al manager,
// no a la graella fixa de sarcs, així que el `Map::collides` del Player
// les ha de consultar via callback. Es desregistra al dtor.
Map::setEntityCollider([this](const SDL_FRect& box) {
return entityCollidesAt(box);
});
} }
EntityManager::~EntityManager() { EntityManager::~EntityManager() {
bus_.unsubscribe(sarc_sub_id_); bus_.unsubscribe(sarc_sub_id_);
bus_.unsubscribe(tile_sub_id_); bus_.unsubscribe(tile_sub_id_);
bus_.unsubscribe(exit_sub_id_); bus_.unsubscribe(exit_sub_id_);
// Desconnectar del Map: si quedem en namespace global apuntant a un
// `this` que mor, qualsevol `Map::collides` posterior cauria en UB.
Map::setEntityCollider(nullptr);
} }
void EntityManager::setExpectedPlayerCount(int n) { void EntityManager::setExpectedPlayerCount(int n) {
@@ -90,12 +102,15 @@ void EntityManager::setDoor(Door* door) {
auto EntityManager::removeEntity(Entity* observer) -> bool { auto EntityManager::removeEntity(Entity* observer) -> bool {
if (observer == nullptr) { return false; } if (observer == nullptr) { return false; }
const bool WAS_DOOR = (door_ == observer); const bool WAS_DOOR = (door_ == observer);
const bool WAS_SARC = (observer->getName() == "sarcophagus"); // `onRemoved` és polimòrfic: cada subclasse decideix què cal
// notificar quan se l'esborra. Avui només Sarcophagus l'override per
// decrementar el seu comptador. La door es detecta per comparació de
// punter (és única, no cal RTTI).
observer->onRemoved();
auto it = std::ranges::find_if(entities_, [observer](const auto& up) { return up.get() == observer; }); auto it = std::ranges::find_if(entities_, [observer](const auto& up) { return up.get() == observer; });
if (it == entities_.end()) { return false; } if (it == entities_.end()) { return false; }
entities_.erase(it); entities_.erase(it);
if (WAS_DOOR) { door_ = nullptr; } if (WAS_DOOR) { door_ = nullptr; }
if (WAS_SARC && total_sarcs_ > 0) { --total_sarcs_; }
// No tocar `opened_sarcs_` aquí: l'editor congela el joc i no pot // No tocar `opened_sarcs_` aquí: l'editor congela el joc i no pot
// disparar-se cap esdeveniment derivat. Quan el GameScene faça el // disparar-se cap esdeveniment derivat. Quan el GameScene faça el
// reload posterior, els comptadors es re-deriven d'inici. // reload posterior, els comptadors es re-deriven d'inici.
@@ -126,18 +141,21 @@ void EntityManager::update(float delta_time) {
std::erase_if(entities_, [](const auto& e) { return e->isDead(); }); std::erase_if(entities_, [](const auto& e) { return e->isDead(); });
} }
void EntityManager::notifyPlayerTilePainted(int col, int row) { void EntityManager::notifyPlayerTilePainted(int col, int row, int player_id) {
for (auto& e : entities_) { e->notifyTilePainted(col, row); } // Fan-out polimòrfic a totes les entitats: cada una decideix què fer
// (`Sarcophagus` actualitza el seu perímetre i recorda qui va tancar
// el cercle, la resta queden en no-op). `player_id` ve del bus
// (`ev.player_id`) — pot ser -1 al broadcast de càrrega inicial de
// nivell, els consumidors filtren si necessiten atribució.
for (auto& e : entities_) { e->notifyTilePainted(col, row, player_id); }
} }
auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* { auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* {
// Cerca lineal sobre `entities_`: cost menyspreable amb poques // Cerca lineal: només `Hole` (i futurs decals trigger) compleixen la
// desenes d'entitats per sala i una crida per frame des dels // doble condició `triggersOnOverlap` + `isFloorDecal`. Costos típics
// consumidors actius (Player a l'aterratge, Mòmia a la decisió de // (poques desenes d'entitats) menyspreables, una crida per frame des
// direcció). Si esdevé calent, es podria indexar per cel·la del // dels consumidors actius (Player a l'aterratge, Mòmia a la decisió
// tilemap. La doble condició filtra exclusivament decals de sòl que // de direcció).
// volen rebre triggers — avui Hole; en el futur pinchos plans,
// plaques de pressió, charcos.
for (const auto& entity : entities_) { for (const auto& entity : entities_) {
if (!entity->triggersOnOverlap()) { continue; } if (!entity->triggersOnOverlap()) { continue; }
if (!entity->isFloorDecal()) { continue; } if (!entity->isFloorDecal()) { continue; }
@@ -148,6 +166,17 @@ auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Ent
return nullptr; return nullptr;
} }
auto EntityManager::entityCollidesAt(const SDL_FRect& box) const -> bool {
// Iteració lineal sobre entitats amb `blocksMovement()`. La convenció
// d'overlap és la mateixa que `Map::collides` (estricta: el toc al
// píxel no compta) per coherència en els passadissos del laberint.
return std::ranges::any_of(entities_, [&box](const auto& e) {
if (!e->blocksMovement()) { return false; }
const auto R = e->getCollisionRect();
return (R.x + R.w) > box.x && (box.x + box.w) > R.x && (R.y + R.h) > box.y && (box.y + box.h) > R.y;
});
}
auto EntityManager::getEntities() const -> std::vector<Entity*> { auto EntityManager::getEntities() const -> std::vector<Entity*> {
std::vector<Entity*> out; std::vector<Entity*> out;
out.reserve(entities_.size()); out.reserve(entities_.size());
+68 -5
View File
@@ -14,15 +14,17 @@ class Entity;
class Sarcophagus; class Sarcophagus;
class Door; class Door;
class Player; class Player;
class DynamicHazard;
// Contenidor i coordinador d'entitats d'un nivell. Responsabilitats: // Contenidor i coordinador d'entitats d'un nivell. Responsabilitats:
// 1. `add` registra una entitat, la connecta al bus i manté propietat. // 1. `add` registra una entitat, la connecta al bus i manté propietat.
// 2. `update(dt)` propaga el tick a totes. // 2. `update(dt)` propaga el tick a totes.
// 3. Comptador de sarcòfags oberts — quan iguala el total registrat, // 3. Comptador de sarcòfags oberts — quan iguala el total registrat,
// crida `door_->open()` automàticament. // crida `door_->open()` automàticament.
// 4. `notifyPlayerTilePainted(col, row)` — fan-out del hook per a tots // 4. `notifyPlayerTilePainted(col, row, player_id)` — fan-out del hook
// els sarcòfags (i qualsevol entitat futura que s'hi subscrigui via // per a tots els sarcòfags (i qualsevol entitat futura que s'hi
// `notifyTilePainted`). // subscrigui via `notifyTilePainted`). El `player_id` permet
// atribuir l'acció al jugador concret (p.ex. qui tanca un sarc).
// //
// NO és un singleton: es crea una instància per nivell. De moment no // NO és un singleton: es crea una instància per nivell. De moment no
// s'usa encara al GameScene (andamiatge pur). // s'usa encara al GameScene (andamiatge pur).
@@ -73,7 +75,11 @@ class EntityManager {
static void renderEntity(const Entity& entity); static void renderEntity(const Entity& entity);
// Accés als punters observacionals a les entitats per a consumidors // Accés als punters observacionals a les entitats per a consumidors
// externs que necessiten construir una llista ordenada (z-order). // externs que necessiten iterar (z-order, aplicar dany, etc.). El
// polimorfisme dins de cada entitat fa la discriminació: l'iterador
// recorre tot i les entitats que no hagin de respondre tornen no-op
// (p.ex. Torch::onWhipHit retorna false sense efecte). Sense sacs
// separats — el `if (entity_is_X)` viu dins de l'entitat, no del caller.
[[nodiscard]] auto getEntities() const -> std::vector<Entity*>; [[nodiscard]] auto getEntities() const -> std::vector<Entity*>;
// Comprova contacte del(s) jugador(s) amb entitats `collides()=true`. // Comprova contacte del(s) jugador(s) amb entitats `collides()=true`.
@@ -98,7 +104,7 @@ class EntityManager {
// notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte // notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte
// (veure Entity::notifyTilePainted). Invocat com a reacció a // (veure Entity::notifyTilePainted). Invocat com a reacció a
// `tile_painted` al bus (re-publicat pel PaintLayer un cop filtrat). // `tile_painted` al bus (re-publicat pel PaintLayer un cop filtrat).
void notifyPlayerTilePainted(int col, int row); void notifyPlayerTilePainted(int col, int row, int player_id);
// Carrega els spawns d'un YAML de nivell i els afegeix al manager: // Carrega els spawns d'un YAML de nivell i els afegeix al manager:
// 1. Llegeix el bloc `entities:` via EntityLoader. // 1. Llegeix el bloc `entities:` via EntityLoader.
@@ -119,6 +125,32 @@ 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();
// Materialitza els pending actors al món sense disparar la cinemàtica
// de SPAWN (`onJustSpawned`). Pensat per a l'editor: vol veure les
// mòmies a la seva posició inicial per poder editar-les, però no
// disparar l'animació EMERGING (l'editor congela el món, l'animació
// no avançaria i quedaria invisible). En sortir de l'editor, GameScene
// fa un `reload` que torna els actors a `pending_actors_` i el flux
// normal els spawneja amb la cinemàtica al final del ROOM_INTRO.
void materializePendingActorsStatic();
// Crea un actor on-demand i el deixa en EMERGING (com a
// `spawnPendingActors` però per a un sol). Pensat per a futurs
// generadors (StaticHazards que produeixen DynamicHazards en runtime,
// p.ex. gotes que cauen, roques que rodolen). Retorna el punter
// observacional o `nullptr` si la plantilla o el factory fallen.
auto spawnActor(const std::string& type, int col, int row) -> DynamicHazard*;
// Reset complet del nivell: destrueix totes les entitats, reseteja // Reset complet del nivell: destrueix totes les entitats, reseteja
// els comptadors interns (sarcòfags oberts, total, exited, flag de // els comptadors interns (sarcòfags oberts, total, exited, flag de
// level_complete) i torna a cridar `loadFromLevel` per regenerar les // level_complete) i torna a cridar `loadFromLevel` per regenerar les
@@ -137,6 +169,7 @@ class EntityManager {
[[nodiscard]] auto getBus() -> EventBus& { return bus_; } [[nodiscard]] auto getBus() -> EventBus& { return bus_; }
[[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); } [[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); }
[[nodiscard]] auto pendingActorCount() const -> std::size_t { return pending_actors_.size(); }
[[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; } [[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; }
[[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; } [[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; }
[[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; } [[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; }
@@ -148,9 +181,39 @@ class EntityManager {
// sarcs. No-op si la porta ja està oberta o si el nivell no en té. // sarcs. No-op si la porta ja està oberta o si el nivell no en té.
void forceOpenDoor(); void forceOpenDoor();
// Hook que `Sarcophagus::onRemoved` crida per decrementar el
// comptador `total_sarcs_`. Públic perquè el sarcòfag és la única
// entitat que necessita aquest manteniment, però l'API es manté
// genèrica: si en el futur Door volgués notificar quelcom semblant,
// afegim el seu propi callback.
void onSarcophagusRemoved() {
if (total_sarcs_ > 0) { --total_sarcs_; }
}
private: private:
// Suport per al callback registrat a `Map::setEntityCollider`. Itera
// les entitats amb `blocksMovement()` i fa overlap estricte (mateixa
// convenció que `Map::collides`). El callback el registra el ctor.
[[nodiscard]] auto entityCollidesAt(const SDL_FRect& box) const -> bool;
EventBus bus_; EventBus bus_;
// Un sol contenidor lògic per a totes les entitats instanciades. La
// discriminació de tipus la fan els virtuals (`onPlayerContact`,
// `onWhipHit`, `shouldReceiveContact`, `notifyTilePainted`...) — el
// manager itera i cada entitat decideix què fa. Hi conviuen
// decoracions, static_hazards, interactables i dynamic_hazards
// (actors).
std::vector<std::unique_ptr<Entity>> entities_; std::vector<std::unique_ptr<Entity>> entities_;
// Cua d'actors declarats al YAML del mapa però encara no instanciats:
// `loadFromLevel` hi empeny tots els DynamicHazards. `spawnPendingActors`
// o `spawnActor` consumeixen aquesta cua (o creen un actor on-demand)
// i ho buiden. El `fkyaml::node` original es guarda perquè conté la
// posició i qualsevol override del map respecte a la plantilla.
struct PendingActor {
std::string type;
fkyaml::node spawn_node;
};
std::vector<PendingActor> pending_actors_;
Door* door_{nullptr}; Door* door_{nullptr};
std::size_t total_sarcs_{0}; std::size_t total_sarcs_{0};
std::size_t opened_sarcs_{0}; std::size_t opened_sarcs_{0};
+317 -53
View File
@@ -4,6 +4,8 @@
// consumidors que només volen la base del manager (p.ex. el smoke test // consumidors que només volen la base del manager (p.ex. el smoke test
// stand-alone) enllacen `entity_manager.cpp` sense arrossegar aquest fitxer. // stand-alone) enllacen `entity_manager.cpp` sense arrossegar aquest fitxer.
#include <algorithm>
#include <cstdint>
#include <filesystem> #include <filesystem>
#include <memory> #include <memory>
#include <string> #include <string>
@@ -18,6 +20,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 +59,56 @@ void EntityManager::renderEntity(const Entity& e) {
} }
void EntityManager::renderAll() { void EntityManager::renderAll() {
// Sense ordenació per z: l'ordre és el d'inserció al vector. El
// render del joc real (GameScene) construeix el seu propi vector
// ordenat per `feet_y`; aquest helper queda per al editor i tests.
for (const auto& e : entities_) { renderEntity(*e); } for (const auto& e : entities_) { renderEntity(*e); }
} }
namespace {
// Filtres "per què aquest jugador no rep contacte d'aquest hazard ara
// mateix": fora de joc, DYING, caient per forat (la transició a mort
// per forat la mana `integrateJump`, no la pot interrompre cap
// hazard), o salt per damunt de l'alçada del cos. Extret a funció
// per acotar la complexitat cognitiva del callsite. Veure la nota a
// `isFallingThroughHole`: si algun dia s'elimina aquest skip, cal
// revisar la cadena DYING(HOLE) → respawn.
auto playerSkipsHazard(const Player& p, int hazard_height_px) -> bool {
if (p.isOutOfPlay() || p.isDying()) { return true; }
if (p.isFallingThroughHole()) { return true; }
return p.isAirborne() && hazard_height_px > 0 && p.getJumpZ() > static_cast<float>(hazard_height_px);
}
auto playerOverlapsEntityBox(const Player& p, const SDL_FRect& box) -> bool {
// Hit-box petita del jugador (`getHitBox`) en lloc de la caja base
// 16×16 — per a "tocar una entitat" volem precisió, no rebot físic.
const auto PBOX = p.getHitBox();
return PBOX.x + PBOX.w > box.x && box.x + box.w > PBOX.x && PBOX.y + PBOX.h > box.y && box.y + box.h > PBOX.y;
}
} // namespace
void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) { void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
if (players.empty()) { return; } if (players.empty()) { return; }
for (const auto& entity : entities_) { // Helper local: aplica el contacte d'una entitat sobre tots els
// Acceptem entitats sòlides (xoc físic) i triggers d'àrea (no // jugadors, amb els filtres per-jugador a `playerSkipsHazard`. Es
// bloquegen però volen rebre `onPlayerContact`). Tanquem el gap // comparteix entre els dos sacs (map_entities_ i actors_).
// latent de FloorTrap/WallTrap (que tenien `collides_=false` i auto apply_contacts = [&players](Entity* entity) {
// mai no es disparaven) i obrim la porta a hazards no-físics
// com `Hole`.
if (!entity->collides() && !entity->triggersOnOverlap()) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
const int E_HEIGHT = entity->getHeight(); const int E_HEIGHT = entity->getHeight();
for (auto* p : players) { for (auto* p : players) {
// Filtrem també DYING: el jugador en cicle de mort no rep if (p == nullptr || playerSkipsHazard(*p, E_HEIGHT)) { continue; }
// contacte amb hazards (un segon toc de mòmia mentre la roda if (playerOverlapsEntityBox(*p, BOX)) { entity->onPlayerContact(p); }
// ja és no-op; cap altra entitat l'ha de fer transicionar).
if (p == nullptr || p->isOutOfPlay() || p->isDying()) { continue; }
// Mentre el jugador cau per un forat, ignorem qualsevol altre
// hazard: la transició a `enterDying()` ja la dispara
// `integrateJump` quan supera `HOLE_FALL_DEPTH`.
//
// NOTA: aquest skip és la defensa que evita que un jugador
// caient per un forat reba contacte d'altres hazards
// "subterranis" durant la caiguda. La cadena de mort per
// forat (`enterDying(HOLE)` → respawn) depén de no ser
// interrompuda per un event `player_hit`/`player_killed`
// mentre `falling_through_hole_=true`. Si el dia que es
// toque aquest skip s'elimina, cal revisar la cadena
// mort/respawn per forat.
if (p->isFallingThroughHole()) { continue; }
// Filtre per altura: si el jugador està en l'aire i està per
// damunt de l'alçada del cos de l'entitat, no hi ha contacte.
// És el mecanisme del salt per esquivar momies, roques, etc.
// Quan height és 0 (entitat sense altura declarada), el filtre
// no es dispara mai i la col·lisió funciona com abans.
if (p->isAirborne() && E_HEIGHT > 0 && p->getJumpZ() > static_cast<float>(E_HEIGHT)) { continue; }
// Contacte actor-actor amb hit-box petita: la caixa base
// 16×16 servia per moure (xoc amb murs), però per a l'event
// "toca una entitat" volem precisió — que la momia mate quan
// els píxels centrals es solapen, no quan els caps es freguen.
const auto PBOX = p->getHitBox();
const bool OVERLAP = PBOX.x + PBOX.w > BOX.x && BOX.x + BOX.w > PBOX.x && PBOX.y + PBOX.h > BOX.y && BOX.y + BOX.h > PBOX.y;
if (OVERLAP) { entity->onPlayerContact(p); }
} }
};
// Filtre polimòrfic unificat: cada entitat decideix si ha de rebre
// contacte (`shouldReceiveContact`). Per defecte equival a `collides()
// || triggersOnOverlap()`; `DynamicHazard` afegeix el filtre de SPAWN
// (mentre està emergint torna false). El callsite no fa dynamic_cast
// ni distingeix categories.
for (const auto& entity : entities_) {
if (!entity->shouldReceiveContact()) { continue; }
apply_contacts(entity.get());
} }
} }
@@ -150,6 +157,147 @@ namespace {
return c["height"].get_value<int>(); return c["height"].get_value<int>();
} }
// Bloc opcional `light: { radius: N, flicker: bool, color: [r,g,b], offset: [x,y] }`
// de la plantilla. Si manca, el radi és 0 i l'entitat no entra a la
// pasada d'il·luminació. `flicker` activa la modulació "tipus foc"
// del radi (només antorxes l'haurien d'usar). `color` només té
// efecte en mode additive — defecte blanc (255,255,255). `offset`
// desplaça el centre del cercle de llum respecte el centre del
// sprite (default (0, 0); Y creix cap avall). La forma del gradient
// (3 anells) i els paràmetres del flicker viuen a
// data/config/lighting.yaml — no cal declarar-los per entitat.
struct LightTemplate {
int radius{0};
bool flicker{false};
std::uint8_t color_r{255};
std::uint8_t color_g{255};
std::uint8_t color_b{255};
int offset_x{0};
int offset_y{0};
// Inicialitzat al global a `readLight`; overrides parcials del
// tipus/sala/spawn modifiquen camps concrets sense tocar la resta.
Lighting::FlickerConfig flicker_params{};
};
// Helper compartit entre les tres capes (tipus, sala via Map, spawn)
// per llegir `color: [r, g, b]` amb clamp defensiu a 0..255.
auto readColorTriplet(const fkyaml::node& seq, std::uint8_t& r, std::uint8_t& g, std::uint8_t& b) -> bool {
if (!seq.is_sequence() || seq.size() != 3) { return false; }
r = static_cast<std::uint8_t>(std::clamp(seq[0].get_value<int>(), 0, 255));
g = static_cast<std::uint8_t>(std::clamp(seq[1].get_value<int>(), 0, 255));
b = static_cast<std::uint8_t>(std::clamp(seq[2].get_value<int>(), 0, 255));
return true;
}
// Helper paral·lel a `readColorTriplet` per a `offset: [x, y]` —
// píxels enters, sense clamp (offsets negatius són legítims; Y
// creix cap avall).
auto readOffsetPair(const fkyaml::node& seq, int& x, int& y) -> bool {
if (!seq.is_sequence() || seq.size() != 2) { return false; }
x = seq[0].get_value<int>();
y = seq[1].get_value<int>();
return true;
}
// Aplica els camps presents al bloc `flicker_params: { ... }` sobre
// una `FlickerConfig` ja inicialitzada (típicament amb el global o
// amb el resultat d'una capa anterior). Camps absents es respecten
// — per a poder sobreescriure'n només un.
void applyFlickerOverrides(const fkyaml::node& fp, Lighting::FlickerConfig& out) {
if (fp.contains("wave_amplitude_ratio")) { out.wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value<float>(); }
if (fp.contains("wave_frequency_hz")) { out.wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
if (fp.contains("noise_amplitude_ratio")) { out.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
if (fp.contains("noise_frequency_hz")) { out.noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
}
auto readLight(const fkyaml::node& tmpl) -> LightTemplate {
LightTemplate out;
// Punt de partida del flicker: el global de lighting.yaml.
out.flicker_params = Lighting::flickerConfig();
if (!tmpl.contains("light")) { return out; }
const auto& l = tmpl["light"];
if (l.contains("radius")) { out.radius = l["radius"].get_value<int>(); }
if (l.contains("flicker")) { out.flicker = l["flicker"].get_value<bool>(); }
if (l.contains("color")) { readColorTriplet(l["color"], out.color_r, out.color_g, out.color_b); }
if (l.contains("offset")) { readOffsetPair(l["offset"], out.offset_x, out.offset_y); }
if (l.contains("flicker_params")) { applyFlickerOverrides(l["flicker_params"], out.flicker_params); }
return out;
}
// Combina els defaults del tipus amb l'override de sala (si n'hi ha).
// Forma 1: la sala no diu res del tipus → es retornen els defaults
// del tipus tal qual (cas habitual / retrocompat).
// Forma 2: la sala declara `<tipus>: false` → radi 0 (entitat sense
// llum encara que el seu YAML en declari).
// Forma 3: la sala declara `<tipus>: true` o `<tipus>: { ... }` → els
// camps presents a l'override sobreescriuen el default; els absents
// (`std::nullopt`) cauen al default del tipus.
auto resolveLightWithRoom(const std::string& type, const LightTemplate& type_default) -> LightTemplate {
const auto* room = Map::getRoomEmitterOverride(type);
if (room == nullptr) { return type_default; }
if (!room->enabled) {
LightTemplate empty;
empty.flicker_params = Lighting::flickerConfig();
return empty;
}
LightTemplate out = type_default;
if (room->radius.has_value()) { out.radius = *room->radius; }
if (room->flicker.has_value()) { out.flicker = *room->flicker; }
if (room->color.has_value()) {
out.color_r = (*room->color)[0];
out.color_g = (*room->color)[1];
out.color_b = (*room->color)[2];
}
if (room->offset.has_value()) {
out.offset_x = (*room->offset)[0];
out.offset_y = (*room->offset)[1];
}
if (room->flicker_wave_amplitude_ratio.has_value()) { out.flicker_params.wave_amplitude_ratio = *room->flicker_wave_amplitude_ratio; }
if (room->flicker_wave_frequency_hz.has_value()) { out.flicker_params.wave_frequency_hz = *room->flicker_wave_frequency_hz; }
if (room->flicker_noise_amplitude_ratio.has_value()) { out.flicker_params.noise_amplitude_ratio = *room->flicker_noise_amplitude_ratio; }
if (room->flicker_noise_frequency_hz.has_value()) { out.flicker_params.noise_frequency_hz = *room->flicker_noise_frequency_hz; }
return out;
}
// Aplica l'override per-spawn (si existeix) per damunt del que ja
// viu a l'entitat (defaults del tipus + override de sala). Tres formes
// a l'entry del YAML de nivell:
// `light: false` → apaga aquesta instància
// `light: true` → informatiu (cau a capes inferiors)
// `light: { radius, flicker, color: [r,g,b], offset: [x,y] }` → camps presents sobreescriuen;
// els absents respecten el que
// ja duia l'entitat
void applySpawnLightOverride(Entity& entity, const fkyaml::node& spawn) {
if (!spawn.contains("light")) { return; }
const auto& l = spawn["light"];
if (l.is_boolean()) {
if (!l.get_value<bool>()) {
entity.setLightRadius(0);
entity.setLightFlicker(false);
}
return;
}
if (!l.is_mapping()) { return; }
if (l.contains("radius")) { entity.setLightRadius(l["radius"].get_value<int>()); }
if (l.contains("flicker")) { entity.setLightFlicker(l["flicker"].get_value<bool>()); }
if (l.contains("color")) {
std::uint8_t r{};
std::uint8_t g{};
std::uint8_t b{};
if (readColorTriplet(l["color"], r, g, b)) { entity.setLightColor(r, g, b); }
}
if (l.contains("offset")) {
int x{};
int y{};
if (readOffsetPair(l["offset"], x, y)) { entity.setLightOffset(x, y); }
}
if (l.contains("flicker_params")) {
Lighting::FlickerConfig fp = entity.getFlickerParams();
applyFlickerOverrides(l["flicker_params"], fp);
entity.setFlickerParams(fp);
}
}
// Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }` // Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }`
// de la plantilla d'entitat. // de la plantilla d'entitat.
// - Sense `base_size` ni `offset`: la caja queda alineada a la vora // - Sense `base_size` ni `offset`: la caja queda alineada a la vora
@@ -202,6 +350,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.
@@ -254,6 +420,16 @@ namespace {
entity->setCollisionBox(*BOX); entity->setCollisionBox(*BOX);
} }
entity->setHeight(readHeight(tmpl)); entity->setHeight(readHeight(tmpl));
// Defaults del tipus → resolució amb override de sala. El layer
// del spawn (per-instància) viu fora de `buildEntity`, perquè
// aquí encara no tenim el node del spawn — el sobreposem a
// `addSpawn` just després.
const auto LIGHT = resolveLightWithRoom(type, readLight(tmpl));
entity->setLightRadius(LIGHT.radius);
entity->setLightFlicker(LIGHT.flicker);
entity->setLightColor(LIGHT.color_r, LIGHT.color_g, LIGHT.color_b);
entity->setLightOffset(LIGHT.offset_x, LIGHT.offset_y);
entity->setFlickerParams(LIGHT.flicker_params);
anchor_out = readAnchor(tmpl); anchor_out = readAnchor(tmpl);
return entity; return entity;
} }
@@ -372,6 +548,17 @@ namespace {
} }
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height}); entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
// Variant del atlas: el spawn pot fixar un sub-sprite estàtic via
// `frame: N`. El render del manager ja sap clipar (col, row) a
// partir d'un índex lineal i la mida del frame; ací només cal
// muntar un `AnimationComponent` d'un sol frame i imposar-lo a la
// entitat. Cap entitat existent al joc emet aquest camp; només
// els muros decoratius amb variants (stone, column, ...) l'usen.
if (spawn.contains("frame")) {
const int FRAME = spawn["frame"].get_value<int>();
entity->setAnimation(AnimationComponent::uniform({FRAME}, 1000.0F, false));
}
manager.add(std::move(entity)); manager.add(std::move(entity));
return true; return true;
} }
@@ -383,6 +570,7 @@ void EntityManager::reload(const std::string& level_yaml_path) {
// se subscriu directament al bus — només publiquen). El bus i les // se subscriu directament al bus — només publiquen). El bus i les
// subscripcions externes (PaintLayer, HUD, GameScene) no es toquen. // subscripcions externes (PaintLayer, HUD, GameScene) no es toquen.
entities_.clear(); entities_.clear();
pending_actors_.clear();
door_ = nullptr; door_ = nullptr;
total_sarcs_ = 0; total_sarcs_ = 0;
opened_sarcs_ = 0; opened_sarcs_ = 0;
@@ -404,6 +592,10 @@ auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* {
std::string anchor; std::string anchor;
auto entity = buildEntity(TYPE, sprite, anchor); auto entity = buildEntity(TYPE, sprite, anchor);
if (entity == nullptr) { return nullptr; } if (entity == nullptr) { return nullptr; }
// Tercera capa de resolució de llum: override per-instància declarat
// al spawn del YAML de la sala. Capes prèvies (tipus, sala) ja viuen
// dins de l'entitat. Aplica abans del std::move per no perdre l'accés.
applySpawnLightOverride(*entity, spawn);
Entity* observer = entity.get(); Entity* observer = entity.get();
bool ok = false; bool ok = false;
@@ -419,33 +611,105 @@ 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 que el
// decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...). // jugador no pot trepitjar mai, de manera que el perímetre dels
// Imprescindible perquè el jugador no pot trepitjar aquests tiles // sarcòfags vecins ja els consideri visitats i la sala sigui
// (cauria al forat) i els sarcòfags vecins no podrien completar el // completable. Inclou dues famílies:
// perímetre — la sala seria incompletable. Usa el canal semàntic // - Decals de sòl (Hole, i futurs pinchos plans, plaques, charcos):
// `tile_painted` (lògica de joc), no `on_tile_entered` (presència // trigger-on-overlap que mataria/atraparia al jugador en trepitjar.
// física del jugador). Ho fem aquí, després del bucle de spawns, // - Entitats sòlides genèriques (debris, columnes...): obstacles
// perquè tots els sarcòfags ja estan registrats al fan-out (la // físics que `Map::collides` bloqueja, així `isPaintable` retorna
// subscripció del manager és al ctor, anterior a qualsevol load). // false per als seus tiles i ningú els pot pintar.
// Usa el canal semàntic `tile_painted` (lògica de joc), no
// `on_tile_entered` (presència física). Es fa aquí, després del bucle
// de spawns, perquè tots els sarcòfags ja estan registrats al fan-out
// (la subscripció del manager és al ctor, anterior a qualsevol load).
int decal_broadcast = 0; int decal_broadcast = 0;
for (const auto& entity : entities_) { for (const auto& entity : entities_) {
if (!entity->isFloorDecal()) { continue; } const bool IS_DECAL = entity->isFloorDecal();
const bool IS_SOLID = entity->blocksMovement();
if (!IS_DECAL && !IS_SOLID) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
const auto [COL, ROW] = Map::pixelToTile(BOX.x, BOX.y); // Per a entitats que ocupen més d'un tile (futurs murs 32×16,
// 16×32, etc.), iterem tota la zona coberta pel rect. Per a decals
// i blocs d'un sol tile cau a un sol broadcast (mateix comportament
// que abans).
const auto [COL0, ROW0] = Map::pixelToTile(BOX.x, BOX.y);
const int COLS = (Map::tile_size > 0) ? std::max(1, static_cast<int>(BOX.w) / Map::tile_size) : 1;
const int ROWS = (Map::tile_size > 0) ? std::max(1, static_cast<int>(BOX.h) / Map::tile_size) : 1;
for (int dr = 0; dr < ROWS; ++dr) {
for (int dc = 0; dc < COLS; ++dc) {
bus_.publish(Event{ bus_.publish(Event{
.name = "tile_painted", .name = "tile_painted",
.payload = TilePos{.col = COL, .row = ROW}, .payload = TilePos{.col = COL0 + dc, .row = ROW0 + dr},
.player_id = -1, .player_id = -1,
}); });
++decal_broadcast; ++decal_broadcast;
} }
}
}
if (decal_broadcast > 0) { if (decal_broadcast > 0) {
Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast); Log::info("EntityManager", "{} tile_painted broadcast inicial (decals + murs)", decal_broadcast);
} }
Log::info("EntityManager", "{} entitats carregades des de {}", LOADED, level_yaml_path); Log::info("EntityManager", "{} entitats + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path);
}
void EntityManager::spawnPendingActors() {
// Itera els pending i els instancia via `addSpawn`. El dispatch d'`add()`
// els enruta a `actors_`. `onJustSpawned()` és polimòrfic: la base
// no fa res, DynamicHazard l'override per disparar `triggerSpawnExit()`
// i entrar a EMERGING. Sense dynamic_cast: la decisió viu dins de
// cada entitat.
for (const auto& pa : pending_actors_) {
Entity* observer = addSpawn(pa.spawn_node);
if (observer != nullptr) { observer->onJustSpawned(); }
}
pending_actors_.clear();
}
void EntityManager::materializePendingActorsStatic() {
// Igual que `spawnPendingActors` però sense `onJustSpawned`: l'actor
// queda en el seu estat inicial (mòmia: ROAMING, walk_east) en lloc
// d'EMERGING. Vegeu el comentari del header per la motivació.
for (const auto& pa : pending_actors_) { addSpawn(pa.spawn_node); }
pending_actors_.clear();
}
auto EntityManager::spawnActor(const std::string& type, int col, int row) -> DynamicHazard* {
// Construïm un node de spawn sintètic amb el mínim que `placeGeneric`
// necessita: `type` i `position: [col, row]`. Si el caller vol
// overrides addicionals (orientació inicial, durada de spawn...),
// afegirà una variant amb fkyaml::node pre-construït més endavant.
fkyaml::node spawn;
try {
const std::string YAML_TEXT = "type: " + type + "\nposition: [" + std::to_string(col) + ", " + std::to_string(row) + "]\n";
spawn = fkyaml::node::deserialize(YAML_TEXT.begin(), YAML_TEXT.end());
} catch (const std::exception&) {
Log::error("EntityManager", "spawnActor: no s'ha pogut construir spawn YAML per {}", type);
return nullptr;
}
Entity* observer = addSpawn(spawn);
if (observer == nullptr) { return nullptr; }
// `onJustSpawned` polimòrfic: per a DynamicHazards arma EMERGING; per
// a un type erroni (no dynamic_hazard) queda no-op i retornem nullptr
// mitjançant el cast final, deixant la signatura `DynamicHazard*` neta.
observer->onJustSpawned();
return dynamic_cast<DynamicHazard*>(observer);
} }
+3
View File
@@ -60,6 +60,9 @@ namespace EntityWriter {
} else if (s.col.has_value() && s.row.has_value()) { } else if (s.col.has_value() && s.row.has_value()) {
out += " position: [" + std::to_string(*s.col) + ", " + std::to_string(*s.row) + "]\n"; out += " position: [" + std::to_string(*s.col) + ", " + std::to_string(*s.row) + "]\n";
} }
if (s.frame.has_value()) {
out += " frame: " + std::to_string(*s.frame) + "\n";
}
} }
return out; return out;
} }
+4
View File
@@ -35,12 +35,16 @@ namespace EntityWriter {
// Cada spawn duplica l'estructura mínima del que el lector espera: // Cada spawn duplica l'estructura mínima del que el lector espera:
// type sempre, i un dels (position, sarc_position) o cap (cas door). // type sempre, i un dels (position, sarc_position) o cap (cas door).
// `frame` és opcional i només l'emeten les entitats amb variants
// estàtiques (debris, columnes); fixa quin sub-sprite del atlas
// representa aquesta instància concreta.
struct Spawn { struct Spawn {
std::string type; std::string type;
std::optional<int> col; std::optional<int> col;
std::optional<int> row; std::optional<int> row;
std::optional<int> sarc_col; std::optional<int> sarc_col;
std::optional<int> sarc_row; std::optional<int> sarc_row;
std::optional<int> frame;
}; };
auto saveEntitiesBlock(const std::string& path, const std::vector<Spawn>& spawns) -> bool; auto saveEntitiesBlock(const std::string& path, const std::vector<Spawn>& spawns) -> bool;
+26 -3
View File
@@ -19,12 +19,21 @@ class Player;
// Event pub/sub minimalista. Hi ha un únic `EventBus` per mundus (propietat // Event pub/sub minimalista. Hi ha un únic `EventBus` per mundus (propietat
// de l'`EntityManager`); les entitats que el necessiten en reben un punter // de l'`EntityManager`); les entitats que el necessiten en reben un punter
// via `Entity::setEventBus`. Aquest sistema cobreix les necessitats // via `Entity::setEventBus`. Aquest sistema cobreix les necessitats
// actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered) // actuals (sarcophagus_opened, level_complete, player_hit, on_tile_entered,
// sense portar-hi senyalització més pesada. // player_stats_changed) sense portar-hi senyalització més pesada.
// //
// Decisió: `std::variant` per al payload — evita any/void* i facilita // Decisió: `std::variant` per al payload — evita any/void* i facilita
// consumir events tipats amb `std::get_if`. Si apareixen més tipus de // consumir events tipats amb `std::get_if`. Si apareixen més tipus de
// payload, s'amplia aquí sense tocar cridants existents. // payload, s'amplia aquí sense tocar cridants existents.
//
// `player_stats_changed`: l'únic canal numèric que toca el HUD. El `Player`
// és la font de veritat de vides/score/tresors i el publica cada vegada
// que muta qualsevol d'aquests camps. El HUD el consumeix com a "snapshot
// total" (no com a delta) — sobreescriu la seua caché amb el payload. Així
// qualsevol via que modifique stats al Player (mort per forat, recompensa
// de sarc, etc.) queda automàticament reflectida sense que cada caller
// haja de recordar publicar res addicional. `player_hit` queda només per
// al feedback visual (flash/shake), separat del comptador.
// Tile (col, row) — payload de `tile_painted` (i altres events de tile sense // Tile (col, row) — payload de `tile_painted` (i altres events de tile sense
// metadada extra). No és un `pair<int,int>` per evitar alias ambigus al // metadada extra). No és un `pair<int,int>` per evitar alias ambigus al
@@ -74,9 +83,20 @@ struct WhipSpawnPayload {
float range_px{0.0F}; float range_px{0.0F};
}; };
// Snapshot complet dels stats d'un jugador — payload de `player_stats_changed`.
// El Player publica un d'aquests cada vegada que muta vides/score/tresors.
// El HUD el consumeix com a estat absolut (no delta): sobreescriu la seua
// caché amb aquests valors. El `player_id` de l'`Event` indica a quin
// bloc del HUD va dirigit.
struct PlayerStatsPayload {
int lives{0};
int score{0};
int treasures{0};
};
struct Event { struct Event {
std::string name; std::string name;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos, WhipSpawnPayload>; using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos, WhipSpawnPayload, PlayerStatsPayload>;
Payload payload; Payload payload;
// Jugador que ha originat (o provocat) l'event. -1 = sense jugador // Jugador que ha originat (o provocat) l'event. -1 = sense jugador
// associat (events del món, com `sarcophagus_opened` des de la pròpia // associat (events del món, com `sarcophagus_opened` des de la pròpia
@@ -129,6 +149,9 @@ class EventBus {
void publish(const std::string& name, const WhipSpawnPayload& whip) { void publish(const std::string& name, const WhipSpawnPayload& whip) {
publish(Event{.name = name, .payload = whip}); publish(Event{.name = name, .payload = whip});
} }
void publish(const std::string& name, const PlayerStatsPayload& stats) {
publish(Event{.name = name, .payload = stats});
}
private: private:
struct Subscription { struct Subscription {
+65
View File
@@ -1,5 +1,7 @@
#pragma once #pragma once
#include <cstdint>
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
// Base d'entitats perilloses: al contacte amb el jugador, apliquen dany. // Base d'entitats perilloses: al contacte amb el jugador, apliquen dany.
@@ -56,8 +58,20 @@ class StaticHazard : public Hazard {
// Perill dinàmic: es mou. Velocitat expressada en píxels/segon i una // Perill dinàmic: es mou. Velocitat expressada en píxels/segon i una
// direcció (vec2). Les subclasses decideixen la política de moviment // direcció (vec2). Les subclasses decideixen la política de moviment
// (IA corridor_wanderer, línia recta, etc.) sobreescribint `step(dt)`. // (IA corridor_wanderer, línia recta, etc.) sobreescribint `step(dt)`.
//
// Estat SPAWN: tota DynamicHazard pot estar en SPAWNING (visible, sense
// moviment ni dany) fins que algú extern arme el countdown amb
// `triggerSpawnExit()`. Quan el comptador arriba a zero passa a ACTIVE i
// recupera la lògica normal (`step()` per frame). Per defecte arrenca en
// ACTIVE — backward-compat amb subclasses que encara no usen el mecanisme.
// El filtre central de col·lisió a `EntityManager::checkPlayerCollisions`
// salta les entitats en SPAWN: per això el contacte no fa dany ni dispara
// triggers durant aquesta fase.
class DynamicHazard : public Hazard { class DynamicHazard : public Hazard {
public: public:
enum class SpawnState : std::uint8_t { SPAWNING,
ACTIVE };
DynamicHazard() = default; DynamicHazard() = default;
void setSpeed(float px_per_s) { speed_ = px_per_s; } void setSpeed(float px_per_s) { speed_ = px_per_s; }
@@ -70,6 +84,40 @@ class DynamicHazard : public Hazard {
[[nodiscard]] auto getDirX() const -> float { return dir_x_; } [[nodiscard]] auto getDirX() const -> float { return dir_x_; }
[[nodiscard]] auto getDirY() const -> float { return dir_y_; } [[nodiscard]] auto getDirY() const -> float { return dir_y_; }
// Entra en SPAWNING amb una duració determinada. El comptador NO
// descompta fins que algú crida `triggerSpawnExit()` — l'entitat queda
// visible i quieta esperant indefinidament.
void enterSpawn(float duration_s) {
spawn_state_ = SpawnState::SPAWNING;
spawn_duration_s_ = duration_s;
spawn_remaining_s_ = duration_s;
spawn_exit_armed_ = false;
}
// Arma el comptador del SPAWN. A partir d'aquí, cada `update(dt)`
// descompta `dt` fins arribar a 0 i transitar a ACTIVE.
void triggerSpawnExit() { spawn_exit_armed_ = true; }
// Transició directa a ACTIVE, sense passar pel countdown. Pensat
// per a subclasses amb SPAWN dirigit per animació: gestionen el
// seu propi `update()` mentre `isSpawning()` i criden ací quan la
// seua animació d'aparició ha completat.
void endSpawn() { spawn_state_ = SpawnState::ACTIVE; }
[[nodiscard]] auto isSpawning() const -> bool { return spawn_state_ == SpawnState::SPAWNING; }
[[nodiscard]] auto getSpawnDurationS() const -> float { return spawn_duration_s_; }
// Just després d'instanciar-se al món (via spawnPendingActors /
// spawnActor), l'actor entra automàticament en la fase EMERGING:
// arma el comptador i comença l'animació de sortida. Substitueix el
// dynamic_cast que el manager feia abans per cridar `triggerSpawnExit`.
void onJustSpawned() override { triggerSpawnExit(); }
// Mentre està en SPAWN (HIDDEN/EMERGING), l'actor NO rep contacte
// ni amb el jugador ni amb attacks: visible però "no juga". Quan
// passa a ACTIVE, recupera el comportament estàndard d'Entity.
[[nodiscard]] auto shouldReceiveContact() const -> bool override {
if (isSpawning()) { return false; }
return Entity::shouldReceiveContact();
}
// Política de moviment per frame. La base desplaça el pixel_rect_ // Política de moviment per frame. La base desplaça el pixel_rect_
// proporcionalment a velocitat i direcció; les subclasses amb IA la // proporcionalment a velocitat i direcció; les subclasses amb IA la
// sobreescriuen per triar nova direcció a les interseccions o rebotar. // sobreescriuen per triar nova direcció a les interseccions o rebotar.
@@ -79,6 +127,19 @@ class DynamicHazard : public Hazard {
} }
void update(float delta_time) override { void update(float delta_time) override {
if (spawn_state_ == SpawnState::SPAWNING) {
// Sense `step()`: l'entitat es queda quieta al pixel_rect_
// inicial. `Entity::update` tic d'animació igual: si en algun
// moment posem un GIF de spawn aquí ja anirà.
Entity::update(delta_time);
if (spawn_exit_armed_) {
spawn_remaining_s_ -= delta_time;
if (spawn_remaining_s_ <= 0.0F) {
spawn_state_ = SpawnState::ACTIVE;
}
}
return;
}
Entity::update(delta_time); Entity::update(delta_time);
step(delta_time); step(delta_time);
} }
@@ -87,4 +148,8 @@ class DynamicHazard : public Hazard {
float speed_{0.0F}; float speed_{0.0F};
float dir_x_{0.0F}; float dir_x_{0.0F};
float dir_y_{0.0F}; float dir_y_{0.0F};
SpawnState spawn_state_{SpawnState::ACTIVE};
float spawn_duration_s_{0.0F};
float spawn_remaining_s_{0.0F};
bool spawn_exit_armed_{false};
}; };
+368 -281
View File
@@ -13,6 +13,7 @@
#include "core/input/gamepad_manager.hpp" #include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.hpp" #include "core/input/input_state.hpp"
#include "core/rendering/canvas.hpp" #include "core/rendering/canvas.hpp"
#include "core/rendering/effects/shadow.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "core/resources/palette_loader.hpp" #include "core/resources/palette_loader.hpp"
@@ -22,6 +23,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"
@@ -35,19 +37,6 @@ namespace {
// repòs i no contribueix a la velocitat. // repòs i no contribueix a la velocitat.
constexpr Sint16 STICK_DEADZONE = 12000; constexpr Sint16 STICK_DEADZONE = 12000;
// Llegeix la mida del frame des del bloc `sprite: { path, size: [w, h] }`
// (mateix format que la resta de plantilles d'entitat, vegeu
// AnimatedSprite). Extracció per mantenir `Player::loadFrom` sota el
// llindar de complexitat cognitiva (25).
void readSpriteSize(const fkyaml::node& yaml, const std::string& yaml_path, int& w, int& h) {
if (!yaml.contains("sprite") || !yaml["sprite"].contains("size")) {
throw std::runtime_error("Player: manca `sprite.size` a " + yaml_path);
}
const auto& size_node = yaml["sprite"]["size"];
w = size_node[0].get_value<int>();
h = size_node[1].get_value<int>();
}
// Acumula la contribució del mando assignat al jugador (cruceta + // Acumula la contribució del mando assignat al jugador (cruceta +
// stick esquerre) sobre els acumuladors de velocitat. Extracció // stick esquerre) sobre els acumuladors de velocitat. Extracció
// d'`update()` per a mantenir la complexitat cognitiva sota llindar // d'`update()` per a mantenir la complexitat cognitiva sota llindar
@@ -71,15 +60,156 @@ namespace {
} // namespace } // namespace
Player::TurnConfig Player::turn_config; namespace {
Player::InertiaConfig Player::inertia_config;
Player::JumpConfig Player::jump_config;
Player::HurtConfig Player::hurt_config;
Player::WhipConfig Player::whip_config;
Player::DeathConfig Player::death_config;
Player::ImmunityConfig Player::immunity_config;
void Player::loadFrom(const std::string& yaml_path) { // ---- Helpers de lectura per sub-blocs del YAML del personatge ----
// Cada helper rep el bloc YAML root i un `Player::Config&` a poblar.
// Camps absents respecten els defaults del Config (no reinicialitzen
// a 0 — la Config ja ve construïda amb els valors default abans).
void readSpriteSizeInto(const fkyaml::node& yaml, const std::string& yaml_path, Player::Config& out) {
if (!yaml.contains("sprite") || !yaml["sprite"].contains("size")) {
throw std::runtime_error("Player: manca `sprite.size` a " + yaml_path);
}
const auto& size_node = yaml["sprite"]["size"];
out.sprite_w = size_node[0].get_value<int>();
out.sprite_h = size_node[1].get_value<int>();
}
void readCollisionInto(const fkyaml::node& yaml, const std::string& yaml_path, Player::Config& out) {
if (!yaml.contains("collision")) {
throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path);
}
const auto& c = yaml["collision"];
out.collision_size = c["size"].get_value<int>();
out.base_size = c["base_size"].get_value<int>();
// Col·lisió centrada en el quadrat inferior del sprite.
out.collision_padding = (out.collision_size - out.base_size) / 2;
out.collision_x_offset = -out.collision_padding;
out.collision_y_offset = (out.sprite_h - out.base_size) - out.collision_padding;
// Hit-box opcional (actor-actor): default = caixa de col·lisió.
out.hit_size = c.contains("hit_size") ? c["hit_size"].get_value<int>() : out.collision_size;
const int HIT_INSET = (out.collision_size - out.hit_size) / 2;
out.hit_x_offset = out.collision_x_offset + HIT_INSET;
out.hit_y_offset = out.collision_y_offset + HIT_INSET;
out.body_height = c.contains("height") ? c["height"].get_value<int>() : 0;
}
void readInertiaInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("inertia")) { return; }
const auto& i = yaml["inertia"];
if (i.contains("brake_time_ms")) { out.inertia.brake_time_ms = i["brake_time_ms"].get_value<int>(); }
if (i.contains("threshold_ms")) { out.inertia.threshold_ms = i["threshold_ms"].get_value<int>(); }
}
void readTurnInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("turn")) { return; }
const auto& t = yaml["turn"];
if (t.contains("duration_ms")) { out.turn.duration_ms = t["duration_ms"].get_value<int>(); }
if (!t.contains("transitions")) { return; }
const auto& tr = t["transitions"];
if (tr.contains("west_to_east")) { out.turn.w_to_e = Player::cardinalFromString(tr["west_to_east"].get_value<std::string>()); }
if (tr.contains("east_to_west")) { out.turn.e_to_w = Player::cardinalFromString(tr["east_to_west"].get_value<std::string>()); }
if (tr.contains("north_to_south")) { out.turn.n_to_s = Player::cardinalFromString(tr["north_to_south"].get_value<std::string>()); }
if (tr.contains("south_to_north")) { out.turn.s_to_n = Player::cardinalFromString(tr["south_to_north"].get_value<std::string>()); }
}
void readJumpInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("jump")) { return; }
const auto& j = yaml["jump"];
if (j.contains("impulse")) { out.jump.impulse_px_per_s = j["impulse"].get_value<float>(); }
if (j.contains("gravity")) { out.jump.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
if (j.contains("landing_stun_ms")) { out.jump.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
if (j.contains("sound")) {
out.jump.sound_key = j["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(out.jump.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + out.jump.sound_key);
}
if (j.contains("land_sound")) {
out.jump.land_sound_key = j["land_sound"].get_value<std::string>();
Resource::Cache::get().loadSound(out.jump.land_sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + out.jump.land_sound_key);
}
}
void readHurtInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("hurt")) { return; }
const auto& h = yaml["hurt"];
if (h.contains("speed")) { out.hurt.initial_speed_px_per_s = h["speed"].get_value<float>(); }
if (h.contains("brake_time_ms")) { out.hurt.brake_time_ms = h["brake_time_ms"].get_value<int>(); }
if (h.contains("sound")) {
out.hurt.sound_key = h["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(out.hurt.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + out.hurt.sound_key);
}
}
void readWhipInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("whip")) { return; }
const auto& w = yaml["whip"];
if (w.contains("range_tiles")) { out.whip.range_tiles = w["range_tiles"].get_value<float>(); }
if (w.contains("swing_ms")) { out.whip.swing_ms = w["swing_ms"].get_value<int>(); }
if (w.contains("cooldown_ms")) { out.whip.cooldown_ms = w["cooldown_ms"].get_value<int>(); }
if (w.contains("damage")) { out.whip.damage = w["damage"].get_value<int>(); }
if (w.contains("sound")) {
out.whip.sound_key = w["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(out.whip.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + out.whip.sound_key);
}
}
void readDeathInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("death")) { return; }
const auto& d = yaml["death"];
if (d.contains("respawn_delay_ms")) { out.death.respawn_delay_ms = d["respawn_delay_ms"].get_value<int>(); }
if (d.contains("lives")) { out.death.initial_lives = d["lives"].get_value<int>(); }
}
void readImmunityInto(const fkyaml::node& yaml, Player::Config& out) {
if (!yaml.contains("immunity")) { return; }
const auto& im = yaml["immunity"];
if (im.contains("duration_ms")) { out.immunity.duration_ms = im["duration_ms"].get_value<float>(); }
if (im.contains("blink_period_ms")) { out.immunity.blink_period_ms = im["blink_period_ms"].get_value<int>(); }
if (im.contains("palette")) {
out.immunity.palette_key = im["palette"].get_value<std::string>();
Resource::Cache::get().loadPalette(out.immunity.palette_key, std::string{Defaults::Paths::PLAYER_DIR} + out.immunity.palette_key + ".pal");
}
}
void readLightInto(const fkyaml::node& yaml, Player::Config& out) {
// Punt de partida del flicker: el global de lighting.yaml. El bloc
// opcional `flicker_params:` del personatge sobreescriu camps concrets.
out.flicker_params = Lighting::flickerConfig();
if (!yaml.contains("light")) { return; }
const auto& l = yaml["light"];
if (l.contains("radius")) { out.light_radius = l["radius"].get_value<int>(); }
if (l.contains("flicker")) { out.light_flicker = l["flicker"].get_value<bool>(); }
if (l.contains("color")) {
const auto& c = l["color"];
if (c.is_sequence() && c.size() == 3) {
out.light_color_r = static_cast<std::uint8_t>(std::clamp(c[0].get_value<int>(), 0, 255));
out.light_color_g = static_cast<std::uint8_t>(std::clamp(c[1].get_value<int>(), 0, 255));
out.light_color_b = static_cast<std::uint8_t>(std::clamp(c[2].get_value<int>(), 0, 255));
}
}
if (l.contains("offset")) {
const auto& o = l["offset"];
if (o.is_sequence() && o.size() == 2) {
out.light_offset_x = o[0].get_value<int>();
out.light_offset_y = o[1].get_value<int>();
}
}
if (l.contains("flicker_params")) {
const auto& fp = l["flicker_params"];
if (fp.contains("wave_amplitude_ratio")) { out.flicker_params.wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value<float>(); }
if (fp.contains("wave_frequency_hz")) { out.flicker_params.wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
if (fp.contains("noise_amplitude_ratio")) { out.flicker_params.noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
if (fp.contains("noise_frequency_hz")) { out.flicker_params.noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
}
}
} // namespace
auto Player::Config::loadFromYaml(const std::string& yaml_path) -> Player::Config {
auto bytes = Resource::Helper::loadFile(yaml_path); auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) { if (bytes.empty()) {
throw std::runtime_error("Player: no s'ha pogut obrir " + yaml_path); throw std::runtime_error("Player: no s'ha pogut obrir " + yaml_path);
@@ -88,160 +218,51 @@ void Player::loadFrom(const std::string& yaml_path) {
const auto* end = begin + bytes.size(); const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end); auto yaml = fkyaml::node::deserialize(begin, end);
readSpriteSize(yaml, yaml_path, sprite_w, sprite_h); Player::Config out;
readSpriteSizeInto(yaml, yaml_path, out);
readCollisionBlock(yaml, yaml_path); readCollisionInto(yaml, yaml_path, out);
readInertiaInto(yaml, out);
// Bloc opcional `inertia:` — si manca, `brake_time_ms` queda a 0 i readTurnInto(yaml, out);
// la inèrcia de frenada no s'activa (parada instantània com abans). readJumpInto(yaml, out);
inertia_config = InertiaConfig{}; readHurtInto(yaml, out);
if (yaml.contains("inertia")) { readWhipInto(yaml, out);
const auto& i = yaml["inertia"]; readDeathInto(yaml, out);
if (i.contains("brake_time_ms")) { inertia_config.brake_time_ms = i["brake_time_ms"].get_value<int>(); } readImmunityInto(yaml, out);
if (i.contains("threshold_ms")) { inertia_config.threshold_ms = i["threshold_ms"].get_value<int>(); } readLightInto(yaml, out);
return out;
} }
// Bloc opcional `turn:` — si manca, `duration_ms` queda a 0 i la void Player::Config::applyRoomLightOverride() {
// mecànica del gir de 180° no s'activa (canvis de direcció instantanis). // Override de sala per al jugador (bloc `lighting.emitters.player`
turn_config = TurnConfig{}; // del YAML del mapa). Si la sala no en declara, els valors base del
if (yaml.contains("turn")) { // YAML del personatge es conserven. Si la sala desactiva
const auto& t = yaml["turn"]; // (`players: false`), `light_radius = 0` (no emet).
if (t.contains("duration_ms")) { turn_config.duration_ms = t["duration_ms"].get_value<int>(); } const auto* room = Map::getRoomEmitterOverride("player");
if (t.contains("transitions")) { if (room == nullptr) { return; }
const auto& tr = t["transitions"]; if (!room->enabled) {
if (tr.contains("west_to_east")) { turn_config.w_to_e = cardinalFromString(tr["west_to_east"].get_value<std::string>()); } light_radius = 0;
if (tr.contains("east_to_west")) { turn_config.e_to_w = cardinalFromString(tr["east_to_west"].get_value<std::string>()); } light_flicker = false;
if (tr.contains("north_to_south")) { turn_config.n_to_s = cardinalFromString(tr["north_to_south"].get_value<std::string>()); } return;
if (tr.contains("south_to_north")) { turn_config.s_to_n = cardinalFromString(tr["south_to_north"].get_value<std::string>()); }
} }
if (room->radius.has_value()) { light_radius = *room->radius; }
if (room->flicker.has_value()) { light_flicker = *room->flicker; }
if (room->color.has_value()) {
light_color_r = (*room->color)[0];
light_color_g = (*room->color)[1];
light_color_b = (*room->color)[2];
}
if (room->offset.has_value()) {
light_offset_x = (*room->offset)[0];
light_offset_y = (*room->offset)[1];
}
if (room->flicker_wave_amplitude_ratio.has_value()) { flicker_params.wave_amplitude_ratio = *room->flicker_wave_amplitude_ratio; }
if (room->flicker_wave_frequency_hz.has_value()) { flicker_params.wave_frequency_hz = *room->flicker_wave_frequency_hz; }
if (room->flicker_noise_amplitude_ratio.has_value()) { flicker_params.noise_amplitude_ratio = *room->flicker_noise_amplitude_ratio; }
if (room->flicker_noise_frequency_hz.has_value()) { flicker_params.noise_frequency_hz = *room->flicker_noise_frequency_hz; }
} }
// Bloc opcional `jump:` — si manca, `impulse_px_per_s` queda a 0 i el void Player::setConfig(Config cfg) {
// botó de salt no fa res. Extret a un mètode estàtic per acotar la cfg_ = std::move(cfg);
// complexitat cognitiva de `loadFrom` sota el llindar de clang-tidy.
readJumpBlock(yaml);
// Bloc opcional `hurt:` — estat post-impacte. Si manca o el speed
// és 0, l'estat HURT mai s'activa (cas degenerat).
readHurtBlock(yaml);
// Bloc opcional `whip:` — paràmetres del látigo. Si manca, el
// jugador no atacarà efectivament (ATTACKING dura 0 ms i el flanc
// d'input es perd).
readWhipBlock(yaml);
// Bloc opcional `death:` — pausa de respawn i comptador inicial de
// vides. Si manca, el respawn és immediat i les vides queden a 3
// per defecte (no és un valor crític: avui no hi ha game over).
readDeathBlock(yaml);
// Bloc opcional `immunity:` — finestra de gràcia post-spawn. Si
// `duration_ms` és 0 (o el bloc manca) la mecànica queda
// desactivada i els hazards toquen al primer frame, com abans.
readImmunityBlock(yaml);
}
void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) {
if (!yaml.contains("collision")) {
throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path);
}
const auto& c = yaml["collision"];
collision_size = c["size"].get_value<int>();
base_size = c["base_size"].get_value<int>();
// Col·lisió centrada en el quadrat inferior del sprite. Si la caixa és
// més gran que la base (cas habitual, 16 vs 12) el padding és simètric
// a cada costat; si són iguals, padding=0 i la caixa coincideix amb la
// base sense offsets.
collision_padding = (collision_size - base_size) / 2;
collision_x_offset = -collision_padding;
collision_y_offset = (sprite_h - base_size) - collision_padding;
// Hit-box opcional (actor-actor). Si no hi és, s'iguala a la base
// per preservar compatibilitat amb YAMLs antics. Concèntrica amb la
// caixa de col·lisió: offset interior = (collision_size - hit_size) / 2.
hit_size = c.contains("hit_size") ? c["hit_size"].get_value<int>() : collision_size;
const int HIT_INSET = (collision_size - hit_size) / 2;
hit_x_offset = collision_x_offset + HIT_INSET;
hit_y_offset = collision_y_offset + HIT_INSET;
// Alçada del cos, llegida del mateix bloc per coherència amb la
// resta d'entitats (mummy, sarcophagus, etc.). 0 = sense filtre per
// altura — el salt no esquiva l'altre jugador.
body_height = c.contains("height") ? c["height"].get_value<int>() : 0;
}
void Player::readJumpBlock(const fkyaml::node& yaml) {
jump_config = JumpConfig{};
if (!yaml.contains("jump")) { return; }
const auto& j = yaml["jump"];
if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value<float>(); }
if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
if (j.contains("shadow_y_offset")) { jump_config.shadow_y_offset = j["shadow_y_offset"].get_value<int>(); }
// Sons opcionals: el nom del fitxer (relatiu a Defaults::Paths::AUDIO_SOUNDS_DIR)
// val tant de clau al Resource::Cache com de basename per a la càrrega.
// Si el camp manca, la clau queda buida i `playSound` no es crida.
if (j.contains("sound")) {
jump_config.sound_key = j["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(jump_config.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + jump_config.sound_key);
}
if (j.contains("land_sound")) {
jump_config.land_sound_key = j["land_sound"].get_value<std::string>();
Resource::Cache::get().loadSound(jump_config.land_sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + jump_config.land_sound_key);
}
}
void Player::readHurtBlock(const fkyaml::node& yaml) {
hurt_config = HurtConfig{};
if (!yaml.contains("hurt")) { return; }
const auto& h = yaml["hurt"];
if (h.contains("speed")) { hurt_config.initial_speed_px_per_s = h["speed"].get_value<float>(); }
if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].get_value<int>(); }
// So d'entrada a HURT — mateix patró que `jump.sound` i `whip.sound`.
if (h.contains("sound")) {
hurt_config.sound_key = h["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(hurt_config.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + hurt_config.sound_key);
}
}
void Player::readDeathBlock(const fkyaml::node& yaml) {
death_config = DeathConfig{};
if (!yaml.contains("death")) { return; }
const auto& d = yaml["death"];
if (d.contains("respawn_delay_ms")) { death_config.respawn_delay_ms = d["respawn_delay_ms"].get_value<int>(); }
if (d.contains("lives")) { death_config.initial_lives = d["lives"].get_value<int>(); }
}
void Player::readImmunityBlock(const fkyaml::node& yaml) {
immunity_config = ImmunityConfig{};
if (!yaml.contains("immunity")) { return; }
const auto& im = yaml["immunity"];
if (im.contains("duration_ms")) { immunity_config.duration_ms = im["duration_ms"].get_value<float>(); }
if (im.contains("blink_period_ms")) { immunity_config.blink_period_ms = im["blink_period_ms"].get_value<int>(); }
// Paleta del blink: el basename val tant de clau al Resource::Cache
// com de path (`Defaults::Paths::PLAYER_DIR + key + .pal`). Mateix patró
// que els sons.
if (im.contains("palette")) {
immunity_config.palette_key = im["palette"].get_value<std::string>();
Resource::Cache::get().loadPalette(immunity_config.palette_key, std::string{Defaults::Paths::PLAYER_DIR} + immunity_config.palette_key + ".pal");
}
}
void Player::readWhipBlock(const fkyaml::node& yaml) {
whip_config = WhipConfig{};
if (!yaml.contains("whip")) { return; }
const auto& w = yaml["whip"];
if (w.contains("range_tiles")) { whip_config.range_tiles = w["range_tiles"].get_value<float>(); }
if (w.contains("swing_ms")) { whip_config.swing_ms = w["swing_ms"].get_value<int>(); }
if (w.contains("cooldown_ms")) { whip_config.cooldown_ms = w["cooldown_ms"].get_value<int>(); }
if (w.contains("damage")) { whip_config.damage = w["damage"].get_value<int>(); }
// So del swing: mateix patró que `jump.sound` — basename val de
// clau al cache i de path. Si manca, queda buit i no es dispara.
if (w.contains("sound")) {
whip_config.sound_key = w["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(whip_config.sound_key, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + whip_config.sound_key);
}
} }
void Player::applyCardinal(Cardinal c) { void Player::applyCardinal(Cardinal c) {
@@ -300,16 +321,16 @@ auto Player::opposite(Cardinal c) -> Cardinal {
return Cardinal::NONE; return Cardinal::NONE;
} }
auto Player::intermediateFor(Cardinal target) -> Cardinal { auto Player::intermediateFor(Cardinal target) const -> Cardinal {
switch (target) { switch (target) {
case Cardinal::EAST: case Cardinal::EAST:
return turn_config.w_to_e; return cfg_.turn.w_to_e;
case Cardinal::WEST: case Cardinal::WEST:
return turn_config.e_to_w; return cfg_.turn.e_to_w;
case Cardinal::SOUTH: case Cardinal::SOUTH:
return turn_config.n_to_s; return cfg_.turn.n_to_s;
case Cardinal::NORTH: case Cardinal::NORTH:
return turn_config.s_to_n; return cfg_.turn.s_to_n;
case Cardinal::NONE: case Cardinal::NONE:
return Cardinal::NONE; return Cardinal::NONE;
} }
@@ -347,11 +368,11 @@ void Player::updateFacingAndTurn(float delta_time, float vx, float vy) {
// a la prèvia. Requereix també que el bloc `turn:` del YAML // a la prèvia. Requereix també que el bloc `turn:` del YAML
// estiga activat (`duration_ms > 0`). // estiga activat (`duration_ms > 0`).
const bool TRIGGER_TURN = const bool TRIGGER_TURN =
(turn_config.duration_ms > 0) && INPUT_CARD != Cardinal::NONE && last_input_cardinal_ != Cardinal::NONE && INPUT_CARD == opposite(last_input_cardinal_); (cfg_.turn.duration_ms > 0) && INPUT_CARD != Cardinal::NONE && last_input_cardinal_ != Cardinal::NONE && INPUT_CARD == opposite(last_input_cardinal_);
if (TRIGGER_TURN) { if (TRIGGER_TURN) {
pending_cardinal_ = INPUT_CARD; pending_cardinal_ = INPUT_CARD;
intermediate_cardinal_ = intermediateFor(INPUT_CARD); intermediate_cardinal_ = intermediateFor(INPUT_CARD);
turning_remaining_ms_ = static_cast<float>(turn_config.duration_ms); turning_remaining_ms_ = static_cast<float>(cfg_.turn.duration_ms);
applyCardinal(intermediate_cardinal_); applyCardinal(intermediate_cardinal_);
} else if (vy < 0.0F) { } else if (vy < 0.0F) {
// Tria de facing amb prevalença vertical: si hi ha component // Tria de facing amb prevalença vertical: si hi ha component
@@ -375,8 +396,9 @@ void Player::updateFacingAndTurn(float delta_time, float vx, float vy) {
} }
} }
Player::Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index) Player::Player(Config cfg, const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index)
: AnimatedSprite(Resource::Cache::get().getAnimationData(anim_key)), : AnimatedSprite(Resource::Cache::get().getAnimationData(anim_key)),
cfg_(std::move(cfg)),
id_(id), id_(id),
controls_(controls), controls_(controls),
map_(map_info), map_(map_info),
@@ -390,7 +412,7 @@ Player::Player(const std::string& anim_key, int id, float x, float y, const Cont
setCurrentAnimation("stand_south"); setCurrentAnimation("stand_south");
// Comptador d'intents inicialitzat amb el valor del YAML (default 3 // Comptador d'intents inicialitzat amb el valor del YAML (default 3
// si el bloc `death:` manca). Fora del cicle DYING no toca a ningú. // si el bloc `death:` manca). Fora del cicle DYING no toca a ningú.
lives_remaining_ = death_config.initial_lives; lives_remaining_ = cfg_.death.initial_lives;
// Spawn point inicial: la posició del ctor val per defecte; el // Spawn point inicial: la posició del ctor val per defecte; el
// GameScene el reescriu via `setSpawnPoint` quan canvia de sala. // GameScene el reescriu via `setSpawnPoint` quan canvia de sala.
spawn_x_ = x; spawn_x_ = x;
@@ -414,6 +436,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
@@ -429,7 +455,7 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
// travessa net del caminant; ningú actua de "muro" durant el // travessa net del caminant; ningú actua de "muro" durant el
// vol, ni a la pujada ni a la baixada. // vol, ni a la pujada ni a la baixada.
// - Tots dos en l'aire → col·lisió només si els rangs verticals // - Tots dos en l'aire → col·lisió només si els rangs verticals
// [jump_z, jump_z + body_height] solapen. Així permetem el xoc // [jump_z, jump_z + cfg_.body_height] solapen. Així permetem el xoc
// mid-aire quan tots dos van un contra l'altre i estan a la // mid-aire quan tots dos van un contra l'altre i estan a la
// mateixa franja d'alçada. // mateixa franja d'alçada.
const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box); const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box);
@@ -448,7 +474,7 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
// Aquí els dos estats coincideixen. Si tots dos volen, el filtre // Aquí els dos estats coincideixen. Si tots dos volen, el filtre
// per altura mira que els rangs verticals encara solapin; si no, // per altura mira que els rangs verticals encara solapin; si no,
// estan a prou distància en Y "real" i no es toquen. // estan a prou distància en Y "real" i no es toquen.
if (MY_AIR && body_height > 0 && std::fabs(MY_Z - p->jump_z_) >= static_cast<float>(body_height)) { if (MY_AIR && cfg_.body_height > 0 && std::fabs(MY_Z - p->jump_z_) >= static_cast<float>(cfg_.body_height)) {
return false; return false;
} }
const auto OTHER = p->getHitBox(); const auto OTHER = p->getHitBox();
@@ -456,7 +482,7 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
}); });
} }
void Player::integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input) { void Player::integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input) const {
// Acceleració instantània: mentre hi ha input, la velocitat salta a // Acceleració instantània: mentre hi ha input, la velocitat salta a
// ±SPEED sense transició. Cobreix també els canvis de direcció (un // ±SPEED sense transició. Cobreix també els canvis de direcció (un
// coast cap a la dreta passa a -SPEED immediatament si es prem esquerra). // coast cap a la dreta passa a -SPEED immediatament si es prem esquerra).
@@ -474,12 +500,12 @@ void Player::integrateAxis(float& phys, float input, float delta_time, float mot
phys = 0.0F; phys = 0.0F;
return; return;
} }
const bool INERTIA_ACTIVE = (inertia_config.brake_time_ms > 0) && (motion_time_ms >= static_cast<float>(inertia_config.threshold_ms)); const bool INERTIA_ACTIVE = (cfg_.inertia.brake_time_ms > 0) && (motion_time_ms >= static_cast<float>(cfg_.inertia.threshold_ms));
if (!INERTIA_ACTIVE) { if (!INERTIA_ACTIVE) {
phys = 0.0F; phys = 0.0F;
return; return;
} }
const float DECEL = SPEED_PX_PER_S / (static_cast<float>(inertia_config.brake_time_ms) / 1000.0F); const float DECEL = SPEED_PX_PER_S / (static_cast<float>(cfg_.inertia.brake_time_ms) / 1000.0F);
const float STEP = DECEL * delta_time; const float STEP = DECEL * delta_time;
if (std::fabs(phys) <= STEP) { if (std::fabs(phys) <= STEP) {
phys = 0.0F; phys = 0.0F;
@@ -602,8 +628,8 @@ void Player::tick(float delta_time) {
if (state_ == State::HURT) { if (state_ == State::HURT) {
(void)consumeJumpEdge(); (void)consumeJumpEdge();
(void)consumeAttackEdge(); (void)consumeAttackEdge();
const float DECEL = (hurt_config.brake_time_ms > 0) const float DECEL = (cfg_.hurt.brake_time_ms > 0)
? hurt_config.initial_speed_px_per_s / (static_cast<float>(hurt_config.brake_time_ms) / 1000.0F) ? cfg_.hurt.initial_speed_px_per_s / (static_cast<float>(cfg_.hurt.brake_time_ms) / 1000.0F)
: 0.0F; : 0.0F;
const float STEP = DECEL * delta_time; const float STEP = DECEL * delta_time;
auto decay = [STEP](float& v) { auto decay = [STEP](float& v) {
@@ -688,7 +714,7 @@ void Player::tick(float delta_time) {
// doble per excés que perdre l'atac per un toc accidental al salt). // doble per excés que perdre l'atac per un toc accidental al salt).
// En entrar a ATTACKING, descartem el flanc del salt per no que // En entrar a ATTACKING, descartem el flanc del salt per no que
// queda penjat per a quan es desbloque. // queda penjat per a quan es desbloque.
if (consumeAttackEdge() && whip_config.swing_ms > 0) { if (consumeAttackEdge() && cfg_.whip.swing_ms > 0) {
enterAttacking(currentFacingCardinal()); enterAttacking(currentFacingCardinal());
(void)consumeJumpEdge(); (void)consumeJumpEdge();
return; return;
@@ -701,7 +727,7 @@ void Player::tick(float delta_time) {
// ho gestionem deixant que el resolver vinga, i si en el moment del // ho gestionem deixant que el resolver vinga, i si en el moment del
// despegue (consumeJumpEdge → startJump) ja estem AIRBORNE, l'altre // despegue (consumeJumpEdge → startJump) ja estem AIRBORNE, l'altre
// braç del switch no canvia res. // braç del switch no canvia res.
if (consumeJumpEdge() && jump_config.impulse_px_per_s > 0.0F) { if (consumeJumpEdge() && cfg_.jump.impulse_px_per_s > 0.0F) {
startJump(); startJump();
} }
} }
@@ -777,7 +803,7 @@ auto Player::currentFacingCardinal() const -> Cardinal {
void Player::enterAttacking(Cardinal facing) { void Player::enterAttacking(Cardinal facing) {
state_ = State::ATTACKING; state_ = State::ATTACKING;
attacking_remaining_ms_ = static_cast<float>(whip_config.swing_ms + whip_config.cooldown_ms); attacking_remaining_ms_ = static_cast<float>(cfg_.whip.swing_ms + cfg_.whip.cooldown_ms);
// Garantim que facing/flip i cardinal queden coherents amb la // Garantim que facing/flip i cardinal queden coherents amb la
// direcció triada (per si l'atac s'ha disparat amb facing residual, // direcció triada (per si l'atac s'ha disparat amb facing residual,
@@ -789,8 +815,8 @@ void Player::enterAttacking(Cardinal facing) {
// So del swing — disparat ací (no des del WhipAttack) perquè és el // So del swing — disparat ací (no des del WhipAttack) perquè és el
// jugador qui executa l'acció; si demà el spawn falla per qualsevol // jugador qui executa l'acció; si demà el spawn falla per qualsevol
// motiu, el so del gest segueix sonant. // motiu, el so del gest segueix sonant.
if (!whip_config.sound_key.empty() && Audio::get() != nullptr) { if (!cfg_.whip.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(whip_config.sound_key, Audio::Group::GAME); Audio::get()->playSound(cfg_.whip.sound_key, Audio::Group::GAME);
} }
// Mapa de Cardinal → int al payload (vegeu `WhipSpawnPayload`). // Mapa de Cardinal → int al payload (vegeu `WhipSpawnPayload`).
@@ -816,7 +842,7 @@ void Player::enterAttacking(Cardinal facing) {
// en deriva tant la draw_rect (sprite del látigo, més gran) com la // en deriva tant la draw_rect (sprite del látigo, més gran) com la
// damage_rect (al nivell dels peus, retallada per bloquejadors). // damage_rect (al nivell dels peus, retallada per bloquejadors).
if (bus_ != nullptr) { if (bus_ != nullptr) {
const float RANGE_PX = whip_config.range_tiles * static_cast<float>(Map::tile_size); const float RANGE_PX = cfg_.whip.range_tiles * static_cast<float>(Map::tile_size);
bus_->publish("whip_spawn", WhipSpawnPayload{ bus_->publish("whip_spawn", WhipSpawnPayload{
.owner = this, .owner = this,
.direction = dir_int, .direction = dir_int,
@@ -850,16 +876,16 @@ void Player::startJump() {
state_ = State::AIRBORNE; state_ = State::AIRBORNE;
jump_z_ = 0.0F; jump_z_ = 0.0F;
jump_vz_ = jump_config.impulse_px_per_s; jump_vz_ = cfg_.jump.impulse_px_per_s;
if (!jump_config.sound_key.empty() && Audio::get() != nullptr) { if (!cfg_.jump.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(jump_config.sound_key, Audio::Group::GAME); Audio::get()->playSound(cfg_.jump.sound_key, Audio::Group::GAME);
} }
// Avisem al bus perquè el GameScene puga spawnejar el polvet de // Avisem al bus perquè el GameScene puga spawnejar el polvet de
// despegue al peu del jugador. // despegue al peu del jugador.
if (bus_ != nullptr) { if (bus_ != nullptr) {
bus_->publish(Event{ bus_->publish(Event{
.name = "player_jumped", .name = "player_jumped",
.payload = WorldPos{.x = getPosX() + (static_cast<float>(sprite_w) * 0.5F), .y = getGroundFeetY()}, .payload = WorldPos{.x = getPosX() + (static_cast<float>(cfg_.sprite_w) * 0.5F), .y = getGroundFeetY()},
.player_id = id_, .player_id = id_,
}); });
} }
@@ -903,7 +929,7 @@ auto Player::chooseHurtDirection(Cardinal preferred) const -> Cardinal {
// abans d'aquesta distància, l'usem; d'altra manera, busquem la // abans d'aquesta distància, l'usem; d'altra manera, busquem la
// millor. // millor.
const auto REQUIRED = static_cast<int>( const auto REQUIRED = static_cast<int>(
0.5F * hurt_config.initial_speed_px_per_s * (static_cast<float>(hurt_config.brake_time_ms) / 1000.0F)); 0.5F * cfg_.hurt.initial_speed_px_per_s * (static_cast<float>(cfg_.hurt.brake_time_ms) / 1000.0F));
if (preferred != Cardinal::NONE) { if (preferred != Cardinal::NONE) {
const auto [DX, DY] = deltasFor(preferred); const auto [DX, DY] = deltasFor(preferred);
@@ -932,14 +958,14 @@ void Player::enterHurt(Cardinal preferred) {
// Si la mecànica no està configurada (speed = 0), no fem res — el // Si la mecànica no està configurada (speed = 0), no fem res — el
// comportament queda equivalent al d'abans, sense empenta. // comportament queda equivalent al d'abans, sense empenta.
if (hurt_config.initial_speed_px_per_s <= 0.0F || hurt_config.brake_time_ms <= 0) { return; } if (cfg_.hurt.initial_speed_px_per_s <= 0.0F || cfg_.hurt.brake_time_ms <= 0) { return; }
const Cardinal CHOSEN = chooseHurtDirection(preferred); const Cardinal CHOSEN = chooseHurtDirection(preferred);
// Activem l'estat i fixem la velocitat segons la direcció triada. // Activem l'estat i fixem la velocitat segons la direcció triada.
state_ = State::HURT; state_ = State::HURT;
if (!hurt_config.sound_key.empty() && Audio::get() != nullptr) { if (!cfg_.hurt.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(hurt_config.sound_key, Audio::Group::GAME); Audio::get()->playSound(cfg_.hurt.sound_key, Audio::Group::GAME);
} }
applyCardinal(CHOSEN); applyCardinal(CHOSEN);
last_input_cardinal_ = CHOSEN; last_input_cardinal_ = CHOSEN;
@@ -947,7 +973,7 @@ void Player::enterHurt(Cardinal preferred) {
pending_cardinal_ = Cardinal::NONE; pending_cardinal_ = Cardinal::NONE;
intermediate_cardinal_ = Cardinal::NONE; intermediate_cardinal_ = Cardinal::NONE;
being_pushed_ = false; being_pushed_ = false;
const float V = hurt_config.initial_speed_px_per_s; const float V = cfg_.hurt.initial_speed_px_per_s;
const auto [DX, DY] = deltasFor(CHOSEN); const auto [DX, DY] = deltasFor(CHOSEN);
intended_vx_ = DX * V; intended_vx_ = DX * V;
intended_vy_ = DY * V; intended_vy_ = DY * V;
@@ -958,7 +984,7 @@ void Player::enterHurt(Cardinal preferred) {
auto Player::integrateJump(float delta_time) -> bool { auto Player::integrateJump(float delta_time) -> bool {
if (state_ != State::AIRBORNE) { return false; } if (state_ != State::AIRBORNE) { return false; }
jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time; jump_vz_ -= cfg_.jump.gravity_px_per_s2 * delta_time;
jump_z_ += jump_vz_ * delta_time; jump_z_ += jump_vz_ * delta_time;
// Caiguda per agujero: el sòl ja no és present sota el jugador, així // Caiguda per agujero: el sòl ja no és present sota el jugador, així
// que NO clampem `jump_z_` a 0. Deixem baixar a negatius (visualment // que NO clampem `jump_z_` a 0. Deixem baixar a negatius (visualment
@@ -997,22 +1023,22 @@ auto Player::integrateJump(float delta_time) -> bool {
jump_z_ = 0.0F; jump_z_ = 0.0F;
jump_vz_ = 0.0F; jump_vz_ = 0.0F;
state_ = State::LANDING_STUN; state_ = State::LANDING_STUN;
landing_stun_remaining_ms_ = static_cast<float>(jump_config.landing_stun_ms); landing_stun_remaining_ms_ = static_cast<float>(cfg_.jump.landing_stun_ms);
if (!jump_config.land_sound_key.empty() && Audio::get() != nullptr) { if (!cfg_.jump.land_sound_key.empty() && Audio::get() != nullptr) {
// Consumeix el preset armat per GameScene al spawn aeri // Consumeix el preset armat per GameScene al spawn aeri
// d'INTRO (single-shot): el primer LAND el sona reverberat, // d'INTRO (single-shot): el primer LAND el sona reverberat,
// els salts següents cauen al camí sec. // els salts següents cauen al camí sec.
if (next_land_reverb_preset_.empty()) { if (next_land_reverb_preset_.empty()) {
Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME); Audio::get()->playSound(cfg_.jump.land_sound_key, Audio::Group::GAME);
} else { } else {
Audio::get()->playSoundWithReverb(jump_config.land_sound_key, next_land_reverb_preset_, Audio::Group::GAME); Audio::get()->playSoundWithReverb(cfg_.jump.land_sound_key, next_land_reverb_preset_, Audio::Group::GAME);
next_land_reverb_preset_.clear(); next_land_reverb_preset_.clear();
} }
} }
if (bus_ != nullptr) { if (bus_ != nullptr) {
bus_->publish(Event{ bus_->publish(Event{
.name = "player_landed", .name = "player_landed",
.payload = WorldPos{.x = getPosX() + (static_cast<float>(sprite_w) * 0.5F), .y = getGroundFeetY()}, .payload = WorldPos{.x = getPosX() + (static_cast<float>(cfg_.sprite_w) * 0.5F), .y = getGroundFeetY()},
.player_id = id_, .player_id = id_,
}); });
} }
@@ -1027,6 +1053,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; }
@@ -1235,8 +1267,8 @@ void Player::enterDying(DeathCause cause) {
// So del cop letal: reaprofitem `hurt.wav` (ja precarregat per // So del cop letal: reaprofitem `hurt.wav` (ja precarregat per
// `readHurtBlock`). El bloc `death:` no té camp de so propi — // `readHurtBlock`). El bloc `death:` no té camp de so propi —
// semànticament l'impacte mortal és la versió forta de `hurt`. // semànticament l'impacte mortal és la versió forta de `hurt`.
if (!hurt_config.sound_key.empty() && Audio::get() != nullptr) { if (!cfg_.hurt.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(hurt_config.sound_key, Audio::Group::GAME); Audio::get()->playSound(cfg_.hurt.sound_key, Audio::Group::GAME);
} }
} }
@@ -1249,7 +1281,7 @@ void Player::applyDyingMovement(float delta_time) {
animate(delta_time); animate(delta_time);
if (!death_animation_complete_ && animationIsCompleted()) { if (!death_animation_complete_ && animationIsCompleted()) {
death_animation_complete_ = true; death_animation_complete_ = true;
respawn_remaining_ms_ = static_cast<float>(death_config.respawn_delay_ms); respawn_remaining_ms_ = static_cast<float>(cfg_.death.respawn_delay_ms);
} }
if (death_animation_complete_) { if (death_animation_complete_) {
respawn_remaining_ms_ -= delta_time * 1000.0F; respawn_remaining_ms_ -= delta_time * 1000.0F;
@@ -1259,11 +1291,67 @@ void Player::applyDyingMovement(float delta_time) {
} }
} }
void Player::resetForJoin() {
lives_remaining_ = cfg_.death.initial_lives;
score_ = 0;
treasures_ = 0;
// Publica l'estat inicial perquè el HUD reompla el bloc en el moment
// de fer join (no només quan el comptador canvia després). Sense açò
// el HUD es quedaria amb la caché prèvia fins al primer event.
publishStatsChanged();
}
void Player::publishStatsChanged() {
if (bus_ == nullptr) { return; }
bus_->publish(Event{
.name = "player_stats_changed",
.payload = PlayerStatsPayload{
.lives = lives_remaining_,
.score = score_,
.treasures = treasures_,
},
.player_id = id_,
});
}
void Player::addScore(int delta) {
if (delta == 0) { return; }
score_ += delta;
publishStatsChanged();
}
void Player::addTreasures(int delta) {
if (delta == 0) { return; }
treasures_ += delta;
publishStatsChanged();
}
void Player::respawnFromDying() { void Player::respawnFromDying() {
// 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;
// Notifica al HUD del nou comptador de vides abans de qualsevol
// transició d'estat. Aquesta crida tanca el bucle de qualsevol via
// de mort (mòmia, traps, rolling rock, FORAT…): totes acaben aquí, i
// ningú més ha de recordar publicar res. Era exactament el punt que
// s'oblidava al camí del forat.
publishStatsChanged();
if (lives_remaining_ <= 0) {
disable();
if (bus_ != nullptr) {
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).
@@ -1373,12 +1461,12 @@ void Player::render() {
// swap independentment. // swap independentment.
const bool BLINK_ON = const bool BLINK_ON =
immunity_remaining_ms_ > 0.0F && immunity_remaining_ms_ > 0.0F &&
immunity_config.blink_period_ms > 0 && cfg_.immunity.blink_period_ms > 0 &&
!immunity_config.palette_key.empty() && !cfg_.immunity.palette_key.empty() &&
surface_ != nullptr && surface_ != nullptr &&
(static_cast<int>(immunity_remaining_ms_) / immunity_config.blink_period_ms) % 2 == 0; (static_cast<int>(immunity_remaining_ms_) / cfg_.immunity.blink_period_ms) % 2 == 0;
if (BLINK_ON) { if (BLINK_ON) {
surface_->setPalette(Resource::Cache::get().getPalette(immunity_config.palette_key)); surface_->setPalette(Resource::Cache::get().getPalette(cfg_.immunity.palette_key));
} }
if (jump_z_ == 0.0F) { if (jump_z_ == 0.0F) {
@@ -1395,6 +1483,17 @@ void Player::render() {
if (CLIP_HOLE) { Rendering::popClip(); } if (CLIP_HOLE) { Rendering::popClip(); }
} }
auto Player::getLightCenter() const -> SDL_FPoint {
// Sprite top-left = (pos_.x, pos_.y). El render desplaça verticalment
// `jump_z_` píxels durant el salt; aplicar el mateix offset aquí fa
// que la llum "salti" amb el jugador en lloc de quedar-se al sòl.
// Sumem també l'offset estàtic configurat al YAML del personatge.
return {
.x = getPosX() + (static_cast<float>(cfg_.sprite_w) * 0.5F) + static_cast<float>(cfg_.light_offset_x),
.y = getPosY() - jump_z_ + (static_cast<float>(cfg_.sprite_h) * 0.5F) + static_cast<float>(cfg_.light_offset_y),
};
}
void Player::renderShadow() const { void Player::renderShadow() const {
// Sense salt actiu, no hi ha sombra: això simplifica els callsites, // Sense salt actiu, no hi ha sombra: això simplifica els callsites,
// que poden cridar `renderShadow` per a tots els jugadors sense // que poden cridar `renderShadow` per a tots els jugadors sense
@@ -1405,36 +1504,17 @@ void Player::renderShadow() const {
// que `enterDying` netegi el flag. // que `enterDying` netegi el flag.
if (falling_through_hole_) { return; } if (falling_through_hole_) { return; }
// Sombra centrada al sprite, recolzada a la línia dels peus reals // `height_factor`: 0 al terra → fotograma més gran, 1 al pic → més
// (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura // petit. Si el `cfg_.jump` té un divisor zero (cas degenerat) caem
// del salt — al pic queda més estreta, donant la sensació de // a 0 per no propagar nan al renderer.
// "elevació" sense necessitat de coordenades 3D reals. Si el const auto PEAK = (cfg_.jump.gravity_px_per_s2 > 0.0F)
// jump_config té un divisor zero (cas degenerat), només dibuixem ? (cfg_.jump.impulse_px_per_s * cfg_.jump.impulse_px_per_s) / (2.0F * cfg_.jump.gravity_px_per_s2)
// la sombra a mida plena per evitar nan.
const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F)
? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2)
: 0.0F; : 0.0F;
const float NORM_Z = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F; const float HEIGHT_FACTOR = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F;
constexpr float MAX_SHRINK = 0.4F; // Fins a un 40% més estreta al pic.
const float SHRINK = 1.0F - (NORM_Z * MAX_SHRINK);
constexpr int SHADOW_H = 3; const auto CENTER_X = static_cast<int>(getPosX() + (static_cast<float>(cfg_.sprite_w) * 0.5F));
const auto SHADOW_W = static_cast<int>(static_cast<float>(base_size) * SHRINK); const auto FEET_Y = static_cast<int>(getGroundFeetY());
const float CX = getPosX() + (static_cast<float>(sprite_w) * 0.5F); Effects::Shadow::render(CENTER_X, FEET_Y, HEIGHT_FACTOR);
const float GROUND_Y = getGroundFeetY();
const auto SX = static_cast<int>(CX - (static_cast<float>(SHADOW_W) * 0.5F));
// Pugem la sombra `shadow_y_offset` px per compensar la franja
// transparent que el sprite del jugador té sota els peus reals
// (perquè la silueta camine pel mig del passadís en falsa
// perspectiva). Sense aquest ajust, la sombra apareix per sota
// del passadís.
const auto SY = static_cast<int>(GROUND_Y) - SHADOW_H - jump_config.shadow_y_offset;
// ARGB negre amb alpha ~40%. `blendRect` mescla sobre el canvas; així
// la sombra fosqueja el sòl (pintat o no) sense tapar-lo del tot.
constexpr std::uint32_t SHADOW_ARGB = 0x00000000U;
constexpr std::uint8_t SHADOW_ALPHA = 100;
Screen::get().blendRect(SX, SY, SHADOW_W, SHADOW_H, SHADOW_ARGB, SHADOW_ALPHA);
} }
// Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el // Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el
@@ -1482,7 +1562,7 @@ auto Player::statePrefix(State state, bool moving, bool in_attack_swing) -> cons
} }
auto Player::animationName(bool moving) const -> std::string { auto Player::animationName(bool moving) const -> std::string {
const bool IN_ATTACK_SWING = (attacking_remaining_ms_ > static_cast<float>(whip_config.cooldown_ms)); const bool IN_ATTACK_SWING = (attacking_remaining_ms_ > static_cast<float>(cfg_.whip.cooldown_ms));
return std::string(statePrefix(state_, moving, IN_ATTACK_SWING)) + facingSuffix(facing_); return std::string(statePrefix(state_, moving, IN_ATTACK_SWING)) + facingSuffix(facing_);
} }
@@ -1503,12 +1583,12 @@ auto Player::getCurrentTile() const -> std::pair<int, int> {
void Player::clampToPlayfield() { void Player::clampToPlayfield() {
// Límits de la zona jugable: la caixa de col·lisió no pot eixir del // Límits de la zona jugable: la caixa de col·lisió no pot eixir del
// playfield (muros). El cap del sprite sí pot sobresortir per damunt. // playfield (muros). El cap del sprite sí pot sobresortir per damunt.
const auto MIN_X = static_cast<float>(map_.playfield_x - collision_x_offset); const auto MIN_X = static_cast<float>(map_.playfield_x - cfg_.collision_x_offset);
const auto MAX_X = static_cast<float>( const auto MAX_X = static_cast<float>(
map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size); map_.playfield_x + map_.playfield_w - cfg_.collision_x_offset - cfg_.collision_size);
auto min_y = static_cast<float>(map_.playfield_y - collision_y_offset); auto min_y = static_cast<float>(map_.playfield_y - cfg_.collision_y_offset);
const auto MAX_Y = static_cast<float>( const auto MAX_Y = static_cast<float>(
map_.playfield_y + map_.playfield_h - collision_y_offset - collision_size); map_.playfield_y + map_.playfield_h - cfg_.collision_y_offset - cfg_.collision_size);
// Passadís de sortida: si la base box queda enterament dins del rang // Passadís de sortida: si la base box queda enterament dins del rang
// X del passadís de la porta, deixem que pugi al strip del mur trasser // X del passadís de la porta, deixem que pugi al strip del mur trasser
@@ -1516,12 +1596,12 @@ void Player::clampToPlayfield() {
// playfield. La regla "enterament dins" exigeix alinear-se al passadís // playfield. La regla "enterament dins" exigeix alinear-se al passadís
// abans de pujar — la base box (12 px) hi cap amb 4 px de marge. // abans de pujar — la base box (12 px) hi cap amb 4 px de marge.
if (map_.door_corridor_w > 0) { if (map_.door_corridor_w > 0) {
const float BASE_X = getPosX() + static_cast<float>(collision_x_offset); const float BASE_X = getPosX() + static_cast<float>(cfg_.collision_x_offset);
const float BASE_RIGHT = BASE_X + static_cast<float>(collision_size); const float BASE_RIGHT = BASE_X + static_cast<float>(cfg_.collision_size);
const auto CORRIDOR_LEFT = static_cast<float>(map_.door_corridor_x); const auto CORRIDOR_LEFT = static_cast<float>(map_.door_corridor_x);
const auto CORRIDOR_RIGHT = static_cast<float>(map_.door_corridor_x + map_.door_corridor_w); const auto CORRIDOR_RIGHT = static_cast<float>(map_.door_corridor_x + map_.door_corridor_w);
if (BASE_X >= CORRIDOR_LEFT && BASE_RIGHT <= CORRIDOR_RIGHT) { if (BASE_X >= CORRIDOR_LEFT && BASE_RIGHT <= CORRIDOR_RIGHT) {
min_y = static_cast<float>(map_.door_corridor_y - collision_y_offset); min_y = static_cast<float>(map_.door_corridor_y - cfg_.collision_y_offset);
} }
} }
@@ -1559,11 +1639,14 @@ 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
setImmunity(immunity_config.duration_ms); // 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(cfg_.immunity.duration_ms);
} }
void Player::disable() { void Player::disable() {
@@ -1594,14 +1677,14 @@ void Player::enterHoleFall(float center_x, float center_y, float floor_y) {
// cas i obligaria a hacks tipus "forçar GROUNDED abans de cridar". // cas i obligaria a hacks tipus "forçar GROUNDED abans de cridar".
// Snap del centre de la hit-box a la coordenada central del forat. // Snap del centre de la hit-box a la coordenada central del forat.
// Resolem `pos = center - (hit_offset + hit_size/2)` per a cada eix: // Resolem `pos = center - (hit_offset + cfg_.hit_size/2)` per a cada eix:
// així el centre exacte de la caja de contacte queda alineat amb // així el centre exacte de la caja de contacte queda alineat amb
// (center_x, center_y), independentment de la geometria interna del // (center_x, center_y), independentment de la geometria interna del
// sprite. `setPosX/Y` és l'API canònica per teleportar el jugador // sprite. `setPosX/Y` és l'API canònica per teleportar el jugador
// (la usen `respawn` i `spawnFromAir`). // (la usen `respawn` i `spawnFromAir`).
const float HIT_HALF = static_cast<float>(hit_size) * 0.5F; const float HIT_HALF = static_cast<float>(cfg_.hit_size) * 0.5F;
setPosX(center_x - static_cast<float>(hit_x_offset) - HIT_HALF); setPosX(center_x - static_cast<float>(cfg_.hit_x_offset) - HIT_HALF);
setPosY(center_y - static_cast<float>(hit_y_offset) - HIT_HALF); setPosY(center_y - static_cast<float>(cfg_.hit_y_offset) - HIT_HALF);
// Frenem qualsevol inèrcia prèvia: la caiguda ha de ser estàtica al // Frenem qualsevol inèrcia prèvia: la caiguda ha de ser estàtica al
// centre del forat. // centre del forat.
@@ -1647,7 +1730,7 @@ void Player::spawnFromAir(float x, float y) {
// visualment es pinta a `pos_.y - jump_z_`). Afegim un xicotet marge // visualment es pinta a `pos_.y - jump_z_`). Afegim un xicotet marge
// perquè el primer frame ja siga clarament off-screen, no just al ras. // perquè el primer frame ja siga clarament off-screen, no just al ras.
constexpr float OFFSCREEN_MARGIN_PX = 8.0F; constexpr float OFFSCREEN_MARGIN_PX = 8.0F;
const float SPAWN_HEIGHT = y + static_cast<float>(sprite_h) + OFFSCREEN_MARGIN_PX; const float SPAWN_HEIGHT = y + static_cast<float>(cfg_.sprite_h) + OFFSCREEN_MARGIN_PX;
state_ = State::AIRBORNE; state_ = State::AIRBORNE;
jump_z_ = SPAWN_HEIGHT; jump_z_ = SPAWN_HEIGHT;
@@ -1667,31 +1750,31 @@ void Player::spawnFromAir(float x, float y) {
auto Player::getBaseBox() const -> SDL_FRect { auto Player::getBaseBox() const -> SDL_FRect {
return { return {
.x = getPosX() + static_cast<float>(collision_x_offset), .x = getPosX() + static_cast<float>(cfg_.collision_x_offset),
.y = getPosY() + static_cast<float>(collision_y_offset), .y = getPosY() + static_cast<float>(cfg_.collision_y_offset),
.w = static_cast<float>(collision_size), .w = static_cast<float>(cfg_.collision_size),
.h = static_cast<float>(collision_size), .h = static_cast<float>(cfg_.collision_size),
}; };
} }
auto Player::getHitBox() const -> SDL_FRect { auto Player::getHitBox() const -> SDL_FRect {
return { return {
.x = getPosX() + static_cast<float>(hit_x_offset), .x = getPosX() + static_cast<float>(cfg_.hit_x_offset),
.y = getPosY() + static_cast<float>(hit_y_offset), .y = getPosY() + static_cast<float>(cfg_.hit_y_offset),
.w = static_cast<float>(hit_size), .w = static_cast<float>(cfg_.hit_size),
.h = static_cast<float>(hit_size), .h = static_cast<float>(cfg_.hit_size),
}; };
} }
auto Player::hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect { auto Player::hitBoxFromBase(const SDL_FRect& base) const -> SDL_FRect {
// Desplaçament intern respecte al cantó de la caixa base: mateixos // Desplaçament intern respecte al cantó de la caixa base: mateixos
// offsets que `getHitBox()` però partint d'una caixa arbitrària. // offsets que `getHitBox()` però partint d'una caixa arbitrària.
const int INSET = (collision_size - hit_size) / 2; const int INSET = (cfg_.collision_size - cfg_.hit_size) / 2;
return { return {
.x = base.x + static_cast<float>(INSET), .x = base.x + static_cast<float>(INSET),
.y = base.y + static_cast<float>(INSET), .y = base.y + static_cast<float>(INSET),
.w = static_cast<float>(hit_size), .w = static_cast<float>(cfg_.hit_size),
.h = static_cast<float>(hit_size), .h = static_cast<float>(cfg_.hit_size),
}; };
} }
@@ -1700,7 +1783,7 @@ void Player::snapTowardsLane(
float target_lane, float target_lane,
float (Player::*get_pos)() const, float (Player::*get_pos)() const,
void (Player::*set_pos)(float)) { void (Player::*set_pos)(float)) {
const auto THRESHOLD = static_cast<float>(collision_size) / 2.0F; const auto THRESHOLD = static_cast<float>(cfg_.collision_size) / 2.0F;
const float DELTA = target_lane - (this->*get_pos)(); const float DELTA = target_lane - (this->*get_pos)();
if (std::abs(DELTA) <= 0.0F || std::abs(DELTA) > THRESHOLD) { return; } if (std::abs(DELTA) <= 0.0F || std::abs(DELTA) > THRESHOLD) { return; }
@@ -1737,6 +1820,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`.
@@ -1796,10 +1883,10 @@ auto Player::wouldHitOuterWall(float dx, float dy) const -> bool {
// corridor — encara no ho està, perquè el snap és justament qui // corridor — encara no ho està, perquè el snap és justament qui
// ha d'aliniar-la. En lloc d'això comprovem si la base box està // ha d'aliniar-la. En lloc d'això comprovem si la base box està
// prou prop del centre del corridor com per a que el snap // prou prop del centre del corridor com per a que el snap
// (mateix THRESHOLD = collision_size/2) puga completar-lo. // (mateix THRESHOLD = cfg_.collision_size/2) puga completar-lo.
if (Map::door_open && dy < 0.0F && map_.door_corridor_w > 0) { if (Map::door_open && dy < 0.0F && map_.door_corridor_w > 0) {
const float CORRIDOR_TARGET = static_cast<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - collision_size) / 2.0F); const float CORRIDOR_TARGET = static_cast<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - cfg_.collision_size) / 2.0F);
const auto THRESHOLD = static_cast<float>(collision_size) / 2.0F; const auto THRESHOLD = static_cast<float>(cfg_.collision_size) / 2.0F;
if (std::abs(CORRIDOR_TARGET - probe.x) <= THRESHOLD) { return false; } if (std::abs(CORRIDOR_TARGET - probe.x) <= THRESHOLD) { return false; }
} }
return true; return true;
@@ -1813,7 +1900,7 @@ void Player::snapToHorizontalLane(float delta_time) {
return; return;
} }
const auto BASE_Y = static_cast<float>(map_.playfield_y - collision_y_offset); const auto BASE_Y = static_cast<float>(map_.playfield_y - cfg_.collision_y_offset);
const auto PITCH_Y = static_cast<float>(map_.pitch_y); const auto PITCH_Y = static_cast<float>(map_.pitch_y);
const int IDX = std::clamp( const int IDX = std::clamp(
static_cast<int>(std::round((getPosY() - BASE_Y) / PITCH_Y)), static_cast<int>(std::round((getPosY() - BASE_Y) / PITCH_Y)),
@@ -1833,7 +1920,7 @@ void Player::snapToVerticalLane(float delta_time) {
} }
// Candidat 1: lane regular del laberint. // Candidat 1: lane regular del laberint.
const auto BASE_X = static_cast<float>(map_.playfield_x - collision_x_offset); const auto BASE_X = static_cast<float>(map_.playfield_x - cfg_.collision_x_offset);
const auto PITCH_X = static_cast<float>(map_.pitch_x); const auto PITCH_X = static_cast<float>(map_.pitch_x);
const int IDX = std::clamp( const int IDX = std::clamp(
static_cast<int>(std::round((getPosX() - BASE_X) / PITCH_X)), static_cast<int>(std::round((getPosX() - BASE_X) / PITCH_X)),
@@ -1846,8 +1933,8 @@ void Player::snapToVerticalLane(float delta_time) {
// forat per on no pot passar. // forat per on no pot passar.
float best_lane = LANE_REG; float best_lane = LANE_REG;
if (Map::door_open && map_.door_corridor_w > 0) { if (Map::door_open && map_.door_corridor_w > 0) {
const float CORRIDOR_CENTER = static_cast<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - collision_size) / 2.0F); const float CORRIDOR_CENTER = static_cast<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - cfg_.collision_size) / 2.0F);
const float LANE_DOOR = CORRIDOR_CENTER - static_cast<float>(collision_x_offset); const float LANE_DOOR = CORRIDOR_CENTER - static_cast<float>(cfg_.collision_x_offset);
const float DELTA_REG = LANE_REG - getPosX(); const float DELTA_REG = LANE_REG - getPosX();
const float DELTA_DOOR = LANE_DOOR - getPosX(); const float DELTA_DOOR = LANE_DOOR - getPosX();
if (std::abs(DELTA_DOOR) < std::abs(DELTA_REG)) { best_lane = LANE_DOOR; } if (std::abs(DELTA_DOOR) < std::abs(DELTA_REG)) { best_lane = LANE_DOOR; }
+246 -185
View File
@@ -7,6 +7,7 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
#include "core/rendering/lighting.hpp"
#include "core/rendering/sprite/animated_sprite.hpp" #include "core/rendering/sprite/animated_sprite.hpp"
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
@@ -54,6 +55,15 @@ class Player : public AnimatedSprite {
HOLE, // caiguda per agujero — animació `death_hole` HOLE, // caiguda per agujero — animació `death_hole`
}; };
// Direcció cardinal completa. Pública perquè el `WhipAttack` la passa a
// `enterHurt` quan colpeja un altre jugador (friendly fire). Declarada
// aquí (a sobre de `Config` perquè `TurnConfig` l'utilitza).
enum class Cardinal : std::uint8_t { NONE,
NORTH,
SOUTH,
EAST,
WEST };
enum class State : std::uint8_t { enum class State : std::uint8_t {
GROUNDED, GROUNDED,
AIRBORNE, AIRBORNE,
@@ -91,43 +101,178 @@ class Player : public AnimatedSprite {
DYING, DYING,
}; };
// Geometria carregada des del YAML del player (p.ex. `data/entities/player/sam.yaml`). // Configuració per-instància del Player. Conté tot el que abans vivia
// `sprite_*` són // com a `inline static` o `static *_config`: així cada jugador (P1=sam,
// la mida del frame (sprite 12×24 amb perspectiva falsa: visualment, els // P2=morcus) pot tenir paràmetres diferents llegits del seu YAML.
// 12 píxels superiors són el cap i els 12 inferiors la base). // GameScene la carrega amb `Config::loadFromYaml` abans d'instanciar
// La caixa de col·lisió és un quadrat `collision_size` concèntric amb el // cada Player i la passa al ctor (mou-la). Per refrescar-la entre
// quadrat inferior del sprite (`base_size`). Els offsets els calculem // sales s'utilitza `Player::setConfig`.
// al load a partir d'aquests tres valors; el mapa de col·lisions i el //
// de dibuix són independents (el sprite pot sobresortir sense afectar). // Els sub-structs (`InertiaConfig`, `TurnConfig`, ...) viuen dins de
inline static int sprite_w = 0; // `Config` per agrupar-los visualment amb la resta del config — no hi
inline static int sprite_h = 0; // ha cap raó tècnica per haver-los a fora.
inline static int base_size = 0; struct Config {
inline static int collision_size = 0; // ---- Geometria del sprite i col·lisió --------------------------
inline static int collision_padding = 0; // `sprite_*` són la mida del frame (sprite 12×24 amb perspectiva
inline static int collision_x_offset = 0; // falsa). La caixa de col·lisió és un quadrat `collision_size`
inline static int collision_y_offset = 0; // concèntric amb el quadrat inferior del sprite (`base_size`).
// Els offsets es calculen al load.
int sprite_w{0};
int sprite_h{0};
int base_size{0};
int collision_size{0};
int collision_padding{0};
int collision_x_offset{0};
int collision_y_offset{0};
// Caixa de "contacte" més petita que la caixa de col·lisió: s'usa per // Caixa de contacte actor-actor (més petita que la col·lisió).
// a les interaccions actor-actor (player↔player, player↔momia/trampa), int hit_size{0};
// on volem que el contacte sigui precís i no dispari a "distància" int hit_x_offset{0};
// de la vora del sprite. La caixa de col·lisió gran continua usant-se int hit_y_offset{0};
// per xocar amb murs i sarcòfags (per mantenir el carril del
// passadís). Sense `hit_size` al YAML, s'iguala a `collision_size` i
// el comportament és idèntic a l'anterior.
inline static int hit_size = 0;
inline static int hit_x_offset = 0;
inline static int hit_y_offset = 0;
// Alçada del cos del jugador en píxels (camp `collision.height` del // Alçada del cos en píxels (filtre de col·lisió per altura).
// YAML, mateixa convenció que entitats com mummy o rolling_rock). int body_height{0};
// L'usen els filtres de col·lisió per altura: si un altre actor ens
// creua mentre `jump_z_` és superior a aquest valor, no col·lisionem.
inline static int body_height = 0;
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor. // ---- Emissió de llum -----------------------------------------
static void loadFrom(const std::string& yaml_path); // Llegit del bloc opcional `light:` del YAML.
// `radius` → 0 = no emet.
// `flicker` → activa modulació tipus foc.
// `color` → tint en mode additive (ignorat en subtractive).
// `offset` → desplaçament del centre respecte el sprite (Y avall).
int light_radius{0};
bool light_flicker{false};
std::uint8_t light_color_r{255};
std::uint8_t light_color_g{255};
std::uint8_t light_color_b{255};
int light_offset_x{0};
int light_offset_y{0};
// Paràmetres del flicker resolts (global + override del YAML +
// override de sala). Només té efecte si `light_flicker` és true.
Lighting::FlickerConfig flicker_params{};
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); // ---- Sub-structs de gameplay ---------------------------------
// Inèrcia: amb `brake_time_ms == 0` el comportament és instantani.
struct InertiaConfig {
int brake_time_ms{0};
int threshold_ms{0};
};
// Gir de 180°: amb `duration_ms == 0` els canvis de direcció són
// instantanis. `Cardinal` ha d'estar visible — declarada a sobre.
struct TurnConfig {
int duration_ms{0};
Cardinal w_to_e{Cardinal::SOUTH};
Cardinal e_to_w{Cardinal::NORTH};
Cardinal n_to_s{Cardinal::EAST};
Cardinal s_to_n{Cardinal::WEST};
};
// Salt: amb `impulse_px_per_s == 0` el botó no té efecte.
struct JumpConfig {
float impulse_px_per_s{0.0F};
float gravity_px_per_s2{0.0F};
int landing_stun_ms{0};
std::string sound_key;
std::string land_sound_key;
};
// HURT (estat post-impacte). Amb `initial_speed_px_per_s == 0`
// entrada/sortida instantànies (sense moviment).
struct HurtConfig {
float initial_speed_px_per_s{0.0F};
int brake_time_ms{0};
std::string sound_key;
};
// Látigo: hitbox `range_tiles`, swing `swing_ms`, cooldown
// `cooldown_ms`. Per ara `damage` només es llegeix.
struct WhipConfig {
float range_tiles{0.0F};
int swing_ms{0};
int cooldown_ms{0};
int damage{0};
std::string sound_key;
};
// Mort/respawn: `respawn_delay_ms` entre l'animació i la
// reaparició; `initial_lives` és el comptador d'intents.
struct DeathConfig {
int respawn_delay_ms{0};
int initial_lives{3};
};
// Immunitat post-spawn. `duration_ms == 0` desactiva la mecànica.
// `palette_key` (basename del .pal) és la paleta plana del blink.
struct ImmunityConfig {
float duration_ms{0.0F};
int blink_period_ms{0};
std::string palette_key;
};
InertiaConfig inertia{};
TurnConfig turn{};
JumpConfig jump{};
HurtConfig hurt{};
WhipConfig whip{};
DeathConfig death{};
ImmunityConfig immunity{};
// Llig tot el YAML del personatge i retorna un `Config` poblat.
// Substitueix l'antic `Player::loadFrom` que mutava estàtics.
// Llança si manca el bloc `collision:` (obligatori).
[[nodiscard]] static auto loadFromYaml(const std::string& yaml_path) -> Config;
// Aplica l'override de sala (bloc `lighting.emitters.player` del
// YAML del mapa) sobre els camps de llum d'aquesta Config. Es
// crida després de `loadFromYaml` i abans de passar la Config al
// ctor de Player (o a `setConfig` en transicions de sala).
void applyRoomLightOverride();
};
Player(Config cfg, const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1);
// Reemplaça el `Config` actual del Player. Cridat per GameScene al
// canvi de sala per re-aplicar valors base + override de la sala nova.
// No toca cap estat de joc (lives, posició, FSM): només la config.
void setConfig(Config cfg);
// Accés a la config viva (lectura). El callsites que abans accedien
// a estàtics fan servir `getters d'instància` (sota); aquest és per
// codi que vol l'objecte sencer.
[[nodiscard]] auto getConfig() const -> const Config& { return cfg_; }
// Vides amb què arrenca un jugador (camp `lives:` del YAML). El
// consumeix el HUD per inicialitzar el marcador i `resetForJoin`
// per a re-entrades mid-partida.
[[nodiscard]] auto getInitialLives() const -> int { return cfg_.death.initial_lives; }
// Geometria del cos — instance getters dels camps de `cfg_` que
// alguns callsites externs (game_scene) consulten directament.
[[nodiscard]] auto getBodyHeight() const -> int { return cfg_.body_height; }
[[nodiscard]] auto getSpriteW() const -> int { return cfg_.sprite_w; }
[[nodiscard]] auto getSpriteH() const -> int { return cfg_.sprite_h; }
[[nodiscard]] auto getCollisionSize() const -> int { return cfg_.collision_size; }
[[nodiscard]] auto getCollisionYOffset() const -> int { return cfg_.collision_y_offset; }
// Emissió de llum (mateix paper que `Entity::getLightRadius` per a
// les entitats). El centre puja amb el salt: durant `jump_z_>0` el
// sprite es dibuixa amb `pos_.y - jump_z_`, i la llum ha de seguir-lo
// per a que el cercle "salti" amb el jugador, no es quedi al sòl.
[[nodiscard]] auto getLightRadius() const -> int { return cfg_.light_radius; }
[[nodiscard]] auto getLightFlicker() const -> bool { return cfg_.light_flicker; }
[[nodiscard]] auto getLightColorR() const -> std::uint8_t { return cfg_.light_color_r; }
[[nodiscard]] auto getLightColorG() const -> std::uint8_t { return cfg_.light_color_g; }
[[nodiscard]] auto getLightColorB() const -> std::uint8_t { return cfg_.light_color_b; }
[[nodiscard]] auto getLightOffsetX() const -> int { return cfg_.light_offset_x; }
[[nodiscard]] auto getLightOffsetY() const -> int { return cfg_.light_offset_y; }
[[nodiscard]] auto getFlickerParams() const -> const Lighting::FlickerConfig& { return cfg_.flicker_params; }
// Seed estable per al flicker: reusem `id_` (0 o 1 en multijugador
// local) amb un offset gran per evitar col·lisions amb el contador
// d'Entity (que arrenca des de 1). Així P1 i P2 oscil·len en fases
// pròpies que no s'encavalquen amb cap entitat.
[[nodiscard]] auto getLightSeed() const -> int { return id_ + 1000; }
[[nodiscard]] auto getLightCenter() const -> SDL_FPoint;
// 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
// escenaris amb un sol jugador. Internament és equivalent a cridar // escenaris amb un sol jugador. Internament és equivalent a cridar
@@ -153,6 +298,21 @@ class Player : public AnimatedSprite {
// xarxa — és únic per dins del procés. // xarxa — és únic per dins del procés.
[[nodiscard]] auto getId() const -> int { return id_; } [[nodiscard]] auto getId() const -> int { return id_; }
// Stats que pinta el HUD. El Player n'és la font de veritat; cada
// mutació es publica via `player_stats_changed` al bus (vegeu
// `publishStatsChanged`). Els getters són per a tests i diagnòstic;
// el HUD no els consulta — escolta el bus.
[[nodiscard]] auto getLives() const -> int { return lives_remaining_; }
[[nodiscard]] auto getScore() const -> int { return score_; }
[[nodiscard]] auto getTreasures() const -> int { return treasures_; }
// Acumuladors. Deltas positius i negatius (sense clamp). Publiquen
// `player_stats_changed` només si el delta no és 0, per estalviar
// soroll al bus. Els consumidors típics: GameScene reparteix puntuació
// de sarcs i pickups (futur) cridant aquests mètodes.
void addScore(int delta);
void addTreasures(int delta);
// Accés i mutació de la velocitat intencionada d'aquest frame. El // Accés i mutació de la velocitat intencionada d'aquest frame. El
// GameScene els toca entre `tick` i `applyMovement` per implementar // GameScene els toca entre `tick` i `applyMovement` per implementar
// col·lisions jugador↔jugador (escalar a push_factor, anul·lar en // col·lisions jugador↔jugador (escalar a push_factor, anul·lar en
@@ -214,7 +374,7 @@ class Player : public AnimatedSprite {
// que coincideix amb aquest mètode i s'usa específicament per // que coincideix amb aquest mètode i s'usa específicament per
// marcar la posició de la sombra a nivell de sòl. // marcar la posició de la sombra a nivell de sòl.
[[nodiscard]] auto getFeetY() const -> float { [[nodiscard]] auto getFeetY() const -> float {
return getPosY() + static_cast<float>(collision_y_offset + collision_size); return getPosY() + static_cast<float>(cfg_.collision_y_offset + cfg_.collision_size);
} }
// Alies semàntic: els peus del jugador al sòl. Coincideix amb // Alies semàntic: els peus del jugador al sòl. Coincideix amb
@@ -296,8 +456,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 +472,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 +486,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 +507,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
@@ -410,7 +583,7 @@ class Player : public AnimatedSprite {
// arbitrària (p. ex. la posició proposada per `tryMove` abans // arbitrària (p. ex. la posició proposada per `tryMove` abans
// d'aplicar-la). Evita recalcular `hit_x_offset`/`hit_y_offset` fora // d'aplicar-la). Evita recalcular `hit_x_offset`/`hit_y_offset` fora
// de Player. // de Player.
[[nodiscard]] static auto hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect; [[nodiscard]] auto hitBoxFromBase(const SDL_FRect& base) const -> SDL_FRect;
// Override del hook de Sprite: `MovingSprite::tryMove` envia aquesta // Override del hook de Sprite: `MovingSprite::tryMove` envia aquesta
// caixa al callback de col·lisió. La base del Player és més petita // caixa al callback de col·lisió. La base del Player és més petita
@@ -494,6 +667,11 @@ class Player : public AnimatedSprite {
// efecte immediat sense haver de tornar a entrar a la GameScene. // efecte immediat sense haver de tornar a entrar a la GameScene.
void refreshControlsFromOptions(); void refreshControlsFromOptions();
// Tot el config del personatge (geometria, llum, jump, hurt, whip,
// death, immunity, turn, inertia). Llegit del YAML per instància
// (P1 i P2 poden tenir valors diferents) i mogut al ctor.
Config cfg_;
int id_{-1}; int id_{-1};
Controls controls_; Controls controls_;
@@ -565,26 +743,6 @@ class Player : public AnimatedSprite {
static auto facingSuffix(Facing facing) -> const char*; static auto facingSuffix(Facing facing) -> const char*;
static auto statePrefix(State state, bool moving, bool in_attack_swing) -> const char*; static auto statePrefix(State state, bool moving, bool in_attack_swing) -> const char*;
// Cardinal complet (N/S/E/W) usat per a la detecció de girs de 180°.
// Pública perquè el `WhipAttack` la passa a `enterHurt` quan colpeja
// un altre jugador (friendly fire).
public:
enum class Cardinal : std::uint8_t { NONE,
NORTH,
SOUTH,
EAST,
WEST };
private:
// Configuració d'inèrcia llegida del bloc `inertia:` del YAML. Amb
// `brake_time_ms == 0` (o bloc absent) la inèrcia està desactivada i
// el comportament és instantani: soltar input para el jugador en sec.
struct InertiaConfig {
int brake_time_ms{0}; // Temps en ms perquè la velocitat caiga de ±SPEED a 0.
int threshold_ms{0}; // Temps mínim en moviment continu per activar la inèrcia.
};
static InertiaConfig inertia_config;
// Integra un eix. Si hi ha input en aquest eix, la velocitat física // Integra un eix. Si hi ha input en aquest eix, la velocitat física
// salta a aquest valor (acceleració instantània). Si no, i hi ha // salta a aquest valor (acceleració instantània). Si no, i hi ha
// input en l'altre eix (`any_input == true`), la velocitat d'aquest // input en l'altre eix (`any_input == true`), la velocitat d'aquest
@@ -592,123 +750,7 @@ class Player : public AnimatedSprite {
// soltant l'input — no volem que arrossegue en diagonal. Només quan // soltant l'input — no volem que arrossegue en diagonal. Només quan
// l'usuari deixa anar tot l'input (`any_input == false`) s'activa la // l'usuari deixa anar tot l'input (`any_input == false`) s'activa la
// inèrcia de frenada (si `motion_time_ms` ha passat el llindar). // inèrcia de frenada (si `motion_time_ms` ha passat el llindar).
static void integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input); void integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input) const;
// Configuració del gir de 180°, llegida del bloc `turn:` del YAML del
// player. Si `duration_ms` és 0 (o el bloc no existeix) la mecànica
// queda desactivada i els canvis de direcció són instantanis.
struct TurnConfig {
int duration_ms{0};
Cardinal w_to_e{Cardinal::SOUTH};
Cardinal e_to_w{Cardinal::NORTH};
Cardinal n_to_s{Cardinal::EAST};
Cardinal s_to_n{Cardinal::WEST};
};
// Definida out-of-class al .cpp: dins de la classe, els initializers
// per defecte de TurnConfig encara no són visibles per al compilador.
static TurnConfig turn_config;
// Configuració del salt llegida del bloc `jump:` del YAML del player.
// Si `impulse_px_per_s == 0` (o el bloc no existeix) la mecànica del
// salt queda desactivada — el botó no té efecte.
struct JumpConfig {
float impulse_px_per_s{0.0F}; // Velocitat vertical inicial.
float gravity_px_per_s2{0.0F}; // Acceleració descendent constant.
int landing_stun_ms{0}; // Stun en aterrar (no es mou ni salta).
int shadow_y_offset{0}; // Px que puja la sombra (compensa transparències al peu del sprite).
std::string sound_key; // Clau al Resource::Cache del so de despegue (buit = silenci).
std::string land_sound_key; // Clau del so d'aterratge (buit = silenci).
};
static JumpConfig jump_config;
// Llig el bloc `collision:` del YAML i n'omple les caixes (size/base/
// hit) + `body_height`. Llança si el bloc no existeix. Membre estàtic
// per accedir als estàtics privats sense exposar-los.
static void readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path);
// Llig el bloc `jump:` del YAML i n'omple `jump_config`. Si el bloc
// manca, deixa la mecànica del salt desactivada.
static void readJumpBlock(const fkyaml::node& yaml);
// Configuració de l'estat HURT (estat post-impacte). Si
// `initial_speed_px_per_s == 0` el jugador entra i surt
// instantàniament (cap moviment).
struct HurtConfig {
float initial_speed_px_per_s{0.0F}; // Velocitat amb què surt projectat (px/s).
int brake_time_ms{0}; // Temps en que la velocitat decau a 0.
// Fitxer .wav reproduït en entrar a HURT (relatiu a `data/audio/sounds/`).
// Mateixa convenció que la resta de sons del jugador: el basename val
// de clau al Resource::Cache i de path. Buit o absent = entrada
// silenciosa.
std::string sound_key;
};
static HurtConfig hurt_config;
// Llig el bloc `hurt:` del YAML i n'omple `hurt_config`.
static void readHurtBlock(const fkyaml::node& yaml);
// Configuració del látigo del jugador llegida del bloc `whip:` del
// YAML. `range_tiles` defineix la longitud de la hitbox abans de
// retallar; `swing_ms` és la durada de l'animació d'atac (des
// d'`enterAttacking` fins a desbloquejar el cooldown); `cooldown_ms`
// afegeix una fase posterior durant la qual el jugador segueix
// bloquejat però la hitbox ja no fa dany. `damage` queda llegit per
// a futur; per ara els objectius reben sempre `enterHurt`.
struct WhipConfig {
float range_tiles{0.0F};
int swing_ms{0};
int cooldown_ms{0};
int damage{0};
// Fitxer .wav reproduït al moment de disparar el swing (relatiu a
// `data/audio/sounds/`). Convenció: el basename val tant de clau al
// Resource::Cache com de path. Buit o absent = swing silenciós.
std::string sound_key;
};
static WhipConfig whip_config;
// Llig el bloc `whip:` del YAML i n'omple `whip_config`. Si manca el
// bloc, els camps queden a 0 i el jugador no pot atacar (el flanc
// d'input es perd silenciosament).
static void readWhipBlock(const fkyaml::node& yaml);
// Configuració de mort/respawn llegida del bloc `death:` del YAML.
// `respawn_delay_ms` és la pausa entre la fi de `death_spinning` i la
// reaparició al spawn; `initial_lives` és el comptador d'intents amb
// què naix cada Player. Si el bloc manca, defaults segurs (0/3).
struct DeathConfig {
int respawn_delay_ms{0};
int initial_lives{3};
};
static DeathConfig death_config;
// Llig el bloc `death:` del YAML i n'omple `death_config`. Si manca,
// `respawn_delay_ms` queda a 0 (el respawn és immediat al final de
// l'animació) i `initial_lives` es manté al default (3).
static void readDeathBlock(const fkyaml::node& yaml);
// Configuració de la immunitat post-spawn llegida del bloc `immunity:`
// del YAML. `duration_ms` és el temps per defecte que dura cada
// immunitat (l'invoca `respawn`). `blink_period_ms` és el mig-cicle del
// parpadeig visual: cada `blink_period_ms` ms, el render alterna
// entre la paleta normal i la paleta `palette_key`. Si `duration_ms`
// és 0 (o el bloc manca) la immunitat queda desactivada i el
// comportament és l'antic.
struct ImmunityConfig {
float duration_ms{0.0F};
int blink_period_ms{0};
// Clau al Resource::Cache de la paleta plana usada al blink (basename
// del .pal a `data/entities/player/`). Buida = no hi ha alternança visual,
// només la finestra lògica d'immunitat (que sense feedback visual
// pot ser confusa, però no és incorrecte).
std::string palette_key;
};
static ImmunityConfig immunity_config;
// Llig el bloc `immunity:` del YAML i n'omple `immunity_config`. Si
// hi ha `palette`, registra la paleta corresponent al Resource::Cache
// (path: `data/entities/player/<palette_key>.pal`). Mateix patró que els altres
// readers — `loadFrom` el crida al final.
static void readImmunityBlock(const fkyaml::node& yaml);
// Activa l'estat ATTACKING en la direcció cardinal indicada (la // Activa l'estat ATTACKING en la direcció cardinal indicada (la
// direcció cap on mira el jugador) i publica `whip_spawn` al bus // direcció cap on mira el jugador) i publica `whip_spawn` al bus
@@ -829,10 +871,23 @@ 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};
// Puntuació acumulada i nombre de tresors recollits. Es modifiquen
// via `addScore` / `addTreasures` (o reset a `resetForJoin`). El HUD
// els sincronitza via `player_stats_changed`.
int score_{0};
int treasures_{0};
// Publica un snapshot complet (vides/score/tresors) al bus si està
// injectat. Centralitza la generació de l'event perquè cap mutador
// se l'oblide.
void publishStatsChanged();
// Coordenades del spawn de respawn (cantó superior-esquerra del // Coordenades del spawn de respawn (cantó superior-esquerra del
// sprite). Les assigna el GameScene via `setSpawnPoint`. 0/0 fins que // sprite). Les assigna el GameScene via `setSpawnPoint`. 0/0 fins que
// el GameScene n'invoque el setter al ctor / `loadRoom`. // el GameScene n'invoque el setter al ctor / `loadRoom`.
@@ -901,10 +956,16 @@ class Player : public AnimatedSprite {
// Extret de `update` per acotar la complexitat cognitiva (clang-tidy). // Extret de `update` per acotar la complexitat cognitiva (clang-tidy).
void updateFacingAndTurn(float delta_time, float vx, float vy); void updateFacingAndTurn(float delta_time, float vx, float vy);
static auto cardinalFromInput(float vx, float vy) -> Cardinal; static auto cardinalFromInput(float vx, float vy) -> Cardinal;
static auto cardinalFromString(const std::string& s) -> Cardinal;
static auto opposite(Cardinal c) -> Cardinal; static auto opposite(Cardinal c) -> Cardinal;
static auto intermediateFor(Cardinal target) -> Cardinal; auto intermediateFor(Cardinal target) const -> Cardinal;
public:
// Helper pur: tradueix un string YAML ("north", "south", ...) al
// Cardinal corresponent. Públic perquè `Config::loadFromYaml` el crida
// des d'un helper de la TU anònima a player.cpp.
static auto cardinalFromString(const std::string& s) -> Cardinal;
private:
// Bus del nivell (opcional). Propietat externa (EntityManager); el // Bus del nivell (opcional). Propietat externa (EntityManager); el
// Player només en guarda un punter per publicar events. // Player només en guarda un punter per publicar events.
EventBus* bus_{nullptr}; EventBus* bus_{nullptr};
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "game/entities/entity.hpp"
// Mur passiu: bloqueja moviment i salt però no té ni estat ni acció. Paral·lel
// a `Decoration` (sense col·lisió) i `Interactable` (col·lisiona i té
// estat/interact). Pensat per a debris/obstacles purament visuals que sí
// han de detenir el jugador: pedres, columnes, restes.
//
// Diferència amb `Interactable`: no override `interact()` ni
// `notifyTilePainted`. La caixa de col·lisió i alçada efectiva surten del
// YAML (`collision.size`, `collision.height`); aquest ctor només fixa els
// flags i un default raonable per a "mur infranquejable".
class SolidDecoration : public Entity {
public:
using Entity::Entity;
SolidDecoration() {
collides_ = true;
interactable_ = false;
height_ = 999;
// Sí bloqueja el moviment del jugador: `Map::collides` el consulta
// via callback per parar el moviment com si fos una paret sòlida.
blocks_movement_ = true;
}
};
+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};
};
+151
View File
@@ -1,10 +1,13 @@
#include "game/map/map.hpp" #include "game/map/map.hpp"
#include <algorithm>
#include <cmath> #include <cmath>
#include <stdexcept> #include <stdexcept>
#include <unordered_map>
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp" #include "utils/log.hpp"
namespace { namespace {
@@ -21,6 +24,138 @@ namespace {
namespace Map { namespace Map {
namespace {
// Predicat extra que el `collides` consulta com a sobre-capa,
// injectat des de `EntityManager` per a entitats sòlides genèriques
// (debris, columnes). El TU local manté l'estat sense exposar-lo
// al header.
std::function<bool(const SDL_FRect&)> g_entity_collider;
// Overrides d'emissor declarats al bloc `lighting.emitters` del YAML
// de la sala. Clau = tipus d'entitat en singular (per concordar amb
// el camp `type:` de cada plantilla). Buit per defecte: una sala
// sense bloc `lighting.emitters` deixa que cada entitat utilitze els
// seus propis defaults del seu YAML de tipus.
std::unordered_map<std::string, RoomEmitterOverride> g_room_emitters;
// Plural → singular per a tipus coneguts. El YAML de sala fa servir
// el plural perquè es llegeix natural ("mummies: false"); el codi
// treballa amb la forma singular que coincideix amb el camp `type:`
// del spawn ("type: mummy"). Si la clau no és reconeguda, passa tal
// qual (suposa que l'autor ja l'ha escrit en singular).
auto normalizeEmitterKey(const std::string& yaml_key) -> std::string {
if (yaml_key == "players") { return "player"; }
if (yaml_key == "mummies") { return "mummy"; }
if (yaml_key == "torches") { return "torch"; }
return yaml_key;
}
// Parser comú d'un color YAML 3-tupla a bytes. Retorna nullopt
// si el node no és una seqüència de 3 elements. Usat pels
// overrides de sala i, en `entity_manager_loadlevel.cpp`, també
// pels defaults de tipus i overrides de spawn (passa pel mateix
// helper per coherència). `clamp` defensiu per a YAMLs que
// entreguen valors fora de 0..255.
auto parseColorTriplet(const fkyaml::node& node) -> std::optional<std::array<std::uint8_t, 3>> {
if (!node.is_sequence() || node.size() != 3) { return std::nullopt; }
auto clamp = [](int v) { return static_cast<std::uint8_t>(std::clamp(v, 0, 255)); };
return std::array<std::uint8_t, 3>{
clamp(node[0].get_value<int>()),
clamp(node[1].get_value<int>()),
clamp(node[2].get_value<int>()),
};
}
// Parser comú d'una 2-tupla d'enters (offset de llum). Retorna
// nullopt si el node no és una seqüència de 2 elements. Sense
// clamp: els offsets negatius són legítims (Y creix cap avall,
// així que `y: -3` puja la llum 3 px).
auto parseIntPair(const fkyaml::node& node) -> std::optional<std::array<int, 2>> {
if (!node.is_sequence() || node.size() != 2) { return std::nullopt; }
return std::array<int, 2>{
node[0].get_value<int>(),
node[1].get_value<int>(),
};
}
// Llegeix el sub-bloc opcional `flicker_params:` d'un emissor de
// sala. Camps individuals — qualsevol absent conserva el valor
// del global (`Lighting::flickerConfig()`) o de la capa que ho
// precedeixi (tipus / sala).
void readFlickerOverrides(const fkyaml::node& fp, RoomEmitterOverride& ov) {
if (fp.contains("wave_amplitude_ratio")) { ov.flicker_wave_amplitude_ratio = fp["wave_amplitude_ratio"].get_value<float>(); }
if (fp.contains("wave_frequency_hz")) { ov.flicker_wave_frequency_hz = fp["wave_frequency_hz"].get_value<float>(); }
if (fp.contains("noise_amplitude_ratio")) { ov.flicker_noise_amplitude_ratio = fp["noise_amplitude_ratio"].get_value<float>(); }
if (fp.contains("noise_frequency_hz")) { ov.flicker_noise_frequency_hz = fp["noise_frequency_hz"].get_value<float>(); }
}
// Llegeix una entrada del mapping `lighting.emitters.<tipus>` en
// qualsevol de les seves 3 formes (bool curt o mapping detallat).
// Extret per mantenir `loadLightingBlock` sota el llindar de
// complexitat cognitiva de clang-tidy.
auto readEmitterEntry(const fkyaml::node& val) -> RoomEmitterOverride {
RoomEmitterOverride ov;
if (val.is_boolean()) {
ov.enabled = val.get_value<bool>();
return ov;
}
if (!val.is_mapping()) { return ov; }
ov.enabled = true;
if (val.contains("radius")) { ov.radius = val["radius"].get_value<int>(); }
if (val.contains("flicker")) { ov.flicker = val["flicker"].get_value<bool>(); }
if (val.contains("color")) { ov.color = parseColorTriplet(val["color"]); }
if (val.contains("offset")) { ov.offset = parseIntPair(val["offset"]); }
if (val.contains("flicker_params")) { readFlickerOverrides(val["flicker_params"], ov); }
return ov;
}
// Llegeix la subclau `mode:` del bloc `lighting:` i actualitza
// `lighting_mode`. Avisa via Log si el valor no és reconegut.
// Extret de `loadLightingBlock` per la mateixa raó.
void readLightingMode(const fkyaml::node& lighting) {
if (!lighting.contains("mode")) { return; }
const auto MODE_STR = lighting["mode"].get_value<std::string>();
if (MODE_STR == "additive") {
lighting_mode = Lighting::Mode::ADDITIVE;
} else if (MODE_STR == "subtractive") {
lighting_mode = Lighting::Mode::SUBTRACTIVE;
} else {
Log::warn("Map", "lighting.mode '{}' desconegut — s'usa subtractive", MODE_STR);
}
}
// Llegeix el bloc opcional `lighting:` del YAML d'una sala i
// actualitza `light_level`, `lighting_mode` i el mapping
// `g_room_emitters`. Idempotent: cada `Map::loadFrom` reseteja
// tot l'estat abans de poblar-lo, així no s'arrosseguen valors
// d'una sala anterior.
void loadLightingBlock(const fkyaml::node& yaml) {
g_room_emitters.clear();
lighting_mode = Lighting::Mode::SUBTRACTIVE;
if (!yaml.contains("lighting")) { return; }
const auto& lighting = yaml["lighting"];
if (lighting.contains("level")) {
light_level = lighting["level"].get_value<float>();
}
readLightingMode(lighting);
if (!lighting.contains("emitters") || !lighting["emitters"].is_mapping()) { return; }
const auto& em = lighting["emitters"];
for (auto it = em.begin(); it != em.end(); ++it) {
const auto KEY = normalizeEmitterKey(it.key().get_value<std::string>());
g_room_emitters[KEY] = readEmitterEntry(it.value());
}
}
} // namespace
void setEntityCollider(std::function<bool(const SDL_FRect&)> fn) {
g_entity_collider = std::move(fn);
}
auto getRoomEmitterOverride(const std::string& type) -> const RoomEmitterOverride* {
const auto IT = g_room_emitters.find(type);
return IT == g_room_emitters.end() ? nullptr : &IT->second;
}
void loadFrom(const std::string& yaml_path) { void loadFrom(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path); auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) { if (bytes.empty()) {
@@ -90,6 +225,17 @@ namespace Map {
// canviarà el flag a true quan transiti a OPEN. // canviarà el flag a true quan transiti a OPEN.
door_open = false; door_open = false;
// Llum base de la sala. Dues formes acceptades:
// - `light_level: <float>` a l'arrel (forma antiga, encara
// suportada per retrocompat).
// - `lighting.level: <float>` dins del bloc nou `lighting:`,
// que també hostatja `lighting.emitters` (overrides per tipus).
// Si manca tot, queda al default 1.0 (sala totalment il·luminada).
// `loadLightingBlock` aplica el `lighting.level` per damunt del
// `light_level:` antic, així el nou bloc guanya si conviuen.
light_level = yaml.contains("light_level") ? yaml["light_level"].get_value<float>() : Defaults::Lighting::ROOM_LIGHT_DEFAULT;
loadLightingBlock(yaml);
Log::info("Map", "geometria {}x{} sarcs {}x{} pitch {}x{} door_col {} des de {}", grid_cols, grid_rows, sarc_w, sarc_h, pitch_x, pitch_y, door_col, yaml_path); Log::info("Map", "geometria {}x{} sarcs {}x{} pitch {}x{} door_col {} des de {}", grid_cols, grid_rows, sarc_w, sarc_h, pitch_x, pitch_y, door_col, yaml_path);
} }
@@ -123,6 +269,11 @@ namespace Map {
}; };
if (overlapStrict(base_box, CORRIDOR)) { return true; } if (overlapStrict(base_box, CORRIDOR)) { return true; }
} }
// Capa final: entitats sòlides genèriques registrades pel manager.
// S'avalua al final perquè els obstacles fixos (sarcs, porta) són
// l'origen del laberint i el camí més curt; les entitats addicionals
// són normalment poques.
if (g_entity_collider && g_entity_collider(base_box)) { return true; }
return false; return false;
} }
+77 -1
View File
@@ -2,9 +2,15 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <array>
#include <cstdint>
#include <functional>
#include <optional>
#include <string> #include <string>
#include <utility> #include <utility>
#include "core/rendering/lighting.hpp"
// Mapa d'una piràmide: sala rectangular amb marcador superior, muros pels // Mapa d'una piràmide: sala rectangular amb marcador superior, muros pels
// quatre costats i graella de sarcòfags envoltada per un passadís a cada // quatre costats i graella de sarcòfags envoltada per un passadís a cada
// cantó. La geometria (graella, pitch, amplada de murs) es carrega des del // cantó. La geometria (graella, pitch, amplada de murs) es carrega des del
@@ -49,6 +55,68 @@ namespace Map {
inline int back_wall_h = 0; inline int back_wall_h = 0;
inline int side_wall_w = 0; inline int side_wall_w = 0;
// Nivell d'il·luminació base de la sala. 1.0 = totalment il·luminada
// (com hui, sense efecte visual); 0.0 = completament negra fora del
// radi d'un emissor. La clau `light_level:` al YAML de la sala el
// sobreescriu — si manca, queda al default 1.0 i la pasada
// d'il·luminació del frame es salta com a no-op. La forma nova és
// declarar-ho dins del bloc `lighting:` (vegeu més avall); `light_level:`
// al root es manté per compatibilitat enrere.
inline float light_level = 1.0F;
// Mode de composició de la pasada d'il·luminació (subtractive per
// defecte; additive si la sala declara `lighting.mode: additive`).
// Veure el comentari de `core/rendering/lighting.hpp` per a la
// semàntica de cada mode. En subtractive, els colors dels emissors
// s'ignoren — el camí és el de sempre.
inline Lighting::Mode lighting_mode = Lighting::Mode::SUBTRACTIVE;
// Override d'emissor declarat al bloc opcional `lighting.emitters.<tipus>`
// de la sala. Tres formes acceptades al YAML:
// <tipus>: false → desactiva la llum per a tot el tipus
// <tipus>: true → activa amb els defaults del tipus
// <tipus>: { radius: N, flicker: B } → activa i sobreescriu camps
// Els camps absents (`std::nullopt`) cauen al default del tipus
// d'entitat (radi/flicker del seu YAML). Els tipus no presents al
// mapping conserven el seu comportament per defecte (com avui).
//
// Conversió de clau YAML → tipus d'entitat: el YAML usa plural
// ("players", "mummies", "torches") perquè es llegeix natural per a
// l'autor de la sala; `loadFrom` normalitza a la clau singular
// ("player", "mummy", "torch") que coincideix amb el camp `type:` de
// cada plantilla d'entitat. Tipus no reconeguts s'accepten "tal qual"
// (singular pass-through) per si en el futur s'hi afegeixen emissors
// nous sense tocar el mapatge.
struct RoomEmitterOverride {
bool enabled{true};
std::optional<int> radius;
std::optional<bool> flicker;
// Color emès en mode additive. `nullopt` → conservar el color
// del tipus (default blanc 255,255,255 si el tipus no en declara).
// La forma YAML és `color: [r, g, b]` (bytes 0..255).
std::optional<std::array<std::uint8_t, 3>> color;
// Offset del centre del cercle de llum respecte el centre del
// sprite (píxels enters; Y creix avall). `nullopt` → conservar
// l'offset del tipus (default [0, 0]). Forma YAML: `offset: [x, y]`.
std::optional<std::array<int, 2>> offset;
// Overrides parcials per cada camp del `FlickerConfig` (ràtio
// d'amplitud i freqüència del wave + noise). `nullopt` → no
// toca aquell camp (conserva el del tipus, que al seu torn pot
// ser el global). Forma YAML: `flicker_params: { wave_frequency_hz: 1.0, ... }`.
std::optional<float> flicker_wave_amplitude_ratio;
std::optional<float> flicker_wave_frequency_hz;
std::optional<float> flicker_noise_amplitude_ratio;
std::optional<float> flicker_noise_frequency_hz;
};
// Consulta l'override de sala per a un tipus d'entitat (clau singular,
// p.ex. "player" / "mummy" / "torch"). Retorna `nullptr` si la sala no
// declara res per aquest tipus — el caller usa els defaults del tipus
// sense modificació. El punter retornat és vàlid fins a la propera
// crida a `Map::loadFrom` (cada càrrega de nivell substitueix el
// mapping intern).
[[nodiscard]] auto getRoomEmitterOverride(const std::string& type) -> const RoomEmitterOverride*;
// Derivats de la geometria base: el pas entre sarcòfags ha de cabre // Derivats de la geometria base: el pas entre sarcòfags ha de cabre
// exactament el jugador, i el mateix passadís s'usa a l'exterior. // exactament el jugador, i el mateix passadís s'usa a l'exterior.
inline int corridor_w = 0; inline int corridor_w = 0;
@@ -91,9 +159,17 @@ namespace Map {
// Mapa de colisions (separat del mapa de dibuix): retorna true si el // Mapa de colisions (separat del mapa de dibuix): retorna true si el
// rectangle `base_box` (la base del jugador, per exemple) s'encavalca // rectangle `base_box` (la base del jugador, per exemple) s'encavalca
// amb cap sarcòfag. // amb cap sarcòfag, el passadís de la porta tancada, o cap entitat
// sòlida genèrica registrada via `setEntityCollider`.
[[nodiscard]] auto collides(const SDL_FRect& base_box) -> bool; [[nodiscard]] auto collides(const SDL_FRect& base_box) -> bool;
// Registre d'un predicat extern que `collides` consulta com a sobre-
// capa: l'usa `EntityManager` per a entitats amb `blocksMovement()`
// (debris, columnes...) que no estan a la graella fixa de sarcòfags.
// Passa `nullptr` per netejar. Reset implícit a `loadFrom` (cada
// càrrega de nivell sol portar un manager nou).
void setEntityCollider(std::function<bool(const SDL_FRect&)> fn);
// true si la caixa `box` queda completament dins del playfield (zona // true si la caixa `box` queda completament dins del playfield (zona
// interior, sense tocar els murs exteriors). El Player ja té clamp // interior, sense tocar els murs exteriors). El Player ja té clamp
// propi que aplica el mateix constrenyiment; això serveix a les // propi que aplica el mateix constrenyiment; això serveix a les
+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
File diff suppressed because it is too large. Load diff
+180 -73
View File
@@ -11,8 +11,10 @@
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "game/debug_config.hpp" #include "game/debug_config.hpp"
#include "game/entities/entity_manager.hpp" #include "game/entities/entity_manager.hpp"
#include "game/entities/player.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
@@ -20,7 +22,6 @@
#endif #endif
class Hud; class Hud;
class Player;
class Text; class Text;
class GameScene : public Scene { class GameScene : public Scene {
@@ -54,24 +55,11 @@ class GameScene : public Scene {
void setState(RoomFsm::State new_state); void setState(RoomFsm::State new_state);
// Avança la transició activa via `RoomFsm::advance`. S'executa cada // Avança la transició activa via `RoomFsm::advance`. S'executa cada
// frame amb delta real (no escalat per la pausa) perquè la transició // frame amb delta real perquè la transició viu en temps de UI. Si
// viu en temps de UI. Si l'estat retornat és diferent de l'actual, // l'estat retornat és diferent de l'actual, crida `setState` (que
// crida `setState` (que reseteja la `Transition`). // reseteja la `Transition`).
void updateRoomTransition(int delta_ms); void updateRoomTransition(int delta_ms);
// Consumeix events d'aquest frame (F11 del KeyConfig "pause") per
// disparar les transicions de pausa. El mando ja no participa: el
// botó START és ara una acció remapeable per dispositiu i el menú
// de servei fa de pausa quan cal. No alterna un flag binari: inicia
// (o inverteix mid-flight) un "dial" de temps que frena el món amb
// easing.
void handlePauseInput();
// Avança la transició activa, recalcula `pause_.scale` i aplica el
// fade de volum de música. S'executa cada frame amb el delta real
// (no escalat) perquè la transició viu en temps real.
void updatePauseTransition(int delta_ms);
// Spawna un Dust transitori centrat a (`feet_x_center`, `feet_y`). // Spawna un Dust transitori centrat a (`feet_x_center`, `feet_y`).
// Cridat per la subscripció del bus a `player_jumped` / // Cridat per la subscripció del bus a `player_jumped` /
// `player_landed` per pintar el polvet de despegue / aterratge. // `player_landed` per pintar el polvet de despegue / aterratge.
@@ -95,17 +83,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 +120,27 @@ 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);
// Handler de l'event `sarcophagus_opened` (un jugador acaba de
// tancar el perímetre). Reparteix la recompensa (`points`,
// `treasures` del YAML del sarc) al Player corresponent, que al
// mutar score/treasures publicarà `player_stats_changed` i el HUD
// s'actualitzarà sol. Extret a mètode per acotar la complexitat
// cognitiva del ctor.
void onSarcophagusOpened(const Event& ev);
// Pre-load dels recursos d'àudio que s'usen mid-partida i no els
// toca el Director (so del countdown, etc.). Si la clau és buida
// o ja està al cache, és no-op. Extret a mètode per acotar la
// 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 +148,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 +197,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
@@ -188,41 +234,11 @@ class GameScene : public Scene {
// la destrucció. // la destrucció.
std::unique_ptr<Hud> hud_; std::unique_ptr<Hud> hud_;
// Pausa de gameplay com a "dial" de temps. En lloc d'un interruptor,
// `pause_.scale` va de 1 (món corrent) a 0 (món congelat) amb una
// corba d'easing durant ENTERING, i al revés durant EXITING. El
// `delta_ms` del món (players + EntityManager) es multiplica per
// aquesta escala; HUD, Notifier i GlobalInputs reben sempre el
// delta real. Les inversions mid-flight capturen la `scale` actual
// com a `from_scale` i escalen la durada a la distància restant per
// mantenir la sensació de velocitat. La font és la mateixa que
// abans — ja cachejada al Director.
std::shared_ptr<Text> pause_font_;
// Font del rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). // Font del rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO).
// Distinta de `pause_font_` perquè el rètol de fase té estètica // Té estètica pròpia (CaesarDressing per defecte, configurable via
// pròpia (CaesarDressing per defecte, configurable via // `data/config/ui.yaml > fonts.room_text`).
// `data/config/ui.yaml > fonts.room_text`). La pausa segueix amb la
// placeholder.
std::shared_ptr<Text> room_text_font_; std::shared_ptr<Text> room_text_font_;
enum class PauseState : std::uint8_t {
IDLE, // Joc corrent; cap transició en marxa. scale == 1.
ENTERING, // Frenant cap a la pausa. scale decreix a 0.
PAUSED, // Món congelat. scale == 0.
EXITING, // Accelerant de tornada. scale creix a 1.
};
struct PauseTransition {
PauseState state{PauseState::IDLE};
float scale{1.0F}; // 0 = parat, 1 = corrent.
float from_scale{1.0F}; // Valor de `scale` al començar la transició.
float to_scale{1.0F}; // Valor objectiu (0.0 o 1.0).
int elapsed_ms{0}; // Temps transcorregut dins la transició.
int duration_ms{0}; // Durada total (escalada a la distància).
UiConfig::CurveFn curve{nullptr}; // Corba activa.
} pause_;
// Configuració de la FSM de sala (intro/outro). Carregada al ctor des // Configuració de la FSM de sala (intro/outro). Carregada al ctor des
// de `data/scenes/game/game.yaml`; valors per defecte si manca el YAML. // de `data/scenes/game/game.yaml`; valors per defecte si manca el YAML.
RoomFsm::Config transition_config_{}; RoomFsm::Config transition_config_{};
@@ -255,18 +271,106 @@ class GameScene : public Scene {
void renderRoomFade(); void renderRoomFade();
void renderRoomText(); void renderRoomText();
// Helpers de `renderRoomText`. Tots consulten `transition_` i // Pasada d'il·luminació CPU sobre el canvas, entre els actors i el
// `transition_config_` directament a través de `this` — així les // HUD: recull els emissors actius (jugadors + entitats amb
// signatures es queden netes i la funció pública s'orquestra amb // `light_radius>0`) i aplica el gradient amb `Map::light_level`
// poques línies. Tres tasques separades: // com a llum de sala. El HUD/notificacions queden fora de la
// - X animada d'una línia (entra per un costat, surt per l'altre). // multiplicació perquè es pinten després.
// - Altura animada del panel translúcid (creix, manté, decreix). void applyLightingPass();
// - 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; // Configuració, estat i transició de la sub-FSM de game over
[[nodiscard]] auto computeRoomTextPanelHeight(int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int; // (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la
// RoomFsm via `GameOverFsm::loadConfig`.
GameOverFsm::Config game_over_config_{};
GameOverFsm::State game_over_state_{GameOverFsm::State::OFF};
GameOverFsm::Transition game_over_transition_{};
// Fade final cap a LOGO. Independent del `outro_fade_` perquè el
// 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;
// Especificació declarativa d'un rètol de transició de dues línies
// amb panel translúcid. ROOM_INTRO, ROOM_OUTRO, CONTINUE? i GAME OVER
// comparteixen la mateixa coreografia (panel + dues línies en
// direccions oposades) i només divergeixen en el text, els índexs
// de fase i les durades. `phase_enter = -1` desactiva l'animació
// d'entrada (cas GAME_OVER, que apareix amb el panel ja amunt).
// Una `lineN` buida indica que la línia no s'ha de pintar (cas
// CONTINUE? fora del hold, on la línia 2 mostra el countdown).
struct TextScreenSpec {
std::string line1;
std::string line2;
int phase_enter{0};
int phase_hold{0};
int phase_exit{0};
int dur_enter_ms{0};
int dur_exit_ms{0};
int line_gap_px{0};
};
// Orquestrador unificat dels quatre rètols de transició. Pinta el
// panel + dues línies a partir de `spec`, deixant la decisió de
// QUÈ pintar (text, fases, durades) als callers `renderRoomText`
// i `renderGameOverText`. Sense estat propi: la fase i temps
// arriben pels paràmetres perquè la mateixa funció serveix les
// dues sub-FSMs (RoomFsm i GameOverFsm).
void renderTextScreen(const TextScreenSpec& spec, int phase, int elapsed_ms) const;
// Posició inicial dels jugadors derivada de `Map::door_col`. Tots // Posició inicial dels jugadors derivada de `Map::door_col`. Tots
// els jugadors comparteixen Y (centre del corredor exterior superior); // els jugadors comparteixen Y (centre del corredor exterior superior);
// X depèn del nombre actiu: amb un jugador queda centrat sota la // X depèn del nombre actiu: amb un jugador queda centrat sota la
@@ -278,7 +382,11 @@ class GameScene : public Scene {
float p2_x{0.0F}; float p2_x{0.0F};
float y{0.0F}; float y{0.0F};
}; };
[[nodiscard]] static auto computeSpawn() -> PlayerSpawn; // Calcula la posició de spawn dels jugadors usant la geometria del
// sprite/col·lisió del `Config` passat (típicament la del P1, que
// dicta el centrament — si en algun futur P2 té geometria diferent,
// el seu propi spawn pot recalcular-se amb el seu `cfg`).
[[nodiscard]] static auto computeSpawn(const Player::Config& cfg) -> PlayerSpawn;
// Recarrega la sala apuntada per `yaml_path`: refresca Map (geometria, // Recarrega la sala apuntada per `yaml_path`: refresca Map (geometria,
// porta tancada), entitats (sarcs/porta/momies des de zero), neteja // porta tancada), entitats (sarcs/porta/momies des de zero), neteja
@@ -313,11 +421,10 @@ class GameScene : public Scene {
#endif #endif
#ifdef _DEBUG #ifdef _DEBUG
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la // Editor modal del tilemap. Mútuament excloent amb el menú de servei:
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i // si el menú està obert, la tecla de toggle de l'editor s'ignora.
// viceversa. Quan `editor_.active()` és true, saltem entitats i // Quan `editor_.active()` és true, saltem entitats i jugador (update
// jugador (update i render) i deixem el tilemap visible perquè es // i render) i deixem el tilemap visible perquè es pugui editar.
// pugui editar.
Editor editor_; Editor editor_;
#endif #endif
}; };
+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:
+139 -25
View File
@@ -40,6 +40,25 @@ namespace {
// Separació extra entre camps dins d'una mateixa línia. // Separació extra entre camps dins d'una mateixa línia.
constexpr int FIELD_GAP = 6; // px entre "Vides 3" i "Tres. 00" constexpr int FIELD_GAP = 6; // px entre "Vides 3" i "Tres. 00"
// Desplaçament de l'ombra del text en píxels (sota-dreta). Petit
// perquè la font del HUD és 8 px d'alçada — qualsevol valor més
// gran tapa el següent caràcter.
constexpr int SHADOW_DX = 1;
constexpr int SHADOW_DY = 1;
// Helpers per pintar text amb ombra. Primer la sombra desplaçada,
// després el text principal al damunt — l'ordre garanteix que la
// sombra mai cobreix el text. El kerning queda al default del
// Text (1 px), igual que la resta del HUD.
void drawShadowed(Text& font, int x, int y, const std::string& s, Uint8 color, Uint8 shadow) {
font.writeColored(x + SHADOW_DX, y + SHADOW_DY, s, shadow);
font.writeColored(x, y, s, color);
}
void drawMonoShadowed(Text& font, int x, int y, const std::string& s, Uint8 color, Uint8 shadow, int cell_w) {
font.writeMonoDigits(x + SHADOW_DX, y + SHADOW_DY, s, shadow, cell_w);
font.writeMonoDigits(x, y, s, color, cell_w);
}
// Format zero-padded a 6 dígits per a la puntuació. // Format zero-padded a 6 dígits per a la puntuació.
auto formatScore(int v) -> std::string { auto formatScore(int v) -> std::string {
const int CLAMPED = std::clamp(v, 0, 999999); const int CLAMPED = std::clamp(v, 0, 999999);
@@ -69,9 +88,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,15 +123,26 @@ 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.
// afecta el HUD visualment: decrementa vides, dispara flash i shake al //
// bloc del jugador. Quan els events evolucionin per portar `player_id` // `player_hit` només fa feedback visual (flash + shake) al bloc del
// i puntuacions, ampliarem aquí sense tocar callers. // jugador colpejat. **No** toca el comptador de vides — la veritat
// viu al Player i arriba per `player_stats_changed`. Així si un hit
// no és mortal (HURT recoverable) el flash es manté coherent sense
// mentir el comptador, i si és mortal el comptador baixa pel canal
// numèric quan el cicle DYING acaba a `respawnFromDying`.
subs_.push_back(bus_.subscribe("player_hit", [this](const Event& ev) { subs_.push_back(bus_.subscribe("player_hit", [this](const Event& ev) {
const auto* dmg = std::get_if<int>(&ev.payload); const auto* dmg = std::get_if<int>(&ev.payload);
// Dispatch pel `player_id` de l'event. 0 = P1 (bloc LEFT), 1 = P2 // Dispatch pel `player_id` de l'event. 0 = P1 (bloc LEFT), 1 = P2
@@ -120,14 +151,28 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font)
// publishers de `player_hit` actuals sí que porten id. // publishers de `player_hit` actuals sí que porten id.
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U; const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
if (!players_[IDX].active) { return; } if (!players_[IDX].active) { return; }
if (players_[IDX].lives > 0) { --players_[IDX].lives; }
const std::size_t BLK_IDX = (IDX == 0) ? static_cast<std::size_t>(BlockId::LEFT) const std::size_t BLK_IDX = (IDX == 0) ? static_cast<std::size_t>(BlockId::LEFT)
: static_cast<std::size_t>(BlockId::RIGHT); : static_cast<std::size_t>(BlockId::RIGHT);
auto& blk = blocks_[BLK_IDX]; auto& blk = blocks_[BLK_IDX];
blk.flash_ms_left = UiConfig::get().hudFlashMs(); blk.flash_ms_left = UiConfig::get().hudFlashMs();
blk.shake_ms_left = UiConfig::get().hudShakeMs(); blk.shake_ms_left = UiConfig::get().hudShakeMs();
blk.shake_intensity = 1.5F; blk.shake_intensity = 1.5F;
Log::info("HUD", "player_hit p{} dmg={} lives={}", IDX + 1, dmg != nullptr ? *dmg : 0, players_[IDX].lives); Log::info("HUD", "player_hit p{} dmg={}", IDX + 1, dmg != nullptr ? *dmg : 0);
}));
// `player_stats_changed`: snapshot total (vides/score/tresors) emès
// pel Player cada vegada que muta qualsevol dels tres. El HUD el
// consumeix com a estat absolut: sobreescriu la caché local sense
// raonament addicional. Això unifica totes les fonts de canvi (mort
// per qualsevol causa, recompensa de sarc, futurs pickups…) en un
// únic canal.
subs_.push_back(bus_.subscribe("player_stats_changed", [this](const Event& ev) {
const auto* st = std::get_if<PlayerStatsPayload>(&ev.payload);
if (st == nullptr) { return; }
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
players_[IDX].lives = st->lives;
players_[IDX].score = st->score;
players_[IDX].treasures = st->treasures;
})); }));
subs_.push_back(bus_.subscribe("level_complete", [](const Event& /*ev*/) { subs_.push_back(bus_.subscribe("level_complete", [](const Event& /*ev*/) {
@@ -136,6 +181,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);
} }
@@ -149,6 +206,10 @@ void Hud::update(int delta_ms) {
if (b.shake_ms_left > 0) { b.shake_ms_left = std::max(0, b.shake_ms_left - delta_ms); } if (b.shake_ms_left > 0) { b.shake_ms_left = std::max(0, b.shake_ms_left - delta_ms); }
if (b.flash_ms_left > 0) { b.flash_ms_left = std::max(0, b.flash_ms_left - delta_ms); } if (b.flash_ms_left > 0) { b.flash_ms_left = std::max(0, b.flash_ms_left - delta_ms); }
} }
const int CYCLE = UiConfig::get().hudBlinkCycleMs();
if (CYCLE > 0) {
blink_elapsed_ms_ = (blink_elapsed_ms_ + delta_ms) % CYCLE;
}
} }
void Hud::setMode(BlockId block, Mode mode) { void Hud::setMode(BlockId block, Mode mode) {
@@ -164,9 +225,16 @@ 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.join_prompt = JoinPromptMode::NONE;
p.score = 0; // Els camps numèrics (lives/score/treasures) ja els ha sobreescrit el
p.treasures = 0; // `player_stats_changed` que el Player publica dins de `resetForJoin`,
// així que aquí no els toquem — evitem trepitjar la veritat del bus
// amb defaults locals.
}
void Hud::setJoinPrompt(int player_one_based, JoinPromptMode mode) {
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 {
@@ -193,6 +261,16 @@ auto Hud::computeOffset(const Block& b) -> SDL_FPoint {
return off; return off;
} }
auto Hud::blinkVisible() const -> bool {
const auto& ui = UiConfig::get();
const int CYCLE = ui.hudBlinkCycleMs();
const float RATIO = ui.hudBlinkVisibleRatio();
if (CYCLE <= 0 || RATIO >= 1.0F) { return true; }
if (RATIO <= 0.0F) { return false; }
const int VISIBLE_MS = static_cast<int>(static_cast<float>(CYCLE) * RATIO);
return blink_elapsed_ms_ < VISIBLE_MS;
}
void Hud::render() const { void Hud::render() const {
// Backdrop de la franja del HUD. Es pinta abans dels tres blocs perquè, // Backdrop de la franja del HUD. Es pinta abans dels tres blocs perquè,
// quan un bloc es mou (entry slide o shake), la zona que deixa al // quan un bloc es mou (entry slide o shake), la zona que deixa al
@@ -234,7 +312,38 @@ 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)];
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? UiConfig::get().hudFlash() : UiConfig::get().hudValue();
// Família de color del bloc: P1 (vermell-crema-vermell-fosc) per al
// jugador 0, P2 (cian-blau-blau-marí) per al jugador 1. Mantenir el
// mateix matís a label/value/shadow dóna jerarquia visual coherent
// entre línies sense empastar (la sombra és un to fosc del mateix
// matís → efecte embossing).
const auto& ui = UiConfig::get();
const Uint8 LBL_C = (player_index == 0) ? ui.hudP1Label() : ui.hudP2Label();
const Uint8 VAL_C = (player_index == 0) ? ui.hudP1Value() : ui.hudP2Value();
const Uint8 SHD_C = (player_index == 0) ? ui.hudP1Shadow() : ui.hudP2Shadow();
// Slot inactiu amb prompt actiu: substituïm el render de zeros pel
// missatge "PULSA START / PARA JUGAR" o "ESPERA / PARA JUGAR" segons
// el mode fixat per la GameScene cada frame. El render de zeros
// queda com a fallback (mode NONE o slot actiu).
if (!p.active && p.join_prompt != JoinPromptMode::NONE) {
if (!blinkVisible()) { return; }
const bool CAN_JOIN = (p.join_prompt == JoinPromptMode::CAN_JOIN);
const std::string L1 = Locale::get().text(
CAN_JOIN ? "hud.join_prompt_can_join_l1" : "hud.join_prompt_wait_l1");
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);
drawShadowed(*font_, X1, y + LINE1_Y, L1, LBL_C, SHD_C);
drawShadowed(*font_, X2, y + LINE2_Y, L2, VAL_C, SHD_C);
return;
}
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? ui.hudFlash() : VAL_C;
// --- Línia 1: "<label>-000000" centrada --- // --- Línia 1: "<label>-000000" centrada ---
const std::string LBL1 = label + SEPARATOR; const std::string LBL1 = label + SEPARATOR;
@@ -243,9 +352,9 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
const int SC_W = font_->lengthMonoDigits(SCORE, MONO_DIGIT_W); const int SC_W = font_->lengthMonoDigits(SCORE, MONO_DIGIT_W);
const int LINE1_W = LBL_W + SC_W; const int LINE1_W = LBL_W + SC_W;
int xx = x + ((w - LINE1_W) / 2); int xx = x + ((w - LINE1_W) / 2);
font_->writeColored(xx, y + LINE1_Y, LBL1, UiConfig::get().hudLabel()); drawShadowed(*font_, xx, y + LINE1_Y, LBL1, LBL_C, SHD_C);
xx += LBL_W; xx += LBL_W;
font_->writeMonoDigits(xx, y + LINE1_Y, SCORE, SCORE_C, MONO_DIGIT_W); drawMonoShadowed(*font_, xx, y + LINE1_Y, SCORE, SCORE_C, SHD_C, MONO_DIGIT_W);
// --- Línia 2: "<vides>-<n> <tresors>-<n>" centrada --- // --- Línia 2: "<vides>-<n> <tresors>-<n>" centrada ---
const std::string V_LBL = Locale::get().text("hud.lives") + SEPARATOR; const std::string V_LBL = Locale::get().text("hud.lives") + SEPARATOR;
@@ -258,16 +367,21 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
const int T_VW = font_->lengthMonoDigits(T_VAL, MONO_DIGIT_W); const int T_VW = font_->lengthMonoDigits(T_VAL, MONO_DIGIT_W);
const int LINE2_W = V_LW + V_VW + FIELD_GAP + T_LW + T_VW; const int LINE2_W = V_LW + V_VW + FIELD_GAP + T_LW + T_VW;
xx = x + ((w - LINE2_W) / 2); xx = x + ((w - LINE2_W) / 2);
font_->writeColored(xx, y + LINE2_Y, V_LBL, UiConfig::get().hudLabel()); drawShadowed(*font_, xx, y + LINE2_Y, V_LBL, LBL_C, SHD_C);
xx += V_LW; xx += V_LW;
font_->writeMonoDigits(xx, y + LINE2_Y, V_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W); drawMonoShadowed(*font_, xx, y + LINE2_Y, V_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
xx += V_VW + FIELD_GAP; xx += V_VW + FIELD_GAP;
font_->writeColored(xx, y + LINE2_Y, T_LBL, UiConfig::get().hudLabel()); drawShadowed(*font_, xx, y + LINE2_Y, T_LBL, LBL_C, SHD_C);
xx += T_LW; xx += T_LW;
font_->writeMonoDigits(xx, y + LINE2_Y, T_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W); drawMonoShadowed(*font_, xx, y + LINE2_Y, T_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
} }
void Hud::renderIdleCenter(int x, int y, int w) const { void Hud::renderIdleCenter(int x, int y, int w) const {
const auto& ui = UiConfig::get();
const Uint8 LBL_C = ui.hudCenterLabel();
const Uint8 VAL_C = ui.hudCenterValue();
const Uint8 SHD_C = ui.hudCenterShadow();
// --- Línia 1: "<piramide>-<n>" centrada --- // --- Línia 1: "<piramide>-<n>" centrada ---
const std::string PIR_LBL = Locale::get().text("hud.pyramid") + SEPARATOR; const std::string PIR_LBL = Locale::get().text("hud.pyramid") + SEPARATOR;
const std::string PIR_VAL = std::to_string(pyramid_); const std::string PIR_VAL = std::to_string(pyramid_);
@@ -275,9 +389,9 @@ void Hud::renderIdleCenter(int x, int y, int w) const {
const int PV_W = font_->lengthMonoDigits(PIR_VAL, MONO_DIGIT_W); const int PV_W = font_->lengthMonoDigits(PIR_VAL, MONO_DIGIT_W);
const int LINE1_W = PL_W + PV_W; const int LINE1_W = PL_W + PV_W;
int xx = x + ((w - LINE1_W) / 2); int xx = x + ((w - LINE1_W) / 2);
font_->writeColored(xx, y + LINE1_Y, PIR_LBL, UiConfig::get().hudLabel()); drawShadowed(*font_, xx, y + LINE1_Y, PIR_LBL, LBL_C, SHD_C);
xx += PL_W; xx += PL_W;
font_->writeMonoDigits(xx, y + LINE1_Y, PIR_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W); drawMonoShadowed(*font_, xx, y + LINE1_Y, PIR_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
// --- Línia 2: "<sala>-<r>" centrada --- // --- Línia 2: "<sala>-<r>" centrada ---
const std::string SALA_LBL = Locale::get().text("hud.room") + SEPARATOR; const std::string SALA_LBL = Locale::get().text("hud.room") + SEPARATOR;
@@ -286,7 +400,7 @@ void Hud::renderIdleCenter(int x, int y, int w) const {
const int SV_W = font_->lengthMonoDigits(SALA_VAL, MONO_DIGIT_W); const int SV_W = font_->lengthMonoDigits(SALA_VAL, MONO_DIGIT_W);
const int LINE2_W = SL_W + SV_W; const int LINE2_W = SL_W + SV_W;
xx = x + ((w - LINE2_W) / 2); xx = x + ((w - LINE2_W) / 2);
font_->writeColored(xx, y + LINE2_Y, SALA_LBL, UiConfig::get().hudLabel()); drawShadowed(*font_, xx, y + LINE2_Y, SALA_LBL, LBL_C, SHD_C);
xx += SL_W; xx += SL_W;
font_->writeMonoDigits(xx, y + LINE2_Y, SALA_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W); drawMonoShadowed(*font_, xx, y + LINE2_Y, SALA_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
} }
+47 -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
@@ -143,6 +170,10 @@ class Hud {
// les constants de classe (no toca cap altre membre). // les constants de classe (no toca cap altre membre).
[[nodiscard]] static auto computeOffset(const Block& b) -> SDL_FPoint; [[nodiscard]] static auto computeOffset(const Block& b) -> SDL_FPoint;
// Estat actual del parpadeig dels prompts inactius. Llegit per
// renderIdleSide() per saltar el draw quan toca ocult.
[[nodiscard]] auto blinkVisible() const -> bool;
EventBus& bus_; EventBus& bus_;
std::vector<EventBus::SubId> subs_; std::vector<EventBus::SubId> subs_;
@@ -152,4 +183,12 @@ class Hud {
std::array<PlayerStats, 2> players_; std::array<PlayerStats, 2> players_;
int pyramid_{1}; int pyramid_{1};
int room_{1}; int room_{1};
// Acumulador continu (ms) per al parpadeig dels prompts inactius.
// Es manté entre canvis de mode (CAN_JOIN ↔ WAIT) per evitar salts
// visuals i sincronitza els dos slots — vegeu Hud::blinkVisible().
int blink_elapsed_ms_{0};
// Vides amb què s'arrenca un slot (`activatePlayer`). Cachejat al
// 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};
}; };
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more