Compare commits

...
91 Commits
Author SHA1 Message Date
JailDesigner d26abcb9d5 chore(version): bump 0.42.0 -> 0.43.0 2026-05-11 22:56:14 +02:00
JailDesigner a358d8ddbd Merge branch 'feat/assets-pipeline' 2026-05-11 22:55:50 +02:00
JailDesigner f7c4d62234 feat(screenshot): desa-les a la carpeta de sistema per plataforma 2026-05-11 22:50:45 +02:00
JailDesigner 06a49e09ba style(hud): rètol de sala daurat sobre banda violeta 2026-05-11 22:31:12 +02:00
JailDesigner 629baaaec3 feat(hud): famílies de color per bloc + ombra del text 2026-05-11 22:18:56 +02:00
JailDesigner 932d195eb0 feat(audio): so del sarcòfag amb pitch aleatori per obertura 2026-05-11 20:46:18 +02:00
JailDesigner 3e31a88f78 fix(anim): walk_* arrenca en frame distint del stand 2026-05-11 19:56:16 +02:00
JailDesigner cba46642c6 feat(mummy): SPAWN dirigit per l'animació raise 2026-05-11 19:47:28 +02:00
JailDesigner 8c84892250 art(dust): retocs al sprite 2026-05-11 17:44:10 +02:00
JailDesigner d14da0e61f fix(make): el target pack no canvia el build_type 2026-05-11 17:30:15 +02:00
JailDesigner 8bdc994b75 feat(render): sombra com a sprite 16x16 compartit 2026-05-11 17:29:05 +02:00
JailDesigner 6a70d6de9f style(hud): sentence case als prompts del slot inactiu 2026-05-11 16:32:57 +02:00
JailDesigner 2523661c8f refactor(game): unifica renderitzat dels rètols de transició via TextScreenSpec 2026-05-11 16:32:57 +02:00
JailDesigner fa27308897 chore(version): bump 0.41.0 -> 0.42.0 2026-05-11 13:34:01 +02:00
JailDesigner e58eb10d6d Merge branch 'feat/single-bucket-polymorphism'
Refactor del sistema d'entitats per substituir filtres i
dynamic_casts dispersos per dispatch polimòrfic.

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

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

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

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

Canvis:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:43:38 +02:00
JailDesigner b7811180ba feat(game): spawn inicial de ROOM_INTRO sense immunitat 2026-05-11 07:53:56 +02:00
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
JailDesigner babb111b6f chore(version): bump 0.36.0 -> 0.37.0 2026-05-08 09:59:23 +02:00
JailDesignerandClaude Opus 4.7 866e133aa9 Merge branch 'feat/logger'
Sistema de log unificat (Log:: wrapper sobre SDL_Log) substituint
els 176 std::cout/std::cerr dispersos per source/. API basada en
std::format, filtre runtime via AEE_LOG (sintaxi *:info,Tag:debug),
i marcadors literals via Log::raw().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:59:16 +02:00
JailDesignerandClaude Opus 4.7 170521aba6 feat(log): afegeix Log::raw() per a marcadors sense prefix
Recupera el comportament històric dels marcadors "Game start" i "Bye!"
que han de sortir pelats (no com a "App: Game start"). Log::raw(level, msg)
és una variant prima sobre SDL_LogMessage que no anteposa cap tag i cau a
SDL_LOG_CATEGORY_APPLICATION (filtrable per AEE_LOG global).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:54:13 +02:00
JailDesignerandClaude Opus 4.7 c8882a3f6e refactor(log): migra els subsistemes restants i elimina iostream
Acaba la migració dels std::cout/cerr a Log::*:

- core/locale, core/ui/ui_config, core/rendering/{screen,text,gif,
  surface, sprite/animated_sprite, sdl3gpu/*}.
- game/{options, debug_config, editor/editor_config, levels/level_manager,
  map/{map,tilemap,paint_layer}, entities/entity_manager_loadlevel}.

Severitats coherents: warns per a configs absents amb fallback,
errors per a YAML malformat o E/S fallida, infos per a estats.

Resultat:
- 0 occurrences de std::cout/std::cerr a source/.
- Cap #include <iostream> orfà (eliminat de surface.cpp).
- Output funcional idèntic al històric; AEE_LOG filtra (ex.
  `AEE_LOG=warn` només mostra warnings, `AEE_LOG=*:warn,Tag:info`
  combina globals i overrides).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:34:24 +02:00
JailDesignerandClaude Opus 4.7 5fce52e0ad refactor(log): migra game/scenes i game/ui a Log::
- logo_scene, title_scene, intro_scene, room_fsm: missatges de
  cicle d'escena com a Log::info amb tag "scene" o "RoomFsm".
  Eliminats els TODO obsolets sobre el logger pendent.
- game_scene (16 logs): events del bus (LEVEL COMPLETE, PLAYER HIT,
  PLAYER KILLED) com a info; transicions de ROOM com a info; warns
  als casos d'àudio absent o música no arrencada.
- hud (3 logs): init i events com a info.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:24:11 +02:00
JailDesignerandClaude Opus 4.7 0b708f1515 refactor(log): migra core/input i core/audio a Log::
- gamepad_manager.cpp (12 logs): connect/disconnect com a info,
  errors d'open/init com a error, warning per BD no carregada.
- key_config.cpp (5 logs): error per arxius malformats, info
  per al recompte de bindings carregats.
- gamepad_bindings.cpp (4 logs): error/info anàleg.
- audio_effects.cpp (2 errors de format).
- sound_effects_config.cpp (3 logs): warn quan no hi ha presets,
  error de parse.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:17:12 +02:00
JailDesignerandClaude Opus 4.7 6e99fd0a8b refactor(log): migra core/resources i core/system a Log::
- resource_pack.cpp i resource_cache.cpp: tots els std::cerr passen a
  Log::error.
- director.cpp: std::cout/cerr/fprintf → Log::info / error / critical
  segons severitat.
- printWithDots (utils.cpp): ara és un wrapper sobre Log::info que
  manté l'amplada total (~50 chars) reproduint l'alineació de punts.
  Els callers pasen el tag sense " : " (ja l'afegeix el logger).
- pack_resources: afegit log.cpp i SDL3 al target perquè
  resource_pack.cpp ara depèn de Log::.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:10:03 +02:00
JailDesignerandClaude Opus 4.7 1243b457be feat(log): introdueix Log:: wrapper sobre SDL_Log
Logger lleuger a source/utils/log.{hpp,cpp} amb API basada en std::format
i filtre runtime via la variable d'entorn AEE_LOG (sintaxi
*:info,Resource::Helper:warn,scene:debug). Substitueix gradualment els
std::cout/cerr dispersos.

- Categories CUSTOM registrades sota demanda; per defecte INFO.
- Categories SDL natives no es toquen tret que l'usuari posi *:level.
- Migra resource_helper.cpp i els marcadors d'inici/fi (main.cpp) com a
  testimoni; la resta de subsistemes es migra en commits següents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:01:09 +02:00
JailDesigner c910a16834 tune(whip): puja swing a 320ms (80ms × 4 frames) 2026-05-07 20:36:15 +02:00
JailDesigner 0ec1a2548a feat(mummy): so mummy_hurt en colpejada de fuet 2026-05-07 20:25:23 +02:00
JailDesigner 4ea8ab7b6d art(whip): actualitza els 4 GIFs direccionals del fuet 2026-05-07 20:12:57 +02:00
JailDesigner 05465b791e refactor(data): reorganitza data/ i centralitza paths a defaults.hpp 2026-05-07 19:28:28 +02:00
JailDesigner 9949096fba chore(rename): aee_2026 -> aee_arcade 2026-05-07 18:39:02 +02:00
JailDesigner ea5762265e chore(version): bump 0.35.0 -> 0.36.0 2026-05-07 16:46:11 +02:00
JailDesigner 78732042cb merge: feature/logo-timeline-redesign 2026-05-07 16:45:34 +02:00
JailDesigner f10671d9c0 feat(logo): timeline lineal de 7 fases amb glitch tail i durades al YAML 2026-05-07 16:44:34 +02:00
JailDesigner 7d5a4cee8e chore(version): bump 0.34.0 -> 0.35.0 2026-05-07 14:07:44 +02:00
JailDesigner d560623e00 merge: feature/logo-scanline-reveal 2026-05-07 14:07:26 +02:00
JailDesigner 3f88dfaaee feat(logo): afegit data/sounds/glitch.wav 2026-05-07 14:07:21 +02:00
JailDesigner e1bd75e00d feat(logo): so glitch.wav sincronitzat amb el scanline reveal 2026-05-07 14:04:48 +02:00
JailDesigner e32cff9dac feat(logo): scanline reveal CRT amb glitch, bursts VHS i pulse de banda 2026-05-07 13:52:57 +02:00
JailDesigner 243ced7498 feat(title): seqüència d'intro temporitzada amb slide-in del peu 2026-05-07 13:08:13 +02:00
JailDesigner fceeb11cdd feat(title): cosmètics i posicions a YAML, font Tiny5 al copyright 2026-05-07 11:51:00 +02:00
JailDesigner 233fcb8226 feat(title): game_logo.gif al lloc del text i font PixeloidSans-Bold per al copyright 2026-05-06 22:35:27 +02:00
JailDesigner 8bc45ccedc feat(logo): FSM amb fade-in/middle/fade-out (500/5000/500 ms) i música del títol a 2,5 s del centre 2026-05-06 20:01:31 +02:00
JailDesigner f7ab6a641a feat(logo,menu): reiniciar des de servei → LOGO amb reset (atura música) i logo_jailgames.gif centrat 2026-05-06 19:54:30 +02:00
JailDesigner 8eaa91f309 chore(version): bump 0.33.0 -> 0.34.0 2026-05-06 19:38:46 +02:00
JailDesigner 23fdf6e1bd merge: refactor escena → flux centralitzat (SceneId/ExitRequest), timeline pur a RoomFsm, FadeToBlack reutilitzat i fade-in inicial a TITLE 2026-05-06 19:36:09 +02:00
JailDesigner 1431f27cfa feat(title): fade-in inicial de negre a escena (500 ms, configurable a ui.yaml) 2026-05-06 19:34:59 +02:00
JailDesigner 29cb4c2211 refactor(room-fsm): timeline pur amb music_tail; fades INTRO/OUTRO via Transitions::FadeToBlack i sense callbacks de fi de música 2026-05-06 19:25:19 +02:00
JailDesigner 3f09858203 refactor(audio,transitions): durada de música pre-calculada al loadMusic i Direction (FADE_IN/FADE_OUT) a FadeToBlack 2026-05-06 19:10:18 +02:00
JailDesigner b6d6ee471c refactor(scenes): flux centralitzat amb SceneId+ExitRequest; TitleScene attract → LOGO amb fade-to-black 2026-05-06 18:33:31 +02:00
JailDesigner 25ce524470 merge: feat/scene-title — TITLE com a escena jugable amb música, SFX i FSM IDLE/STARTING 2026-05-06 18:15:17 +02:00
JailDesigner 921e10bbb3 feat(title): música title.ogg amb fade-out, SFX player_start al START i bombament continu del stream sortint per evitar talls quan SDL drena a 2x 2026-05-06 17:44:30 +02:00
JailDesigner 36fc496226 feat(title): parpadeig STARTING amb cicle 50/50 i període configurable 2026-05-06 14:04:27 +02:00
JailDesigner 0618452224 feat(transitions): mosaic dissolve fade-out al final de la fase STARTING (configurable a ui.yaml) 2026-05-06 13:54:09 +02:00
JailDesigner ca64115bfe feat(title): FSM IDLE/STARTING amb START per jugador i propagació de StartingPlayers 2026-05-06 13:28:34 +02:00
JailDesigner 1fc3ab8e31 feat(effects): heat-haze sinusoidal al fons del títol (configurable a ui.yaml) 2026-05-06 11:48:29 +02:00
JailDesigner 9233bd8d75 feat(title): fons title_bg.gif i outline 4-dir off-white als textos taronges 2026-05-06 11:27:45 +02:00
JailDesigner e7a024bcdb fix(fonts): regenerar ari amb box_height auto (14, no 11) — el override forçat tallava els glifos 2026-05-06 10:15:49 +02:00
JailDesigner 158968e2c2 feat(title): peu amb minilogo i copyright; fonts ari regenerades amb © 2026-05-06 10:00:30 +02:00
JailDesigner dadbee9bad feat(title): primera passada — fons casi negre, títol+ARCADE i prompt START intermitent (configurable a ui.yaml) 2026-05-06 09:01:28 +02:00
JailDesigner 5710568af8 feat(input): binding START per jugador (teclat 1/2 fix, mando remapejable; pausa només per F11) 2026-05-06 08:23:01 +02:00
JailDesigner 165499b9d4 Merge branch 'feat/player-immunity' 2026-05-05 13:19:56 +02:00
166 changed files with 5836 additions and 1036 deletions

No files matched your search

+2 -2
View File
@@ -8,8 +8,8 @@ dist/
*.dylib *.dylib
*.dll *.dll
*.exe *.exe
aee_2026 aee_arcade
_aee_2026 _aee_arcade
.vscode/ .vscode/
.idea/ .idea/
.cache/ .cache/
+19 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
project(aee_2026 VERSION 0.1.0 LANGUAGES CXX) project(aee_arcade VERSION 0.1.0 LANGUAGES CXX)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE) set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE)
@@ -92,6 +92,11 @@ set(APP_SOURCES
source/core/rendering/sprite/moving_sprite.cpp source/core/rendering/sprite/moving_sprite.cpp
source/core/rendering/sprite/animated_sprite.cpp source/core/rendering/sprite/animated_sprite.cpp
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/scan_reveal.cpp
source/core/rendering/effects/shadow.cpp
source/core/rendering/transitions/mosaic.cpp
source/core/rendering/transitions/fade_to_black.cpp
source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp
source/core/rendering/sdl3gpu/viewport.cpp source/core/rendering/sdl3gpu/viewport.cpp
source/core/rendering/sdl3gpu/pipelines/crtpi_pipeline.cpp source/core/rendering/sdl3gpu/pipelines/crtpi_pipeline.cpp
@@ -100,18 +105,22 @@ set(APP_SOURCES
source/core/resources/resource_pack.cpp source/core/resources/resource_pack.cpp
source/core/resources/palette_loader.cpp source/core/resources/palette_loader.cpp
source/utils/easing.cpp source/utils/easing.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
@@ -214,8 +223,10 @@ target_link_libraries(${PROJECT_NAME} PRIVATE SDL3::SDL3)
add_executable(pack_resources EXCLUDE_FROM_ALL add_executable(pack_resources EXCLUDE_FROM_ALL
tools/pack_resources/pack_resources.cpp tools/pack_resources/pack_resources.cpp
source/core/resources/resource_pack.cpp source/core/resources/resource_pack.cpp
source/utils/log.cpp
) )
target_include_directories(pack_resources PRIVATE "${CMAKE_SOURCE_DIR}/source") target_include_directories(pack_resources PRIVATE "${CMAKE_SOURCE_DIR}/source")
target_link_libraries(pack_resources PRIVATE SDL3::SDL3)
target_compile_options(pack_resources PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(pack_resources PRIVATE -Wall -Wextra -Wpedantic)
# --- REGENERACIÓ AUTOMÀTICA DE build/resources.pack --- # --- REGENERACIÓ AUTOMÀTICA DE build/resources.pack ---
@@ -357,9 +368,11 @@ if(BUILD_TESTS)
add_executable(test_entities_smoke add_executable(test_entities_smoke
tests/test_entities_smoke.cpp tests/test_entities_smoke.cpp
tests/hole_player_stubs.cpp tests/hole_player_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/map/map.cpp source/game/map/map.cpp
source/utils/log.cpp
${ENTITY_SOURCES_BASE} ${ENTITY_SOURCES_BASE}
) )
target_include_directories(test_entities_smoke PRIVATE target_include_directories(test_entities_smoke PRIVATE
@@ -385,9 +398,12 @@ if(BUILD_TESTS)
tests/test_whip_attack.cpp tests/test_whip_attack.cpp
tests/whip_attack_player_stubs.cpp tests/whip_attack_player_stubs.cpp
tests/hole_player_stubs.cpp tests/hole_player_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
${ENTITY_SOURCES_BASE} ${ENTITY_SOURCES_BASE}
source/game/entities/concrete/whip_attack.cpp source/game/entities/concrete/whip_attack.cpp
) )
@@ -412,6 +428,7 @@ if(BUILD_TESTS)
source/core/input/key_config.cpp source/core/input/key_config.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/utils/log.cpp
) )
target_include_directories(test_scene_flow PRIVATE target_include_directories(test_scene_flow PRIVATE
"${CMAKE_SOURCE_DIR}/source" "${CMAKE_SOURCE_DIR}/source"
@@ -451,6 +468,7 @@ if(BUILD_TESTS)
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/utils/easing.cpp source/utils/easing.cpp
source/utils/log.cpp
) )
target_include_directories(test_game_fsm PRIVATE target_include_directories(test_game_fsm PRIVATE
"${CMAKE_SOURCE_DIR}/source" "${CMAKE_SOURCE_DIR}/source"
+8 -2
View File
@@ -1,4 +1,4 @@
PROJECT := aee_2026 PROJECT := aee_arcade
BUILDDIR := build BUILDDIR := build
# Detecció de plataforma. En Windows CMake defaulteja a "NMake Makefiles" # Detecció de plataforma. En Windows CMake defaulteja a "NMake Makefiles"
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
# AEE 2026 — Tecles d'UI (Global Inputs) # AEE ARCADE — Tecles d'UI (Global Inputs)
# #
# Mapeig id → codi SDL (el que reconeix SDL_GetScancodeFromName). Les tecles # Mapeig id → codi SDL (el que reconeix SDL_GetScancodeFromName). Les tecles
# de moviment del jugador viuen a part (Defaults::KeysGame); ací només hi ha # de moviment del jugador viuen a part (Defaults::KeysGame); ací només hi ha
File renamed without changes.
+40 -7
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
@@ -58,6 +58,15 @@ fonts:
gif: "data/fonts/ari-w9500-condensed-display.gif" gif: "data/fonts/ari-w9500-condensed-display.gif"
fnt: "data/fonts/ari-w9500-condensed-display.fnt" fnt: "data/fonts/ari-w9500-condensed-display.fnt"
kerning: 0 kerning: 0
# Copyright de la TITLE: pixel-art estricte (Tiny5-Regular size 5,
# caixa 3×6 px) generat amb font-gen. Diferenciada de la `placeholder`
# que pinta el "press start" — així la jerarquia tipogràfica del peu
# queda neta.
copyright:
name: "Tiny5-Regular"
gif: "data/fonts/Tiny5-Regular.gif"
fnt: "data/fonts/Tiny5-Regular.fnt"
kerning: 1
hud: hud:
bg_enabled: false bg_enabled: false
@@ -65,6 +74,24 @@ hud:
flash_ms: 250 # durada del flash quan un jugador rep un hit flash_ms: 250 # durada del flash quan un jugador rep un hit
shake_ms: 300 # durada del shake que acompanya el flash shake_ms: 300 # durada del shake que acompanya el flash
# Famílies de color del HUD per bloc. Cada bloc té `label` (to apagat
# per etiquetes), `value` (to viu per a la xifra) i `shadow` (to fosc
# del mateix matís — efecte embossing 1 px a baix-dreta). Si manca un
# camp, cau al default compilat a `Defaults::UI`. El `colors.hud_flash`
# és comú a totes tres famílies.
hud_p1:
label: 7 # vermell pur
value: 183 # crema-préssec
shadow: 2 # vermell fosc
hud_center:
label: 29 # ambre cremat
value: 55 # or brillant
shadow: 10 # marró cremat
hud_p2:
label: 248 # cian pur
value: 253 # blau pàl·lid
shadow: 64 # blau marí
# Geometria del popup superior (Notifier). En píxels del canvas, llevat de # Geometria del popup superior (Notifier). En píxels del canvas, llevat de
# slide_ms (durada en ms de l'animació d'entrada i d'eixida; constant # slide_ms (durada en ms de l'animació d'entrada i d'eixida; constant
# independentment de la mida de la caixa). El padding vertical és asimètric a # independentment de la mida de la caixa). El padding vertical és asimètric a
@@ -92,9 +119,15 @@ pause:
enter_curve: outCubic enter_curve: outCubic
exit_curve: inCubic exit_curve: inCubic
# Sons del menú de servei. Els fitxers viuen a data/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ú.
sounds: sounds:
menu_select: "menu_select.wav" menu_select: "menu_select.wav"
menu_accept: "menu_accept.wav" menu_accept: "menu_accept.wav"
# Disparat quan un jugador prem START a la TITLE (primera pulsació o
# la del segon jugador dins de la finestra de STARTING).
player_start: "player_start.wav"
# Soroll/scratch sincronitzat amb la fase SCAN de LogoScene (scanline
# reveal). Es dispara al constructor de l'escena.
logo_glitch: "glitch.wav"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+10
View File
@@ -36,10 +36,17 @@ animations:
walk_north: { frames: [4, 5, 6, 7], fps: 8, loop: true } walk_north: { frames: [4, 5, 6, 7], fps: 8, loop: true }
walk_east: { frames: [8, 9, 10, 11], fps: 8, loop: true } walk_east: { frames: [8, 9, 10, 11], fps: 8, loop: true }
walk_west: { frames: [8, 9, 10, 11], fps: 8, loop: true, flip_h: true } walk_west: { frames: [8, 9, 10, 11], fps: 8, loop: true, flip_h: true }
raise: { frames: [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27], fps: 8 , loop: false }
ai: ai:
type: corridor_wanderer type: corridor_wanderer
direction_choice: random direction_choice: random
initial_direction: east initial_direction: east
# Estat SPAWN: la mòmia s'instancia al pixel_rect_ inicial quan
# GameScene crida `spawnPendingActors()` (subfase text-exit de
# ROOM_INTRO, fase 4) amb l'animació `raise` activa. La duració del
# SPAWN la marca la pròpia animació (no-loop): al `completed()`,
# transita a ROAMING i comença a deambular.
stats: stats:
speed: 30 speed: 30
damage: 1 damage: 1
@@ -58,3 +65,6 @@ hurt:
duration_ms: 600 duration_ms: 600
speed: 60 speed: 60
whippable: true whippable: true
# So opcional: basename del fitxer dins `data/audio/sounds/`. Es dispara
# un sol cop per fuetada quan la mòmia entra a HURT. Si manca, no sona.
sound: mummy_hurt.wav
File renamed without changes.
File renamed without changes.
@@ -30,20 +30,20 @@ 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 }
hurt_east: { frames: [8, 9, 10, 11], frame_ms: 100 } hurt_east: { frames: [8, 9, 10, 11], frame_ms: 100 }
hurt_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true } hurt_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true }
attack_south: { frames: [20, 21, 22, 23], frame_ms: 50 } attack_south: { frames: [20, 21, 22, 23], frame_ms: 80 }
attack_north: { frames: [24, 25, 26, 27], frame_ms: 50 } attack_north: { frames: [24, 25, 26, 27], frame_ms: 80 }
attack_east: { frames: [28, 29, 30, 31], frame_ms: 50 } attack_east: { frames: [28, 29, 30, 31], frame_ms: 80 }
attack_west: { frames: [28, 29, 30, 31], frame_ms: 50, flip_h: true } attack_west: { frames: [28, 29, 30, 31], frame_ms: 80, flip_h: true }
recover_south: { frames: [23] } recover_south: { frames: [23] }
recover_north: { frames: [27] } recover_north: { frames: [27] }
@@ -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
@@ -119,7 +116,7 @@ hurt:
# recuperació en què la hitbox ja no fa dany. # recuperació en què la hitbox ja no fa dany.
whip: whip:
range_tiles: 3 range_tiles: 3
swing_ms: 200 swing_ms: 320
cooldown_ms: 200 cooldown_ms: 200
damage: 1 damage: 1
sound: whip.wav sound: whip.wav
@@ -136,7 +133,7 @@ death:
# poden ferir ni matar el jugador (els forats sí que el maten — la # poden ferir ni matar el jugador (els forats sí que el maten — la
# caiguda no és dany convencional). Visualment es manifesta amb un # caiguda no és dany convencional). Visualment es manifesta amb un
# parpadeig que alterna la paleta normal del sprite amb la paleta # parpadeig que alterna la paleta normal del sprite amb la paleta
# `palette` (basename de `data/player/<name>.pal`) cada # `palette` (basename de `data/entities/player/<name>.pal`) cada
# `blink_period_ms` ms. Si `duration_ms` és 0 (o el bloc manca), la # `blink_period_ms` ms. Si `duration_ms` és 0 (o el bloc manca), la
# mecànica queda desactivada i el comportament és l'antic. # mecànica queda desactivada i el comportament és l'antic.
immunity: immunity:
File renamed without changes.
@@ -30,20 +30,20 @@ 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 }
hurt_east: { frames: [8, 9, 10, 11], frame_ms: 100 } hurt_east: { frames: [8, 9, 10, 11], frame_ms: 100 }
hurt_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true } hurt_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true }
attack_south: { frames: [20, 21, 22, 23], frame_ms: 50 } attack_south: { frames: [20, 21, 22, 23], frame_ms: 80 }
attack_north: { frames: [24, 25, 26, 27], frame_ms: 50 } attack_north: { frames: [24, 25, 26, 27], frame_ms: 80 }
attack_east: { frames: [28, 29, 30, 31], frame_ms: 50 } attack_east: { frames: [28, 29, 30, 31], frame_ms: 80 }
attack_west: { frames: [28, 29, 30, 31], frame_ms: 50, flip_h: true } attack_west: { frames: [28, 29, 30, 31], frame_ms: 80, flip_h: true }
recover_south: { frames: [23] } recover_south: { frames: [23] }
recover_north: { frames: [27] } recover_north: { frames: [27] }
@@ -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
@@ -119,7 +116,7 @@ hurt:
# recuperació en què la hitbox ja no fa dany. # recuperació en què la hitbox ja no fa dany.
whip: whip:
range_tiles: 3 range_tiles: 3
swing_ms: 200 swing_ms: 320
cooldown_ms: 200 cooldown_ms: 200
damage: 1 damage: 1
sound: whip.wav sound: whip.wav
@@ -136,7 +133,7 @@ death:
# poden ferir ni matar el jugador (els forats sí que el maten — la # poden ferir ni matar el jugador (els forats sí que el maten — la
# caiguda no és dany convencional). Visualment es manifesta amb un # caiguda no és dany convencional). Visualment es manifesta amb un
# parpadeig que alterna la paleta normal del sprite amb la paleta # parpadeig que alterna la paleta normal del sprite amb la paleta
# `palette` (basename de `data/player/<name>.pal`) cada # `palette` (basename de `data/entities/player/<name>.pal`) cada
# `blink_period_ms` ms. Si `duration_ms` és 0 (o el bloc manca), la # `blink_period_ms` ms. Si `duration_ms` és 0 (o el bloc manca), la
# mecànica queda desactivada i el comportament és l'antic. # mecànica queda desactivada i el comportament és l'antic.
immunity: immunity:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.6 KiB

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

After

Width:  |  Height:  |  Size: 98 B

+2 -2
View File
@@ -25,7 +25,7 @@ sprites:
# Animació compartida entre les 4 direccions: la seqüència de frames i les # Animació compartida entre les 4 direccions: la seqüència de frames i les
# durades són idèntiques; només canvia quina surface/mida es clipa. # durades són idèntiques; només canvia quina surface/mida es clipa.
# - `frames` : índex de tile dins l'spritesheet (graella deduïda). # - `frames` : índex de tile dins l'spritesheet (graella deduïda).
# - `frame_ms` : durada uniforme per frame; 50 ms × 4 ≈ 200 ms swing. # - `frame_ms` : durada uniforme per frame; 80 ms × 4 = 320 ms swing.
# - `loop: false` : el látigo viu una animació i mor (`markDead`). # - `loop: false` : el látigo viu una animació i mor (`markDead`).
# - `active_frames` : valors dins de `frames` durant els quals la # - `active_frames` : valors dins de `frames` durant els quals la
# hitbox aplica dany. Wind-up (0) i recovery (3) no # hitbox aplica dany. Wind-up (0) i recovery (3) no
@@ -33,6 +33,6 @@ sprites:
animations: animations:
swing: swing:
frames: [0, 1, 2, 3] frames: [0, 1, 2, 3]
frame_ms: 50 frame_ms: 80
loop: false loop: false
active_frames: [1, 2] active_frames: [1, 2]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 B

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 327 B

+133
View File
@@ -0,0 +1,133 @@
# Font: Tiny5-Regular — generado desde Tiny5-Regular.ttf size 8
# Generado con font_gen.py
box_width 6
box_height 9
columns 15
# codepoint_decimal ancho_visual
32 2 # U+0020
33 1 # !
34 3 # "
35 5 # #
36 3 # $
37 4 # %
38 4 # &
39 1 # '
40 2 # (
41 2 # )
42 3 # *
43 3 # +
44 2 # ,
45 3 # -
46 1 # .
47 2 # /
48 3 # 0
49 2 # 1
50 3 # 2
51 3 # 3
52 3 # 4
53 3 # 5
54 3 # 6
55 3 # 7
56 3 # 8
57 3 # 9
58 1 # :
59 2 # ;
60 3 # <
61 3 # =
62 3 # >
63 3 # ?
64 4 # @
65 4 # A
66 4 # B
67 4 # C
68 4 # D
69 4 # E
70 4 # F
71 4 # G
72 4 # H
73 1 # I
74 3 # J
75 4 # K
76 3 # L
77 5 # M
78 4 # N
79 4 # O
80 4 # P
81 4 # Q
82 4 # R
83 4 # S
84 3 # T
85 4 # U
86 3 # V
87 5 # W
88 3 # X
89 3 # Y
90 3 # Z
91 2 # [
92 2 # \
93 2 # ]
94 3 # ^
95 3 # _
96 2 # `
97 3 # a
98 3 # b
99 3 # c
100 3 # d
101 3 # e
102 2 # f
103 3 # g
104 3 # h
105 1 # i
106 1 # j
107 3 # k
108 1 # l
109 5 # m
110 3 # n
111 3 # o
112 3 # p
113 3 # q
114 2 # r
115 3 # s
116 2 # t
117 3 # u
118 3 # v
119 5 # w
120 3 # x
121 3 # y
122 3 # z
123 3 # {
124 1 # |
125 3 # }
126 2 # ~
192 4 # À
193 4 # Á
200 4 # È
201 4 # É
204 2 # Ì
205 2 # Í
210 4 # Ò
211 4 # Ó
217 4 # Ù
218 4 # Ú
209 4 # Ñ
199 4 # Ç
224 3 # à
225 3 # á
232 3 # è
233 3 # é
236 2 # ì
237 2 # í
242 3 # ò
243 3 # ó
249 3 # ù
250 3 # ú
241 3 # ñ
231 3 # ç
161 1 # ¡
191 3 # ¿
171 5 # «
187 5 # »
183 1 # ·
169 3 # ©
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+6 -5
View File
@@ -1,8 +1,8 @@
# Font: ari-w9500-condensed-display — generado desde ari-w9500-condensed-display.ttf size 11 # Font: ari-w9500-condensed-display — generado desde ari-w9500-condensed-display.ttf size 11
# Generado con tools/font_gen/font_gen.py # Generado con font_gen.py
box_width 12 box_width 12
box_height 11 box_height 14
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 3 # Ì 204 4 # Ì
205 3 # Í 205 4 # Í
210 9 # Ò 210 9 # Ò
211 9 # Ó 211 9 # Ó
217 7 # Ù 217 7 # Ù
@@ -118,7 +118,7 @@ columns 15
232 7 # è 232 7 # è
233 7 # é 233 7 # é
236 4 # ì 236 4 # ì
237 3 # í 237 4 # í
242 7 # ò 242 7 # ò
243 7 # ó 243 7 # ó
249 7 # ù 249 7 # ù
@@ -130,3 +130,4 @@ columns 15
171 10 # « 171 10 # «
187 10 # » 187 10 # »
183 3 # · 183 3 # ·
169 10 # ©
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+6 -5
View File
@@ -1,8 +1,8 @@
# Font: ari-w9500-display — generado desde ari-w9500-display.ttf size 11 # Font: ari-w9500-display — generado desde ari-w9500-display.ttf size 11
# Generado con tools/font_gen/font_gen.py # Generado con font_gen.py
box_width 12 box_width 12
box_height 11 box_height 14
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 3 # Ì 204 4 # Ì
205 3 # Í 205 4 # Í
210 10 # Ò 210 10 # Ò
211 10 # Ó 211 10 # Ó
217 8 # Ù 217 8 # Ù
@@ -118,7 +118,7 @@ columns 15
232 8 # è 232 8 # è
233 8 # é 233 8 # é
236 4 # ì 236 4 # ì
237 3 # í 237 4 # í
242 8 # ò 242 8 # ò
243 8 # ó 243 8 # ó
249 8 # ù 249 8 # ù
@@ -130,3 +130,4 @@ columns 15
171 10 # « 171 10 # «
187 10 # » 187 10 # »
183 3 # · 183 3 # ·
169 10 # ©
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+25 -2
View File
@@ -1,4 +1,4 @@
# AEE 2026 — Català # AEE ARCADE — Català
# lang: ca # lang: ca
notification: notification:
@@ -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"
@@ -38,6 +38,17 @@ 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.
title:
press_start: "Polsa START per a començar"
# Textos de la transició d'entrada/sortida de sala (FSM de GameScene). # Textos de la transició d'entrada/sortida de sala (FSM de GameScene).
# Tant l'INTRO com l'OUTRO es renderitzen com a dues línies separades # Tant l'INTRO com l'OUTRO es renderitzen com a dues línies separades
@@ -49,6 +60,12 @@ game:
room_intro_room: "Sala {r}" room_intro_room: "Sala {r}"
room_complete_line1: "Fase" room_complete_line1: "Fase"
room_complete_line2: "Completada" room_complete_line2: "Completada"
# Rètol del game over (sub-FSM de la GameScene). El countdown es
# pinta com a segona línia (un enter) durant la fase 1 de
# CONTINUE_PROMPT — només cal traduir el rètol superior.
continue_prompt: "Continuar?"
game_over_line1: "Fi del"
game_over_line2: "Joc"
menu: menu:
titles: titles:
@@ -58,6 +75,7 @@ menu:
controls: "Controls" controls: "Controls"
keyboard: "Teclat" keyboard: "Teclat"
gamepad: "Comandament" gamepad: "Comandament"
tweaks: "Jugabilitat"
system: "Sistema" system: "Sistema"
labels: labels:
player_1: "Jugador 1" player_1: "Jugador 1"
@@ -69,6 +87,7 @@ menu:
controls_submenu: "Controls" controls_submenu: "Controls"
keyboard_submenu: "Teclat" keyboard_submenu: "Teclat"
gamepad_submenu: "Comandament" gamepad_submenu: "Comandament"
tweaks_submenu: "Jugabilitat"
system_submenu: "Sistema" system_submenu: "Sistema"
move_up: "Amunt" move_up: "Amunt"
move_down: "Avall" move_down: "Avall"
@@ -76,6 +95,7 @@ menu:
move_right: "Dreta" move_right: "Dreta"
jump: "Saltar" jump: "Saltar"
attack: "Atacar" attack: "Atacar"
start: "Start"
service: "Opcions" service: "Opcions"
gamepad_p1: "Comandament P1" gamepad_p1: "Comandament P1"
gamepad_p2: "Comandament P2" gamepad_p2: "Comandament P2"
@@ -96,6 +116,9 @@ menu:
music_volume: "Volum música" music_volume: "Volum música"
sounds: "Sons" sounds: "Sons"
sounds_volume: "Volum sons" sounds_volume: "Volum sons"
whip_pierces_walls: "Látec travessa sarcs"
friendly_fire: "Foc amic"
players_collide: "Col·lisió entre jugadors"
restart: "Reiniciar" restart: "Reiniciar"
exit_game: "Eixir del joc" exit_game: "Eixir del joc"
values: values:
+25 -2
View File
@@ -1,4 +1,4 @@
# AEE 2026 — English # AEE ARCADE — English
# lang: en # lang: en
notification: notification:
@@ -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"
@@ -38,6 +38,17 @@ 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.
title:
press_start: "Press START to begin"
# Room transition texts (GameScene FSM intro/outro). Both INTRO and # Room transition texts (GameScene FSM intro/outro). Both INTRO and
# OUTRO render as two independent lines with their own animation. # OUTRO render as two independent lines with their own animation.
@@ -48,6 +59,12 @@ game:
room_intro_room: "Room {r}" room_intro_room: "Room {r}"
room_complete_line1: "Stage" room_complete_line1: "Stage"
room_complete_line2: "Complete" room_complete_line2: "Complete"
# Game over labels (sub-FSM in GameScene). The countdown is drawn as
# a numeric second line during CONTINUE_PROMPT phase 1, so only the
# top label needs translation.
continue_prompt: "Continue?"
game_over_line1: "Game"
game_over_line2: "Over"
menu: menu:
titles: titles:
@@ -57,6 +74,7 @@ menu:
controls: "Controls" controls: "Controls"
keyboard: "Keyboard" keyboard: "Keyboard"
gamepad: "Gamepad" gamepad: "Gamepad"
tweaks: "Gameplay"
system: "System" system: "System"
labels: labels:
player_1: "Player 1" player_1: "Player 1"
@@ -68,6 +86,7 @@ menu:
controls_submenu: "Controls" controls_submenu: "Controls"
keyboard_submenu: "Keyboard" keyboard_submenu: "Keyboard"
gamepad_submenu: "Gamepad" gamepad_submenu: "Gamepad"
tweaks_submenu: "Gameplay"
system_submenu: "System" system_submenu: "System"
move_up: "Up" move_up: "Up"
move_down: "Down" move_down: "Down"
@@ -75,6 +94,7 @@ menu:
move_right: "Right" move_right: "Right"
jump: "Jump" jump: "Jump"
attack: "Attack" attack: "Attack"
start: "Start"
service: "Options" service: "Options"
gamepad_p1: "Gamepad P1" gamepad_p1: "Gamepad P1"
gamepad_p2: "Gamepad P2" gamepad_p2: "Gamepad P2"
@@ -95,6 +115,9 @@ menu:
music_volume: "Music volume" music_volume: "Music volume"
sounds: "Sounds" sounds: "Sounds"
sounds_volume: "Sound volume" sounds_volume: "Sound volume"
whip_pierces_walls: "Whip pierces sarcs"
friendly_fire: "Friendly fire"
players_collide: "Player collision"
restart: "Restart" restart: "Restart"
exit_game: "Exit game" exit_game: "Exit game"
values: values:
-48
View File
@@ -1,48 +0,0 @@
# Configuració de la GameScene: timings i corbes de la FSM de sala
# (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria
# i font del text es reutilitzen del tema UI (data/ui.yaml > pause +
# notification). Si manca el fitxer o alguna clau, GameScene cau als
# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp.
room_intro:
fade_in_ms: 500 # negre opac → món visible
text_delay_ms: 500 # espera amb el món net abans de sortir el text
text_enter_ms: 400 # text esquerra → centre
text_hold_ms: 2000 # text quiet al centre
text_exit_ms: 400 # text centre → fora per l'esquerra
line_gap_px: -4 # px extra entre les dues línies (piràmide / sala)
# Delays d'spawn aeri **mesurats des de la fi de la música d'INTRO**
# (callback `Audio::setOnMusicEnded`). 0 = cau al moment exacte que
# `room_intro.ogg` acaba; valors > 0 escalonen els jugadors. En mode
# single-player s'usa només `player_spawn_delay_ms`.
player_spawn_delay_ms: 0
player2_spawn_delay_ms: 300
# Preset de reverb (catàleg a `data/sounds.yaml`) aplicat al primer
# LAND de cada jugador durant la cinemàtica d'entrada. La sala dona
# "més detall" sonor a l'INTRO; els salts del joc i els respawns
# post-mort cauen al so sec. Buit o absent = sense reverb.
land_reverb: stone_hall
room_outro:
text_enter_ms: 400
text_hold_ms: 2000
text_exit_ms: 400
fade_out_ms: 500 # món visible → negre opac
line_gap_px: -4 # px extra entre les dues línies (fase / completada)
black_hold_ms: 500 # pausa en negre abans d'entrar a la pròxima sala
text:
# Noms d'easing tal com els resol UiConfig::curveByName (vegeu
# `data/ui.yaml`). El text entra suau i surt accelerant.
enter_curve: outCubic
exit_curve: inCubic
# Panel translúcid pintat sota el rètol de transició (INTRO i OUTRO).
# Va centrat verticalment al centre del playfield i ocupa tota
# l'amplada del canvas. Creix d'altura amb la mateixa enter_curve del
# text i decreix amb l'exit_curve. `color` són tres canals RGB (0..255);
# `alpha` és la transparència del blend (0 = invisible, 255 = opac).
text_panel:
color: [36, 182, 170]
alpha: 128
height_px: 32
+96
View File
@@ -0,0 +1,96 @@
# Configuració de la GameScene: timings i corbes de la FSM de sala
# (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria
# i font del text es reutilitzen del tema UI (data/config/ui.yaml > pause +
# notification). Si manca el fitxer o alguna clau, GameScene cau als
# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp.
room_intro:
fade_in_ms: 500 # negre opac → món visible
text_delay_ms: 500 # espera amb el món net abans de sortir el text
text_enter_ms: 400 # text esquerra → centre
text_hold_ms: 2000 # text quiet al centre
text_exit_ms: 400 # text centre → fora per l'esquerra
line_gap_px: -4 # px extra entre les dues línies (piràmide / sala)
# Delays d'spawn aeri **mesurats des de la fi de la música d'INTRO**
# (callback `Audio::setOnMusicEnded`). 0 = cau al moment exacte que
# `room_intro.ogg` acaba; valors > 0 escalonen els jugadors. En mode
# single-player s'usa només `player_spawn_delay_ms`.
player_spawn_delay_ms: 0
player2_spawn_delay_ms: 300
# Preset de reverb (catàleg a `data/config/sounds.yaml`) aplicat al primer
# LAND de cada jugador durant la cinemàtica d'entrada. La sala dona
# "més detall" sonor a l'INTRO; els salts del joc i els respawns
# post-mort cauen al so sec. Buit o absent = sense reverb.
land_reverb: stone_hall
room_outro:
text_enter_ms: 400
text_hold_ms: 2000
text_exit_ms: 400
fade_out_ms: 500 # món visible → negre opac
line_gap_px: -4 # px extra entre les dues línies (fase / completada)
black_hold_ms: 500 # pausa en negre abans d'entrar a la pròxima sala
text:
# Noms d'easing tal com els resol UiConfig::curveByName (vegeu
# `data/config/ui.yaml`). El text entra suau i surt accelerant.
enter_curve: outCubic
exit_curve: inCubic
# Panel translúcid pintat sota el rètol de transició (INTRO i OUTRO).
# Va centrat verticalment al centre del playfield i ocupa tota
# l'amplada del canvas. Creix d'altura amb la mateixa enter_curve del
# text i decreix amb l'exit_curve. `color` són tres canals RGB (0..255);
# `alpha` és la transparència del blend (0 = invisible, 255 = opac).
text_panel:
# Violeta cerimonial: contrasta amb l'or del text (room_text), no
# compite amb el blau del bloc P2 del HUD, i deixa la sorra de les
# sales respirar al darrere.
color: [72, 36, 85]
alpha: 160
height_px: 32
# Sub-FSM de game over: dispara CONTINUE? quan tots els jugadors
# 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
+24
View File
@@ -0,0 +1,24 @@
# Configuració de la LogoScene (logotip de l'editora). El revelat del
# logo a la fase SCAN imita un escaneig CRT: una banda lluminosa baixa
# de dalt a baix; per damunt es veu la imatge ja "impressa", per sota
# encara és negre. Al voltant de la banda hi ha glitch (offset
# horitzontal de files amb falloff per distància) i ocasionalment
# apareixen bursts VHS (franges desplaçades) damunt la part ja
# revelada. Quan la banda toca el peu del logo, un flash blanc tanca
# l'efecte i s'entra a MIDDLE (logo estable).
logo:
scan:
duration_ms: 1500 # durada total del scan top→bottom
band_height_px: 4 # alçada (px) de la banda lluminosa
band_alpha: 200 # opacitat (0..255) de la capa blanca de la banda
band_pulse_hz: 20.0 # freqüència del pulse de brillantor (Hz)
band_pulse_depth: 0.45 # profunditat del pulse (0..1; 0 = sense pulse)
# Glitch d'impacte (files al voltant de la banda)
glitch_amplitude_px: 6 # offset horitzontal màxim a la banda
glitch_band_radius_px: 12 # files damunt/sota la banda amb glitch (falloff lineal)
# Bursts VHS sobre la part ja revelada
vhs_burst_per_sec: 3 # bursts/segon mitjà
vhs_burst_duration_ms: 80 # durada de cada burst
vhs_burst_height_px: 8 # alçada de la franja afectada
vhs_burst_amplitude_px: 8 # offset horitzontal del burst
flash_ms: 100 # flash blanc final quan acaba el scan
Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+77
View File
@@ -0,0 +1,77 @@
# Configuració de la TitleScene.
# `bg_color` i `text_color` són índexs sobre la paleta "ui" (uzebox.pal):
# 1 = (36,0,0) gairebé negre amb un mínim de càlid; 31 = (255,109,0)
# taronja pur. `duration_ms` és el timeout abans de saltar a la
# següent escena (skipable amb tecla no reservada). Els dos
# `blink_*_ms` defineixen el cicle del prompt "press start": el text
# es veu durant `visible_ms` i s'amaga durant `hidden_ms` repetint-se.
title:
duration_ms: 30000
bg_color: 1
text_color: 31
# Color del text del © de copyright (peu de la TITLE). Off-white càlid:
# índex 183 = (255,218,170), tipus crema/peach, ~90% blanc amb tint càlid.
copyright_color: 183
# Color de l'outline del prompt "PRESS START". Marró fosc càlid (índex
# 19 = 109,72,0), coherent amb la paleta del logo. Es manté separat de
# `copyright_color` perquè l'outline i el text del © tenen rols visuals
# diferents (contrast vs. peu informatiu).
press_start_outline_color: 19
# Posicions Y dels tres elements verticals de la TITLE, en percentatge
# sobre l'alçada del canvas (240 px). El logo s'ancora pel seu top
# (la imatge cau cap avall des d'aquest Y); el prompt i el copyright
# es centren verticalment sobre la línia.
logo_y_percent: 15
prompt_y_percent: 68
copyright_y_percent: 92
# Separació vertical en píxels entre el minilogo i el text del © al peu.
mini_logo_gap_px: 2
blink_visible_ms: 800
blink_hidden_ms: 200
# Fase STARTING (entre la primera pulsació de START i el salt a la
# següent escena). `starting_ms` és la durada fixa; durant aquesta
# fase el parpadeig accelera per `starting_blink_speedup` (2.0 = doble
# de freqüència). Si l'altre jugador prem START dins d'aquesta
# finestra, el comptador es reseteja i s'arrenca amb BOTH.
starting_ms: 3000
# Durant STARTING el prompt commuta a un cicle simètric 50/50
# (visible/ocult) amb aquest període total. 200 ms = 5 Hz, prou
# ràpid per llegir-se com a "pulsació confirmada".
starting_blink_period_ms: 200
# Fade-out estil "mosaic dissolve" durant l'última part de STARTING.
# `fade_out_ms` ha de ser <= starting_ms (els últims X ms del STARTING
# es fan amb el dissolve actiu fins a quedar negre). `fade_out_max_block`
# és la mida màxima de bloc en píxels al final del fade.
fade_out_ms: 800
fade_out_max_block: 32
# Fade-out de `title.ogg` en entrar a STARTING. Independent del visual:
# la música pot acabar abans o després del mosaic dissolve.
music_fade_out_ms: 3000
# Fade-to-black per a la sortida d'attract (timeout cap a LOGO). NO és
# mosaic — capa negra lineal damunt del canvas. La música no es toca:
# `title.ogg` continua sonant cap a LOGO, que ja veurà què en fa.
attract_fade_to_black_ms: 800
# Seqüència d'aparició dels elements de la TITLE en fase IDLE. Tots
# els temps són absoluts des de l'entrada a l'escena. `fade_in_ms` és
# la capa negra inicial que s'esvaeix cap al fons; els altres marquen
# quan apareix cada element. Si l'usuari prem START en qualsevol
# moment, la seqüència es completa instantàniament.
intro:
fade_in_ms: 500 # fade-in negre inicial sobre el fons
game_logo_at_ms: 1500 # visibilitat binària del game_logo
mini_logo_at_ms: 2000 # inici del slide-in del mini-logo
mini_logo_slide_ms: 500 # durada del slide
copyright_at_ms: 2200 # inici del slide-in del copyright
copyright_slide_ms: 500 # durada del slide
slide_curve: outCubic # corba compartida per mini_logo i copyright
press_start_at_ms: 3000 # quan apareix PRESS START amb parpadeig
# Distorsió sinusoidal del fons (efecte de calor del desert).
# `amplitude_px`: desplaçament màxim de cada fila en píxels.
# `wavelength_px`: distància vertical entre dos pics de l'ona.
# `speed_hz`: velocitat amb què avança la fase (cicles per segon).
# Si `enabled` és false, el fons es pinta sense distorsió.
heat_haze:
enabled: true
amplitude_px: 1.5
wavelength_px: 32.0
speed_hz: 0.6
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+2 -2
View File
@@ -22,8 +22,8 @@ real (logo animat, menú de títol, cutscene).
Fitxer personal de desenvolupament, **fora del repo**. Viu a la mateixa carpeta Fitxer personal de desenvolupament, **fora del repo**. Viu a la mateixa carpeta
de sistema que `config.yaml`: de sistema que `config.yaml`:
- Linux (XDG): `$XDG_CONFIG_HOME/jailgames/aee_2026/debug.yaml` - Linux (XDG): `$XDG_CONFIG_HOME/jailgames/aee_arcade/debug.yaml`
- Per defecte: `~/.config/jailgames/aee_2026/debug.yaml` - Per defecte: `~/.config/jailgames/aee_arcade/debug.yaml`
### Comportament segons build ### Comportament segons build
+4 -4
View File
@@ -1,6 +1,6 @@
# Sistema d'entitats # Sistema d'entitats
Registre del disseny, els treballs fets i els pendents per al sistema d'entitats (sarcòfags, porta, momia, antorxa, trampes, roca) d'aee_2026. Material de referència per a decisions futures — no és documentació d'API. Registre del disseny, els treballs fets i els pendents per al sistema d'entitats (sarcòfags, porta, momia, antorxa, trampes, roca) d'aee_arcade. Material de referència per a decisions futures — no és documentació d'API.
Estats possibles: `DONE`, `WIP`, `DEFERRED`, `WONTFIX`. Estats possibles: `DONE`, `WIP`, `DEFERRED`, `WONTFIX`.
@@ -24,7 +24,7 @@ Estats possibles: `DONE`, `WIP`, `DEFERRED`, `WONTFIX`.
## 1. Context i objectiu inicial ## 1. Context i objectiu inicial
aee_2026 és un clon tipus Oh Mummy! amb estètica Amidar. Els sarcòfags eren fins ara rectangles purament geomètrics calculats a [Map::getSarcRect](../source/game/map/map.cpp#L67); no hi havia entitats de joc (momies, antorxes, trampes, portes). aee_arcade és un clon tipus Oh Mummy! amb estètica Amidar. Els sarcòfags eren fins ara rectangles purament geomètrics calculats a [Map::getSarcRect](../source/game/map/map.cpp#L67); no hi havia entitats de joc (momies, antorxes, trampes, portes).
**Objectiu inicial** (prompt de partida): **Objectiu inicial** (prompt de partida):
@@ -158,7 +158,7 @@ Al CMakeLists: `ENTITY_SOURCES` conté els fitxers core; `APP_SOURCES` afegeix `
- `make build`: OK, 0 warnings nous. - `make build`: OK, 0 warnings nous.
- `./build/test_entities_smoke`: **9/9 OK**. - `./build/test_entities_smoke`: **9/9 OK**.
- `ctest --output-on-failure`: 1/1 suite passed. - `ctest --output-on-failure`: 1/1 suite passed.
- `timeout 5 xvfb-run ./build/aee_2026`: 5s sense crash, stdout inclou: - `timeout 5 xvfb-run ./build/aee_arcade`: 5s sense crash, stdout inclou:
``` ```
Surface : torch.gif ... LOADED Surface : torch.gif ... LOADED
Surface : sarcophagus.gif ... LOADED (×2) Surface : sarcophagus.gif ... LOADED (×2)
@@ -323,7 +323,7 @@ Dos events encadenats perquè el *pintat* no és la *presència*: es filtra per
- `make debug` net, sense warnings ni de clang-tidy ni de cppcheck als fitxers nous. - `make debug` net, sense warnings ni de clang-tidy ni de cppcheck als fitxers nous.
- `./build/test_entities_smoke`: **ALL OK** (14/14). T9 adaptat (publica `tile_painted` en lloc d'`on_tile_entered`); la resta de tests del sistema d'entitats passen sense tocar. - `./build/test_entities_smoke`: **ALL OK** (14/14). T9 adaptat (publica `tile_painted` en lloc d'`on_tile_entered`); la resta de tests del sistema d'entitats passen sense tocar.
- `timeout 5 xvfb-run ./build/aee_2026`: arrenca, log `PaintLayer: 21x15 tiles (336x240 px), tileset data/tilesets/tomb.gif` després de `EntityManager: 27 entitats carregades`, surt amb `Bye!` sense crash. - `timeout 5 xvfb-run ./build/aee_arcade`: arrenca, log `PaintLayer: 21x15 tiles (336x240 px), tileset data/tilesets/tomb.gif` després de `EntityManager: 27 entitats carregades`, surt amb `Bye!` sense crash.
### Pendent derivat ### Pendent derivat
+4 -4
View File
@@ -1,7 +1,7 @@
# Pla d'implementació — Menú de servei # Pla d'implementació — Menú de servei
Pla per a portar el menú de servei de `../aee` (`source/core/rendering/menu.cpp`) Pla per a portar el menú de servei de `../aee` (`source/core/rendering/menu.cpp`)
a `aee_2026`, adaptat a l'arquitectura actual (Scenes + Director instanciat + a `aee_arcade`, adaptat a l'arquitectura actual (Scenes + Director instanciat +
`Options` amb setters persistents + `Screen` ARGB + `Text` bitmap). Document de `Options` amb setters persistents + `Screen` ARGB + `Text` bitmap). Document de
referència per a implementar-ho en una sessió futura; no s'ha escrit cap codi referència per a implementar-ho en una sessió futura; no s'ha escrit cap codi
encara. encara.
@@ -33,7 +33,7 @@ Tot el que segueix és la base; la còpia mental serveix per a no reinventar res
## 2. Diferències d'arquitectura rellevants ## 2. Diferències d'arquitectura rellevants
| Punt | `../aee` | `aee_2026` | | Punt | `../aee` | `aee_arcade` |
| ----------------- | ---------------------------------------------------- | -------------------------------------------------------------------------- | | ----------------- | ---------------------------------------------------- | -------------------------------------------------------------------------- |
| Overlay | `Overlay::render()` dibuixa Menú + Notifier al buffer | No hi ha Overlay. Cada Scene crida `Notifier::render()` i `RenderInfo::render()` | | Overlay | `Overlay::render()` dibuixa Menú + Notifier al buffer | No hi ha Overlay. Cada Scene crida `Notifier::render()` i `RenderInfo::render()` |
| Director | Singleton `Director::get()` amb `requestQuit/requestRestart` | Classe instanciada; sortida via `SDL_EVENT_QUIT` a `EventBuffer` | | Director | Singleton `Director::get()` amb `requestQuit/requestRestart` | Classe instanciada; sortida via `SDL_EVENT_QUIT` a `EventBuffer` |
@@ -223,7 +223,7 @@ es necessita.
### Limitació: pàgina Controls diferida ### Limitació: pàgina Controls diferida
El menú de `../aee` té una pàgina Controls (`buildControls`) que permet El menú de `../aee` té una pàgina Controls (`buildControls`) que permet
remappar `up/down/left/right` i la tecla de menú. A `aee_2026`: remappar `up/down/left/right` i la tecla de menú. A `aee_arcade`:
- Les tecles de joc són `Defaults::KeysGame::{UP,DOWN,LEFT,RIGHT}` — constants - Les tecles de joc són `Defaults::KeysGame::{UP,DOWN,LEFT,RIGHT}` — constants
`constexpr`, no mutables. `constexpr`, no mutables.
@@ -273,7 +273,7 @@ y, w, h, argb, alpha)` que opere sobre `canvas_.data()`. El canvas és
fons del menú pot ser `alpha=220` i es veu el joc apagat per darrere. fons del menú pot ser `alpha=220` i es veu el joc apagat per darrere.
Mida fixa recomanada per al MVP: `BOX_W = 220`, altura dinàmica (igual que Mida fixa recomanada per al MVP: `BOX_W = 220`, altura dinàmica (igual que
`../aee`). Canvas lògic d'`aee_2026` és `GameCanvas::WIDTH`/`HEIGHT` — `../aee`). Canvas lògic d'`aee_arcade` és `GameCanvas::WIDTH`/`HEIGHT` —
confirmar mida real (mateixos 320×200?) a `utils/defines.hpp` abans de confirmar mida real (mateixos 320×200?) a `utils/defines.hpp` abans de
hardcoded. hardcoded.
+3 -3
View File
@@ -7,17 +7,17 @@
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Aventures en Egipte ARCADE</string> <string>Aventures en Egipte ARCADE</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>aee_2026</string> <string>aee_arcade</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>icon</string> <string>icon</string>
<key>CFBundleIconName</key> <key>CFBundleIconName</key>
<string>icon</string> <string>icon</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>org.jailgames.aee_2026</string> <string>org.jailgames.aee_arcade</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>aee_2026</string> <string>aee_arcade</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
@@ -1,2 +1,2 @@
// aee_2026.rc // aee_arcade.rc
IDI_ICON1 ICON "release/icons/icon.ico" IDI_ICON1 ICON "release/icons/icon.ico"
+31
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.
@@ -194,6 +217,14 @@ void Audio::setOnMusicEnded(std::function<void()> callback) {
if (engine_) { engine_->setOnMusicEnded(std::move(callback)); } if (engine_) { engine_->setOnMusicEnded(std::move(callback)); }
} }
// Resol el nom contra el cache de recursos i retorna la durada pre-calculada
// al `loadMusic`. 0 si la pista no existeix — així el caller pot decidir
// fallback (p. ex. usar un timeout fix) sense haver de propagar errors.
auto Audio::getMusicDurationMs(const std::string& name) -> int {
auto* music = AudioResource::getMusic(name);
return (music != nullptr) ? music->duration_ms : 0;
}
// Consulta directament l'estat a Ja i el projecta al subconjunt d'estats que // Consulta directament l'estat a Ja i el projecta al subconjunt d'estats que
// exposa Audio (INVALID/DISABLED de Ja col·lapsen a STOPPED — la capa d'usuari // exposa Audio (INVALID/DISABLED de Ja col·lapsen a STOPPED — la capa d'usuari
// només vol saber si està sonant, pausat o parat). // només vol saber si està sonant, pausat o parat).
+17 -1
View File
@@ -76,11 +76,22 @@ 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 processat per un efecte definit a data/sounds.yaml // Reprodueix un so amb la velocitat (i to) escalats per `speed`:
// 1.0 = normal, 0.95 ≈ -5% (més greu i lent), 1.05 ≈ +5% (més
// agut i ràpid). Mateixa semàntica que `setMusicSpeed`. Útil per a
// variacions subtils que eviten la fatiga d'escoltar el mateix
// sample idèntic (p.ex. obertures de sarcòfag, picks d'ítems).
void playSound(const std::string& name, Group group, float speed);
// Reprodueix un so processat per un efecte definit a data/config/sounds.yaml
// (preset_name busca a SoundEffectsConfig). Si el preset no existeix // (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
// sec — l'usuari sent el so igualment, sense la cua. // sec — l'usuari sent el so igualment, sense la cua.
@@ -121,6 +132,11 @@ class Audio {
[[nodiscard]] auto isMusicEnabled() const -> bool { return music_enabled_; } [[nodiscard]] auto isMusicEnabled() const -> bool { return music_enabled_; }
[[nodiscard]] static auto getMusicState() -> MusicState; // Estat real consultat a Ja:: [[nodiscard]] static auto getMusicState() -> MusicState; // Estat real consultat a Ja::
[[nodiscard]] auto getCurrentMusicName() const -> const std::string& { return music_.name; } [[nodiscard]] auto getCurrentMusicName() const -> const std::string& { return music_.name; }
// Durada de la pista resolta per nom (mil·lisegons). 0 si la pista no
// existeix al cache de recursos o si el seu header OGG no permet
// calcular-la. Pensat per a clients que necessiten un timeline
// determinista (p. ex. RoomFsm) sense dependre de callbacks de fi.
[[nodiscard]] static auto getMusicDurationMs(const std::string& name) -> int;
private: private:
// --- Tipus anidats --- // --- Tipus anidats ---
+3 -3
View File
@@ -5,10 +5,10 @@
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <iostream>
#include <vector> #include <vector>
#include "core/audio/jail_audio.hpp" #include "core/audio/jail_audio.hpp"
#include "utils/log.hpp"
namespace AudioEffects { namespace AudioEffects {
@@ -46,11 +46,11 @@ namespace AudioEffects {
} else if (spec.format == SDL_AUDIO_U8) { } else if (spec.format == SDL_AUDIO_U8) {
bytes_per_sample = 1; bytes_per_sample = 1;
} else { } else {
std::cerr << "AudioEffects: format de so no suportat (només U8 o S16)\n"; Log::error("AudioEffects", "format de so no suportat (només U8 o S16)");
return false; return false;
} }
if (spec.channels < 1 || spec.channels > 2) { if (spec.channels < 1 || spec.channels > 2) {
std::cerr << "AudioEffects: el so ha de ser mono o estèreo\n"; Log::error("AudioEffects", "el so ha de ser mono o estèreo");
return false; return false;
} }
+65 -1
View File
@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
#include <cstdint>
#include <cstdio> #include <cstdio>
#include <memory> #include <memory>
#include <optional> #include <optional>
@@ -127,11 +128,44 @@ namespace Ja {
void Engine::updateOutgoingFade() { void Engine::updateOutgoingFade() {
if (outgoing_music_.stream == nullptr || !outgoing_music_.fade.active) { return; } if (outgoing_music_.stream == nullptr || !outgoing_music_.fade.active) { return; }
// Mentre la fosa està activa, mantenim el stream amb una reserva
// de samples per davant del cursor (mateix patró que pumpMusic
// per al current_music_). Així el stream no es buida ni quan SDL
// drena més ràpid del previst en haver sounds bound a la mateixa
// device. Si l'OGG arriba a EOF, rebobina (la fosa pot ser més
// llarga que la pista).
if (outgoing_music_.music != nullptr && outgoing_music_.music->vorbis != nullptr) {
const Music& music = *outgoing_music_.music;
const int BYTES_PER_SECOND = music.spec.freq * music.spec.channels * MUSIC_BYTES_PER_SAMPLE;
const int LOW_WATER = static_cast<int>(MUSIC_LOW_WATER_SECONDS * static_cast<float>(BYTES_PER_SECOND));
while (SDL_GetAudioStreamAvailable(outgoing_music_.stream) < LOW_WATER) {
short chunk[MUSIC_CHUNK_SHORTS];
const int SAMPLES = stb_vorbis_get_samples_short_interleaved(
music.vorbis,
music.spec.channels,
static_cast<short*>(chunk),
MUSIC_CHUNK_SHORTS);
if (SAMPLES <= 0) {
stb_vorbis_seek_start(music.vorbis);
continue;
}
const int BYTES = SAMPLES * music.spec.channels * MUSIC_BYTES_PER_SAMPLE;
SDL_PutAudioStreamData(outgoing_music_.stream, static_cast<const void*>(chunk), BYTES);
}
}
const float PROGRESS = fadeProgress(outgoing_music_.fade); const float PROGRESS = fadeProgress(outgoing_music_.fade);
if (PROGRESS >= 1.0F) { if (PROGRESS >= 1.0F) {
SDL_DestroyAudioStream(outgoing_music_.stream); SDL_DestroyAudioStream(outgoing_music_.stream);
outgoing_music_.stream = nullptr; outgoing_music_.stream = nullptr;
outgoing_music_.fade.active = false; outgoing_music_.fade.active = false;
// Deixem el Vorbis del Music original en un estat conegut per
// a la pròxima reproducció. (playMusic també fa seek_start,
// però fer-ho ací evita estats intermedis si algú consulta.)
if (outgoing_music_.music != nullptr && outgoing_music_.music->vorbis != nullptr) {
stb_vorbis_seek_start(outgoing_music_.music->vorbis);
}
outgoing_music_.music = nullptr;
} else { } else {
SDL_SetAudioStreamGain(outgoing_music_.stream, outgoing_music_.fade.initial_volume * (1.0F - PROGRESS)); SDL_SetAudioStreamGain(outgoing_music_.stream, outgoing_music_.fade.initial_volume * (1.0F - PROGRESS));
} }
@@ -191,7 +225,14 @@ namespace Ja {
} }
preFillOutgoing(current_music_, duration_ms); preFillOutgoing(current_music_, duration_ms);
outgoing_music_.stream = current_music_->stream; outgoing_music_.stream = current_music_->stream;
// Guardem la referència al Music perquè updateOutgoingFade puga
// seguir bombant Vorbis cap al stream durant tota la fosa. NO fem
// seek_start ací: la decompressió ha de continuar des d'on estava
// perquè el so siga continu. El seek_start es farà quan la fosa
// acabe (o quan playMusic la interrompi via stopMusic).
outgoing_music_.music = current_music_;
outgoing_music_.fade = { outgoing_music_.fade = {
.active = true, .active = true,
.start_time = SDL_GetTicks(), .start_time = SDL_GetTicks(),
@@ -200,7 +241,6 @@ namespace Ja {
}; };
current_music_->stream = nullptr; current_music_->stream = nullptr;
current_music_->state = MusicState::STOPPED; current_music_->state = MusicState::STOPPED;
if (current_music_->vorbis != nullptr) { stb_vorbis_seek_start(current_music_->vorbis); }
} }
template <typename Fn> template <typename Fn>
@@ -246,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; }
@@ -265,6 +310,10 @@ namespace Ja {
SDL_DestroyAudioStream(outgoing_music_.stream); SDL_DestroyAudioStream(outgoing_music_.stream);
outgoing_music_.stream = nullptr; outgoing_music_.stream = nullptr;
outgoing_music_.fade.active = false; outgoing_music_.fade.active = false;
if (outgoing_music_.music != nullptr && outgoing_music_.music->vorbis != nullptr) {
stb_vorbis_seek_start(outgoing_music_.music->vorbis);
}
outgoing_music_.music = nullptr;
} }
incoming_fade_.active = false; incoming_fade_.active = false;
@@ -381,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) {
@@ -521,6 +576,15 @@ namespace Ja {
music->spec.channels = static_cast<int>(INFO.channels); music->spec.channels = static_cast<int>(INFO.channels);
music->spec.freq = static_cast<int>(INFO.sample_rate); music->spec.freq = static_cast<int>(INFO.sample_rate);
music->spec.format = SDL_AUDIO_S16; music->spec.format = SDL_AUDIO_S16;
// Pre-càlcul de la durada en ms a partir del header. stb_vorbis ja
// ha decodificat la informació necessària a `stb_vorbis_open_memory`;
// aquesta consulta no descodifica àudio, només llig el comptador
// de samples. Si el sample_rate fos 0 (header malmès) deixem
// duration_ms a 0.
if (INFO.sample_rate > 0) {
const auto SAMPLES = stb_vorbis_stream_length_in_samples(music->vorbis);
music->duration_ms = static_cast<int>((static_cast<std::uint64_t>(SAMPLES) * 1000ULL) / INFO.sample_rate);
}
music->state = MusicState::STOPPED; music->state = MusicState::STOPPED;
return music.release(); return music.release();
+29
View File
@@ -112,6 +112,12 @@ namespace Ja {
std::string filename; std::string filename;
int times{0}; // loops restants (-1 = infinit, 0 = un sol play) int times{0}; // loops restants (-1 = infinit, 0 = un sol play)
// Durada total de la pista en mil·lisegons, mesurada via
// `stb_vorbis_stream_length_in_samples / sample_rate` al
// `loadMusic`. 0 si el càlcul no és possible (header malmès).
// L'usen consumidors que necessiten un timeline pre-calculat —
// p. ex. la FSM de sala — sense dependre de callbacks de fi.
int duration_ms{0};
SDL_AudioStream* stream{nullptr}; SDL_AudioStream* stream{nullptr};
MusicState state{MusicState::INVALID}; MusicState state{MusicState::INVALID};
}; };
@@ -125,6 +131,13 @@ namespace Ja {
struct OutgoingMusic { struct OutgoingMusic {
SDL_AudioStream* stream{nullptr}; SDL_AudioStream* stream{nullptr};
// Referència al Music original perquè updateOutgoingFade puga
// continuar descomprimint des de Vorbis cap al stream durant
// tota la fosa. Sense això, només tenim el pre-fill puntual i
// SDL drena el stream més ràpid del previst quan hi ha sounds
// bound a la mateixa device (~2x), buidant-lo a meitat del
// fade i sentint-se com un tall sec.
Music* music{nullptr};
FadeState fade; FadeState fade;
}; };
@@ -158,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.
@@ -171,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
+4 -4
View File
@@ -1,10 +1,10 @@
#include "core/audio/sound_effects_config.hpp" #include "core/audio/sound_effects_config.hpp"
#include <iostream>
#include <string> #include <string>
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace { namespace {
@@ -27,7 +27,7 @@ auto SoundEffectsConfig::get() -> SoundEffectsConfig& {
void SoundEffectsConfig::load(const std::string& file_path) { void SoundEffectsConfig::load(const std::string& file_path) {
auto bytes = Resource::Helper::loadFile(file_path); auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) { if (bytes.empty()) {
std::cerr << "SoundEffectsConfig: no s'ha pogut obrir " << file_path << " — cap preset d'efecte disponible\n"; Log::warn("SoundEffectsConfig", "no s'ha pogut obrir {} — cap preset d'efecte disponible", file_path);
return; return;
} }
@@ -60,9 +60,9 @@ void SoundEffectsConfig::load(const std::string& file_path) {
} }
} }
std::cout << "SoundEffectsConfig: " << echoes_.size() << " preset(s) d'eco i " << reverbs_.size() << " de reverb des de " << file_path << '\n'; Log::info("SoundEffectsConfig", "{} preset(s) d'eco i {} de reverb des de {}", echoes_.size(), reverbs_.size(), file_path);
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cerr << "SoundEffectsConfig: error parsejant " << file_path << ": " << e.what() << '\n'; Log::error("SoundEffectsConfig", "error parsejant {}: {}", file_path, e.what());
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@
#include "core/audio/jail_audio.hpp" // Per a Ja::EchoParams / Ja::ReverbParams #include "core/audio/jail_audio.hpp" // Per a Ja::EchoParams / Ja::ReverbParams
// Catàleg de presets d'efectes carregat des de data/sounds.yaml. La capa // Catàleg de presets d'efectes carregat des de data/config/sounds.yaml. La capa
// Audio (playSoundWithEcho/playSoundWithReverb) hi accedeix per nom: si el // Audio (playSoundWithEcho/playSoundWithReverb) hi accedeix per nom: si el
// preset no existeix, el so es reprodueix sec amb un avís a stderr. // preset no existeix, el so es reprodueix sec amb un avís a stderr.
// //
+2 -1
View File
@@ -62,7 +62,7 @@ void DefineButtons::disable() {
} }
void DefineButtons::resetSequence() { void DefineButtons::resetSequence() {
// Ordre: ATTACK, JUMP, SERVICE — replica l'estructura de // Ordre: ATTACK, JUMP, START, SERVICE — replica l'estructura de
// `clearButtons()` de coffee_crisis_arcade_edition (seqüència ordenada // `clearButtons()` de coffee_crisis_arcade_edition (seqüència ordenada
// d'accions amb la SERVICE al final) adaptada al joc actual. La label // d'accions amb la SERVICE al final) adaptada al joc actual. La label
// de SERVICE va a una clau pròpia (`menu.items.service`) per no // de SERVICE va a una clau pròpia (`menu.items.service`) per no
@@ -70,6 +70,7 @@ void DefineButtons::resetSequence() {
sequence_.clear(); sequence_.clear();
sequence_.push_back({"menu.items.attack", GamepadBindings::Action::ATTACK, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)}); sequence_.push_back({"menu.items.attack", GamepadBindings::Action::ATTACK, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)});
sequence_.push_back({"menu.items.jump", GamepadBindings::Action::JUMP, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)}); sequence_.push_back({"menu.items.jump", GamepadBindings::Action::JUMP, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)});
sequence_.push_back({"menu.items.start", GamepadBindings::Action::START, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)});
sequence_.push_back({"menu.items.service", GamepadBindings::Action::SERVICE, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)}); sequence_.push_back({"menu.items.service", GamepadBindings::Action::SERVICE, static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID)});
index_ = 0; index_ = 0;
} }
+10 -10
View File
@@ -3,12 +3,12 @@
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <fstream> #include <fstream>
#include <iostream>
#include <sstream> #include <sstream>
#include <string_view> #include <string_view>
#include <utility> #include <utility>
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace { namespace {
@@ -24,6 +24,7 @@ namespace {
ActionPair{GamepadBindings::Action::JUMP, std::string_view{"jump"}}, ActionPair{GamepadBindings::Action::JUMP, std::string_view{"jump"}},
ActionPair{GamepadBindings::Action::ATTACK, std::string_view{"attack"}}, ActionPair{GamepadBindings::Action::ATTACK, std::string_view{"attack"}},
ActionPair{GamepadBindings::Action::SERVICE, std::string_view{"service"}}, ActionPair{GamepadBindings::Action::SERVICE, std::string_view{"service"}},
ActionPair{GamepadBindings::Action::START, std::string_view{"start"}},
}; };
using ButtonPair = std::pair<int, std::string_view>; using ButtonPair = std::pair<int, std::string_view>;
@@ -89,7 +90,7 @@ auto GamepadBindings::get() -> GamepadBindings& {
} }
auto GamepadBindings::defaultButton(Action action) -> int { auto GamepadBindings::defaultButton(Action action) -> int {
// Defaults històrics de l'aee_2026 (vegeu Player::accumulateGamepadMovement // Defaults històrics de l'aee_arcade (vegeu Player::accumulateGamepadMovement
// i les consultes del Director per al menú). // i les consultes del Director per al menú).
switch (action) { switch (action) {
case Action::JUMP: case Action::JUMP:
@@ -98,6 +99,8 @@ auto GamepadBindings::defaultButton(Action action) -> int {
return static_cast<int>(SDL_GAMEPAD_BUTTON_WEST); return static_cast<int>(SDL_GAMEPAD_BUTTON_WEST);
case Action::SERVICE: case Action::SERVICE:
return static_cast<int>(SDL_GAMEPAD_BUTTON_BACK); return static_cast<int>(SDL_GAMEPAD_BUTTON_BACK);
case Action::START:
return static_cast<int>(SDL_GAMEPAD_BUTTON_START);
} }
return static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID); return static_cast<int>(SDL_GAMEPAD_BUTTON_INVALID);
} }
@@ -164,8 +167,7 @@ void GamepadBindings::load(const std::string& file_path) {
try { try {
auto yaml = fkyaml::node::deserialize(CONTENT); auto yaml = fkyaml::node::deserialize(CONTENT);
if (!yaml.contains("gamepads") || !yaml["gamepads"].is_sequence()) { if (!yaml.contains("gamepads") || !yaml["gamepads"].is_sequence()) {
std::cerr << "GamepadBindings: secció 'gamepads' absent o invàlida a " Log::error("GamepadBindings", "secció 'gamepads' absent o invàlida a {}", file_path);
<< file_path << '\n';
return; return;
} }
for (const auto& entry : yaml["gamepads"]) { for (const auto& entry : yaml["gamepads"]) {
@@ -185,11 +187,9 @@ void GamepadBindings::load(const std::string& file_path) {
} }
configs_.push_back(std::move(cfg)); configs_.push_back(std::move(cfg));
} }
std::cout << "GamepadBindings: " << configs_.size() << " mandos carregats de " Log::info("GamepadBindings", "{} mandos carregats de {}", configs_.size(), file_path);
<< file_path << '\n';
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cerr << "GamepadBindings: error parsejant " << file_path << ": " Log::error("GamepadBindings", "error parsejant {}: {}", file_path, e.what());
<< e.what() << '\n';
} }
} }
@@ -197,7 +197,7 @@ auto GamepadBindings::save() -> bool {
if (file_path_.empty()) { return false; } if (file_path_.empty()) { return false; }
std::ostringstream out; std::ostringstream out;
out << "# AEE 2026 - Vinculacions de mando per dispositiu.\n"; out << "# AEE ARCADE - Vinculacions de mando per dispositiu.\n";
out << "# Auto-generat des del menú de servei. Editable a mà.\n"; out << "# Auto-generat des del menú de servei. Editable a mà.\n";
out << "gamepads:\n"; out << "gamepads:\n";
for (const auto& cfg : configs_) { for (const auto& cfg : configs_) {
@@ -212,7 +212,7 @@ auto GamepadBindings::save() -> bool {
std::ofstream file(file_path_, std::ios::binary); std::ofstream file(file_path_, std::ios::binary);
if (!file) { if (!file) {
std::cerr << "GamepadBindings: no es pot escriure " << file_path_ << '\n'; Log::error("GamepadBindings", "no es pot escriure {}", file_path_);
return false; return false;
} }
file << out.str(); file << out.str();
+1
View File
@@ -25,6 +25,7 @@ class GamepadBindings {
JUMP, JUMP,
ATTACK, ATTACK,
SERVICE, SERVICE,
START,
}; };
// Triggers L2/R2 representats com a "botons virtuals" — replica la // Triggers L2/R2 representats com a "botons virtuals" — replica la
+12 -20
View File
@@ -1,13 +1,13 @@
#include "core/input/gamepad_manager.hpp" #include "core/input/gamepad_manager.hpp"
#include <algorithm> #include <algorithm>
#include <iostream>
#include <iterator> #include <iterator>
#include <string> #include <string>
#include <utility> #include <utility>
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include "core/rendering/notification_presets.hpp" #include "core/rendering/notification_presets.hpp"
#include "utils/log.hpp"
namespace { namespace {
@@ -94,8 +94,7 @@ void GamepadManager::addGamepad(SDL_JoystickID jid, bool notify) {
if (!SDL_IsGamepad(jid)) { return; } if (!SDL_IsGamepad(jid)) { return; }
SDL_Gamepad* pad = SDL_OpenGamepad(jid); SDL_Gamepad* pad = SDL_OpenGamepad(jid);
if (pad == nullptr) { if (pad == nullptr) {
std::cout << "GamepadManager: SDL_OpenGamepad(" << jid Log::error("GamepadManager", "SDL_OpenGamepad({}) ha fallat: {}", jid, SDL_GetError());
<< ") ha fallat: " << SDL_GetError() << '\n';
return; return;
} }
std::string name = uniqueName(prettyName(SDL_GetGamepadName(pad))); std::string name = uniqueName(prettyName(SDL_GetGamepadName(pad)));
@@ -104,8 +103,7 @@ void GamepadManager::addGamepad(SDL_JoystickID jid, bool notify) {
// reconnexió es resoldrà per nom. // reconnexió es resoldrà per nom.
const char* raw_path = SDL_GetGamepadPath(pad); const char* raw_path = SDL_GetGamepadPath(pad);
std::string path = (raw_path != nullptr) ? raw_path : ""; std::string path = (raw_path != nullptr) ? raw_path : "";
std::cout << "GamepadManager: connectat " << name Log::info("GamepadManager", "connectat {}{}", name, path.empty() ? std::string{} : " [" + path + "]");
<< (path.empty() ? "" : " [" + path + "]") << '\n';
entries_.push_back(Entry{.pad = pad, .instance_id = jid, .pretty_name = name, .path = path}); entries_.push_back(Entry{.pad = pad, .instance_id = jid, .pretty_name = name, .path = path});
// Hot-plug: si alguna assignació pendent encaixa amb aquest mando, // Hot-plug: si alguna assignació pendent encaixa amb aquest mando,
@@ -129,7 +127,7 @@ void GamepadManager::removeGamepad(SDL_JoystickID jid) {
std::string name = std::move(it->pretty_name); std::string name = std::move(it->pretty_name);
SDL_Gamepad* pad = it->pad; SDL_Gamepad* pad = it->pad;
if (pad != nullptr) { SDL_CloseGamepad(pad); } if (pad != nullptr) { SDL_CloseGamepad(pad); }
std::cout << "GamepadManager: desconnectat " << name << '\n'; Log::info("GamepadManager", "desconnectat {}", name);
entries_.erase(it); entries_.erase(it);
// Qualsevol assignació que apuntava a aquest mando queda amb `pad` // Qualsevol assignació que apuntava a aquest mando queda amb `pad`
@@ -145,8 +143,7 @@ void GamepadManager::removeGamepad(SDL_JoystickID jid) {
void GamepadManager::init() { void GamepadManager::init() {
if (initialized_) { return; } if (initialized_) { return; }
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) { if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
std::cout << "GamepadManager: SDL_INIT_GAMEPAD ha fallat: " Log::error("GamepadManager", "SDL_INIT_GAMEPAD ha fallat: {}", SDL_GetError());
<< SDL_GetError() << '\n';
return; return;
} }
// `gamecontrollerdb.txt` és opcional. Viu a l'arrel del projecte // `gamecontrollerdb.txt` és opcional. Viu a l'arrel del projecte
@@ -161,10 +158,9 @@ void GamepadManager::init() {
} }
const int ADDED = SDL_AddGamepadMappingsFromFile(db_path.c_str()); const int ADDED = SDL_AddGamepadMappingsFromFile(db_path.c_str());
if (ADDED < 0) { if (ADDED < 0) {
std::cout << "GamepadManager: no s'ha pogut carregar " << db_path Log::warn("GamepadManager", "no s'ha pogut carregar {}: {}", db_path, SDL_GetError());
<< ": " << SDL_GetError() << '\n';
} else { } else {
std::cout << "GamepadManager: " << ADDED << " mappings carregats de " << db_path << '\n'; Log::info("GamepadManager", "{} mappings carregats de {}", ADDED, db_path);
} }
int count = 0; int count = 0;
@@ -175,7 +171,7 @@ void GamepadManager::init() {
} }
SDL_free(ids); SDL_free(ids);
} }
std::cout << "GamepadManager: " << entries_.size() << " mando(s) detectat(s) a l'inici\n"; Log::info("GamepadManager", "{} mando(s) detectat(s) a l'inici", entries_.size());
initialized_ = true; initialized_ = true;
} }
@@ -268,8 +264,7 @@ void GamepadManager::assignToPlayer(int player_id, const std::string& name, cons
if (a.pad != nullptr) { if (a.pad != nullptr) {
for (auto& other : assignments_) { for (auto& other : assignments_) {
if (&other != &a && other.pad == a.pad) { if (&other != &a && other.pad == a.pad) {
std::cout << "GamepadManager: mando " << a.name Log::info("GamepadManager", "mando {} reassignat (last-wins)", a.name);
<< " reassignat (last-wins)\n";
other.pad = nullptr; other.pad = nullptr;
} }
} }
@@ -277,11 +272,9 @@ void GamepadManager::assignToPlayer(int player_id, const std::string& name, cons
// Traç de l'assignació persistida (vinculada o pendent) perquè quede // Traç de l'assignació persistida (vinculada o pendent) perquè quede
// visible a la consola igual que les autoassignacions. // visible a la consola igual que les autoassignacions.
if (a.pad != nullptr) { if (a.pad != nullptr) {
std::cout << "GamepadManager: assignat " << a.name Log::info("GamepadManager", "assignat {} → P{}", a.name, player_id + 1);
<< " → P" << (player_id + 1) << '\n';
} else { } else {
std::cout << "GamepadManager: P" << (player_id + 1) << " pendent (" Log::info("GamepadManager", "P{} pendent ({} no connectat)", player_id + 1, a.name.empty() ? a.path : a.name);
<< (a.name.empty() ? a.path : a.name) << " no connectat)\n";
} }
} }
@@ -307,8 +300,7 @@ auto GamepadManager::autoAssignFreeSlots() -> std::vector<AutoAssigned> {
slot.name = e.pretty_name; slot.name = e.pretty_name;
slot.path = e.path; slot.path = e.path;
assigned.push_back(AutoAssigned{.player_id = pid, .name = e.pretty_name, .path = e.path}); assigned.push_back(AutoAssigned{.player_id = pid, .name = e.pretty_name, .path = e.path});
std::cout << "GamepadManager: auto-assigned " << e.pretty_name Log::info("GamepadManager", "auto-assigned {} → P{}", e.pretty_name, pid + 1);
<< " → P" << (pid + 1) << '\n';
break; break;
} }
} }
+19 -7
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"
@@ -26,7 +27,7 @@ namespace GlobalInputs {
// La lliguem a la durada de la notificació EXIT perquè el missatge i // La lliguem a la durada de la notificació EXIT perquè el missatge i
// la confirmació expiren alhora — si no, l'usuari podria veure el // la confirmació expiren alhora — si no, l'usuari podria veure el
// text però ja no ser dins del rang de confirmació (o al revés). // text però ja no ser dins del rang de confirmació (o al revés).
// El valor concret viu a data/ui.yaml (timings.exit_ms) via UiConfig. // El valor concret viu a data/config/ui.yaml (timings.exit_ms) via UiConfig.
constexpr int MIN_ZOOM = 1; constexpr int MIN_ZOOM = 1;
constexpr int MAX_ZOOM = 6; constexpr int MAX_ZOOM = 6;
@@ -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));
} }
} }
@@ -161,7 +173,7 @@ namespace GlobalInputs {
// Debug helper: dispara una notificació prou llarga perquè // Debug helper: dispara una notificació prou llarga perquè
// `wrapToWidth` la parteixi en (almenys) dues línies. Útil per a // `wrapToWidth` la parteixi en (almenys) dues línies. Útil per a
// verificar visualment el padding asimètric, el line_gap i la caixa // verificar visualment el padding asimètric, el line_gap i la caixa
// multilinia després de tocar la geometria a `data/ui.yaml`. // multilinia després de tocar la geometria a `data/config/ui.yaml`.
void onDebugLongNotification() { void onDebugLongNotification() {
Notification::notify("Notificació de prova llarga per a comprovar que el text es parteix correctament en dues línies dins la caixa."); Notification::notify("Notificació de prova llarga per a comprovar que el text es parteix correctament en dues línies dins la caixa.");
} }
+6 -6
View File
@@ -1,11 +1,11 @@
#include "core/input/key_config.hpp" #include "core/input/key_config.hpp"
#include <fstream> #include <fstream>
#include <iostream>
#include <string> #include <string>
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
auto KeyConfig::get() -> KeyConfig& { auto KeyConfig::get() -> KeyConfig& {
static KeyConfig instance_; static KeyConfig instance_;
@@ -15,7 +15,7 @@ auto KeyConfig::get() -> KeyConfig& {
void KeyConfig::load(const std::string& file_path) { void KeyConfig::load(const std::string& file_path) {
auto bytes = Resource::Helper::loadFile(file_path); auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) { if (bytes.empty()) {
std::cerr << "KeyConfig: no s'ha pogut obrir " << file_path << '\n'; Log::error("KeyConfig", "no s'ha pogut obrir {}", file_path);
return; return;
} }
@@ -24,7 +24,7 @@ void KeyConfig::load(const std::string& file_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);
if (!yaml.contains("keys") || !yaml["keys"].is_sequence()) { if (!yaml.contains("keys") || !yaml["keys"].is_sequence()) {
std::cerr << "KeyConfig: falta la secció 'keys' a " << file_path << '\n'; Log::error("KeyConfig", "falta la secció 'keys' a {}", file_path);
return; return;
} }
bindings_.clear(); bindings_.clear();
@@ -34,14 +34,14 @@ void KeyConfig::load(const std::string& file_path) {
const auto CODE = entry["code"].get_value<std::string>(); const auto CODE = entry["code"].get_value<std::string>();
const SDL_Scancode SC = SDL_GetScancodeFromName(CODE.c_str()); const SDL_Scancode SC = SDL_GetScancodeFromName(CODE.c_str());
if (SC == SDL_SCANCODE_UNKNOWN) { if (SC == SDL_SCANCODE_UNKNOWN) {
std::cerr << "KeyConfig: codi SDL desconegut '" << CODE << "' per id '" << ID << "'\n"; Log::warn("KeyConfig", "codi SDL desconegut '{}' per id '{}'", CODE, ID);
continue; continue;
} }
bindings_[ID] = SC; bindings_[ID] = SC;
} }
std::cout << "KeyConfig: " << bindings_.size() << " bindings des de " << file_path << '\n'; Log::info("KeyConfig", "{} bindings des de {}", bindings_.size(), file_path);
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cerr << "KeyConfig: error parsejant " << file_path << ": " << e.what() << '\n'; Log::error("KeyConfig", "error parsejant {}: {}", file_path, e.what());
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
// Mapeig id → SDL_Scancode carregat des de data/input/keys.yaml. Consumit // Mapeig id → SDL_Scancode carregat des de data/config/input.yaml. Consumit
// per GlobalInputs i (en el futur) per menús de remapat. No té cap cost // per GlobalInputs i (en el futur) per menús de remapat. No té cap cost
// global: si la clau no existeix, retorna SDL_SCANCODE_UNKNOWN i GlobalInputs // global: si la clau no existeix, retorna SDL_SCANCODE_UNKNOWN i GlobalInputs
// ignora l'acció. D'aquesta manera podem afegir nous bindings al YAML sense // ignora l'acció. D'aquesta manera podem afegir nous bindings al YAML sense
+5 -5
View File
@@ -1,11 +1,11 @@
#include "core/locale/locale.hpp" #include "core/locale/locale.hpp"
#include <fstream> #include <fstream>
#include <iostream>
#include <string> #include <string>
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "utils/log.hpp"
namespace { namespace {
@@ -36,7 +36,7 @@ auto Locale::get() -> Locale& {
void Locale::load(const std::string& file_path) { void Locale::load(const std::string& file_path) {
auto bytes = Resource::Helper::loadFile(file_path); auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) { if (bytes.empty()) {
std::cerr << "Locale: no s'ha pogut obrir " << file_path << '\n'; Log::error("Locale", "no s'ha pogut obrir {}", file_path);
return; return;
} }
@@ -46,15 +46,15 @@ void Locale::load(const std::string& file_path) {
auto yaml = fkyaml::node::deserialize(begin, end); auto yaml = fkyaml::node::deserialize(begin, end);
strings_.clear(); strings_.clear();
flatten(yaml, "", strings_); flatten(yaml, "", strings_);
std::cout << "Locale: " << strings_.size() << " traduccions des de " << file_path << '\n'; Log::info("Locale", "{} traduccions des de {}", strings_.size(), file_path);
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cerr << "Locale: error parsejant " << file_path << ": " << e.what() << '\n'; Log::error("Locale", "error parsejant {}: {}", file_path, e.what());
} }
} }
auto Locale::text(const std::string& key) const -> std::string { auto Locale::text(const std::string& key) const -> std::string {
auto it = strings_.find(key); auto it = strings_.find(key);
if (it != strings_.end()) { return it->second; } if (it != strings_.end()) { return it->second; }
std::cerr << "Locale: clau no trobada: " << key << '\n'; Log::warn("Locale", "clau no trobada: {}", key);
return key; return key;
} }
@@ -0,0 +1,48 @@
#include "core/rendering/effects/heat_haze.hpp"
#include <SDL3/SDL.h>
#include <cmath>
#include <numbers>
#include "core/rendering/surface.hpp"
namespace Effects {
namespace {
constexpr float TWO_PI = 2.0F * std::numbers::pi_v<float>;
}
HeatHaze::HeatHaze(Params params)
: params_(params) {}
void HeatHaze::tick(int delta_ms) {
const float DT = static_cast<float>(delta_ms) / 1000.0F;
phase_ = std::fmod(phase_ + (TWO_PI * params_.speed_hz * DT), TWO_PI);
}
void HeatHaze::render(const Surface& src, int x, int y) const {
// Pas 1 — blit pla de tota la Surface al destí. És la base que
// tapa els marges que les files desplaçades deixarien al
// descobert, evitant haver de demanar al caller un pre-fill. Per
// a fons amb gradient continu (cels, sorres) la costura amb el
// pas 2 no es nota; per a fons d'alt contrast cal moderar
// l'amplitud.
src.render(x, y);
// Pas 2 — re-blit fila a fila amb desplaçament horitzontal sin.
// Wavelength <=0 dóna divisió absurda: cau a 1 px com a salvaguarda;
// a la pràctica el caller passa un valor >0 vingut d'ui.yaml.
const float WL = (params_.wavelength_px > 0.0F) ? params_.wavelength_px : 1.0F;
const int H = src.getHeight();
const auto W = static_cast<float>(src.getWidth());
for (int row = 0; row < H; ++row) {
const float ARG = (TWO_PI * static_cast<float>(row) / WL) + phase_;
const int DX = static_cast<int>(std::lround(params_.amplitude_px * std::sin(ARG)));
if (DX == 0) { continue; } // El pas 1 ja ha pintat aquesta fila on toca.
SDL_FRect row_rect{0.0F, static_cast<float>(row), W, 1.0F};
src.render(x + DX, y + row, &row_rect);
}
}
} // namespace Effects
@@ -0,0 +1,37 @@
#pragma once
class Surface;
namespace Effects {
// Distorsió sinusoidal horitzontal de cada fila: dx(y) =
// amplitude · sin(2π·y/wavelength + phase). La phase avança al
// ritme marcat per `speed_hz` (cicles per segon). Es pinta sobre
// qualsevol Surface — habitualment un fons de canvas — i manté el
// seu propi estat temporal, així dues instàncies poden tenir
// paràmetres i fases independents.
//
// `render()` fa dos passos: primer un blit pla de tota la Surface
// (perquè els marges no quedin descoberts pel desplaçament) i
// després el re-blit fila a fila amb el sin. La costura és
// invisible en fons amb gradient continu i pràcticament invisible
// en altres si l'amplitud és moderada.
class HeatHaze {
public:
struct Params {
float amplitude_px;
float wavelength_px;
float speed_hz;
};
explicit HeatHaze(Params params);
void tick(int delta_ms);
void render(const Surface& src, int x, int y) const;
private:
Params params_;
float phase_{0.0F};
};
} // namespace Effects
@@ -0,0 +1,219 @@
#include "core/rendering/effects/scan_reveal.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <numbers>
#include <random>
#include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
namespace Effects {
namespace {
// Hash determinista per a (row, time_bucket): retorna float a
// [-1, 1]. Usat per generar el jitter horitzontal de la banda
// sense necessitat d'un RNG d'estat (cada frame el resultat
// canvia perquè el time_bucket canvia, però dins d'un mateix
// bucket dues files diferents donen valors distints).
auto rowJitter(int row, int time_bucket) -> float {
const auto SEED = static_cast<std::uint32_t>((row * 73856093) ^ (time_bucket * 19349663));
// Mix barato (xorshift) per descorrelacionar bits.
std::uint32_t x = SEED;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
// Normalitzem a [-1, 1].
const float NORM = static_cast<float>(x) / static_cast<float>(UINT32_MAX);
return (NORM * 2.0F) - 1.0F;
}
// Bucket de temps per al jitter: agrupant en blocs de ~50ms el
// jitter "salta" 20 cops per segon — visualment "viu" sense
// ser caos pur a 60fps.
constexpr int JITTER_TIME_BUCKET_MS = 50;
} // namespace
ScanReveal::ScanReveal()
: rng_(std::random_device{}()) {}
void ScanReveal::begin(const Params& params) {
params_ = params;
elapsed_ms_ = 0;
flash_remaining_ms_ = 0;
flash_started_ = false;
burst_remaining_ms_ = 0;
burst_y_norm_ = 0.0F;
burst_dx_ = 0;
tail_started_ = false;
tail_remaining_ms_ = 0;
}
void ScanReveal::tick(int delta_ms) {
if (params_.duration_ms <= 0) { return; }
// 1) Avanç del scan (clampejat a duration_ms).
elapsed_ms_ = std::min(elapsed_ms_ + delta_ms, params_.duration_ms);
// 2) Tick del burst VHS actiu, si n'hi ha.
if (burst_remaining_ms_ > 0) {
burst_remaining_ms_ = std::max(0, burst_remaining_ms_ - delta_ms);
}
// 3) Spawn d'un nou burst. Permès durant el scan i durant el
// glitch tail post-flash; bloquejat exactament durant el
// flash perquè aquell és un punt visual de tancament net.
// Probabilitat per frame ≈ (per_sec/1000) · delta_ms.
const bool TAIL_ACTIVE = tail_started_ && tail_remaining_ms_ > 0;
const bool SCAN_ONGOING = elapsed_ms_ < params_.duration_ms;
if (burst_remaining_ms_ == 0 && (SCAN_ONGOING || TAIL_ACTIVE) && params_.vhs_burst_per_sec > 0 && params_.vhs_burst_duration_ms > 0) {
const float P = (static_cast<float>(params_.vhs_burst_per_sec) / 1000.0F) * static_cast<float>(delta_ms);
std::uniform_real_distribution<float> dist01(0.0F, 1.0F);
if (dist01(rng_) < P) {
burst_remaining_ms_ = params_.vhs_burst_duration_ms;
burst_y_norm_ = dist01(rng_);
std::uniform_int_distribution<int> dist_sign(0, 1);
const int SIGN = (dist_sign(rng_) == 0) ? 1 : -1;
burst_dx_ = SIGN * params_.vhs_burst_amplitude_px;
}
}
// 4) Dispara el flash quan el scan acaba (una sola vegada).
if (!flash_started_ && elapsed_ms_ >= params_.duration_ms && params_.flash_ms > 0) {
flash_started_ = true;
flash_remaining_ms_ = params_.flash_ms;
}
// Decrement del flash mentre estiga actiu.
if (flash_remaining_ms_ > 0) {
flash_remaining_ms_ = std::max(0, flash_remaining_ms_ - delta_ms);
}
// 5) Glitch tail: arranca un sol cop quan el scan ha acabat i el
// flash s'ha esgotat. Si no hi havia flash configurat, també
// arranca tot just acabar el scan.
if (!tail_started_ && elapsed_ms_ >= params_.duration_ms && flash_remaining_ms_ <= 0 && params_.tail_glitch_ms > 0) {
tail_started_ = true;
tail_remaining_ms_ = params_.tail_glitch_ms;
}
if (tail_remaining_ms_ > 0) {
tail_remaining_ms_ = std::max(0, tail_remaining_ms_ - delta_ms);
}
}
auto ScanReveal::isActive() const -> bool {
return params_.duration_ms > 0 && !isFinished();
}
auto ScanReveal::isFinished() const -> bool {
if (params_.duration_ms <= 0) { return true; }
// Acabat = scan al final + flash exhaurit + glitch tail
// exhaurit (o sense tail configurat).
const bool SCAN_DONE = elapsed_ms_ >= params_.duration_ms;
const bool FLASH_DONE = flash_remaining_ms_ <= 0;
const bool TAIL_DONE = (params_.tail_glitch_ms <= 0) || (tail_started_ && tail_remaining_ms_ <= 0);
return SCAN_DONE && FLASH_DONE && TAIL_DONE;
}
void ScanReveal::render(const Surface& src, int x, int y) const {
if (params_.duration_ms <= 0) {
// Sense efecte: render pla.
src.render(x, y);
return;
}
const int H = src.getHeight();
const auto W_F = static_cast<float>(src.getWidth());
const float T = static_cast<float>(elapsed_ms_) / static_cast<float>(params_.duration_ms);
// Banda: comença a y=-band_height i acaba a y=H. Així al t=0 cap
// fila és visible i al t=1 totes ho són.
const float SWEEP_F = -static_cast<float>(params_.band_height_px) + (T * static_cast<float>(H + params_.band_height_px));
const int BAND_TOP = static_cast<int>(std::floor(SWEEP_F));
const int BAND_BOT = BAND_TOP + params_.band_height_px;
const int TIME_BUCKET = elapsed_ms_ / JITTER_TIME_BUCKET_MS;
const int BURST_Y_PX = static_cast<int>(burst_y_norm_ * static_cast<float>(H));
const bool BURST_ACTIVE = burst_remaining_ms_ > 0;
// Durant el glitch tail post-flash, BAND_TOP queda just damunt
// del peu del logo, fet que (amb el codi de jitter que segueix)
// generaria jitter residual a les últimes files. Aquí el
// suprimim explícitament: el tail només viu de bursts VHS.
const bool TAIL_ACTIVE = tail_started_ && tail_remaining_ms_ > 0;
// Render fila a fila (només files revelades).
for (int row = 0; row <= BAND_BOT && row < H; ++row) {
int dx = 0;
// Jitter de banda: amplitud màxima a la banda, falloff
// lineal cap als marges del radi de glitch. Inactiu durant
// el glitch tail (no hi ha banda).
const int DIST_TO_BAND = std::abs(row - BAND_TOP);
if (!TAIL_ACTIVE && params_.glitch_band_radius_px > 0 && DIST_TO_BAND <= params_.glitch_band_radius_px && params_.glitch_amplitude_px > 0) {
const float FALLOFF = 1.0F - (static_cast<float>(DIST_TO_BAND) / static_cast<float>(params_.glitch_band_radius_px));
const float JIT = rowJitter(row, TIME_BUCKET);
dx += static_cast<int>(std::lround(
FALLOFF * static_cast<float>(params_.glitch_amplitude_px) * JIT));
}
// Burst VHS: només damunt la part ja revelada (row < BAND_TOP)
// perquè per sota encara no hi ha imatge "estable" que poder
// distorsionar visualment.
if (BURST_ACTIVE && row < BAND_TOP && row >= BURST_Y_PX && row < BURST_Y_PX + params_.vhs_burst_height_px) {
dx += burst_dx_;
}
SDL_FRect row_rect{0.0F, static_cast<float>(row), W_F, 1.0F};
src.render(x + dx, y + row, &row_rect);
}
auto& screen = Screen::get();
// Capa lluminosa de la banda. Verticalment només cobreix les
// files del logo (és on viu el scan), però horitzontalment
// travessa tot el canvas com un feix CRT, així sobre fons negre
// no es veuen les costures del rectangle del Surface. L'alpha
// base es modula amb un pulse sinusoidal a `band_pulse_hz` per
// simular el ritme de refresc d'un CRT.
if (params_.band_alpha > 0 && params_.band_height_px > 0) {
const int VISIBLE_TOP = std::clamp(BAND_TOP, 0, H);
const int VISIBLE_BOT = std::clamp(BAND_BOT, 0, H);
const int VISIBLE_H = VISIBLE_BOT - VISIBLE_TOP;
if (VISIBLE_H > 0) {
// Pulse: fracció ∈ [0,1] avançada a la freqüència donada,
// mapejada a un multiplicador ∈ [1-depth, 1] sobre l'alpha
// base. Amb depth=0 el pulse és inactiu (multiplicador 1).
const float PULSE_DEPTH = std::clamp(params_.band_pulse_depth, 0.0F, 1.0F);
const float TWO_PI = 2.0F * std::numbers::pi_v<float>;
const float PHASE = TWO_PI * params_.band_pulse_hz * (static_cast<float>(elapsed_ms_) / 1000.0F);
const float WAVE = 0.5F + (0.5F * std::sin(PHASE)); // 0..1
const float MULT = (1.0F - PULSE_DEPTH) + (PULSE_DEPTH * WAVE);
const float ALPHA_F = static_cast<float>(params_.band_alpha) * MULT;
const auto ALPHA = static_cast<std::uint8_t>(
std::clamp(static_cast<int>(std::round(ALPHA_F)), 0, 255));
if (ALPHA > 0) {
screen.blendRect(0, y + VISIBLE_TOP, screen.getCanvasWidth(), VISIBLE_H, 0xFFFFFFFFU, ALPHA);
}
}
}
// Flash final: capa blanca damunt de TOT el canvas (no només
// del rectangle del logo) — així el flash banya l'escena
// sencera com un CRT i no es veuen les costures del Surface
// sobre fons negre. Alpha proporcional a `flash_remaining_ms_
// / flash_ms` (decreix suau cap a 0 al final del flash).
if (flash_remaining_ms_ > 0 && params_.flash_ms > 0) {
const float FLASH_T = static_cast<float>(flash_remaining_ms_) / static_cast<float>(params_.flash_ms);
const auto ALPHA = static_cast<std::uint8_t>(
std::round(255.0F * std::clamp(FLASH_T, 0.0F, 1.0F)));
if (ALPHA > 0) {
screen.blendRect(0, 0, screen.getCanvasWidth(), screen.getCanvasHeight(), 0xFFFFFFFFU, ALPHA);
}
}
}
} // namespace Effects
@@ -0,0 +1,96 @@
#pragma once
#include <cstdint>
#include <random>
class Surface;
namespace Effects {
// Revelat tipus "scanline CRT" d'una Surface. Una banda lluminosa
// baixa des de dalt cap a baix de la imatge: les files damunt de la
// banda es renderitzen normalment (ja revelades), les files sota
// queden invisibles, i a la banda mateixa s'aplica un overlay
// blanc semi-transparent. Al voltant de la banda les files reben un
// jitter horitzontal aleatori (falloff lineal amb la distància) i
// damunt la part ja revelada apareixen ocasionalment "bursts VHS" —
// franges horitzontals desplaçades durant uns ms — per a un toc
// demoscene/VHS deteriorat.
//
// Quan la banda surt per sota del logo i s'esgota `duration_ms`,
// dispara un flash blanc breu. Si `tail_glitch_ms > 0`, després del
// flash el logo queda totalment visible mentre els bursts VHS
// continuen saltant durant aquest temps (glitch tail). Quan el tail
// s'exhaureix `isFinished()=true`. El caller decideix què fa amb el
// control de fase a partir d'aquí.
//
// Ús:
// ScanReveal sr;
// sr.begin({.duration_ms = 1500, ...});
// // cada frame:
// sr.tick(delta_ms);
// sr.render(*logo, x, y);
// if (sr.isFinished()) { ... canvi de fase ... }
class ScanReveal {
public:
struct Params {
int duration_ms;
int band_height_px;
int band_alpha;
float band_pulse_hz;
float band_pulse_depth;
int glitch_amplitude_px;
int glitch_band_radius_px;
int vhs_burst_per_sec;
int vhs_burst_duration_ms;
int vhs_burst_height_px;
int vhs_burst_amplitude_px;
int flash_ms;
// Glitch tail post-flash: el logo queda visible sencer i els
// bursts VHS continuen saltant durant aquest temps. 0 ho
// desactiva (efecte acaba al flash).
int tail_glitch_ms;
};
ScanReveal();
void begin(const Params& params);
void tick(int delta_ms);
// Renderitza un frame del revelat sobre la Surface `src`. El
// caller s'encarrega de pintar negre per sota (les files no
// revelades simplement no es dibuixen — queda el que hi haja al
// canvas, normalment negre).
void render(const Surface& src, int x, int y) const;
[[nodiscard]] auto isActive() const -> bool;
[[nodiscard]] auto isFinished() const -> bool;
private:
Params params_{};
int elapsed_ms_{0};
// Flash post-scan: arranca quan el scan toca fons; decreix amb
// `delta_ms` fins a 0. Mentre `flash_active_` és true encara no
// hem reportat finished (donant marge per pintar el flash sencer).
int flash_remaining_ms_{0};
bool flash_started_{false};
// Burst VHS actiu (com a màxim un alhora — simplifica i ja és
// suficient per al ritme visual). y_norm ∈ [0,1] es resol en
// píxels al `render()` perquè aquí no coneixem l'alçada del logo.
int burst_remaining_ms_{0};
float burst_y_norm_{0.0F};
int burst_dx_{0};
// Glitch tail post-flash: arranca quan el flash s'esgota i
// permet que els bursts continuen saltant amb el logo ja del tot
// visible. `isFinished()` espera al final d'aquest tail.
bool tail_started_{false};
int tail_remaining_ms_{0};
// RNG per als bursts. Seed des de random_device a `begin()`
// perquè cada execució sigui visualment diferent.
std::minstd_rand rng_;
};
} // namespace Effects
+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
+6 -7
View File
@@ -1,10 +1,11 @@
#include "core/rendering/gif.hpp" #include "core/rendering/gif.hpp"
#include <cstring> #include <cstring>
#include <iostream>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include "utils/log.hpp"
namespace GIF { namespace GIF {
namespace { namespace {
@@ -111,7 +112,7 @@ namespace GIF {
while (cur_code != -1) { while (cur_code != -1) {
out[dictionary[cur_code].len - 1] = dictionary[cur_code].byte; out[dictionary[cur_code].len - 1] = dictionary[cur_code].byte;
if (dictionary[cur_code].prev == cur_code) { if (dictionary[cur_code].prev == cur_code) {
std::cerr << "Internal error; self-reference detected." << '\n'; Log::error("GIF", "internal error; self-reference detected.");
throw std::runtime_error("Internal error in decompress: self-reference"); throw std::runtime_error("Internal error in decompress: self-reference");
} }
cur_code = dictionary[cur_code].prev; cur_code = dictionary[cur_code].prev;
@@ -155,8 +156,7 @@ namespace GIF {
if (prev > -1 && code_length < LZW_MAX_CODE_LENGTH) { if (prev > -1 && code_length < LZW_MAX_CODE_LENGTH) {
if (code > dictionary_ind) { if (code > dictionary_ind) {
std::cerr << "code = " << std::hex << code Log::error("GIF", "code = {:x}, but dictionary_ind = {}", code, dictionary_ind);
<< ", but dictionary_ind = " << dictionary_ind << '\n';
throw std::runtime_error("LZW error: code exceeds dictionary_ind."); throw std::runtime_error("LZW error: code exceeds dictionary_ind.");
} }
@@ -167,8 +167,7 @@ namespace GIF {
// Verifica que 'code' sea un índice válido antes de usarlo. // Verifica que 'code' sea un índice válido antes de usarlo.
if (code < 0 || static_cast<size_t>(code) >= dictionary.size()) { if (code < 0 || static_cast<size_t>(code) >= dictionary.size()) {
std::cerr << "Invalid LZW code " << code Log::error("GIF", "invalid LZW code {}, dictionary size {}", code, dictionary.size());
<< ", dictionary size " << dictionary.size() << '\n';
throw std::runtime_error("LZW error: invalid code encountered"); throw std::runtime_error("LZW error: invalid code encountered");
} }
@@ -287,7 +286,7 @@ namespace GIF {
} else if (block_type == IMAGE_DESCRIPTOR) { } else if (block_type == IMAGE_DESCRIPTOR) {
return processImageDescriptor(buffer); return processImageDescriptor(buffer);
} else { } else {
std::cerr << "Unrecognized block type " << std::hex << static_cast<int>(block_type) << '\n'; Log::error("GIF", "unrecognized block type {:x}", static_cast<int>(block_type));
return std::vector<uint8_t>{}; return std::vector<uint8_t>{};
} }
block_type = *buffer++; block_type = *buffer++;
@@ -9,7 +9,7 @@
// Temes per a les notificacions de pantalla. Un "preset" captura els dos // Temes per a les notificacions de pantalla. Un "preset" captura els dos
// colors (text i fons, índexs sobre la paleta "ui") i el temps de visibilitat // colors (text i fons, índexs sobre la paleta "ui") i el temps de visibilitat
// en mil·lisegons. Els valors concrets viuen a data/ui.yaml (carregat per // en mil·lisegons. Els valors concrets viuen a data/config/ui.yaml (carregat per
// UiConfig); per llançar una notificació amb un preset concret, cridar // UiConfig); per llançar una notificació amb un preset concret, cridar
// Notification::notify(text, preset). Sense preset s'usa el DEFAULT. // Notification::notify(text, preset). Sense preset s'usa el DEFAULT.
namespace Notification { namespace Notification {
@@ -22,7 +22,7 @@ namespace Notification {
// Preset per defecte: text clar sobre fons fosc. Es construeix en cada // Preset per defecte: text clar sobre fons fosc. Es construeix en cada
// crida a partir d'UiConfig perquè els valors no són constexpr: viuen a // crida a partir d'UiConfig perquè els valors no són constexpr: viuen a
// data/ui.yaml i es carreguen a l'arrencada. // data/config/ui.yaml i es carreguen a l'arrencada.
[[nodiscard]] inline auto defaultPreset() -> Preset { [[nodiscard]] inline auto defaultPreset() -> Preset {
return { return {
.text_color = UiConfig::get().notificationText(), .text_color = UiConfig::get().notificationText(),
+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;
} }
+9 -3
View File
@@ -6,13 +6,13 @@
#include <cstddef> #include <cstddef>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <iostream>
#include <memory> #include <memory>
#include <ranges> #include <ranges>
#include "core/rendering/canvas.hpp" #include "core/rendering/canvas.hpp"
#include "core/rendering/sdl3gpu/viewport.hpp" #include "core/rendering/sdl3gpu/viewport.hpp"
#include "utils/defines.hpp" #include "utils/defines.hpp"
#include "utils/log.hpp"
namespace { namespace {
@@ -112,11 +112,11 @@ void Screen::createSdl() {
backend->setVSync(config_.vsync); backend->setVSync(config_.vsync);
if (backend->init(window_, canvas_width_, canvas_height_)) { if (backend->init(window_, canvas_width_, canvas_height_)) {
gpu_driver_ = backend->getDriverName(); gpu_driver_ = backend->getDriverName();
std::cout << "Screen: path GPU actiu (driver: " << gpu_driver_ << ")\n"; Log::info("Screen", "path GPU actiu (driver: {})", gpu_driver_);
shader_backend_ = std::move(backend); shader_backend_ = std::move(backend);
return; return;
} }
std::cerr << "Screen: GPU no disponible, fallback a SDL_Renderer\n"; Log::warn("Screen", "GPU no disponible, fallback a SDL_Renderer");
} }
gpu_driver_ = "sdl"; gpu_driver_ = "sdl";
@@ -226,6 +226,12 @@ void Screen::fillRect(int x, int y, int w, int h, std::uint32_t argb) {
const int Y0 = std::max(y, CLIP.y); const int Y0 = std::max(y, CLIP.y);
const int X1 = std::min(x + w, CLIP.x + CLIP.w); const int X1 = std::min(x + w, CLIP.x + CLIP.w);
const int Y1 = std::min(y + h, CLIP.y + CLIP.h); const int Y1 = std::min(y + h, CLIP.y + CLIP.h);
// Bail-out defensiu: si el rang és buit o invertit després del clip,
// `std::fill(first, last)` amb `first > last` és UB i a la pràctica
// entra en bucle infinit (vist en un crash a l'overflow del Notifier
// quan la caixa quedava més ampla que el canvas). El `blendRect`
// d'aquí baix ja és segur perquè usa `for(ix; ix < X1; ...)`.
if (X1 <= X0 || Y1 <= Y0) { return; }
for (int iy = Y0; iy < Y1; ++iy) { for (int iy = Y0; iy < Y1; ++iy) {
std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_); std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_);
std::fill(row + X0, row + X1, argb); std::fill(row + X0, row + X1, argb);
+32 -20
View File
@@ -5,6 +5,7 @@
#include <ctime> #include <ctime>
#include <filesystem> #include <filesystem>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
#include "external/stb_image_write.h" #include "external/stb_image_write.h"
@@ -13,21 +14,18 @@ namespace Screenshot {
namespace { namespace {
// Crea la carpeta `screenshots/` si no existeix i torna la ruta // Estat de mòdul: ruta base on s'escriu la subcarpeta
// completa al fitxer. El paràmetre `filename` rep el nom nu (sense // `screenshots/`. Buida per defecte ⇒ relativa al CWD (fallback
// directori) per poder mostrar-lo a la notificació. // si el Director no ha pogut establir la ruta per plataforma).
auto generateFilePath(std::string& filename) -> std::string { std::string g_base_dir; // NOLINT(*-avoid-non-const-global-variables)
std::filesystem::create_directories("screenshots");
const auto NOW = std::chrono::system_clock::now(); // Construeix la ruta del directori on van les captures:
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW); // `<base>/screenshots/`. Si la base és buida, retorna
const std::tm* tm = std::localtime(&TIME); // "screenshots/" relativa al CWD.
auto screenshotsDir() -> std::string {
char timestamp[20]; if (g_base_dir.empty()) { return "screenshots/"; }
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm); const bool ENDS_WITH_SEP = (g_base_dir.back() == '/' || g_base_dir.back() == '\\');
return g_base_dir + (ENDS_WITH_SEP ? "" : "/") + "screenshots/";
filename = std::string("scr_") + timestamp + ".png";
return "screenshots/" + filename;
} }
// Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write // Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write
@@ -47,14 +45,28 @@ namespace Screenshot {
} // namespace } // namespace
auto save(const std::uint32_t* buffer, int width, int height) -> std::string { void setBaseDir(std::string dir) {
g_base_dir = std::move(dir);
}
auto save(const std::uint32_t* buffer, int width, int height) -> Result {
const std::string FOLDER = screenshotsDir();
std::error_code ec;
std::filesystem::create_directories(FOLDER, ec);
const auto NOW = std::chrono::system_clock::now();
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW);
const std::tm* tm = std::localtime(&TIME);
char timestamp[20];
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm);
const std::string FILENAME = std::string("scr_") + timestamp + ".png";
const std::string FILEPATH = FOLDER + FILENAME;
// Còpia local per no tocar el canvas original durant la conversió. // Còpia local per no tocar el canvas original durant la conversió.
std::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height)); std::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height));
if (!writePng(copy.data(), width, height, FILEPATH)) { return {}; }
std::string filename; return {.filename = FILENAME, .folder = FOLDER};
const std::string FILEPATH = generateFilePath(filename);
return writePng(copy.data(), width, height, FILEPATH) ? filename : "";
} }
} // namespace Screenshot } // namespace Screenshot
+23 -4
View File
@@ -3,11 +3,30 @@
#include <cstdint> #include <cstdint>
#include <string> #include <string>
// Volca un buffer ARGB8888 a un fitxer PNG dins de la carpeta `screenshots/` // Volca un buffer ARGB8888 a un fitxer PNG dins de la subcarpeta
// (relativa al CWD). El nom es genera amb un timestamp i la funció retorna // `screenshots/` del directori configurat amb `setBaseDir`. El nom es
// el nom del fitxer creat, o una cadena buida si l'escriptura ha fallat. // genera amb un timestamp i la funció retorna el basename i la ruta
// del directori on s'ha escrit (o un Result amb `filename` buit si
// l'escriptura ha fallat).
namespace Screenshot { namespace Screenshot {
auto save(const std::uint32_t* buffer, int width, int height) -> std::string; // Configura el directori base on s'escriuran les captures (s'hi
// crea una subcarpeta `screenshots/`). El Director el crida una
// vegada al boot amb `system_folder_` perquè les captures vagin
// al mateix lloc per plataforma que la resta de fitxers generats
// (config.yaml, gamepad_configs.yaml, shaders/, etc.). Si no es
// crida, el fallback és el CWD — comportament antic.
void setBaseDir(std::string dir);
// Resultat d'una captura: el `filename` és el basename del PNG
// (per a notificacions/títol) i el `folder` és la ruta absoluta
// on s'ha guardat (per a poder dir "guardada a {folder}"). Si
// l'escriptura falla, ambdós camps tornen buits.
struct Result {
std::string filename;
std::string folder;
};
auto save(const std::uint32_t* buffer, int width, int height) -> Result;
} // namespace Screenshot } // namespace Screenshot
@@ -1,10 +1,10 @@
#include "core/rendering/sdl3gpu/pipelines/crtpi_pipeline.hpp" #include "core/rendering/sdl3gpu/pipelines/crtpi_pipeline.hpp"
#include <cstring> #include <cstring>
#include <iostream>
#include "core/rendering/sdl3gpu/shader_factory.hpp" #include "core/rendering/sdl3gpu/shader_factory.hpp"
#include "core/rendering/sdl3gpu/viewport.hpp" #include "core/rendering/sdl3gpu/viewport.hpp"
#include "utils/log.hpp"
#ifdef __APPLE__ #ifdef __APPLE__
#include "core/rendering/sdl3gpu/msl/crtpi_frag.msl.h" #include "core/rendering/sdl3gpu/msl/crtpi_frag.msl.h"
@@ -29,7 +29,7 @@ namespace Rendering {
SDL_GPUShader* frag = createShaderSPIRV(device_, kcrtpi_frag_spv, kcrtpi_frag_spv_size, "main", SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); SDL_GPUShader* frag = createShaderSPIRV(device_, kcrtpi_frag_spv, kcrtpi_frag_spv_size, "main", SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1);
#endif #endif
if (vert == nullptr || frag == nullptr) { if (vert == nullptr || frag == nullptr) {
std::cerr << "CrtPiPipeline: creació de shaders fallida: " << SDL_GetError() << '\n'; Log::error("CrtPiPipeline", "creació de shaders fallida: {}", SDL_GetError());
if (vert != nullptr) { SDL_ReleaseGPUShader(device_, vert); } if (vert != nullptr) { SDL_ReleaseGPUShader(device_, vert); }
if (frag != nullptr) { SDL_ReleaseGPUShader(device_, frag); } if (frag != nullptr) { SDL_ReleaseGPUShader(device_, frag); }
return false; return false;
@@ -57,7 +57,7 @@ namespace Rendering {
SDL_ReleaseGPUShader(device_, frag); SDL_ReleaseGPUShader(device_, frag);
if (pipeline_ == nullptr) { if (pipeline_ == nullptr) {
std::cerr << "CrtPiPipeline: creació del pipeline ha fallat: " << SDL_GetError() << '\n'; Log::error("CrtPiPipeline", "creació del pipeline ha fallat: {}", SDL_GetError());
return false; return false;
} }
@@ -2,10 +2,10 @@
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <iostream>
#include "core/rendering/sdl3gpu/shader_factory.hpp" #include "core/rendering/sdl3gpu/shader_factory.hpp"
#include "core/rendering/sdl3gpu/viewport.hpp" #include "core/rendering/sdl3gpu/viewport.hpp"
#include "utils/log.hpp"
#ifdef __APPLE__ #ifdef __APPLE__
#include "core/rendering/sdl3gpu/msl/postfx_frag.msl.h" #include "core/rendering/sdl3gpu/msl/postfx_frag.msl.h"
@@ -59,11 +59,11 @@ namespace Rendering {
const char* driver = preferred_driver_.empty() ? nullptr : preferred_driver_.c_str(); const char* driver = preferred_driver_.empty() ? nullptr : preferred_driver_.c_str();
device_ = SDL_CreateGPUDevice(FORMAT, false, driver); device_ = SDL_CreateGPUDevice(FORMAT, false, driver);
if (device_ == nullptr) { if (device_ == nullptr) {
std::cerr << "SDL3GPUShader: SDL_CreateGPUDevice ha fallat: " << SDL_GetError() << '\n'; Log::error("SDL3GPUShader", "SDL_CreateGPUDevice ha fallat: {}", SDL_GetError());
return false; return false;
} }
if (!SDL_ClaimWindowForGPUDevice(device_, window_)) { if (!SDL_ClaimWindowForGPUDevice(device_, window_)) {
std::cerr << "SDL3GPUShader: SDL_ClaimWindowForGPUDevice ha fallat: " << SDL_GetError() << '\n'; Log::error("SDL3GPUShader", "SDL_ClaimWindowForGPUDevice ha fallat: {}", SDL_GetError());
return false; return false;
} }
SDL_SetGPUSwapchainParameters(device_, window_, SDL_GPU_SWAPCHAINCOMPOSITION_SDR, bestPresentMode(vsync_)); SDL_SetGPUSwapchainParameters(device_, window_, SDL_GPU_SWAPCHAINCOMPOSITION_SDR, bestPresentMode(vsync_));
@@ -82,7 +82,7 @@ namespace Rendering {
fragment_shader_ = createShaderSPIRV(device_, kpostfx_frag_spv, kpostfx_frag_spv_size, "main", SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); fragment_shader_ = createShaderSPIRV(device_, kpostfx_frag_spv, kpostfx_frag_spv_size, "main", SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1);
#endif #endif
if (vertex_shader_ == nullptr || fragment_shader_ == nullptr) { if (vertex_shader_ == nullptr || fragment_shader_ == nullptr) {
std::cerr << "SDL3GPUShader: SDL_CreateGPUShader ha fallat: " << SDL_GetError() << '\n'; Log::error("SDL3GPUShader", "SDL_CreateGPUShader ha fallat: {}", SDL_GetError());
return false; return false;
} }
return true; return true;
@@ -105,7 +105,7 @@ namespace Rendering {
pipeline_ = SDL_CreateGPUGraphicsPipeline(device_, &info); pipeline_ = SDL_CreateGPUGraphicsPipeline(device_, &info);
if (pipeline_ == nullptr) { if (pipeline_ == nullptr) {
std::cerr << "SDL3GPUShader: SDL_CreateGPUGraphicsPipeline ha fallat: " << SDL_GetError() << '\n'; Log::error("SDL3GPUShader", "SDL_CreateGPUGraphicsPipeline ha fallat: {}", SDL_GetError());
return false; return false;
} }
return true; return true;
@@ -137,7 +137,7 @@ namespace Rendering {
sampler_ = SDL_CreateGPUSampler(device_, &samp_info); sampler_ = SDL_CreateGPUSampler(device_, &samp_info);
if (scene_texture_ == nullptr || upload_buffer_ == nullptr || sampler_ == nullptr) { if (scene_texture_ == nullptr || upload_buffer_ == nullptr || sampler_ == nullptr) {
std::cerr << "SDL3GPUShader: creació de recursos GPU ha fallat: " << SDL_GetError() << '\n'; Log::error("SDL3GPUShader", "creació de recursos GPU ha fallat: {}", SDL_GetError());
return false; return false;
} }
return true; return true;
@@ -1,12 +1,12 @@
#include "core/rendering/sprite/animated_sprite.hpp" #include "core/rendering/sprite/animated_sprite.hpp"
#include <cmath> #include <cmath>
#include <iostream>
#include <numeric> #include <numeric>
#include <utility> #include <utility>
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
#include "utils/log.hpp"
// El parser pure de YAML d'animacions (parseAnimationsFromYaml + helpers) // El parser pure de YAML d'animacions (parseAnimationsFromYaml + helpers)
// viu a `animated_sprite_parser.cpp` perquè és testable sense Surface ni // viu a `animated_sprite_parser.cpp` perquè és testable sense Surface ni
@@ -74,10 +74,10 @@ AnimatedSprite::AnimatedSprite(const AnimationResource& cached_data) {
if (!first.frames.empty()) { setClip(first.frames[0].rect); } if (!first.frames.empty()) { setClip(first.frames[0].rect); }
} }
} catch (const fkyaml::exception& e) { } catch (const fkyaml::exception& e) {
std::cerr << "YAML parsing error in animation " << cached_data.name << ": " << e.what() << '\n'; Log::error("AnimatedSprite", "YAML parsing error in animation {}: {}", cached_data.name, e.what());
throw; throw;
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cerr << "Error loading animation " << cached_data.name << ": " << e.what() << '\n'; Log::error("AnimatedSprite", "Error loading animation {}: {}", cached_data.name, e.what());
throw; throw;
} }
} }
@@ -140,7 +140,7 @@ void AnimatedSprite::setCurrentAnimation(const std::string& name) {
if (current_animation_ == name) { return; } if (current_animation_ == name) { return; }
auto it = animations_.find(name); auto it = animations_.find(name);
if (it == animations_.end()) { if (it == animations_.end()) {
std::cout << "** Warning: could not find \"" << name << "\" animation" << '\n'; Log::warn("AnimatedSprite", "could not find \"{}\" animation", name);
return; return;
} }
current_animation_ = name; current_animation_ = name;
+1 -2
View File
@@ -2,7 +2,6 @@
#include <algorithm> #include <algorithm>
#include <cstring> #include <cstring>
#include <iostream>
#include <stdexcept> #include <stdexcept>
#include <vector> #include <vector>
@@ -63,7 +62,7 @@ Surface::Surface(const std::string& file_path) {
palette_[i] = Rendering::OPAQUE_ALPHA | rgb[i]; palette_[i] = Rendering::OPAQUE_ALPHA | rgb[i];
} }
printWithDots("Surface : ", getFileName(file_path), "[ LOADED ]"); printWithDots("Surface", getFileName(file_path), "[ LOADED ]");
} }
void Surface::clear(Uint8 color) { void Surface::clear(Uint8 color) {
+3 -3
View File
@@ -3,7 +3,6 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <cstddef> #include <cstddef>
#include <iostream>
#include <sstream> #include <sstream>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
@@ -11,6 +10,7 @@
#include "core/rendering/sprite/sprite.hpp" #include "core/rendering/sprite/sprite.hpp"
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include "utils/log.hpp"
#include "utils/utils.hpp" #include "utils/utils.hpp"
// Decodifica el pròxim codepoint UTF-8 de la cadena i avança 'pos' al byte // Decodifica el pròxim codepoint UTF-8 de la cadena i avança 'pos' al byte
@@ -55,7 +55,7 @@ auto Text::loadTextFile(const std::string& file_path) -> std::shared_ptr<File> {
auto file_data = Resource::Helper::loadFile(file_path); auto file_data = Resource::Helper::loadFile(file_path);
if (file_data.empty()) { if (file_data.empty()) {
std::cerr << "Error: Fitxer no trobat " << getFileName(file_path) << '\n'; Log::error("Text File", "fitxer no trobat: {}", getFileName(file_path));
throw std::runtime_error("Fitxer no trobat: " + getFileName(file_path)); throw std::runtime_error("Fitxer no trobat: " + getFileName(file_path));
} }
@@ -101,7 +101,7 @@ auto Text::loadTextFile(const std::string& file_path) -> std::shared_ptr<File> {
} }
} }
printWithDots("Text File : ", getFileName(file_path), "[ LOADED ]"); printWithDots("Text File", getFileName(file_path), "[ LOADED ]");
return tf; return tf;
} }
@@ -0,0 +1,44 @@
#include "core/rendering/transitions/fade_to_black.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include "core/rendering/screen.hpp"
namespace Transitions {
void FadeToBlack::begin(int duration_ms, Direction direction) {
duration_ms_ = duration_ms;
elapsed_ms_ = 0;
direction_ = direction;
}
void FadeToBlack::tick(int delta_ms) {
if (duration_ms_ <= 0) { return; }
elapsed_ms_ = std::min(elapsed_ms_ + delta_ms, duration_ms_);
}
auto FadeToBlack::progress() const -> float {
if (duration_ms_ <= 0) { return 0.0F; }
return std::clamp(static_cast<float>(elapsed_ms_) / static_cast<float>(duration_ms_), 0.0F, 1.0F);
}
void FadeToBlack::render() const {
if (duration_ms_ <= 0) { return; }
const float P = progress();
// Alpha lineal — sense corba quadràtica (això és prerogativa del
// dissolve mosaic, on volem que la mascara fosca arribe tard
// perquè es vegen els blocs). Per al fade net, lineal 0..255.
// Direcció FADE_IN inverteix la rampa: el negre comença opac i
// s'esvaeix cap a transparent, deixant veure l'escena pintada
// darrere abans de `render()`.
const float ALPHA_F = (direction_ == Direction::FADE_IN) ? (1.0F - P) : P;
const auto ALPHA = static_cast<std::uint8_t>(std::round(255.0F * ALPHA_F));
if (ALPHA == 0) { return; }
auto& screen = Screen::get();
screen.blendRect(0, 0, screen.getCanvasWidth(), screen.getCanvasHeight(), 0x00000000U, ALPHA);
}
} // namespace Transitions
@@ -0,0 +1,51 @@
#pragma once
#include <cstdint>
namespace Transitions {
// Fade lineal contra negre. A diferència de `Mosaic`, no rebina cap
// píxel; només posa una capa negra al damunt del canvas amb alpha
// que evoluciona linealment al llarg de la durada. És la transició
// "neta" per defecte; ideal per a entrades/sortides on no convé
// l'estètica chunky de l'arcade dissolve.
//
// La direcció controla el sentit de l'alpha:
// FADE_OUT: alpha 0 → 255 (món visible → negre opac)
// FADE_IN: alpha 255 → 0 (negre opac → món visible)
// En tots dos casos `progress()` segueix sent monotònic 0 → 1
// (mateixa semàntica per al caller); només el render inverteix.
//
// Mateixa interfície que `Mosaic` (begin/tick/render/isActive/
// isFinished/progress) perquè els llocs de crida puguen escollir-ne
// una o l'altra sense canviar el patró d'ús.
class FadeToBlack {
public:
enum class Direction : std::uint8_t { FADE_OUT,
FADE_IN };
FadeToBlack() = default;
// Arrenca un fade nou. Si ja n'hi havia un actiu, el reemplaça.
// `duration_ms <= 0` deixa l'efecte inactiu.
void begin(int duration_ms, Direction direction = Direction::FADE_OUT);
void tick(int delta_ms);
[[nodiscard]] auto isActive() const -> bool { return duration_ms_ > 0 && elapsed_ms_ < duration_ms_; }
[[nodiscard]] auto isFinished() const -> bool { return duration_ms_ > 0 && elapsed_ms_ >= duration_ms_; }
[[nodiscard]] auto progress() const -> float;
[[nodiscard]] auto direction() const -> Direction { return direction_; }
// Pinta la capa negra amb alpha derivat del `progress()` i la
// direcció. Cridar després del render normal de l'escena,
// abans de `Screen::present()`.
void render() const;
private:
int duration_ms_{0};
int elapsed_ms_{0};
Direction direction_{Direction::FADE_OUT};
};
} // namespace Transitions
@@ -0,0 +1,71 @@
#include "core/rendering/transitions/mosaic.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include "core/rendering/screen.hpp"
namespace Transitions {
Mosaic::Mosaic(Params params)
: params_(params) {}
void Mosaic::begin(int duration_ms) {
duration_ms_ = duration_ms;
elapsed_ms_ = 0;
}
void Mosaic::tick(int delta_ms) {
if (duration_ms_ <= 0) { return; }
elapsed_ms_ = std::min(elapsed_ms_ + delta_ms, duration_ms_);
}
auto Mosaic::progress() const -> float {
if (duration_ms_ <= 0) { return 0.0F; }
return std::clamp(static_cast<float>(elapsed_ms_) / static_cast<float>(duration_ms_), 0.0F, 1.0F);
}
void Mosaic::render() const {
if (duration_ms_ <= 0) { return; }
const float P = progress();
const int MAX_BLOCK = std::max(1, params_.max_block_size);
// Blocs de mida creixent al llarg del fade. A p=0 → 1 px (sense
// efecte visible); a p=1 → max_block. Es fa cap-amunt amb ceil
// perquè el primer increment arribi aviat (el salt visual de
// 1→2 ja es nota).
const int BLOCK = std::clamp(static_cast<int>(std::ceil(static_cast<float>(MAX_BLOCK) * P)), 1, MAX_BLOCK);
auto& screen = Screen::get();
std::uint32_t* canvas = screen.getCanvasArgb();
const int W = screen.getCanvasWidth();
const int H = screen.getCanvasHeight();
if (BLOCK > 1) {
// Sample-and-fill in-place. Per a cada bloc, mostregem el
// píxel central (les vores poden caure fora del canvas en
// blocs de la última columna/fila → clamp).
for (int by = 0; by < H; by += BLOCK) {
const int Y_END = std::min(by + BLOCK, H);
const int SY = std::min(by + (BLOCK / 2), H - 1);
for (int bx = 0; bx < W; bx += BLOCK) {
const int X_END = std::min(bx + BLOCK, W);
const int SX = std::min(bx + (BLOCK / 2), W - 1);
const std::uint32_t COLOR = canvas[(SY * W) + SX];
for (int yy = by; yy < Y_END; ++yy) {
std::uint32_t* row = canvas + (static_cast<std::ptrdiff_t>(yy) * W);
std::fill(row + bx, row + X_END, COLOR);
}
}
}
}
// Fade-to-black quadràtic per damunt: la mascara fosca arriba
// tard (a p=0.5 només 25% d'alpha) perquè es vegin els blocs.
const auto ALPHA = static_cast<std::uint8_t>(std::round(255.0F * P * P));
if (ALPHA > 0) {
screen.blendRect(0, 0, W, H, 0x00000000U, ALPHA);
}
}
} // namespace Transitions
@@ -0,0 +1,45 @@
#pragma once
namespace Transitions {
// Fade-out estil "mosaic dissolve": al llarg del temps els píxels del
// canvas es reagrupen en blocs cada cop més grans (1×1 → max_block_size×
// max_block_size). Cada bloc es pinta amb el píxel central del què
// tenia abans, donant aquell aspecte chunky d'arcade. Al damunt
// s'aplica un fade-to-black quadràtic (alpha = p² × 255) perquè el
// resultat acabi en negre opac.
//
// La classe NO sap res del Screen abans de `render()`. El caller hi
// crida després de pintar la seva escena: l'efecte llegeix els
// píxels actuals del canvas, els rebina i escriu pixels modificats
// al mateix canvas. És, per tant, un post-procés in-place.
class Mosaic {
public:
struct Params {
int max_block_size;
};
explicit Mosaic(Params params);
// Arrenca un fade nou. Si ja n'hi havia un actiu, el reemplaça.
// `duration_ms <= 0` deixa l'efecte inactiu.
void begin(int duration_ms);
void tick(int delta_ms);
[[nodiscard]] auto isActive() const -> bool { return duration_ms_ > 0 && elapsed_ms_ < duration_ms_; }
[[nodiscard]] auto isFinished() const -> bool { return duration_ms_ > 0 && elapsed_ms_ >= duration_ms_; }
[[nodiscard]] auto progress() const -> float;
// Aplica el mosaic + alpha negre sobre el canvas actual de Screen.
// Cridar després del render normal de l'escena, abans de
// `Screen::present()`.
void render() const;
private:
Params params_;
int duration_ms_{0};
int elapsed_ms_{0};
};
} // namespace Transitions
+10 -10
View File
@@ -1,12 +1,12 @@
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
#include <algorithm> #include <algorithm>
#include <iostream>
#include <stdexcept> #include <stdexcept>
#include "core/audio/jail_audio.hpp" #include "core/audio/jail_audio.hpp"
#include "core/resources/palette_loader.hpp" #include "core/resources/palette_loader.hpp"
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include "utils/log.hpp"
#include "utils/utils.hpp" #include "utils/utils.hpp"
// Deleters dels recursos d'àudio: necessiten veure el tipus complet de // Deleters dels recursos d'àudio: necessiten veure el tipus complet de
@@ -37,19 +37,19 @@ namespace Resource {
throw std::runtime_error("Cache: cannot load animation " + file_path); throw std::runtime_error("Cache: cannot load animation " + file_path);
} }
animations_.emplace_back(AnimationResource{.name = name, .yaml_data = std::move(bytes)}); animations_.emplace_back(AnimationResource{.name = name, .yaml_data = std::move(bytes)});
printWithDots("Animation : ", getFileName(file_path), "[ LOADED ]"); printWithDots("Animation", getFileName(file_path), "[ LOADED ]");
} }
void Cache::loadPalette(const std::string& name, const std::string& file_path) { void Cache::loadPalette(const std::string& name, const std::string& file_path) {
auto palette = PaletteLoader::loadFromFile(file_path); auto palette = PaletteLoader::loadFromFile(file_path);
palettes_.emplace_back(PaletteResource{.name = name, .palette = palette}); palettes_.emplace_back(PaletteResource{.name = name, .palette = palette});
printWithDots("Palette : ", getFileName(file_path), "[ LOADED ]"); printWithDots("Palette", getFileName(file_path), "[ LOADED ]");
} }
auto Cache::getSurface(const std::string& name) -> std::shared_ptr<Surface> { auto Cache::getSurface(const std::string& name) -> std::shared_ptr<Surface> {
auto it = std::ranges::find_if(surfaces_, [&name](const auto& s) { return s.name == name; }); auto it = std::ranges::find_if(surfaces_, [&name](const auto& s) { return s.name == name; });
if (it == surfaces_.end()) { if (it == surfaces_.end()) {
std::cerr << "Cache: surface not found: " << name << '\n'; Log::error("Cache", "surface not found: {}", name);
throw std::runtime_error("Surface not found: " + name); throw std::runtime_error("Surface not found: " + name);
} }
return it->surface; return it->surface;
@@ -58,7 +58,7 @@ namespace Resource {
auto Cache::getAnimationData(const std::string& name) -> const AnimationResource& { auto Cache::getAnimationData(const std::string& name) -> const AnimationResource& {
auto it = std::ranges::find_if(animations_, [&name](const auto& a) { return a.name == name; }); auto it = std::ranges::find_if(animations_, [&name](const auto& a) { return a.name == name; });
if (it == animations_.end()) { if (it == animations_.end()) {
std::cerr << "Cache: animation not found: " << name << '\n'; Log::error("Cache", "animation not found: {}", name);
throw std::runtime_error("Animation not found: " + name); throw std::runtime_error("Animation not found: " + name);
} }
return *it; return *it;
@@ -67,7 +67,7 @@ namespace Resource {
auto Cache::getPalette(const std::string& name) -> const Palette& { auto Cache::getPalette(const std::string& name) -> const Palette& {
auto it = std::ranges::find_if(palettes_, [&name](const auto& p) { return p.name == name; }); auto it = std::ranges::find_if(palettes_, [&name](const auto& p) { return p.name == name; });
if (it == palettes_.end()) { if (it == palettes_.end()) {
std::cerr << "Cache: palette not found: " << name << '\n'; Log::error("Cache", "palette not found: {}", name);
throw std::runtime_error("Palette not found: " + name); throw std::runtime_error("Palette not found: " + name);
} }
return it->palette; return it->palette;
@@ -83,7 +83,7 @@ namespace Resource {
throw std::runtime_error("Cache: Ja::loadMusic failed for " + file_path); throw std::runtime_error("Cache: Ja::loadMusic failed for " + file_path);
} }
musics_.emplace_back(MusicResource{.name = name, .music = std::unique_ptr<Ja::Music, MusicDeleter>(raw)}); musics_.emplace_back(MusicResource{.name = name, .music = std::unique_ptr<Ja::Music, MusicDeleter>(raw)});
printWithDots("Music : ", getFileName(file_path), "[ LOADED ]"); printWithDots("Music", getFileName(file_path), "[ LOADED ]");
} }
void Cache::loadSound(const std::string& name, const std::string& file_path) { void Cache::loadSound(const std::string& name, const std::string& file_path) {
@@ -96,13 +96,13 @@ namespace Resource {
throw std::runtime_error("Cache: Ja::loadSound failed for " + file_path); throw std::runtime_error("Cache: Ja::loadSound failed for " + file_path);
} }
sounds_.emplace_back(SoundResource{.name = name, .sound = std::unique_ptr<Ja::Sound, SoundDeleter>(raw)}); sounds_.emplace_back(SoundResource{.name = name, .sound = std::unique_ptr<Ja::Sound, SoundDeleter>(raw)});
printWithDots("Sound : ", getFileName(file_path), "[ LOADED ]"); printWithDots("Sound", getFileName(file_path), "[ LOADED ]");
} }
auto Cache::getMusic(const std::string& name) -> Ja::Music* { auto Cache::getMusic(const std::string& name) -> Ja::Music* {
auto it = std::ranges::find_if(musics_, [&name](const auto& m) { return m.name == name; }); auto it = std::ranges::find_if(musics_, [&name](const auto& m) { return m.name == name; });
if (it == musics_.end()) { if (it == musics_.end()) {
std::cerr << "Cache: music not found: " << name << '\n'; Log::error("Cache", "music not found: {}", name);
throw std::runtime_error("Music not found: " + name); throw std::runtime_error("Music not found: " + name);
} }
return it->music.get(); return it->music.get();
@@ -111,7 +111,7 @@ namespace Resource {
auto Cache::getSound(const std::string& name) -> Ja::Sound* { auto Cache::getSound(const std::string& name) -> Ja::Sound* {
auto it = std::ranges::find_if(sounds_, [&name](const auto& s) { return s.name == name; }); auto it = std::ranges::find_if(sounds_, [&name](const auto& s) { return s.name == name; });
if (it == sounds_.end()) { if (it == sounds_.end()) {
std::cerr << "Cache: sound not found: " << name << '\n'; Log::error("Cache", "sound not found: {}", name);
throw std::runtime_error("Sound not found: " + name); throw std::runtime_error("Sound not found: " + name);
} }
return it->sound.get(); return it->sound.get();
+8 -10
View File
@@ -1,11 +1,12 @@
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include <fstream> #include <fstream>
#include <iostream>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include "core/resources/resource_pack.hpp" #include "core/resources/resource_pack.hpp"
#include "utils/defaults.hpp"
#include "utils/log.hpp"
namespace Resource::Helper { namespace Resource::Helper {
@@ -26,14 +27,14 @@ namespace Resource::Helper {
// Silenciem si veníem de prefer_disk: aleshores caurem // Silenciem si veníem de prefer_disk: aleshores caurem
// al pack i no és cap error real que el fitxer no estigui // al pack i no és cap error real que el fitxer no estigui
// al disc. // al disc.
if (verbose) { std::cerr << "Resource::Helper: cannot open " << FULL << '\n'; } if (verbose) { Log::error("Resource::Helper", "cannot open {}", FULL); }
return {}; return {};
} }
std::streamsize size = file.tellg(); std::streamsize size = file.tellg();
file.seekg(0, std::ios::beg); file.seekg(0, std::ios::beg);
std::vector<std::uint8_t> data(static_cast<std::size_t>(size)); std::vector<std::uint8_t> data(static_cast<std::size_t>(size));
if (!file.read(reinterpret_cast<char*>(data.data()), size)) { if (!file.read(reinterpret_cast<char*>(data.data()), size)) {
std::cerr << "Resource::Helper: failed to read " << FULL << '\n'; Log::error("Resource::Helper", "failed to read {}", FULL);
return {}; return {};
} }
return data; return data;
@@ -51,14 +52,11 @@ namespace Resource::Helper {
g_pack_loaded = g_pack.loadPack(pack_file); g_pack_loaded = g_pack.loadPack(pack_file);
if (g_pack_loaded) { if (g_pack_loaded) {
std::cout << "Resource::Helper: pack loaded (" << g_pack.getResourceCount() Log::info("Resource::Helper", "pack loaded ({} entries) from {}", g_pack.getResourceCount(), pack_file);
<< " entries) from " << pack_file << '\n';
} else if (enable_fallback) { } else if (enable_fallback) {
std::cout << "Resource::Helper: no pack at " << pack_file Log::info("Resource::Helper", "no pack at {} — using filesystem fallback", pack_file);
<< " — using filesystem fallback\n";
} else { } else {
std::cerr << "Resource::Helper: FATAL — no pack at " << pack_file Log::critical("Resource::Helper", "FATAL — no pack at {} and fallback disabled", pack_file);
<< " and fallback disabled\n";
return false; return false;
} }
return true; return true;
@@ -92,7 +90,7 @@ namespace Resource::Helper {
// carpeta `data/`). // carpeta `data/`).
const auto FROM_PACK = [&filepath]() -> std::vector<std::uint8_t> { const auto FROM_PACK = [&filepath]() -> std::vector<std::uint8_t> {
if (!g_pack_loaded) { return {}; } if (!g_pack_loaded) { return {}; }
constexpr std::string_view PREFIX = "data/"; const std::string_view PREFIX{Defaults::Paths::DATA_ROOT};
std::string key = filepath; std::string key = filepath;
if (key.starts_with(PREFIX)) { key.erase(0, PREFIX.size()); } if (key.starts_with(PREFIX)) { key.erase(0, PREFIX.size()); }
if (g_pack.hasResource(key)) { return g_pack.getResource(key); } if (g_pack.hasResource(key)) { return g_pack.getResource(key); }
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more