Compare commits

...
66 Commits
Author SHA1 Message Date
JailDesigner 291cbdc957 Merge branch 'p2-support': suport del segon jugador 2026-04-24 19:30:32 +02:00
JailDesigner 858e444842 feat(player): hit-box 8×8 per a contacte actor-actor (player, momia, trampa) 2026-04-24 19:23:30 +02:00
JailDesigner 511f8d5b09 fix(player): el jugador empentat es queda en stand, no walk 2026-04-24 19:00:04 +02:00
JailDesigner e22f3240da feat(player): col·lisió i empenta entre jugadors amb push_factor al YAML del mapa 2026-04-24 18:54:19 +02:00
JailDesigner 1cc2e28b1b refactor(player): divideix update en tick+applyMovement i exposa velocitat intencionada 2026-04-24 18:49:52 +02:00
JailDesigner 820b9f2d21 feat(ui): bloc del jugador 2 al menú de teclat (només lectura) 2026-04-24 18:18:55 +02:00
JailDesigner f3977170b7 fix(ui): trunca el value al menú amb "..." quan se sol·lapa amb la label 2026-04-24 18:15:39 +02:00
JailDesigner 996ff0053e fix(input): dedup de mandos, auto-assign per ordre i cycles sense col·lisió 2026-04-24 18:06:24 +02:00
JailDesigner e5b8d7731b feat(ui): tria del mando per jugador al menú de servei 2026-04-24 15:17:14 +02:00
JailDesigner a725b76684 feat(input): assignació mando↔jugador persistida a config.yaml 2026-04-24 15:13:44 +02:00
JailDesigner 4e9d5df872 feat(players): suport del segon jugador amb events amb player_id 2026-04-24 14:33:52 +02:00
JailDesigner 74dad71cc2 feat(player): gir de 180° amb pose intermèdia configurable al YAML 2026-04-24 13:49:17 +02:00
JailDesigner 5c70019eb5 feat(game): pausa graduada amb easing i separador al HUD 2026-04-24 11:56:38 +02:00
JailDesigner 8aa4ec3daf build: aïlla la implementació de stb_vorbis en un TU propi
Mateix patró que stb_image_write: jail_audio.cpp defineix
STB_VORBIS_HEADER_ONLY i només veu les declaracions; les definicions
viuen a source/external/stb_vorbis_impl.cpp, ja exclòs del pre-commit.
Això permet retirar també la desactivació global de
clang-analyzer-core.UndefinedBinaryOperatorResult al .clang-tidy, i el
check queda actiu arreu del nostre propi codi.
2026-04-24 11:18:59 +02:00
JailDesigner 6c1c042637 build: aïlla la implementació de stb_image_write en un TU propi
screenshot.cpp ara només inclou les declaracions de stb_image_write.h;
la macro STB_IMAGE_WRITE_IMPLEMENTATION viu a un .cpp nou dins de
source/external/, que ja està exclòs del pre-commit hook (clang-format
i clang-tidy) i cobert pel seu .clang-tidy amb Checks: '-*'. Així
podem retirar la desactivació global de clang-analyzer-optin.portability
.UnixAPI i el check queda actiu arreu del nostre propi codi.
2026-04-24 11:15:32 +02:00
JailDesigner dc8a7723e3 feat(ui): captura F9, tema a data/ui.yaml i retocs estètics
Captura de pantalla amb F9: volca el canvas ARGB a
screenshots/scr_<timestamp>.png via stb_image_write.

Tot el tema d'UI (colors, timings, fonts, flag hud.bg_enabled)
passa a data/ui.yaml via UiConfig; els valors de Defaults::UI
queden com a fallbacks si el fitxer falta o li manca alguna clau.

Retocs estètics: paleta "uzebox" per a la UI, strings del HUD
(P1/P2/V/T/PIRAMIDE/SALA) traduïts a locale, i fons del marcador
opcional amb backdrop continu de franja per evitar el trencament
visual durant el shake o l'animació d'entrada dels panells.
2026-04-24 10:58:02 +02:00
JailDesigner 82b229222f feat(hud): marcador de la franja superior amb 3 blocs i animació d'entrada 2026-04-23 23:46:41 +02:00
JailDesigner 67ecb704d2 feat(pyramid): usa variants 16×32 sam (jugador) i momia 2026-04-23 17:33:47 +02:00
JailDesigner 936adf1405 Merge branch 'feat/paint-layer'
Fase 6: pintat de tiles amb PaintLayer com a font única del pintat.
Cadena on_tile_entered → tile_painted → sarcs. Feedback visual per
jugador via player{1,2}_paint_tile al YAML del mapa.
2026-04-23 13:42:49 +02:00
JailDesigner 0d914cc6dd docs(entities): documenta la fase 6 — pintat de tiles 2026-04-23 13:41:57 +02:00
JailDesigner 1117264758 feat(pyramid): declara player1_paint_tile i player2_paint_tile 2026-04-23 13:32:15 +02:00
JailDesigner d0503ff8b1 feat(game_scene): integra PaintLayer i llegeix paint_tile del YAML del mapa 2026-04-23 13:31:13 +02:00
JailDesigner e3cf2eb773 refactor(entities): rename notifyTileEntered → notifyTilePainted i escolta tile_painted 2026-04-23 13:22:02 +02:00
JailDesigner 0f56ce7567 feat(player): publica PaintedTile amb l'índex de pintat propi 2026-04-23 13:19:01 +02:00
JailDesigner 87b08a7c7c feat(map): afegeix PaintLayer com a font única del pintat 2026-04-23 13:17:47 +02:00
JailDesigner 00eb696859 feat(map): afegeix Map::isPaintable(col, row) 2026-04-23 13:15:08 +02:00
JailDesigner 48ff1839bc feat(event_bus): afegeix PaintedTile al payload 2026-04-23 13:13:47 +02:00
JailDesigner 01145464ed fix(editor): resol ruta via Resource::Helper per guardar amb CWD arbitrari 2026-04-23 12:43:23 +02:00
JailDesigner f30bb1fb0f feat(editor): TilePicker amb scroll vertical i selecció per clic 2026-04-23 12:18:22 +02:00
JailDesigner 05cdcfa660 feat(editor): brush amb preview snap-to-grid i pintura per clic 2026-04-23 12:11:54 +02:00
JailDesigner a612a8255f feat(editor): mode editor mínim amb toggle, border blau i mutex amb pausa 2026-04-23 12:09:04 +02:00
JailDesigner 329bf692e6 feat(editor): Screen::windowToCanvas() per a input de ratòli 2026-04-23 11:58:58 +02:00
JailDesigner 219f8033eb feat(editor): Tilemap editable amb setTileAt/save (Debug-only) 2026-04-23 11:56:06 +02:00
JailDesigner 6d8423764d feat(editor): writer surgical del bloc data: al YAML del tilemap 2026-04-23 11:51:49 +02:00
JailDesigner 459d18b49c feat(game): pausa amb F11/START i contorn NSEO al Text 2026-04-23 09:47:59 +02:00
JailDesignerandClaude Opus 4.7 243ba06351 refactor(tilemap): índexs directes al YAML en lloc de llegenda ASCII
`data` passa a ser una llista de files d'enters (tile index lineal),
sense la indirecció `legend:` + símbols. Desapareix el límit implícit
de ~60 tiles distints i el grid segueix llegint-se com a matriu visual
gràcies al padding de 3 dígits. `-1` marca cel·la buida.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:11:21 +02:00
JailDesigner 4da05b3d76 chore(release): v0.21.0 — empaquetat de recursos (AE26) i neteja de fonts
- Nou format de pack AE26 v1 (XOR + djb2), eina `pack_resources`,
  regeneració automàtica de `build/resources.pack` a cada build.
- `Resource::Helper` amb init/shutdown, fallback a disc només en Debug
  (apunta al source tree); Release exigeix pack present.
- Callers de YAML migrats a `loadFile`+deserialize des de buffer.
- Arrel neta: el pack viu a `build/`, eliminat `sync_data`.
- Placeholder font -> `ari-w9500-condensed-display`; esborrades variants
  bold/condensed no usades a `data/fonts/`.
2026-04-23 08:51:21 +02:00
JailDesigner aeed0e01c2 Merge branch 'feat/mummy-intersections': decisió de direcció a creuaments 2026-04-22 22:53:56 +02:00
JailDesigner 9d256b7f2d feat(mummy): decisió a creuaments de corredor
La mòmia ara decideix direcció a cada creuament de corredor (edge-detect
de travessar la línia central d'un corredor perpendicular) i no només
quan topa amb un mur o sarc. A la detecció, amarra la caixa al centre
exacte del creuament per evitar drift sub-píxel i tria entre continuar
recta o girar a una perpendicular lliure. Mai mitja volta a un creuament
— la mitja volta queda reservada al cas de bloqueig sense alternatives.
2026-04-22 22:52:51 +02:00
JailDesigner 5312abc761 chore(release): v0.20.0 — notificacions en case frase i renom de presets 2026-04-22 22:05:18 +02:00
JailDesigner 4dd2b6d2a9 feat(mouse): amaga el cursor automàticament després de 3s sense moviment 2026-04-22 21:34:05 +02:00
JailDesigner d5abc6343e fix(gamepad): no retallar el nom del mand a 25 caràcters 2026-04-22 21:20:53 +02:00
JailDesigner a2b01ba9d5 Merge branch 'feat/gamepad' 2026-04-22 19:44:42 +02:00
JailDesigner a230351b79 feat(gamepad): controls de gamepad per al player i el menú de servei 2026-04-22 19:43:37 +02:00
JailDesigner 7c35ffaa74 feat(gamepad): detecció i notificacions hot-plug amb GamepadManager 2026-04-22 19:39:16 +02:00
JailDesigner be9309d342 feat(notifier): suport multilinia amb wrap automàtic per espais 2026-04-22 19:33:18 +02:00
JailDesigner 591b4b58e5 Merge branch 'feat/service-menu' 2026-04-22 19:07:41 +02:00
JailDesigner fb40d0effb feat(menu): submenú Controls i bloqueig de polling amb menú obert 2026-04-22 19:05:36 +02:00
JailDesigner 350625dec0 feat(menu): primera versió del menú de servei 2026-04-22 18:15:00 +02:00
JailDesigner e4a8b4cd3e docs: pla d'implementació del menú de servei 2026-04-22 13:55:05 +02:00
JailDesignerandClaude Opus 4.7 eb07e7e564 feat(fonts): deinterlace GIF i font ari-w9500 per placeholders
Fix: gif.cpp no desfeia l'interlace, trinxava qualsevol GIF entrellaçat
(tota la sortida de Pillow). Afegeix Defaults::UI::Font per centralitzar
noms/rutes i mou data/font → data/fonts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:45:06 +02:00
JailDesigner 03f1c5f926 fix(makefile): alinear targets amb ../aee i ../projecte_2026
Abans:
  make / make build -> Debug
  make release      -> Release (sense empaquetar)

Ara, mateixa convenció que els dos projectes germans:
  make              -> Release (binari d'ús normal)
  make debug        -> Debug (flux amb debug.yaml)
  make release      -> Release + empaquetat distribució (empaquetat TODO)

Afegit make run / make run-debug per conveniència i make help.
docs/convencions.md actualitzat amb la nova taula target -> comportament.
2026-04-22 11:48:14 +02:00
JailDesigner 6800786194 fix(resources): resoldre rutes de data/ respecte a SDL_GetBasePath
Abans el binari només arrencava si la CWD era la que contenia data/
(típicament build/). Executat des de qualsevol altra carpeta no trobava
keys.yaml, paletes, etc. i es moria al carregar el primer recurs.

Patró seguit de ../aee: Resource::Helper guarda un data root absolut
i resolve(rel) el prefixa davant de rutes no absolutes. Director fa
setDataRoot(SDL_GetBasePath()) com a primera acció del ctor, així tots
els loaders posteriors hi passen sense canvis al call site.

Migrats a Resource::Helper::resolve els loaders que obrien std::ifstream
directament amb el path rebut: Locale, KeyConfig, Player, Map, Tilemap
i EntityLoader. Els que ja usaven Resource::Helper::loadFile
(Resource::Cache, PaletteLoader) hereten el fix automàticament.

Els fitxers de system_folder_ (config.yaml, debug.yaml, postfx.yaml,
crtpi.yaml) es passen com a rutes absolutes i resolve() les deixa
intactes, així no es veuen afectats.

Test targets (test_entities_smoke, test_scene_flow) enllacen ara
resource_helper.cpp perquè el seu codi passa per Helper::resolve. Els
tests corren amb CWD = repo root, on les rutes "data/..." funcionen
amb data root buit sense canviar cap assert.

Verificat executant el binari des de build/, /tmp i / — tots tres
troben els recursos i arrenquen correctament, tant en debug com en
release (release sempre arranca a LOGO).
2026-04-22 11:29:51 +02:00
JailDesigner 9c9b6028e7 docs: convencions de commit i debug.yaml
Documenta dues peces que no estaven enlloc:

- Missatges de commit: valencià, frases curtes, sense firma automàtica
  (cap Co-Authored-By ni signatura de Claude Code).
- debug.yaml: ruta dins la carpeta de sistema, format, valors vàlids,
  i la diferència de comportament entre build release (sempre LOGO,
  ignora el fitxer) i build debug (llig el fitxer i el crea amb GAME
  si no existeix).

Entrada nova a l'índex de docs/README.md.
2026-04-22 10:35:19 +02:00
JailDesigner 2cb21211ef test(scenes): smoke del flux d'escenes i parser de debug.yaml
Cobreix les dues peces pures del flux:
- parseStartScene / resolveStartScene: tots els valors vàlids, fallback
  a GAME per valor invàlid, YAML corrupte o fitxer inexistent.
- createDefaultIfMissing: crea el fitxer amb start_scene=GAME si no
  existeix i és idempotent quan ja hi és.
- isReservedScancode: F1/F12/Escape reservats, SPACE/fletxes no, i
  UNKNOWN sempre fals.

La lògica de update/render dels placeholders no es testeja perquè
arrossegaria Screen/Notifier/RenderInfo (tot l'stack SDL). Aquesta
integració queda coberta pel smoke xvfb-run del binari.

Stand-alone target `test_scene_flow` que només enllaça els .cpp pures
necessaris, amb ctest afegit.
2026-04-22 10:35:07 +02:00
JailDesigner 87a9f098b5 feat(debug): selector d'escena inicial via debug.yaml
Director encadena LOGO → TITLE → INTRO → GAME amb factories injectades
al ctor de cada placeholder (sense que cap escena conegui el tipus de
la següent). L'escena d'arrencada surt de `debug.yaml` en debug build i
és sempre LOGO en release.

Comportament:
- Release: `debug.yaml` s'ignora; arranca sempre a LOGO.
- Debug: es llig `debug.yaml`. Si no existeix, es crea automàticament
  amb `start_scene: GAME` per no arrossegar 15 s de placeholders al dia
  a dia de gameplay. Valor invàlid o YAML corrupte → warning + GAME,
  però el fitxer no es reescriu (respectem l'edició manual).

`debug.yaml` viu a `$XDG_CONFIG_HOME/jailgames/aee_2026/`, al costat
de `config.yaml`, i queda fora del repo (ajust per màquina de dev).
2026-04-22 10:33:36 +02:00
JailDesigner d31483124e feat(scenes): afegir placeholders LogoScene/TitleScene/IntroScene
Tres escenes de 5 s cadascuna, activables al flux que precedeix GameScene.
Classes separades (sense base comuna) perquè divergiran quan es
desenvolupi contingut real.

Avancen per timeout o per qualsevol tecla no reservada. Les tecles
reservades (F1-F12, Escape) són les declarades a data/input/keys.yaml i
mantenen el seu comportament habitual via GlobalInputs. La
classificació viu a Placeholder::isReservedScancode, i KeyConfig exposa
bindings() per enumerar-les sense duplicar la llista.

Encara no integrats al Director: això es fa al següent commit quan
s'afegeix el selector d'escena inicial.
2026-04-22 10:31:48 +02:00
JailDesignerandClaude Opus 4.7 2883df57da docs: afegit README.md amb índex de docs/
Taula mínima per distingir d'un cop d'ull les auditories arxivades
(audit-core-*, audit-game) dels documents de disseny vius
(entities-system, player-animations).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:24:31 +02:00
JailDesignerandClaude Opus 4.7 510ab55a33 docs: actualitza estat post-refactor/options-class
- audit-game.md cabecera: contadors actualitzats (32 DONE, 1 DONE
  parcial, 5 WONTFIX, 0 DEFERRED) i frase dels DEFERRED en pretèrit
  perfecte. INCONS-01 queda com l'únic DONE (parcial), intencionalment.
- player-animations.md secció 4: el patró antic
  "AnimatedSprite::update integra + col·lisió eix-separada reverteix
  cada eix" es substitueix per "animate(dt) + tryMove(vx*dt, vy*dt,
  collidesFn) amb collisionBox() virtual" — coincidint amb el codi
  post-PATCH-01.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:22:38 +02:00
JailDesigner a7e681a347 Merge branch 'refactor/options-class'
Tanca els 4 ítems DEFERRED de l'auditoria audit/game (COUP-02, COUP-03,
CONV-02 part Options, PATCH-01). Veure docs/audit-game.md → "Registro
de commits — refactor/options-class" per al desglossament dels commits
i les decisions preses durant el refactor.
2026-04-22 09:09:20 +02:00
JailDesignerandClaude Opus 4.7 b19a6e25da docs(audit-game): close refactor/options-class — final commit log
- Registre de commits omplert amb els hashes reals dels 3 commits de la
  rama (a3b03b8 / 64e0178 / d9f6fb4).
- player-animations.md: el recompte de smoke tests deia "14/14" però
  `tests/test_entities_smoke.cpp` només invoca 13 funcions `test*` des
  de `main()`. Nota corregida a "13/13" amb explicació.

Tanca els 4 ítems DEFERRED de `audit/game` (COUP-02, COUP-03,
CONV-02 part Options, PATCH-01).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:54:58 +02:00
JailDesignerandClaude Opus 4.7 d9f6fb45f8 refactor(sprite): PATCH-01 add tryMove + virtual collisionBox to base
- Sprite: nou hook virtual `collisionBox() -> SDL_FRect` (default
  `pos_`). Subclasses amb falsa perspectiva el sobreescriuen amb una
  sub-caixa més petita.
- MovingSprite: nou `tryMove(dx, dy, CollisionFn)` que aplica eix a
  eix i reverteix l'eix que col·lisiona; deixa lliscar el sprite
  contra obstacles.
- Player: override `collisionBox()` retornant `getBaseBox()` i
  `update()` reescrita per fer `animate(dt) + tryMove(vx*dt, vy*dt,
  collidesFn)` en lloc del patró antic `update + revertir + reaplicar`.

El comportament observable és idèntic: la integració `v*dt` parteix
de la mateixa posició (post-snap), els revertits per col·lisió són
els mateixos i el `MOVED` que controla l'animació stand/walk segueix
comparant amb `FRAME_START_X/Y`.

Smoke tests 13/13 OK; log Game start → Bye! sota xvfb-run idèntic al
baseline. clang-tidy i cppcheck nets sobre els fitxers modificats.

Refs: docs/audit-game.md (PATCH-01).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:53:11 +02:00
JailDesignerandClaude Opus 4.7 64e0178ee9 refactor(game): COUP-02 inject MapInfo snapshot into Player
Afegit `Map::Info` (POD trivialment copiable) que empaqueta playfield,
pitch, grid, tile_size, tilemap_origin + 2 punters a funció (collides,
inside_playfield). `Map::makeInfo()` el construeix a partir dels globals
del namespace.

`Player` ara rep `const Map::Info&` al ctor i en guarda una còpia plana
(`map_`), substituint totes les lectures de `Map::*` per `map_.*`. La
inclusió `#include "game/map/map.hpp"` puja al header per la dependència
de `Map::Info`; player.cpp deixa d'incloure'l (transitiu).

Només el Player es desacobla — momies, roques i `loadFromLevel`
continuen llegint `Map::*` directament, com diu la nota original del
audit (mentre hi hagi un Map global actiu, l'acoblament és acceptable).
Si quan s'acabi PATCH-01 algun consumidor extra chirría, COUP-02b en
una rama nova.

Smoke tests 13/13 OK; log Game start → Bye! sota xvfb-run idèntic al
baseline. clang-tidy i cppcheck nets sobre els fitxers modificats.

Refs: docs/audit-game.md (COUP-02).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:46:34 +02:00
JailDesignerandClaude Opus 4.7 a3b03b87be refactor(game): COUP-03 + CONV-02 encapsulate Options state behind setters
L'estat mutable d'Options (window, video, audio, locale, presets, paths)
es mou a un anonymous namespace dins options.cpp. L'API pública del
namespace passa a ser:

- Getters const-ref: window(), video(), audio(), locale(),
  configVersion(), postfxPresets(), crtpiPresets().
- Setters específics que muten i persisteixen via saveToFile():
  setWindowZoom, setWindowFullscreen, setVideoVSync,
  setVideoScalingMode, setVideoRenderInfoPosition,
  setVideoRenderInfoShowTime, setShaderEnabled, setActiveShaderName,
  setPostFXPreset, setCrtPiPreset.

Els setters NO notifiquen els subsistemes: el caller (global_inputs.cpp)
crida explícitament Screen::setWindowZoom, reapplyShader, etc. La part
"observers" del audit es deixa WONTFIX justificat — notificar des
d'Options invertiria la direcció Director→Screen/Audio i Options
aprenria del layout dels subsistemes.

CONV-02 tanca al pas: la part Options dels "globals sense observers"
queda resolta amb la nova encapsulació. Map::* (l'altra meitat de
CONV-02) ja s'havia tancat amb PATCH-07.

Smoke tests 13/13 OK; log Game start → Bye! sota xvfb-run +
SDL_VIDEODRIVER=dummy + SIGINT idèntic al baseline de main.
clang-tidy i cppcheck nets sobre els fitxers modificats.

Refs: docs/audit-game.md (COUP-03, CONV-02, INCONS-01).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:37:59 +02:00
JailDesigner 32dcf264c4 feat(entities): caja-peus concèntrica i animacions direccionals a la momia, còpia de data/ sempre sincronitzada; v0.19.0 2026-04-21 23:21:32 +02:00
JailDesigner a3592d6c73 feat(player): animacions direccionals i stand automàtic en col·lisió; v0.18.0 2026-04-21 20:41:49 +02:00
126 changed files with 11354 additions and 777 deletions

No files matched your search

-5
View File
@@ -9,11 +9,6 @@ Checks:
- -bugprone-easily-swappable-parameters
- -bugprone-narrowing-conversions
- -modernize-avoid-c-arrays,-warnings-as-errors
# Fals positiu recurrent en codi C de tercers (source/external/stb_vorbis.c).
# HeaderFilterRegex no aplica a clang-analyzer-*, així que cal desactivar
# explícitament. El check inferència sobre variables locals de legacy C
# genera reports que no reflecteixen bugs reals.
- -clang-analyzer-core.UndefinedBinaryOperatorResult
WarningsAsErrors: '*'
# Headers nostres (excloem source/external/ que conté dependències de tercers no editables)
+92 -7
View File
@@ -56,13 +56,20 @@ set(APP_SOURCES
source/core/audio/audio_adapter.cpp
source/core/audio/jail_audio.cpp
source/core/locale/locale.cpp
source/core/ui/ui_config.cpp
source/core/input/key_config.cpp
source/core/input/global_inputs.cpp
source/core/input/input_state.cpp
source/core/input/gamepad_manager.cpp
source/core/input/mouse.cpp
source/core/rendering/screen.cpp
source/core/rendering/canvas.cpp
source/core/rendering/notifier.cpp
source/core/rendering/render_info.cpp
source/core/rendering/gif.cpp
source/core/rendering/screenshot.cpp
source/external/stb_image_write_impl.cpp
source/external/stb_vorbis_impl.cpp
source/core/rendering/surface.cpp
source/core/rendering/text.cpp
source/core/rendering/sprite/sprite.cpp
@@ -73,14 +80,29 @@ set(APP_SOURCES
source/core/rendering/sdl3gpu/pipelines/crtpi_pipeline.cpp
source/core/resources/resource_helper.cpp
source/core/resources/resource_cache.cpp
source/core/resources/resource_pack.cpp
source/core/resources/palette_loader.cpp
source/utils/easing.cpp
source/utils/utils.cpp
source/game/debug_config.cpp
source/game/options.cpp
source/game/options_apply.cpp
source/game/ui/service_menu.cpp
source/game/ui/hud.cpp
source/game/scenes/placeholder_input.cpp
source/game/scenes/logo_scene.cpp
source/game/scenes/title_scene.cpp
source/game/scenes/intro_scene.cpp
source/game/scenes/game_scene.cpp
source/game/entities/player.cpp
source/game/map/map.cpp
source/game/map/tilemap.cpp
source/game/map/tilemap_writer.cpp
source/game/map/paint_layer.cpp
# Mòdul editor (només activa contingut en Debug via `#ifdef _DEBUG`).
# En Release el fitxer compila a un object buit i l'enllaçador no
# arrossega cap símbol del joc final.
source/game/editor/editor.cpp
${ENTITY_SOURCES}
# Fitxer només del binari principal: `loadFromLevel` depèn de
# Resource::Cache (→ Surface → GIF → àudio). Separat per no arrossegar
@@ -150,19 +172,43 @@ target_include_directories(${PROJECT_NAME} PRIVATE
target_link_libraries(${PROJECT_NAME} PRIVATE SDL3::SDL3)
# Copia recursius de la carpeta data/ al costat del binari per que les rutes
# relatives ("data/player/player.gif") funcionen sense desplegament.
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/data"
"$<TARGET_FILE_DIR:${PROJECT_NAME}>/data"
COMMENT "Copying data/ next to the binary"
# --- EINA STANDALONE: pack_resources ---
# Executable auxiliar que empaqueta `data/` a `build/resources.pack`.
# EXCLUDE_FROM_ALL: només es compila quan algun target en depèn (ho fa
# `resource_pack`). Build manual: `cmake --build build --target pack_resources`.
add_executable(pack_resources EXCLUDE_FROM_ALL
tools/pack_resources/pack_resources.cpp
source/core/resources/resource_pack.cpp
)
target_include_directories(pack_resources PRIVATE "${CMAKE_SOURCE_DIR}/source")
target_compile_options(pack_resources PRIVATE -Wall -Wextra -Wpedantic)
# --- REGENERACIÓ AUTOMÀTICA DE build/resources.pack ---
# A cada build re-empaquetem data/ si algun fitxer dins ha canviat. Evita
# debugar amb un pack obsolet. CONFIGURE_DEPENDS força CMake a re-globbar
# a la pròxima invocació (recull fitxers nous afegits a data/).
file(GLOB_RECURSE DATA_FILES CONFIGURE_DEPENDS "${CMAKE_SOURCE_DIR}/data/*")
set(RESOURCE_PACK "${CMAKE_BINARY_DIR}/resources.pack")
add_custom_command(
OUTPUT ${RESOURCE_PACK}
COMMAND $<TARGET_FILE:pack_resources>
"${CMAKE_SOURCE_DIR}/data"
"${RESOURCE_PACK}"
DEPENDS pack_resources ${DATA_FILES}
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMENT "Empaquetant data/ → build/resources.pack"
VERBATIM
)
add_custom_target(resource_pack ALL DEPENDS ${RESOURCE_PACK})
add_dependencies(${PROJECT_NAME} resource_pack)
target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic)
target_compile_definitions(${PROJECT_NAME} PRIVATE
$<$<CONFIG:Debug>:_DEBUG>
$<$<CONFIG:Release>:RELEASE_BUILD>
# En Debug el fallback a disc apunta al source tree (sense re-packejar).
AEE_SOURCE_DIR="${CMAKE_SOURCE_DIR}/"
)
# --- STATIC ANALYSIS / FORMAT TARGETS ---
@@ -255,6 +301,8 @@ option(BUILD_TESTS "Build smoke tests" ON)
if(BUILD_TESTS)
add_executable(test_entities_smoke
tests/test_entities_smoke.cpp
source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp
source/game/map/map.cpp
${ENTITY_SOURCES}
)
@@ -268,4 +316,41 @@ if(BUILD_TESTS)
add_test(NAME entities_smoke
COMMAND test_entities_smoke
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
# Smoke del flux d'escenes i del parser de debug.yaml. Stand-alone:
# només enllaça els helpers purs (debug_config, placeholder_input,
# key_config) perquè la integració completa dels placeholders arrossegaria
# tot l'stack SDL (Screen/Notifier/RenderInfo), coberta pel smoke xvfb.
add_executable(test_scene_flow
tests/test_scene_flow.cpp
source/game/debug_config.cpp
source/game/scenes/placeholder_input.cpp
source/core/input/key_config.cpp
source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp
)
target_include_directories(test_scene_flow PRIVATE
"${CMAKE_SOURCE_DIR}/source"
"${CMAKE_BINARY_DIR}"
)
target_link_libraries(test_scene_flow PRIVATE SDL3::SDL3)
target_compile_options(test_scene_flow PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME scene_flow
COMMAND test_scene_flow
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
# Smoke del writer surgical del tilemap (escriu el bloc `data:` sense
# tocar la resta del fitxer). Pura manipulació de text, zero
# dependències: no cal ni SDL ni el Resource::Cache.
add_executable(test_tilemap_writer
tests/test_tilemap_writer.cpp
source/game/map/tilemap_writer.cpp
)
target_include_directories(test_tilemap_writer PRIVATE
"${CMAKE_SOURCE_DIR}/source"
)
target_compile_options(test_tilemap_writer PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME tilemap_writer
COMMAND test_tilemap_writer
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
endif()
+80 -18
View File
@@ -14,47 +14,74 @@ endif
CMAKE_DEFS := -DGIT_HASH=$(GIT_HASH)
.PHONY: all configure build run clean rebuild release show-version format format-check tidy tidy-fix cppcheck hooks-install
.PHONY: all debug release run run-debug clean rebuild show-version pack format format-check tidy tidy-fix cppcheck hooks-install help
all: build
# ==============================================================================
# COMPILACIÓ
# ==============================================================================
# Convenció alineada amb ../aee i ../projecte_2026:
# make → Release (binari d'ús normal)
# make debug → Debug (flux complet + `debug.yaml` per triar escena inicial)
# make release → Release + empaquetat per a distribució (TODO, pendent)
# ==============================================================================
configure:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Debug $(CMAKE_DEFS)
build: configure
@cmake --build $(BUILDDIR) -j$(JOBS)
run: build
@./$(BUILDDIR)/$(PROJECT)
release:
all:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) -j$(JOBS)
debug:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Debug $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) -j$(JOBS)
run: all
@./$(BUILDDIR)/$(PROJECT)
run-debug: debug
@./$(BUILDDIR)/$(PROJECT)
# Release + empaquetat per distribució (zip/dmg/tar.gz). De moment només
# compila amb CMAKE_BUILD_TYPE=Release; l'empaquetat OS-specific està
# pendent (veure Makefile de ../aee com a referència).
release: all
@echo "TODO: empaquetat per a distribució (zip/dmg/tar.gz) — pendent"
show-version:
@echo "$(PROJECT) $(VERSION) ($(GIT_HASH))"
clean:
@rm -rf $(BUILDDIR)
rebuild: clean build
rebuild: clean all
# Empaqueta data/ a $(BUILDDIR)/resources.pack (format AE26). Força un rebuild
# del pack encara que res no hagi canviat dins data/. L'empaquetat també es fa
# automàticament a cada `make`/`make debug` via el target CMake `resource_pack`.
pack:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target pack_resources
@./$(BUILDDIR)/pack_resources data $(BUILDDIR)/resources.pack
# ==============================================================================
# CODE QUALITY (delegados a cmake)
# ==============================================================================
format: configure
format:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target format
format-check: configure
format-check:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target format-check
tidy: configure
tidy:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target tidy
tidy-fix: configure
tidy-fix:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target tidy-fix
cppcheck: configure
cppcheck:
@cmake -S . -B $(BUILDDIR) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target cppcheck
# ==============================================================================
@@ -63,3 +90,38 @@ cppcheck: configure
hooks-install:
@git config core.hooksPath .githooks
@echo "Git hooks activats: $(shell pwd)/.githooks"
# ==============================================================================
# AJUDA
# ==============================================================================
help:
@echo "Makefile per a $(PROJECT)"
@echo ""
@echo " Compilació:"
@echo " make - Compilar amb cmake (Release)"
@echo " make debug - Compilar amb cmake (Debug)"
@echo ""
@echo " Execució:"
@echo " make run - Compilar (Release) i executar"
@echo " make run-debug - Compilar (Debug) i executar"
@echo ""
@echo " Release:"
@echo " make release - Release + empaquetat (TODO pendent)"
@echo ""
@echo " Recursos:"
@echo " make pack - Empaquetar data/ a $(BUILDDIR)/resources.pack (format AE26)"
@echo ""
@echo " Qualitat de codi:"
@echo " make format - Formatar codi amb clang-format"
@echo " make format-check - Verificar format sense modificar"
@echo " make tidy - Anàlisi estàtic amb clang-tidy"
@echo " make tidy-fix - Anàlisi estàtic amb auto-fix"
@echo " make cppcheck - Anàlisi estàtic amb cppcheck"
@echo ""
@echo " Altres:"
@echo " make clean - Esborrar carpeta $(BUILDDIR)/"
@echo " make rebuild - clean + all"
@echo " make show-version - Mostrar versió"
@echo " make hooks-install - Activar git hooks del projecte"
@echo ""
@echo " Versió actual: $(VERSION) ($(GIT_HASH))"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+35
View File
@@ -0,0 +1,35 @@
name: momia
type: dynamic_hazard
sprite:
path: momia.gif
size: [16, 32]
tile_size: [1, 1]
# Variant 16×32 de la momia: el sprite fa 16×32 (dos 16×16: cap i peus)
# i la caja de col·lisió amb el mapa és 16×16, coincident amb el
# rectangle-base 16×16 dels peus. Com que `size` i `base_size` són
# iguals, el padding és zero i la caja s'alinea exactament amb la
# silueta inferior del sprite; amb `anchor: feet` ocupa la cel·la
# 16×16 del corredor on naix.
anchor: feet
collision:
size: [16, 16]
base_size: [16, 16]
flags:
collides: true
interactable: false
# Strip de 12 frames en graella 4×3 (mateix esquema que el jugador):
# 0..3 → walk_down (cara avall)
# 4..7 → walk_up (cara amunt)
# 8..11 → walk (lateral; flip horitzontal segons el sentit)
# La momia no té variants `stand_*` perquè mai no para.
animations:
walk_down: { frames: [0, 1, 2, 3], fps: 8, loop: true }
walk_up: { frames: [4, 5, 6, 7], fps: 8, loop: true }
walk: { frames: [8, 9, 10, 11], fps: 8, loop: true }
ai:
type: corridor_wanderer
direction_choice: random
initial_direction: right
stats:
speed: 30
damage: 1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 833 B

After

Width:  |  Height:  |  Size: 1.8 KiB

+17 -8
View File
@@ -4,23 +4,32 @@ sprite:
path: mummy.gif
size: [12, 24]
tile_size: [1, 1]
# La momia usa falsa perspectiva: el sprite fa 24 px d'alt però la caixa
# AABB només ocupa els 12×12 de baix (els "peus"). L'spawn `position` del
# map és el tile dels peus: el sprite es centra horitzontalment i la
# seua vora inferior s'alinea amb la vora inferior del tile.
# La momia usa falsa perspectiva: el sprite fa 12×24 (dos 12×12: cap i
# peus) però la caja de col·lisió amb el mapa és 16×16, concèntrica amb
# el rectangle-base 12×12 dels peus (igual que fa el jugador). Així la
# caixa s'estén 2 px a cada costat i 2 px per sota dels peus visuals,
# i amb `anchor: feet` s'alinea exactament amb el tile 16×16 on naix —
# la momia va pel centre del corredor i la caja ocupa la cel·la completa.
anchor: feet
collision:
size: [12, 12]
size: [16, 16]
base_size: [12, 12]
flags:
collides: true
interactable: false
# Strip de 12 frames en graella 12×1 (mateix esquema que el jugador):
# 0..3 → walk_down (cara avall)
# 4..7 → walk_up (cara amunt)
# 8..11 → walk (lateral; flip horitzontal segons el sentit)
# La momia no té variants `stand_*` perquè mai no para.
animations:
idle: { frames: [0], fps: 1, loop: true }
walk: { frames: [0], fps: 8, loop: true }
walk_down: { frames: [0, 1, 2, 3], fps: 8, loop: true }
walk_up: { frames: [4, 5, 6, 7], fps: 8, loop: true }
walk: { frames: [8, 9, 10, 11], fps: 8, loop: true }
ai:
type: corridor_wanderer
direction_choice: random
initial_direction: right
stats:
speed: 40
speed: 30
damage: 1
File renamed without changes.
File renamed without changes.
+132
View File
@@ -0,0 +1,132 @@
# Font: ari-w9500-condensed-display — generado desde ari-w9500-condensed-display.ttf size 11
# Generado con tools/font_gen/font_gen.py
box_width 12
box_height 11
columns 15
# codepoint_decimal ancho_visual
32 3 # U+0020
33 3 # !
34 6 # "
35 9 # #
36 7 # $
37 11 # %
38 8 # &
39 3 # '
40 5 # (
41 5 # )
42 5 # *
43 7 # +
44 4 # ,
45 6 # -
46 3 # .
47 6 # /
48 7 # 0
49 6 # 1
50 7 # 2
51 7 # 3
52 8 # 4
53 7 # 5
54 7 # 6
55 7 # 7
56 7 # 8
57 7 # 9
58 3 # :
59 4 # ;
60 7 # <
61 7 # =
62 7 # >
63 7 # ?
64 12 # @
65 8 # A
66 7 # B
67 8 # C
68 8 # D
69 7 # E
70 7 # F
71 8 # G
72 7 # H
73 3 # I
74 6 # J
75 8 # K
76 7 # L
77 9 # M
78 8 # N
79 9 # O
80 7 # P
81 9 # Q
82 7 # R
83 7 # S
84 7 # T
85 7 # U
86 7 # V
87 10 # W
88 8 # X
89 7 # Y
90 8 # Z
91 5 # [
92 6 # \
93 5 # ]
94 6 # ^
95 7 # _
96 5 # `
97 7 # a
98 7 # b
99 7 # c
100 7 # d
101 7 # e
102 6 # f
103 7 # g
104 7 # h
105 3 # i
106 3 # j
107 7 # k
108 3 # l
109 9 # m
110 7 # n
111 7 # o
112 7 # p
113 7 # q
114 6 # r
115 6 # s
116 6 # t
117 7 # u
118 7 # v
119 8 # w
120 7 # x
121 7 # y
122 7 # z
123 6 # {
124 3 # |
125 6 # }
126 8 # ~
192 8 # À
193 8 # Á
200 7 # È
201 7 # É
204 3 # Ì
205 3 # Í
210 9 # Ò
211 9 # Ó
217 7 # Ù
218 7 # Ú
209 8 # Ñ
199 8 # Ç
224 7 # à
225 7 # á
232 7 # è
233 7 # é
236 4 # ì
237 3 # í
242 7 # ò
243 7 # ó
249 7 # ù
250 7 # ú
241 7 # ñ
231 7 # ç
161 3 # ¡
191 7 # ¿
171 10 # «
187 10 # »
183 3 # ·
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

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

After

Width:  |  Height:  |  Size: 2.3 KiB

+15 -3
View File
@@ -29,12 +29,24 @@ keys:
- id: toggle_vsync
code: "F8"
desc: "Activa / desactiva VSync"
- id: screenshot
code: "F9"
desc: "Captura de pantalla (PNG)"
- id: toggle_render_info
code: "F12"
code: "F10"
desc: "Cicle de l'overlay de debug: apagat / dalt / baix"
- id: toggle_render_time
- id: pause
code: "F11"
desc: "Mostra o amaga el temps de joc dins de l'overlay"
desc: "Pausa el joc (o botó START del mando)"
- id: menu_toggle
code: "F12"
desc: "Obri / tanca el menú de servei"
- id: editor_toggle
code: "9"
desc: "Entra/surt del mode editor (només Debug)"
- id: editor_picker
code: "T"
desc: "Obri/tanca el tilepicker dins de l'editor"
- id: exit
code: "Escape"
desc: "Eixir (confirmar amb segona pulsació)"
+88 -15
View File
@@ -3,20 +3,93 @@
notification:
test: "notificacio de prova"
gamepad_connected: "connectat"
gamepad_disconnected: "desconnectat"
input:
zoom: "zoom"
fullscreen_on: "pantalla completa"
fullscreen_off: "mode finestra"
shader_on: "shaders activats"
shader_off: "shaders desactivats"
vsync_on: "vsync activat"
vsync_off: "vsync desactivat"
shader: "shader"
preset: "preset"
scaling_disabled: "escalat: sense"
scaling_stretch: "escalat: stretch"
scaling_letterbox: "escalat: letterbox"
scaling_overscan: "escalat: overscan"
scaling_integer: "escalat: enter"
press_again_exit: "prem una altra volta per eixir"
zoom: "Zoom"
fullscreen_on: "Pantalla completa"
fullscreen_off: "Mode finestra"
shader_on: "Shaders activats"
shader_off: "Shaders desactivats"
vsync_on: "VSync activat"
vsync_off: "VSync desactivat"
screenshot: "Captura"
shader: "Shader"
preset: "Preset"
scaling_disabled: "Escalat sense"
scaling_stretch: "Escalat stretch"
scaling_letterbox: "Escalat letterbox"
scaling_overscan: "Escalat overscan"
scaling_integer: "Escalat enter"
press_again_exit: "Prem una altra volta per eixir"
paused: "Pausa"
editor_on: "Editor ON"
editor_off: "Editor OFF"
editor_save_error: "Editor: error guardant el mapa"
hud:
p1: "P1"
p2: "P2"
lives: "Vides"
treasures: "Tres"
pyramid: "Piramide"
room: "Sala"
menu:
titles:
root: "Menú de servei"
video: "Vídeo"
audio: "Àudio"
controls: "Controls"
keyboard: "Teclat"
gamepad: "Comandament"
system: "Sistema"
labels:
player_1: "Jugador 1"
player_2: "Jugador 2"
gamepad_unavailable: "No disponible"
items:
video_submenu: "Vídeo"
audio_submenu: "Àudio"
controls_submenu: "Controls"
keyboard_submenu: "Teclat"
gamepad_submenu: "Comandament"
system_submenu: "Sistema"
move_up: "Amunt"
move_down: "Avall"
move_left: "Esquerra"
move_right: "Dreta"
gamepad_p1: "Comandament P1"
gamepad_p2: "Comandament P2"
zoom: "Zoom"
screen: "Pantalla completa"
vsync: "VSync"
scaling_mode: "Escalat"
shader: "Shader"
shader_type: "Tipus de shader"
preset: "Preset"
render_info: "Info de render"
uptime: "Mostra temps"
master_enable: "Àudio"
master_volume: "Volum general"
music: "Música"
music_volume: "Volum música"
sounds: "Sons"
sounds_volume: "Volum sons"
restart: "Reiniciar"
exit_game: "Eixir del joc"
values:
on: "Actiu"
off: "Inactiu"
submenu_arrow: ">"
scaling_disabled: "Sense"
scaling_stretch: "Stretch"
scaling_letterbox: "Letterbox"
scaling_overscan: "Overscan"
scaling_integer: "Enter"
render_info_off: "Apagat"
render_info_top: "Dalt"
render_info_bottom: "Baix"
gamepad_none: "Cap"
gamepad_disconnected: "Desconnectat"
+83 -15
View File
@@ -3,20 +3,88 @@
notification:
test: "test notification"
gamepad_connected: "connected"
gamepad_disconnected: "disconnected"
input:
zoom: "zoom"
fullscreen_on: "fullscreen"
fullscreen_off: "windowed"
shader_on: "shaders on"
shader_off: "shaders off"
vsync_on: "vsync on"
vsync_off: "vsync off"
shader: "shader"
preset: "preset"
scaling_disabled: "scaling: off"
scaling_stretch: "scaling: stretch"
scaling_letterbox: "scaling: letterbox"
scaling_overscan: "scaling: overscan"
scaling_integer: "scaling: integer"
press_again_exit: "press again to exit"
zoom: "Zoom"
fullscreen_on: "Fullscreen"
fullscreen_off: "Windowed"
shader_on: "Shaders on"
shader_off: "Shaders off"
vsync_on: "VSync on"
vsync_off: "VSync off"
screenshot: "Screenshot"
shader: "Shader"
preset: "Preset"
scaling_disabled: "Scaling off"
scaling_stretch: "Scaling stretch"
scaling_letterbox: "Scaling letterbox"
scaling_overscan: "Scaling overscan"
scaling_integer: "Scaling integer"
press_again_exit: "Press again to exit"
paused: "Paused"
editor_on: "Editor ON"
editor_off: "Editor OFF"
editor_save_error: "Editor: map save failed"
hud:
p1: "P1"
p2: "P2"
lives: "Lives"
treasures: "Tre"
pyramid: "Pyramid"
room: "Room"
menu:
titles:
root: "Service menu"
video: "Video"
audio: "Audio"
controls: "Controls"
keyboard: "Keyboard"
gamepad: "Gamepad"
system: "System"
labels:
player_1: "Player 1"
gamepad_unavailable: "Not available"
items:
video_submenu: "Video"
audio_submenu: "Audio"
controls_submenu: "Controls"
keyboard_submenu: "Keyboard"
gamepad_submenu: "Gamepad"
system_submenu: "System"
move_up: "Up"
move_down: "Down"
move_left: "Left"
move_right: "Right"
zoom: "Zoom"
screen: "Fullscreen"
vsync: "VSync"
scaling_mode: "Scaling"
shader: "Shader"
shader_type: "Shader type"
preset: "Preset"
render_info: "Render info"
uptime: "Show time"
master_enable: "Audio"
master_volume: "Master volume"
music: "Music"
music_volume: "Music volume"
sounds: "Sounds"
sounds_volume: "Sound volume"
restart: "Restart"
exit_game: "Exit game"
values:
on: "On"
off: "Off"
submenu_arrow: ">"
scaling_disabled: "Off"
scaling_stretch: "Stretch"
scaling_letterbox: "Letterbox"
scaling_overscan: "Overscan"
scaling_integer: "Integer"
render_info_off: "Off"
render_info_top: "Top"
render_info_bottom: "Bottom"
+39 -41
View File
@@ -3,18 +3,30 @@
# en el tilemap però només 1.5 visibles al canvas, així que la textura es
# dibuixa amb un origen de x=-8). Vertical no cal overscan: 15·16 = 240.
#
# Símbols del grid:
# W = paret (tile 99 del tileset, o (3,3) en 0-indexed)
# F = sòl (llista = un tile aleatori entre aquests per cada casella)
# . = cap tile (els sarcòfags seran actors, ací deixem transparent)
#
# Índexs lineals: els tiles van d'esquerra a dreta, de dalt a baix. Amb un
# tileset de 512×256 i tiles de 16×16 hi ha 32 tiles per fila.
# Format del tilemap:
# `data` és una llista de files; cada fila és una llista d'índexs del
# tileset (lineal esq→dre, dalt→baix). Amb un tileset de 512×256 i tiles
# de 16×16 hi ha 32 tiles per fila. Un valor negatiu (p.ex. -1) deixa
# la cel·la buida (transparent), útil per caselles que ocuparà un actor.
tileset: data/tilesets/tomb.gif
tile_size: 16
origin: [-8, 0]
# Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl
# pintable. Un índex per jugador perquè a multijugador local el recorregut
# es distingeix a simple vista. Provisional fins que l'art del pintat
# estigui definit; canviar aquí l'índex és tot el que cal. Omet el camp
# per desactivar el pintat per a aquest jugador.
player1_paint_tile: 0
player2_paint_tile: 4
# Física del contacte entre jugadors. Quan un jugador està quiet i l'altre
# el topa, el segon l'empenta a aquesta fracció de la velocitat normal (i
# ell mateix es ralentitza per mantenir el grup). En head-on, ambdós
# s'anul·len (sense push, sense moviment).
push_factor: 0.30
# Geometria lògica de la piràmide (separada del render visual del tileset).
# La graella de sarcòfags, el pitch entre ells i les amplades dels murs
# decideixen els límits del jugador i les seues caixes de col·lisió. Abans
@@ -31,38 +43,22 @@ geometry:
back_wall_h: 24
side_wall_w: 24
legend:
W: 292
X: 169
Y: 1
Z: 202
T: 200
R: 262
G: 263
H: 230
I: 231
J: 233
A: 2
B: 34
C: 33
D: 32
data: |
YYYYYYYYYYYYYYYYYYYYY
YHJJJJJJJJJJJJJJJJJIY
YZWWWWWWWWWWWWWWWWWTY
YZDDADDBBDDADDDDBDDTY
YZDAAADAAADAAABAAADTY
YZDDDCCDDDDDADDDBDDTY
YZDAAADAAAAAAADAAADTY
YZDDCCDDDDDADDBDDDDTY
YZDAAADAAADAAADAAADTY
YZDDDDCCDDDDADDDDDDTY
YZDAAADAAADAAADAAADTY
YZDDDABDDDDADCDDBDDTY
YZDAAADAAADAAADAAADTY
YZDDBBDDADDDBCDDDDDTY
YRXXXXXXXXXXXXXXXXXGY
data:
- [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
- [ 1, 91,105,105,105,105,105,105,105,105,105,105,105,105,105,105,105,105,105, 92, 1]
- [ 1, 98,113,113, 95,113, 94,113,113,113,113,113,113,113, 94,113,102,113,113, 96, 1]
- [ 1, 98, 8, 8, 6, 8, 17, 8, 10, 8, 17, 8, 8, 8, 17, 8, 8, 8, 8, 96, 1]
- [ 1, 98, 8, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 96, 1]
- [ 1, 98, 17, 8, 9, 2, 8, 10, 8, 8, 8, 8, 10, 8, 8, 26, 8, 8, 17, 96, 1]
- [ 1, 98, 8, 2, 2, 2, 2, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 96, 1]
- [ 1, 98, 17, 8, 8, 26, 8, 8, 9, 8, 8, 8, 8, 8, 10, 8, 6, 8, 17, 96, 1]
- [ 1, 98, 8, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 96, 1]
- [ 1, 98, 17, 8, 10, 8, 8, 8, 2, 8, 8, 8, 9, 8, 8, 8, 10, 8, 17, 96, 1]
- [ 1, 98, 8, 2, 2, 2, 8, 2, 2, 2, 6, 2, 2, 2, 8, 2, 2, 2, 8, 96, 1]
- [ 1, 98, 17, 8, 10, 8, 8, 10, 2, 8, 8, 26, 8, 8, 8, 8, 10, 8, 17, 96, 1]
- [ 1, 98, 8, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 96, 1]
- [ 1, 98, 8, 10, 6, 6, 17, 8, 8, 8, 17, 8, 9, 8, 17, 8, 2, 8, 8, 96, 1]
- [ 1, 99, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89,100, 1]
# Spawns d'entitats per a aquest nivell. Es llegeixen des de
# EntityManager::loadFromLevel al GameScene. Convencions:
@@ -75,7 +71,7 @@ data: |
#
# Dos sarcòfags en cantonades oposades → perímetres aïllats (cap tile en
# comú). La porta va al centre inferior, on el tilemap té els tiles
# d'obertura (R/G). La momia al corredor central. L'antorxa al costat
# d'obertura (262/263). La momia al corredor central. L'antorxa al costat
# superior esquerre per verificar render d'una entitat decorativa.
entities:
- type: torch
@@ -128,5 +124,7 @@ entities:
sarc_position: [3, 4]
- type: door
position: [9, 1]
- type: mummy
- type: momia
position: [10, 7]
- type: momia
position: [7, 9]
+10 -12
View File
@@ -2,6 +2,8 @@
# per a poder carregar Map sense errors, però el seu propòsit real són
# els spawns de sota: l'smoke verifica que EntityLoader::readSpawns els
# retorna correctament i que EntityManager pot instanciar-los i wire-ar-los.
#
# Format del tilemap: vegeu pyramid.yaml.
tileset: data/tilesets/tomb.gif
tile_size: 16
@@ -18,18 +20,14 @@ geometry:
back_wall_h: 24
side_wall_w: 24
legend:
Y: 1
X: 169
data: |
YYYYYYYYYYYYYY
YXXXXXXXXXXXXY
YXXXXXXXXXXXXY
YXXXXXXXXXXXXY
YXXXXXXXXXXXXY
YXXXXXXXXXXXXY
YYYYYYYYYYYYYY
data:
- [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
- [ 1,169,169,169,169,169,169,169,169,169,169,169,169, 1]
- [ 1,169,169,169,169,169,169,169,169,169,169,169,169, 1]
- [ 1,169,169,169,169,169,169,169,169,169,169,169,169, 1]
- [ 1,169,169,169,169,169,169,169,169,169,169,169,169, 1]
- [ 1,169,169,169,169,169,169,169,169,169,169,169,169, 1]
- [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
entities:
- type: torch
+259
View File
@@ -0,0 +1,259 @@
JASC-PAL
0100
256
0 0 0
36 0 0
72 0 0
109 0 0
145 0 0
182 0 0
218 0 0
255 0 0
0 36 0
36 36 0
72 36 0
109 36 0
145 36 0
182 36 0
218 36 0
255 36 0
0 72 0
36 72 0
72 72 0
109 72 0
145 72 0
182 72 0
218 72 0
255 72 0
0 109 0
36 109 0
72 109 0
109 109 0
145 109 0
182 109 0
218 109 0
255 109 0
0 145 0
36 145 0
72 145 0
109 145 0
145 145 0
182 145 0
218 145 0
255 145 0
0 182 0
36 182 0
72 182 0
109 182 0
145 182 0
182 182 0
218 182 0
255 182 0
0 218 0
36 218 0
72 218 0
109 218 0
145 218 0
182 218 0
218 218 0
255 218 0
0 255 0
36 255 0
72 255 0
109 255 0
145 255 0
182 255 0
218 255 0
255 255 0
0 0 85
36 0 85
72 0 85
109 0 85
145 0 85
182 0 85
218 0 85
255 0 85
0 36 85
36 36 85
72 36 85
109 36 85
145 36 85
182 36 85
218 36 85
255 36 85
0 72 85
36 72 85
72 72 85
109 72 85
145 72 85
182 72 85
218 72 85
255 72 85
0 109 85
36 109 85
72 109 85
109 109 85
145 109 85
182 109 85
218 109 85
255 109 85
0 145 85
36 145 85
72 145 85
109 145 85
145 145 85
182 145 85
218 145 85
255 145 85
0 182 85
36 182 85
72 182 85
109 182 85
145 182 85
182 182 85
218 182 85
255 182 85
0 218 85
36 218 85
72 218 85
109 218 85
145 218 85
182 218 85
218 218 85
255 218 85
0 255 85
36 255 85
72 255 85
109 255 85
145 255 85
182 255 85
218 255 85
255 255 85
0 0 170
36 0 170
72 0 170
109 0 170
145 0 170
182 0 170
218 0 170
255 0 170
0 36 170
36 36 170
72 36 170
109 36 170
145 36 170
182 36 170
218 36 170
255 36 170
0 72 170
36 72 170
72 72 170
109 72 170
145 72 170
182 72 170
218 72 170
255 72 170
0 109 170
36 109 170
72 109 170
109 109 170
145 109 170
182 109 170
218 109 170
255 109 170
0 145 170
36 145 170
72 145 170
109 145 170
145 145 170
182 145 170
218 145 170
255 145 170
0 182 170
36 182 170
72 182 170
109 182 170
145 182 170
182 182 170
218 182 170
255 182 170
0 218 170
36 218 170
72 218 170
109 218 170
145 218 170
182 218 170
218 218 170
255 218 170
0 255 170
36 255 170
72 255 170
109 255 170
145 255 170
182 255 170
218 255 170
255 255 170
0 0 255
36 0 255
72 0 255
109 0 255
145 0 255
182 0 255
218 0 255
255 0 255
0 36 255
36 36 255
72 36 255
109 36 255
145 36 255
182 36 255
218 36 255
255 36 255
0 72 255
36 72 255
72 72 255
109 72 255
145 72 255
182 72 255
218 72 255
255 72 255
0 109 255
36 109 255
72 109 255
109 109 255
145 109 255
182 109 255
218 109 255
255 109 255
0 145 255
36 145 255
72 145 255
109 145 255
145 145 255
182 145 255
218 145 255
255 145 255
0 182 255
36 182 255
72 182 255
109 182 255
145 182 255
182 182 255
218 182 255
255 182 255
0 218 255
36 218 255
72 218 255
109 218 255
145 218 255
182 218 255
218 218 255
255 218 255
0 255 255
36 255 255
72 255 255
109 255 255
145 255 255
182 255 255
218 255 255
255 255 255
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+54
View File
@@ -0,0 +1,54 @@
# player animation
tileSetFile: morcus.gif
frameWidth: 16
frameHeight: 32
# Geometria de col·lisió del jugador (independent de l'animació):
# size → costat del quadrat de col·lisió (16×16)
# base_size → costat del quadrat visual inferior del sprite (els "peus");
# s'usa per centrar la caixa de col·lisió sobre ell.
collision:
size: 16
base_size: 16
# Caixa de contacte actor-actor (jugador↔jugador, jugador↔momia/trampa).
# Més petita que `size` perquè els contactes d'acció siguin precisos;
# `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar
# els passadissos. Centrada en el mateix punt que la caixa base.
hit_size: 8
animations:
- name: stand_down
frames: [0]
- name: stand_up
frames: [4]
- name: stand
frames: [8]
- name: walk_down
speed: 0.1
loopFrom: 0
frames: [0, 1, 2, 3]
- name: walk_up
speed: 0.1
loopFrom: 0
frames: [4, 5, 6, 7]
- name: walk
speed: 0.1
loopFrom: 0
frames: [8, 9, 10, 11]
# Gir de 180°: mentre dura, el facing queda fixat a la direcció
# intermèdia (efecte purament visual; el moviment continua sense
# alterar-se). Els girs de 90° no passen per cap intermedi.
turn:
duration_ms: 80
# Direcció intermèdia per a cada gir de 180° (sentit horari).
transitions:
west_to_east: down # passa per baix (stand_down)
east_to_west: up # passa per dalt (stand_up)
north_to_south: east # passa per la dreta (stand, sense flip)
south_to_north: west # passa per l'esquerra (stand, amb flip)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

-37
View File
@@ -1,37 +0,0 @@
# player animation
tileSetFile: player.gif
frameWidth: 12
frameHeight: 24
# Geometria de col·lisió del jugador (independent de l'animació):
# size → costat del quadrat de col·lisió (16×16)
# base_size → costat del quadrat visual inferior del sprite (els "peus");
# s'usa per centrar la caixa de col·lisió sobre ell.
collision:
size: 16
base_size: 12
animations:
- name: stand_down
frames: [0]
- name: stand_up
frames: [4]
- name: stand
frames: [8]
- name: walk_down
speed: 0.07
loopFrom: 0
frames: [0, 1, 2, 3]
- name: walk_up
speed: 0.07
loopFrom: 0
frames: [4, 5, 6, 7]
- name: walk
speed: 0.07
loopFrom: 0
frames: [8, 9, 10, 11]
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+54
View File
@@ -0,0 +1,54 @@
# player animation
tileSetFile: sam.gif
frameWidth: 16
frameHeight: 32
# Geometria de col·lisió del jugador (independent de l'animació):
# size → costat del quadrat de col·lisió (16×16)
# base_size → costat del quadrat visual inferior del sprite (els "peus");
# s'usa per centrar la caixa de col·lisió sobre ell.
collision:
size: 16
base_size: 16
# Caixa de contacte actor-actor (jugador↔jugador, jugador↔momia/trampa).
# Més petita que `size` perquè els contactes d'acció siguin precisos;
# `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar
# els passadissos. Centrada en el mateix punt que la caixa base.
hit_size: 8
animations:
- name: stand_down
frames: [1]
- name: stand_up
frames: [5]
- name: stand
frames: [9]
- name: walk_down
speed: 0.1
loopFrom: 0
frames: [0, 1, 2, 3]
- name: walk_up
speed: 0.1
loopFrom: 0
frames: [4, 5, 6, 7]
- name: walk
speed: 0.1
loopFrom: 0
frames: [8, 9, 10, 11]
# Gir de 180°: mentre dura, el facing queda fixat a la direcció
# intermèdia (efecte purament visual; el moviment continua sense
# alterar-se). Els girs de 90° no passen per cap intermedi.
turn:
duration_ms: 80
# Direcció intermèdia per a cada gir de 180° (sentit horari).
transitions:
west_to_east: down # passa per baix (stand_down)
east_to_west: up # passa per dalt (stand_up)
north_to_south: east # passa per la dreta (stand, sense flip)
south_to_north: west # passa per l'esquerra (stand, amb flip)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

+65
View File
@@ -0,0 +1,65 @@
# Tema de la UI: índexs de paleta, timings, fonts i flags visuals. Tota
# clau absent cau al valor compilat a Defaults::UI (veure defaults.hpp).
colors:
render_info_text: 58
render_info_shadow: 16
canvas_bg: 72
notification_text: 232
notification_bg: 64
exit_text: 71
exit_bg: 2
menu_bg: 2
menu_border: 232
menu_title: 237
menu_label: 58
menu_value: 232
menu_cursor: 71
menu_disabled: 16
hud_bg: 9
hud_label: 58
hud_value: 187
hud_flash: 237
timings:
notification_ms: 4000
exit_ms: 4000
fonts:
hud:
name: "8bithud"
gif: "data/fonts/8bithud.gif"
fnt: "data/fonts/8bithud.fnt"
kerning: 1
placeholder:
name: "ari-w9500-condensed-display"
gif: "data/fonts/ari-w9500-condensed-display.gif"
fnt: "data/fonts/ari-w9500-condensed-display.fnt"
kerning: 1
menu_title:
name: "ari-w9500-display"
gif: "data/fonts/ari-w9500-display.gif"
fnt: "data/fonts/ari-w9500-display.fnt"
kerning: 0
menu_items:
name: "ari-w9500-condensed-display"
gif: "data/fonts/ari-w9500-condensed-display.gif"
fnt: "data/fonts/ari-w9500-condensed-display.fnt"
kerning: 0
hud:
bg_enabled: true
entry_ms: 800 # durada de l'animació d'entrada dels tres blocs
flash_ms: 250 # durada del flash quan un jugador rep un hit
shake_ms: 300 # durada del shake que acompanya el flash
# Pausa graduada de la GameScene: el món no para d'un colp, s'atura amb
# easing i torna a arrancar amb una altra corba. Els timings es poden
# afinar independentment, i les corbes permeten estils diferents entrant
# i sortint (p.ex. frenar suau i arrancar seca). Corbes disponibles:
# linear, outQuad, inQuad, inOutQuad, outCubic, inCubic.
pause:
enter_ms: 400
exit_ms: 250
enter_curve: outCubic
exit_curve: inCubic
+10
View File
@@ -0,0 +1,10 @@
# `docs/`
| Fitxer | Tipus | Estat |
|--------|-------|-------|
| [audit-core-audio.md](audit-core-audio.md) | Auditoria de `source/core/audio/` | Arxivada (tancada amb `refactor/audio-engine-class`) |
| [audit-core-rendering.md](audit-core-rendering.md) | Auditoria de `source/core/rendering/` | Arxivada (tots els ítems DONE/WONTFIX/DEFERRED documentats) |
| [audit-game.md](audit-game.md) | Auditoria de `source/game/` | Arxivada (tancada amb `refactor/options-class`) |
| [convencions.md](convencions.md) | Convencions del repo (commits, `debug.yaml`) | Viu |
| [entities-system.md](entities-system.md) | Disseny del sistema d'entitats (factory, bus, EntityManager, hazards) | Viu |
| [player-animations.md](player-animations.md) | Notes d'animacions direccionals i `stand` automàtic del Player | Viu |
+14 -4
View File
@@ -3,7 +3,7 @@
Rama: `audit/game`.
Cobertura: `source/game/{entities,map,scenes,ui}` + `source/game/options.{hpp,cpp}` (12 fitxers, ~1.486 línies).
Estado global: **28 DONE, 2 DONE (parcial), 5 WONTFIX, 4 DEFERRED** sobre 39 ítems (el top 5 compta dins dels seus IDs individuals). Cada canvi té un commit aïllat i s'ha provat amb `SDL_VIDEODRIVER=dummy` + `SIGINT` (el log de `Game start` a `Bye!` és idèntic entre commits; la branca `config.yaml` de backup verificada manualment amb un fitxer corrupte). Els 4 DEFERRED (COUP-02, COUP-03, CONV-02 resta, PATCH-01) van a la futura branca `refactor/options-class`.
Estado global: **32 DONE, 1 DONE (parcial), 5 WONTFIX, 0 DEFERRED** sobre 39 ítems (el top 5 compta dins dels seus IDs individuals). Cada canvi té un commit aïllat i s'ha provat amb `SDL_VIDEODRIVER=dummy` + `SIGINT` (el log de `Game start` a `Bye!` és idèntic entre commits; la branca `config.yaml` de backup verificada manualment amb un fitxer corrupte). Els 4 DEFERRED inicials (COUP-02, COUP-03, CONV-02 part Options, PATCH-01) s'han tancat a la branca `refactor/options-class` (registre de commits al final del fitxer); l'únic ítem que queda en DONE (parcial) és INCONS-01, intencionadament — la nota n'explica el motiu.
Estados posibles:
- `PENDING` — identificat, sense tocar
@@ -69,14 +69,14 @@ Els IDs es mantenen estables una vegada assignats. Cada entrada apunta al fitxer
|----|--------------|----------|-----------|-------|-------|
| COUP-01 | `core/input/global_inputs.cpp:15` | `core/` inclou `game/ui/notification_presets.hpp` | Crítico | DONE | `notification_presets.hpp` mogut a `core/rendering/` (al costat del `Notifier` que consumeix). `global_inputs.cpp` i el comentari de `notifier.hpp` actualitzats |
| COUP-02 | `player.cpp` + `Map::PLAYFIELD_*`, `PITCH_*`, `GRID_*`, `collides` | `Player` coneix la geometria interna del mapa | Importante | DEFERRED | Player només llegeix globals de `Map::`, no muta res. Injectar `MapInfo&` té sentit quan hi haja múltiples mapes simultanis o un sistema tipus ECS. Mentre hi haja un Map actiu global, l'acoblament és acceptable. Es refacta amb COUP-03 a `refactor/options-class` |
| COUP-03 | `options.hpp:96-105` | Globals `inline` mutats lliurement des de `core/input/global_inputs.cpp` | Importante | DEFERRED | Encapsular tots els globals darrere setters + observers per Screen/Audio/RenderInfo és un refactor ample (equivalent a COUP-01 de l'audit d'àudio, que va anar a una branca pròpia). Es tractarà a `refactor/options-class` perquè toca tots els callers de core/input. Es fa en branca separada |
| COUP-03 | `options.hpp:96-105` | Globals `inline` mutats lliurement des de `core/input/global_inputs.cpp` | Importante | DONE | Estat mutable mogut a anonymous namespace dins `options.cpp`; API pública són getters const-ref (`window()`, `video()`, `audio()`, `locale()`, `postfxPresets()`, `crtpiPresets()`) + setters específics (`setWindowZoom`, `setShaderEnabled`, `setActiveShaderName`, ...) que muten i persisteixen via `saveToFile()`. Els setters NO notifiquen els subsistemes: el caller (`global_inputs.cpp`) crida després `Screen::setWindowZoom`, `reapplyShader()`, etc. La part "observers" del audit es deixa WONTFIX (veure CONV-02): notificar des d'Options invertiria la direcció `Director→Screen/Audio` i Options aprenria del layout dels subsistemes |
| COUP-04 | `tilemap.cpp:117-138` | Clip+blit directe al canvas ARGB que podria viure a `Surface` | Menor | WONTFIX | Únic caller en tot el projecte. Promoure a `Surface::blitInto` ara afegiria una API genèrica per a un ús únic (YAGNI). Si un segon client apareix, es mourea en aquell moment |
## 5. Inconsistències internes
| ID | Fitxer:línia | Troballa | Severitat | Estat | Notes |
|----|--------------|----------|-----------|-------|-------|
| INCONS-01 | `game/*` | 3 estils de mòdul: namespace+inline (Options), classe all-static (Map), OOP (Player/Tilemap), namespace+constexpr (UiColor/Notification) | Importante | DONE (parcial) | Resolt "classe all-static" amb PATCH-07 (Map → namespace). Queden 3 estils diferenciats per responsabilitat: `namespace` per a estat compartit (Options, Map), classes per a entitats amb estat (Player, Tilemap), `namespace+constexpr` per a alias/presets (UiColor, Notification). La distinció ara és intencional, no fortuïta |
| INCONS-01 | `game/*` | 3 estils de mòdul: namespace+inline (Options), classe all-static (Map), OOP (Player/Tilemap), namespace+constexpr (UiColor/Notification) | Importante | DONE (parcial) | Resolt "classe all-static" amb PATCH-07 (Map → namespace). Queden 3 estils diferenciats per responsabilitat: `namespace` per a estat compartit (Options, Map), classes per a entitats amb estat (Player, Tilemap), `namespace+constexpr` per a alias/presets (UiColor, Notification). La distinció ara és intencional, no fortuïta. NOTA (post `refactor/options-class`): "encapsular" en COUP-03 vol dir ocultar l'estat mutable darrere de l'API del namespace (anonymous namespace + getters/setters), no convertir Options a classe — el patró `namespace per a estat compartit` queda preservat |
| INCONS-02 | `options.cpp:372,477,530,579,607` | `std::cerr` en comptes de `std::cout` (convenció del projecte) | Importante | DONE | Totes les ocurrències (10, incloent les afegides a PATCH-04) substituïdes per `std::cout`. Test manual: amb config.yaml trencat el missatge de backup va per stdout |
| INCONS-03 | `options.cpp` vs `tilemap.cpp:77-78` | Diferent política en YAML absent: `contains()` vs accés directe que llança | Menor | WONTFIX | Les dues polítiques són correctes per al seu cas: `Tilemap`/`Map::loadFrom` tenen fitxers de definició (asset obligatori → llançar); `Options` parseja preferències de l'usuari (camp absent → manté default). No és inconsistència sinó dos contractes diferents |
| INCONS-04 | `options.cpp`, `player.cpp` | Locals ALL-CAPS (`CONTENT`, `IT`, `DT`, `OLD_X`) barrejats amb `snake_case` | Menor | WONTFIX | El `.clang-tidy` del projecte exigeix `LocalConstantCase = UPPER_CASE` i `VariableCase = lower_case` — els ALL-CAPS són per a `const`/`constexpr` i els snake_case per a mutables. La barreja es correcta i està dictada pel linter |
@@ -97,6 +97,16 @@ Els IDs es mantenen estables una vegada assignats. Cada entrada apunta al fitxer
| ID | Fitxer:línia | Troballa | Severitat | Estat | Notes |
|----|--------------|----------|-----------|-------|-------|
| CONV-01 | `player.*`, `map.*`, `ui/*`, `game_scene.cpp` | Speed, caixa de col·lisió, geometria, índexs paleta, rutes d'assets, nom del track: tot cablejat | Crítico | DONE | Tapat per HC-01/02/03/04/05/06: speed a Defaults::Player, col·lisió a player.yaml, geometria a pyramid.yaml, índexs paleta a Defaults::UI, rutes a Defaults::Pyramid + Config |
| CONV-02 | `map.*`, `options.*` | Interfaces+callbacks: `Map` simulated class, `Options` globals sense observers | Importante | DONE (parcial) | Map resolt amb PATCH-07 (namespace). La part d'Options/observers va amb COUP-03 (DEFERRED a `refactor/options-class`) |
| CONV-02 | `map.*`, `options.*` | Interfaces+callbacks: `Map` simulated class, `Options` globals sense observers | Importante | DONE | Map resolt amb PATCH-07 (namespace). Options resolt amb COUP-03: estat mutable encapsulat darrere de l'API del namespace (anonymous ns + getters/setters). La part "observers" (notificacions automàtiques a Screen/Audio) queda WONTFIX justificat: invertiria la direcció de dependència `Director→Screen/Audio` que el projecte manté intencionadament |
| CONV-03 | `options.cpp` | `std::cerr` viola la regla "std::cout only" | Importante | DONE | Resolt amb INCONS-02 |
| CONV-04 | `player.cpp:27` | `SDL_GetKeyboardState` directe en `Player::update` (fuga de SDL al gameplay) | Menor | WONTFIX | `SDL_GetKeyboardState` és l'accés correcte per a state-based polling a 60Hz; `EventBuffer` és per a esdeveniments discrets. La fuga és cosmètica — abstraure-la no aportaria valor |
---
## Registro de commits — `refactor/options-class`
Branca per a tancar els 4 ítems DEFERRED de `audit/game`. Base: `main` (32dcf26).
- `a3b03b8` — COUP-03 + CONV-02: estat mutable d'`Options` mogut a anonymous ns + API de getters/setters; `global_inputs.cpp` i `director.cpp` adaptats. CONV-02 (part Options) tanca al pas. La part "observers" del audit es deixa WONTFIX (invertiria `Director→Screen/Audio`)
- `64e0178` — COUP-02: `Map::Info` plana injectada al `Player` (només Player; momia/roca/loadlevel resten amb `Map::*` global, com diu la nota original del audit)
- `d9f6fb4` — PATCH-01: `tryMove(dx, dy, CollisionFn)` a `MovingSprite` + hook virtual `collisionBox()` a `Sprite`; `Player::update` deixa de fer `update + revertir + reaplicar` i passa a `animate(dt) + tryMove`
+65
View File
@@ -0,0 +1,65 @@
# Convencions del repo
## Missatges de commit
- En **valencià**, frases curtes i compactes.
- **Sense firma**: res de `Co-Authored-By:`, `Generated with Claude Code`, ni cap altra firma automàtica.
- Un commit per unitat lògica. Si el canvi afecta àrees separades (feature + docs + test), separar en commits diferents.
- Prefix opcional estil convencional (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`) quan ajude a la llegibilitat.
Exemple correcte:
```
feat(scenes): afegir placeholders LogoScene/TitleScene/IntroScene
Tres escenes de 5 s cadascuna que precedeixen GameScene. Cada classe
té el seu fitxer perquè divergiran quan es desenvolupe el contingut
real (logo animat, menú de títol, cutscene).
```
## `debug.yaml` (ajust de dev, no versionat)
Fitxer personal de desenvolupament, **fora del repo**. Viu a la mateixa carpeta
de sistema que `config.yaml`:
- Linux (XDG): `$XDG_CONFIG_HOME/jailgames/aee_2026/debug.yaml`
- Per defecte: `~/.config/jailgames/aee_2026/debug.yaml`
### Comportament segons build
Convenció alineada amb `../aee` i `../projecte_2026`:
- **Release** (`make` — binari d'ús normal): `debug.yaml` s'ignora completament.
El joc arranca sempre a `LOGO` i recorre el flux sencer
`LOGO → TITLE → INTRO → GAME`, tal com ho veurà el jugador final.
- **Debug** (`make debug`): es llig `debug.yaml`. Si no existeix, es crea
automàticament amb `start_scene: GAME` perquè el dia a dia de gameplay no
arrossegui els 15 s de placeholders. Editant-lo a mà, es pot triar el punt
d'entrada.
- `make release` fa el Release + empaquetat per a distribució (zip/dmg/tar.gz);
l'empaquetat concret està pendent d'implementar.
### Format
```yaml
start_scene: GAME # valors vàlids: LOGO, TITLE, INTRO, GAME
```
- `start_scene: LOGO` → arranca a `LogoScene` i recorre `LOGO → TITLE → INTRO → GAME`.
- `start_scene: TITLE` → arranca a `TitleScene` i recorre `TITLE → INTRO → GAME`.
- `start_scene: INTRO` → arranca a `IntroScene` i recorre `INTRO → GAME`.
- `start_scene: GAME` → arranca directament a `GameScene` (valor que s'escriu
per defecte en la creació automàtica).
### Comportament davant d'errors (build debug)
- Fitxer buit, valor desconegut o YAML invàlid → `GAME` amb warning a stderr.
El fitxer no es reescriu automàticament: es respecta l'edició manual per si
l'usuari ha teclejat alguna cosa intencionadament.
### Durada dels placeholders
Cada escena placeholder (LOGO, TITLE, INTRO) mostra el seu nom i un compte
regressiu de 5 segons al centre de la pantalla. Es pot saltar amb qualsevol
tecla excepte les registrades a `data/input/keys.yaml` (F1–F12 i Escape),
que mantenen el seu comportament de hotkey global.
+95 -3
View File
@@ -248,10 +248,102 @@ Tanca els hooks d'IA i col·lisió que quedaven apuntalats a les fases 1–3.
- **Smoke tests**: T12 (CollisionBox), T13 (Mummy::step), T11 (sarc YAML),
T10 (torch anim). Total 14/14.
## 7. Estat global
## 7. Fase 5 — Convenció de caixa "peus" com el jugador + animacions direccionals (DONE)
- **Andamiatge, integració, render animat, falsa perspectiva, moviment**: DONE. Els 26 actors del `pyramid.yaml` (4 torches, 20 sarcs, 1 door, 1 mummy) es carreguen, animen, es col·loquen amb l'anchor correcte, col·lisionen segons caixa lògica i el z-order interleava per peus.
- **Pendents majors**: ENT-HAZ-01 (sistema de vides), ENT-DOOR-01 (transició d'escena), ENT-IA-02 (bias IA), PER-01 (perímetre amb blocked_predicate quan els sarcs siguin veïns).
Alinea la momia amb la convenció de col·lisió i animació del Player, i soluciona regressions de navegació derivades del canvi de caixa.
### Caixa concèntrica amb el rectangle-base
- **`collision.base_size: [bw, bh]`** (nou camp YAML, opcional). Si apareix, la caja no s'alinea a la vora inferior del sprite sinó que queda **concèntrica amb el rectangle-base `bw×bh` dels peus** (els `bh` píxels inferiors del sprite). Fórmula: `off_y = sprite_h − (base_h + H) / 2`. Sense `base_size`, comportament antic (alineada a la vora inferior del sprite).
- **`anchor: feet` ara alinea la caja, no el sprite**. Si l'entitat té `CollisionBox`, el loader col·loca `pixel_rect_` perquè la caja quedi exactament al tile. Sense caja, fallback a la convenció antiga (sprite centrat horitzontalment + vora inferior al tile). Per la momia: sprite 12×24 a (tile_x+2, tile_y−10); caja 16×16 a (tile_x, tile_y) — omple el tile sencer com fa el jugador.
- **`mummy.yaml`**: `collision: { size: [16, 16], base_size: [12, 12] }`. Abans era `[12, 12]` alineada a baix; visualment quedava "enganxada al mur inferior" dins dels corredors horitzontals.
### Snap-to-edge per girar al playfield
La caja 16×16 té exactament l'amplada del corredor (16 px). Amb el moviment contínu `speed × delta_time` (~0.66 px/frame), la momia s'aturava a una posició subpíxel dins de l'última cel·la (p. ex. `x=279.87` en lloc de `x=280`) i qualsevol gir perpendicular col·lidia amb el sarc adjacent al limit. [`Mummy::snapToPlayfieldEdge`](../source/game/entities/concrete/mummy.cpp) redueix el `dx/dy` fins a la vora exacta quan el candidat sortiria del playfield; el gir posterior ja parteix d'una posició alineada amb el lane.
### Animacions direccionals
- **`mummy.gif` a 12 frames** (graella 12×1) amb el mateix esquema que el jugador: 0–3 walk_down, 4–7 walk_up, 8–11 walk lateral. Sense variants `stand_*` (mai no para).
- **`Mummy` té tres `AnimationComponent`** privats + un helper `changeDirection(dx, dy)` (únic punt d'entrada per canviar direcció) que selecciona l'animació i aplica el flip: `dx ≠ 0` → lateral amb flip horitzontal segons signe; `dy < 0` → up; `dy > 0` → down.
- **`Entity::flip_` a la base** + `getFlip()`. `renderEntity` el passa a `Surface::render`, que ja suportava `SDL_FlipMode`. Les entitats estàtiques queden a `SDL_FLIP_NONE` automàticament.
### Pipeline de build: sincronització de `data/`
Canvi a [CMakeLists.txt](../CMakeLists.txt): la còpia de `data/` al build era un `add_custom_command(POST_BUILD)`, que només es dispara quan el binari es relinkeja. Si s'editava només un YAML o un GIF sense tocar codi, `build/data/` quedava obsolet. Ara és un `add_custom_target(sync_data ALL ...)` amb dependència del target principal — els custom targets sense BYPRODUCTS es consideren sempre out-of-date, així que la còpia s'executa a cada `cmake --build`.
### Refactors de suport
- `Mummy::step` partit: `snapToPlayfieldEdge`, `collectDirectionPools`, `applyChoice` — per mantenir la complexitat cognitiva sota el llindar de clang-tidy (< 25).
## 8. Fase 6 — Pintat de tiles (DONE)
Introdueix la mecànica central: el jugador deixa rastre al terra en trepitjar-lo, i els sarcòfags compten tiles **pintats** (no tiles *trepitjats*). Font única de veritat del pintat: una nova capa `PaintLayer` escolta el moviment del Player, filtra pintabilitat i re-publica `tile_painted`, que és el que ara observen els sarcs.
### Cadena d'events resultant
```
Player canvi de tile
→ bus: on_tile_entered { col, row, paint_tile_index }
→ PaintLayer: filtra Map::isPaintable + "primer pinta guanya"
→ buffer ARGB + bus: tile_painted { col, row }
→ EntityManager fan-out → Sarcophagus::notifyTilePainted
→ perímetre complet → OPENING → OPEN → sarcophagus_opened
→ tots oberts → Door::open
```
Dos events encadenats perquè el *pintat* no és la *presència*: es filtra per pintabilitat i per "ja pintat", i el re-publish garanteix que els sarcs no vegin falsos positius (p. ex. el jugador lliscant contra un mur, o un tile ja marcat per l'altre jugador).
### Canvis per fitxer
| Path | Canvi |
|---|---|
| [event_bus.hpp](../source/game/entities/event_bus.hpp) | Nou `struct PaintedTile { col, row, tile_index }` al `Payload` variant + overload `publish`. |
| [map.hpp](../source/game/map/map.hpp) / [.cpp](../source/game/map/map.cpp) | `Map::isPaintable(col, row)` → `true` si el tile cau dins del playfield i no s'encavalca cap sarc. |
| [map/paint_layer.hpp](../source/game/map/paint_layer.hpp) / [.cpp](../source/game/map/paint_layer.cpp) | Classe nova. Subscriu `on_tile_entered`, marca `painted_[row][col]`, blita el tile al buffer ARGB propi, publica `tile_painted`. `render()` anàleg a `Tilemap::render`. |
| [player.hpp](../source/game/entities/player.hpp) / [.cpp](../source/game/entities/player.cpp) | Camp `paint_tile_index_` al ctor (default -1). El Player publica `PaintedTile` amb el seu índex en lloc de `TilePos`. |
| [entity.hpp](../source/game/entities/entity.hpp) / [.cpp](../source/game/entities/entity.cpp) | Rename `notifyTileEntered` → `notifyTilePainted`. |
| [concrete/sarcophagus.hpp](../source/game/entities/concrete/sarcophagus.hpp) / [.cpp](../source/game/entities/concrete/sarcophagus.cpp) | Mateix rename. La lògica del `visited_` és idèntica — només canvia qui li truca el hook. |
| [entity_manager.hpp](../source/game/entities/entity_manager.hpp) / [.cpp](../source/game/entities/entity_manager.cpp) | La subscripció canvia d'`on_tile_entered` → `tile_painted`. Fan-out renombrat a `notifyPlayerTilePainted`. |
| [game_scene.hpp](../source/game/scenes/game_scene.hpp) / [.cpp](../source/game/scenes/game_scene.cpp) | Membre `std::unique_ptr<PaintLayer> paint_layer_` (declarat després d'`entity_manager_` per garantir ordre invers de destrucció). Helper local `readOptionalInt` llegeix `player1_paint_tile` del YAML del mapa i el passa al Player. `render()` crida `paint_layer_->render()` entre `tilemap_.render()` i els actors; s'amaga al mode editor. |
| [data/maps/pyramid.yaml](../data/maps/pyramid.yaml) | Camps nous `player1_paint_tile: 2` i `player2_paint_tile: 2` (índex provisional, canviable sense recompilar). |
| [tests/test_entities_smoke.cpp](../tests/test_entities_smoke.cpp) | T9 adaptat: publica `tile_painted` directament al bus per aïllar el fan-out d'EntityManager de la resta de la cadena. |
### Decisions de disseny
- **Centralitzat vs per-sarc**: l'estat "pintat" viu al `PaintLayer`, no dins cada sarc. Els sarcs només apunten els tiles del seu perímetre que han estat notificats pintats. Aquesta separació deixa preparat per a items-en-sarc (obrir quan `N` items trobats, no quan tots oberts) i per a altres productors de pintat futurs (enemics, trampes).
- **"Primer pinta guanya"**: `PaintLayer::tryPaint` retorna false si el tile ja està pintat. No se sobreescriu amb l'índex d'un altre jugador. Reduïm fonts de decisió inconsistent; el rastre visual és estable.
- **Paint tile per jugador**: l'índex l'aporta el YAML del mapa (`player1_paint_tile`, `player2_paint_tile`), no el YAML del jugador. Raó: depèn del tileset del mapa — cada nivell pot oferir rastres diferents; canviar de mapa canvia l'índex vàlid.
- **Render al mode editor**: desactivat. L'editor edita el tilemap base; la capa de pintat és d'estat de joc, no de dades del nivell. Quan `editor_.active()`, `SKIP_ACTORS=true` i el `paint_layer_->render()` se salta amb la resta d'actors.
- **Resolució**: 16×16 reutilitzant `Map::tile_size` i l'origen del tilemap. El `PaintLayer` emmagatzema `tile_size_` com a camp propi per facilitar una eventual migració a 8×8 (graella 2× més fina) sense tocar la interfície.
- **Persistència**: runtime only. No hi ha camp `painted:` al YAML. Entrar al `GameScene` reseteja el pintat.
- **No cal `is_blocked` al `sarc->place(...)`** per a aquest nivell: l'`outer_corridor` del `pyramid.yaml` és exactament 1 tile, cosa que garanteix que el perímetre de qualsevol sarc (fins i tot els de cantonades) cau íntegrament dins del playfield i és pintable. Quedaria rellevant si el disseny futur posa sarcs sense passadís al voltant — cobert per PER-01.
### Verificació
- `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.
- `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.
### Pendent derivat
| ID | Descripció | Estat |
|---|---|---|
| PAINT-01 | **Tile de pintat definitiu** quan l'art estigui madur. Ara `2` provisional al `pyramid.yaml`. Canviar l'índex no demana recompilar. | DEFERRED |
| PAINT-02 | **Resolució 8×8** si es vol més granularitat. El `PaintLayer` porta el seu propi `tile_size_`; cal propagar-lo al ctor per a no lligar-lo a `Tilemap::getTileSize()`. També cal decidir com es mapen els perímetres dels sarcs a sub-caselles. | DEFERRED |
| PAINT-03 | **Smoke unit del `PaintLayer`**: "primer pinta guanya", filtre d'`isPaintable`, publicació de `tile_painted`. Ara la cadena està coberta end-to-end via T9 (fan-out del bus) i via l'smoke d'arrencada, però el filtre i la deduplicació no tenen test directe. | DEFERRED |
| PAINT-04 | **Player 2 cablejat al `GameScene`**: la lectura del YAML i el ctor ja suporten un segon jugador amb `paint_tile_index_` propi; només cal instanciar-lo amb Controls distints (p. ex. WASD) i afegir-lo al vector. | DEFERRED |
### Memòria durable afectada
INF-02 queda parcialment cobert: el `PaintedTile` ja porta `tile_index` (per jugador), que és una forma d'identificar qui pinta sense haver de propagar un `player_id` explícit. Si en el futur cal saber *qui* (p. ex. per puntuació individual), caldrà afegir el camp; el canvi és local al struct.
---
## 9. Estat global
- **Andamiatge, integració, render animat, falsa perspectiva, moviment, animació direccional de momia, pintat de tiles**: DONE. El `pyramid.yaml` actual (4 torches, 20 sarcs, 1 door, 2 mummies) es carrega, anima, col·loca amb l'anchor correcte, col·lisiona segons caixa lògica, el z-order interleava per peus, la momia gira visualment a cada intersecció, i el jugador deixa rastre al terra.
- **Pendents majors**: ENT-HAZ-01 (sistema de vides), ENT-DOOR-01 (transició d'escena), ENT-IA-02 (bias IA), PER-01 (perímetre amb blocked_predicate quan els sarcs siguin veïns), PAINT-03 (test unit del PaintLayer), PAINT-04 (Player 2 cablejat).
- **Smoke tests**: 14/14 passant; regla durable de cobrir amb smoke tota lògica no trivial respectada a totes les fases.
El document és viu: cada tasca nova hauria d'afegir-s'hi (entrada amb ID, descripció, estat) al fer-la o ajornar-la.
+85
View File
@@ -0,0 +1,85 @@
# Animacions i direccionalitat del jugador
Documenta els afegits d'aquesta sessió a `source/game/entities/player.*`: sis
animacions direccionals amb `facing_` persistent, regla de flip només per al
lateral i transició automàtica a `stand` quan la col·lisió bloqueja el
moviment. Tot el canvi és local al `Player`; no s'ha tocat l'EntityManager
ni el bus.
## 1. YAML — sis animacions
El fitxer `data/player/player.yaml` defineix ara sis animacions, agrupades
en tres files de 4 frames sobre `player.gif` (convenció habitual: d'esquerra
a dreta i de dalt a baix):
| Nom | Frames | Descripció |
|---------------|------------|----------------------------|
| `stand_down` | `[0]` | parat mirant cap avall |
| `walk_down` | `[0..3]` | caminant cap avall |
| `stand_up` | `[4]` | parat mirant cap amunt |
| `walk_up` | `[4..7]` | caminant cap amunt |
| `stand` | `[8]` | parat lateral |
| `walk` | `[8..11]` | caminant lateral |
Les laterals (`stand` i `walk`) ja existien; les variants `_up` i `_down`
són noves i permeten distingir visualment la direcció de moviment.
## 2. `Facing` persistent
S'ha afegit l'enum privat `Facing { SIDE, UP, DOWN }` amb el membre
`facing_{SIDE}`. La tria es fa a cada `update`, amb prevalença lateral
per si un dia es permetessin diagonals:
- `vx != 0` → `SIDE` (inclou diagonals).
- `vx == 0 && vy < 0` → `UP`.
- `vx == 0 && vy > 0` → `DOWN`.
- Cap input → es conserva l'últim `facing_` perquè l'animació `stand_*`
coincideixi amb l'última direcció caminada.
## 3. Regla de flip
- `SIDE`: el sprite es flipeja horitzontalment si `vx < 0`, i es restaura
a `FLIP_NONE` si `vx > 0`. El sprite original mira a la dreta.
- `UP` / `DOWN`: el flip es força explícitament a `FLIP_NONE`. Els sprites
frontals/dorsals no s'han d'espillar només perquè l'últim lateral havia
estat cap a l'esquerra.
## 4. `stand` automàtic a la col·lisió
Quan el jugador prem una tecla però la col·lisió (mapa o sarcòfag) li
impedeix avançar, l'animació `walk*` s'atura i es força la variant
`stand_*` corresponent al facing. L'algorisme:
1. Al principi del frame es captura `FRAME_START_X` / `FRAME_START_Y`.
2. S'aplica snap al passadís perpendicular si hi ha pur moviment H o V.
3. `animate(delta_time)` avança el frame de l'animació actual sense
tocar la posició.
4. `tryMove(vx*dt, vy*dt, collidesFn)` (a `MovingSprite`) aplica el
moviment per eix: si la `collisionBox()` resultant col·lisiona —
el Player la sobreescriu retornant `getBaseBox()` —, l'eix es
reverteix. Així el jugador llisca contra murs/sarcòfags en lloc de
bloquejar-se en sec.
5. Després de `clampToPlayfield`, es compara la posició final amb el
valor capturat al pas 1. Si és idèntica, `MOVED = false` i es tria
`stand` / `stand_up` / `stand_down` segons `facing_`. Si hi ha
hagut moviment real (incloent-hi snap), s'aplica la variant
`walk*` del mateix facing.
D'aquesta manera, xocar contra un mur o un sarcòfag fa que el jugador es
quede immòbil en compte d'animar el caminat "en el lloc". El facing
segueix reflectint la intenció (on vol anar), no el resultat, perquè
l'`stand_up` d'empentar-se contra un mur ha de mostrar el personatge
mirant amunt.
## 5. Estat
- DONE: sis animacions al YAML, `facing_` persistent, flip condicionat,
`stand` automàtic a la col·lisió.
- Build i smoke tests (13/13) verdes. (Aquesta nota deia "14/14" abans;
el comptatge real al `tests/test_entities_smoke.cpp` són 13 funcions
`test*` invocades des de `main()` — corregit a `refactor/options-class`.)
- Sense smoke test dedicat: la lògica està entrellaçada amb teclat i
`Map::collides`; l'smoke actual de `Player` no cobreix teclat simulat.
- Pendent (ajornat): si algun dia es vol feedback auditiu de wall-bump
(so en xocar), la condició natural és `!MOVED && (vx != 0 || vy != 0)`
al final d'`update`.
+472
View File
@@ -0,0 +1,472 @@
# Pla d'implementació — Menú de servei
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 +
`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
encara.
Objectiu visual: caixa centrada amb vora i títol, navegable amb fletxes +
Retorn + Retrocés, oberta i tancada amb F12 (hotkey `menu_toggle`), retolada amb
la font `ari-w9500-display`.
---
## 1. Referència a `../aee`
Tot el que segueix és la base; la còpia mental serveix per a no reinventar res:
- [source/core/rendering/menu.hpp](../../aee/source/core/rendering/menu.hpp) — API
pública (`init/destroy/toggle/close/isOpen/isVisible/render/handleKey`).
- [source/core/rendering/menu.cpp](../../aee/source/core/rendering/menu.cpp) —
implementació completa (676 línies). Conté:
- Struct `Item` i `Page` amb `ItemKind::{Toggle, Cycle, IntRange, Submenu,
KeyBind, Action}`.
- Animació d'obertura/tancament (`OPEN_SPEED`, `CLOSE_SPEED`,
`HEIGHT_RATE`) i transició entre pàgines (`pushPage/popPage`).
- Dibuix manual sobre `Uint32* pixel_data` amb `blendRect/fillRect/drawBorder`.
- `buildRoot/buildVideo/buildAudio/buildControls/buildGame/buildSystem`.
- Claus de `locale/ca.yaml` sota `menu.{titles, items, values}` — es poden
reutilitzar gairebé literalment.
---
## 2. Diferències d'arquitectura rellevants
| Punt | `../aee` | `aee_2026` |
| ----------------- | ---------------------------------------------------- | -------------------------------------------------------------------------- |
| 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` |
| `Options` | Membres `inline` mutats directament | Getters const-ref + setters que persisteixen automàticament |
| Setter → subsistema | Dins del mètode (ex. `Screen::toggleVSync`) | El caller crida `Options::setX(v)` **i** `Screen::setX(v)` per separat |
| Remapping tecles | `KeyConfig::scancodePtr(id)` retorna mutable | `KeyConfig` actual no té `scancodePtr` — veure limitació a §5 |
| Render | Pixel-level (blend manual sobre `Uint32*`) | `Screen::fillRect(argb)` + `Text::writeDX`; ARGB cru via `getCanvasArgb()` |
| Font | `8bithud.fnt` via `Text` | `Text` via `Resource::Cache` (paletat sobre `ui`) |
Conseqüències per al disseny:
1. El menú ha d'integrar-se en el pipeline de render de cada Scene, no via
Overlay. Pot ser tan simple com una línia al final de cada `render()`
abans de `Screen::get().present()`, al costat de Notifier/RenderInfo.
2. El menú intercepta input **abans** que `GlobalInputs::update()` processi
les hotkeys (si és obert, només F12 i les fletxes arriben al menú; la
resta d'esdeveniments es consumeixen perquè no activin F1–F11).
3. Els setters de `Options::` ja persisteixen. El menú crida
`Options::setX(v); Screen::setX(v);` com fa `global_inputs.cpp`.
---
## 3. Ubicació i mòduls nous
```
source/game/ui/service_menu.hpp (nou)
source/game/ui/service_menu.cpp (nou)
data/fonts/ari-w9500-display.{fnt,gif} (ja existeixen)
data/locale/ca.yaml (afegir clau arrel `menu:`)
data/locale/en.yaml (afegir clau arrel `menu:`)
data/input/keys.yaml (canviar `toggle_render_info` → `menu_toggle` en F12, o moure F12 a menu i reubicar render_info)
```
Raonament:
- Viu a `game/ui/` perquè el contingut de les pàgines toca `Options::` i
`Screen::` del joc; no és un primitiu de renderització com `Text` o
`Notifier`.
- Exposa l'API com a **singleton** (`ServiceMenu::get()`) per coherència amb
`Notifier` i `RenderInfo`.
- `init()` rep `std::shared_ptr<Text>` + `Palette ui_palette`, igual que
`Notifier::init()`. El Director construeix la font display i la injecta.
Esquelet de l'API:
```cpp
// source/game/ui/service_menu.hpp
class ServiceMenu {
public:
static auto get() -> ServiceMenu&;
void init(std::shared_ptr<Text> font, const Palette& ui_palette);
[[nodiscard]] auto isOpen() const -> bool; // accepta input
[[nodiscard]] auto isVisible() const -> bool; // dibuixa caixa (també tancant-se)
void toggle();
void close();
void update(int delta_ms); // avança animacions
void render(); // dibuixa sobre el canvas de Screen
// Retorna true si el menú ha consumit l'esdeveniment (tecla empassada).
auto handleEvent(const SDL_Event& ev) -> bool;
};
```
---
## 4. Hotkey i integració amb `GlobalInputs`
Decisió: F12 passa a ser `menu_toggle` i el cicle de l'overlay d'info es mou
a una altra tecla (per ex. F10) o es manté a F12 i s'obre el menú amb una
altra (per ex. F9). Opció recomanada:
- `menu_toggle` → **F9** (tecla nova, no conflictiva).
- `toggle_render_info` es queda a F12 (hàbit actual).
Motius: evita trencar la memòria muscular existent i deixa F12 — una tecla
familiar per a debug — lliure per a la seua funció actual.
Afegir a `data/input/keys.yaml`:
```yaml
- id: menu_toggle
code: "F9"
desc: "Obri/tanca el menú de servei"
```
Flux d'input (escena GameScene::update):
```cpp
// Abans de GlobalInputs::update, donar prioritat al menú.
for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) {
if (ServiceMenu::get().isOpen() && ServiceMenu::get().handleEvent(*it)) {
it = EventBuffer::events.erase(it);
} else if (it->type == SDL_EVENT_KEY_DOWN &&
it->key.scancode == KeyConfig::get().scancode("menu_toggle")) {
ServiceMenu::get().toggle();
it = EventBuffer::events.erase(it);
} else {
++it;
}
}
GlobalInputs::update(delta_ms);
```
- Mentre el menú està obert, les hotkeys (F1–F8, F11, F12) queden bloquejades
perquè els KEY_DOWN els consumeix el menú.
- Escape dins del menú ↔ close (o cancel·lació de captura de tecla, si es
porta el KeyBind — veure §5).
- F9 sempre passa el toggle (encara que el menú estiga obert).
Alternativa més neta a mig termini: un `InputRouter` centralitzat al Director
amb prioritats (Menu → GlobalInputs → Scene). De moment, cadascuna de les
Scenes (`LogoScene`, `TitleScene`, `IntroScene`, `GameScene`) crida el bloc
d'input-del-menú al principi de `update()`, igual que ja fan amb
`GlobalInputs::update`.
---
## 5. Contingut de les pàgines (adaptat a `Options` actuals)
### Root
- Vídeo → submenú
- Àudio → submenú
- Sistema → submenú
_No hi ha Controls ni Game:_ `Options::` actual no exposa remapping de tecles
de joc (les `KeysGame` són constants de `Defaults::`), ni opcions "de joc"
equivalents a `use_new_logo` / `show_preload`. Si en el futur apareixen, es
pot afegir sense reescriure res.
### Vídeo
Items, cadascun mapa el setter+notificador com `global_inputs.cpp`:
| Label | Tipus | Origen | Handler |
| --------------------- | -------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
| `menu.items.zoom` | IntRange | `Options::window().zoom` | `Options::setWindowZoom(v); Screen::get().setWindowZoom(v)` (rang 1–6, clamp) |
| `menu.items.screen` | Toggle | `Options::window().fullscreen` | `Options::setWindowFullscreen(v); Screen::get().setFullscreen(v)` |
| `menu.items.vsync` | Toggle | `Options::video().vsync` | `Options::setVideoVSync(v); Screen::get().setVSync(v)` |
| `menu.items.scaling_mode` | Cycle | `Options::video().scaling_mode` | `Options::setVideoScalingMode(m); Screen::get().setScalingMode(m)` |
| `menu.items.shader` | Toggle | `Options::video().shader.enabled` | `Options::setShaderEnabled(v)` + `reapplyShader()` (copiar helper de `global_inputs.cpp`)|
| `menu.items.shader_type` | Cycle | `Options::video().shader.current_shader` | `Options::setActiveShaderName("postfx"/"crtpi")` + `reapplyShader()` |
| `menu.items.preset` | Cycle | preset actiu segons shader | `Options::setPostFXPreset(s)` o `setCrtPiPreset(s)` + `reapplyShader()` |
| `menu.items.render_info` | Cycle | `Options::video().render_info_position` | `Options::setVideoRenderInfoPosition(p)` |
| `menu.items.uptime` | Toggle | `Options::video().render_info_show_time` | `Options::setVideoRenderInfoShowTime(v)` (sols visible si render_info ≠ OFF) |
Claus `visible` (oculta quan no apliquen):
- `shader_type`, `preset` → només si `shader.enabled == true`.
- `uptime` → només si `render_info_position != OFF`.
### Àudio
| Label | Tipus | Origen | Handler |
| ---------------------------- | -------- | ---------------------------------- | ------------------------------------------------------------------------------------ |
| `menu.items.master_enable` | Toggle | `Options::audio().enabled` | Caldrà afegir `Options::setAudioEnabled(v)` (no hi és avui) + `Audio::applyConfig()` |
| `menu.items.master_volume` | IntRange | `Options::audio().volume` | `Options::setAudioVolume(v)` (afegir) + `Audio::applyConfig()` |
| `menu.items.music` | Toggle | `Options::audio().music.enabled` | `Options::setAudioMusicEnabled(v)` (afegir) + `Audio::applyConfig()` |
| `menu.items.music_volume` | IntRange | `Options::audio().music.volume` | `Options::setAudioMusicVolume(v)` (afegir) + `Audio::applyConfig()` |
| `menu.items.sounds` | Toggle | `Options::audio().sound.enabled` | `Options::setAudioSoundEnabled(v)` (afegir) + `Audio::applyConfig()` |
| `menu.items.sounds_volume` | IntRange | `Options::audio().sound.volume` | `Options::setAudioSoundVolume(v)` (afegir) + `Audio::applyConfig()` |
**Dependència prèvia:** `Options::` no té setters d'àudio. Cal afegir-los
seguint el mateix patró que `setVideoVSync` (muten namespace intern +
`saveToFile()`) i exposar `Audio::applyConfig(Audio::Config)` o
`Audio::setVolumes(...)` — depenent del que ja existeixi a `core/audio/`.
Les escales de volum són 0.0–1.0 amb pas 0.05 (5 %), com a `../aee`.
### Sistema
Subtítol: `v<VERSION> (<GIT_HASH>)` — usar el `version.h` generat per CMake
(ja existeix a `source/version.h.in`).
| Label | Tipus | Handler |
| --------------------- | ------ | -------------------------------------------------------------------------------- |
| `menu.items.exit_game`| Action | Injectar un `SDL_EVENT_QUIT` a `EventBuffer::events` perquè `Director::processEvents` el vegi |
_Sense `Restart`_ de moment: Director no té un pas de reinici en calent, i
simular-lo a partir del director actual implica destruir l'escena i
reconstruir el flux — fora d'abast d'aquest menú. Deixar-ho com a TODO si
es necessita.
### Limitació: pàgina Controls diferida
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`:
- Les tecles de joc són `Defaults::KeysGame::{UP,DOWN,LEFT,RIGHT}` — constants
`constexpr`, no mutables.
- `KeyConfig` actual no exposa `scancodePtr(id)`.
Opcions, de menys a més invasiu:
1. **No portar Controls** en aquesta iteració (recomanat per a la primera
versió).
2. Migrar `KeysGame` a `Options::` + afegir `KeyConfig::scancodePtr(id)` per
a hotkeys. Requereix tocar `Player::Controls`, el config.yaml i keys.yaml.
Si s'opta per (2), la pàgina Controls s'afegeix exactament igual que a
`../aee`.
---
## 6. Renderitzat
Dos camins segons nivell d'ambició:
### A. Simple — sòlid opac (MVP)
- Caixa centrada: `Screen::get().fillRect(x, y, w, h, ui_palette_[idx_fons])`.
- Vora: 4 `fillRect` d'1 px amb color `ui_palette_[idx_vora]`.
- Text: `Text::writeDX(CENTER_FLAG | COLOR_FLAG | SHADOW_FLAG, ...)`.
- Cap blend; el joc queda totalment ocult darrere de la caixa (equivalent a
`../aee` però amb `BG_ALPHA = 255`).
Afegir dos índexs nous a `utils/defaults.hpp → UI::`:
```cpp
constexpr Uint8 MENU_BG = /* triar paleta sega-cube */;
constexpr Uint8 MENU_BORDER = /* triar */;
constexpr Uint8 MENU_TITLE = /* blanc */;
constexpr Uint8 MENU_LABEL = /* gris clar */;
constexpr Uint8 MENU_VALUE = /* cian/groc */;
constexpr Uint8 MENU_CURSOR = /* destacat */;
constexpr Uint8 MENU_EMPTY = /* gris */;
```
### B. Semi-transparent (opcional, iteració 2)
Calca el `blendRect` de `../aee`: expandir `Screen::` amb un `blendRect(x,
y, w, h, argb, alpha)` que opere sobre `canvas_.data()`. El canvas és
`std::uint32_t*` format ABGR (equivalent a ARGB little-endian). Amb això, el
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
`../aee`). Canvas lògic d'`aee_2026` és `GameCanvas::WIDTH`/`HEIGHT` —
confirmar mida real (mateixos 320×200?) a `utils/defines.hpp` abans de
hardcoded.
---
## 7. Font `ari-w9500-display`
Ja és al disc i ja té el metadatfnt corresponent
([data/fonts/ari-w9500-display.fnt](../data/fonts/ari-w9500-display.fnt),
11 px d'altura, 12 px de box). És paletable sobre `ui` com les altres
fonts bitmap.
Al `Director::Director()`, afegir després del bloc de fonts HUD/Placeholder:
```cpp
constexpr const char* MENU_FONT_NAME = "ari-w9500-display";
constexpr const char* MENU_FONT_GIF = "data/fonts/ari-w9500-display.gif";
constexpr const char* MENU_FONT_FNT = "data/fonts/ari-w9500-display.fnt";
const std::string MENU_FONT_GIF_KEY = std::string(MENU_FONT_NAME) + ".gif";
Resource::Cache::get().loadSurface(MENU_FONT_GIF_KEY, MENU_FONT_GIF);
Resource::Cache::get().getSurface(MENU_FONT_GIF_KEY)->setPalette(
Resource::Cache::get().getPalette("ui"));
auto menu_font_surface = Resource::Cache::get().getSurface(MENU_FONT_GIF_KEY);
auto menu_text = std::make_shared<Text>(menu_font_surface, MENU_FONT_FNT);
ServiceMenu::get().init(menu_text, ui_palette);
```
Centralitzar les constants a `Defaults::UI::Font::MENU_{NAME,GIF,FNT}` perquè
encaixi amb el memo `project_ui_fonts.md` ("tot a `Defaults::UI::Font`").
---
## 8. Locale
Copiar les claus de `../aee/data/locale/ca.yaml` sota `menu:` (titles, items,
values) a **ca.yaml** i **en.yaml**. El primer commit pot incloure només `ca`;
`en` val la pena fer-lo a la vegada perquè és traducció directa.
Valors a eliminar de la còpia (no apliquen ací):
- `menu.items.{use_new_logo, show_title_credits, show_preload}` (no hi ha
pàgina Game).
- `menu.items.{move_up, move_down, move_left, move_right, menu_key,
restart, aspect_4_3, texture_filter, internal_resolution, supersampling}`
(no portats a l'MVP — veure §5 i les decisions de pàgina Vídeo).
Claus noves que no tenia `../aee` — cap per a l'MVP; si s'afegeixen pàgines
en futures iteracions, traduir a mesura.
---
## 9. Fases d'implementació
Cada fase és un commit. Ordre perquè cada pas compila i s'executa.
### Fase 1 — setters d'àudio a `Options::`
_Preparatori; sense això la pàgina Àudio no pot mutar res._
- Afegir `setAudioEnabled`, `setAudioVolume`, `setAudioMusicEnabled`,
`setAudioMusicVolume`, `setAudioSoundEnabled`, `setAudioSoundVolume` a
`options.hpp/.cpp`. Patró exacte de `setVideoVSync`: muten estat intern +
`saveToFile()`.
- Exposar un mètode de reconfiguració en calent a `core/audio/`
(`Audio::applyConfig` o similar) si no hi és ja.
- Smoke test: `xvfb-run` 5 s, joc arrenca i música sona.
### Fase 2 — hotkey i esquelet `ServiceMenu`
- Afegir `menu_toggle` a `data/input/keys.yaml` amb F9.
- Crear `service_menu.{hpp,cpp}` amb API mínima: `init`, `toggle`, `close`,
`isOpen`, `isVisible`, `update`, `render` (render de caixa buida amb
títol fix "Opcions"), `handleEvent` (només F9, Escape, Backspace).
- Al Director: construir font `ari-w9500-display`, inicialitzar
`ServiceMenu::get()`.
- A `GameScene::update()`: bloc d'input abans de `GlobalInputs::update`
(veure §4). A `GameScene::render()`: `ServiceMenu::get().render()` abans
de `Screen::present()`. **No** replicar encara a Logo/Title/Intro — el
menú només té sentit quan l'usuari juga.
- Smoke test: F9 obri caixa centrada, Escape/F9 la tanca.
### Fase 3 — framework de pàgines
- Portar `Item`, `Page`, `ItemKind`, `stack_`, `pushPage`/`popPage`,
`nextVisibleCursor`, `isVisible(Item)`, `handleKey` de `../aee` (quasi
literalment).
- Portar `renderPageContent` simplificat (sense transicions ni clipping
avançat al principi): render de títol, línia, items amb cursor i valor.
- Afegir les claus `menu.titles.root` i `menu.values.*` a `ca.yaml`/`en.yaml`.
- Implementar `buildRoot()` amb tres entrades Submenu buides (es naveguen
però totes porten a pàgines sense items visibles, text "(Buit)").
- Smoke test: navegació amunt/avall, enter a submenú, backspace torna.
### Fase 4 — Pàgina Vídeo
- Implementar `buildVideo()` amb els items de §5.
- Copiar el helper `reapplyShader()` de `global_inputs.cpp` a un lloc
compartit (p. ex. `game/options_apply.{hpp,cpp}`) perquè el cridin
tant `global_inputs` com el menú, sense duplicar la lògica.
- Afegir claus `menu.items.{zoom,screen,vsync,scaling_mode,shader,shader_type,preset,render_info,uptime}`.
- Smoke test: obrir menú, toggle vsync, canvi d'escala, comprovar que la
notificació NO apareix (és el menú qui fa el canvi, no les hotkeys).
### Fase 5 — Pàgina Àudio
- `buildAudio()` amb els 6 items. Helpers `volPct`/`stepVolume` copiats de
`../aee`.
- Claus `menu.items.{master_enable,master_volume,music,music_volume,sounds,sounds_volume}`.
- Smoke test: baixar volum de música, pujar volum de sons.
### Fase 6 — Pàgina Sistema
- `buildSystem()` amb subtítol de versió i `exit_game` que injecta
`SDL_EVENT_QUIT`.
- Smoke test: obrir sistema, exit_game tanca el joc net.
### Fase 7 (opcional) — polit
- Animació d'obertura/tancament (porta les constants `OPEN_SPEED`,
`CLOSE_SPEED`, `HEIGHT_RATE`, `TRANSITION_SPEED` de `../aee`).
- `blendRect` a `Screen::` per a fons semi-transparent.
- Replicar el bloc d'input del menú a `Title/Intro/Logo` si s'acorda que el
menú hi és accessible també.
- Traduccions EN si no es van fer abans.
---
## 10. Tests
- **Smoke (xvfb-run 5 s)** després de cada fase — memòria `feedback_smoke_test_mandatory`.
- Unitaris amb Catch2 si el framework està muntat (veure `tests/` actual):
- `ServiceMenu::toggle()` idempotent després de `close()`.
- `nextVisibleCursor` salta items ocults.
- `handleEvent` retorna `true` per a KEY_DOWN quan està obert, `false`
quan està tancat.
- Manual:
- Obrir F9 → tancar F9 → obrir de nou (no leak d'estat).
- Entrar a Vídeo → Shader off → verificar que `shader_type` i `preset`
desapareixen (anim o salt).
- Tancar el joc, re-obrir, verificar que `config.yaml` conserva els
canvis (els fa `Options::setX`).
---
## 11. Riscos i punts oberts
- **Input routing als altres Scenes.** Si l'usuari vol obrir el menú
durant el LOGO/TITLE/INTRO, cal replicar el bloc d'input. Decisió: no.
Motiu: són placeholders amb 5 s de durada; obrir un menú allà és
overkill. Anotar-ho a la capçalera de `service_menu.hpp`.
- **`EventBuffer::events.erase(it)`** invalida els iteradors — mà amb el
loop. Patró amb `it = events.erase(it)` ↔ `++it` és segur.
- **Blend rect vs. GPU pipeline.** Si el shader PostFX reprocessa el
canvas, el menú ha d'estar pintat *abans* del shader (i ja ho està,
perquè el canvas ARGB va al shader). Res a fer; tot passa per
`Screen::present()` al final.
- **Captura de tecla (`KeyBind`).** No s'implementa aquesta iteració
(§5). Si es porta, l'event router ha de distingir "menú obert i
capturant" — el menú consumeix **tota** tecla, no només Enter/Backspace/etc.
- **`ari-w9500-display` és paletada.** Ha de compartir la paleta `ui` amb
la resta d'overlays. Verificar que el GIF només té la tinta a l'índex
`Text::INK_COLOR_INDEX = 1`; si no, el font_gen l'ha de regenerar (veure
memòria `reference_font_generator`).
- **F9 lliure?** Abans d'agafar F9 per al `menu_toggle`, confirmar que
cap altra hotkey hi és mapejada en builds futures.
---
## 12. Resum d'arxius a tocar
**Nous:**
- `source/game/ui/service_menu.hpp`
- `source/game/ui/service_menu.cpp`
- (opcional) `source/game/options_apply.{hpp,cpp}` per compartir
`reapplyShader` amb `global_inputs.cpp`.
**Modificats:**
- `source/utils/defaults.hpp` — afegir índexs `UI::MENU_*` + `UI::Font::MENU_*`.
- `source/game/options.hpp/.cpp` — setters d'àudio (fase 1).
- `source/core/system/director.cpp` — carregar font menú + `ServiceMenu::get().init(...)`.
- `source/game/scenes/game_scene.cpp` — bloc d'input del menú a `update`,
render del menú a `render` abans de `present`.
- `data/input/keys.yaml` — afegir `menu_toggle: F9`.
- `data/locale/ca.yaml`, `data/locale/en.yaml` — secció `menu:`.
- `CMakeLists.txt` o `source/CMakeLists.txt` — afegir el nou `service_menu.cpp`.
**Sense tocar per a l'MVP:**
- `source/core/input/key_config.*` (no cal `scancodePtr`).
- `source/core/audio/*` si ja exposa configuració en calent; si no, mínima
adició.
- LogoScene/TitleScene/IntroScene.
+4 -3
View File
@@ -5,9 +5,10 @@
#include <cstdio>
#include <memory>
// Implementació completa de stb_vorbis en aquest TU. HEADER_ONLY es queda
// indefinit aquí perquè el .c aporti les definicions. La resta del projecte
// només veu el forward-decl de jail_audio.hpp.
// Només declaracions de stb_vorbis: STB_VORBIS_HEADER_ONLY omet el bloc
// d'implementació. Les definicions les aporta source/external/stb_vorbis_impl.cpp
// (TU aïllat perquè clang-analyzer no dispari fals positius al nostre codi).
#define STB_VORBIS_HEADER_ONLY
// clang-format off
// NOLINTNEXTLINE(bugprone-suspicious-include)
#include "external/stb_vorbis.c"
+295
View File
@@ -0,0 +1,295 @@
#include "core/input/gamepad_manager.hpp"
#include <algorithm>
#include <iostream>
#include <iterator>
#include <string>
#include <utility>
#include "core/locale/locale.hpp"
#include "core/rendering/notification_presets.hpp"
namespace {
#ifdef __EMSCRIPTEN__
// A Emscripten, SDL 3.4+ no casa el GUID dels mandos web: Chrome/Android
// no aporten Vendor/Product ID i el parser de SDL extreu dades brutes.
// El W3C Gamepad API, però, garanteix un layout estàndard quan el mapping
// reporta "standard", així que injectem un mapping SDL explícit amb eixe
// layout per al GUID del joystick abans que SDL intenti obrir-lo com a
// gamepad. Fora d'Emscripten és un no-op.
void installWebStandardMapping(SDL_JoystickID jid) {
SDL_GUID guid = SDL_GetJoystickGUIDForID(jid);
char guid_str[33];
SDL_GUIDToString(guid, guid_str, sizeof(guid_str));
const char* name = SDL_GetJoystickNameForID(jid);
if (name == nullptr || *name == '\0') { name = "Standard Gamepad"; }
char mapping[512];
SDL_snprintf(mapping, sizeof(mapping),
"%s,%s,"
"a:b0,b:b1,x:b2,y:b3,"
"leftshoulder:b4,rightshoulder:b5,"
"lefttrigger:b6,righttrigger:b7,"
"back:b8,start:b9,"
"leftstick:b10,rightstick:b11,"
"dpup:b12,dpdown:b13,dpleft:b14,dpright:b15,"
"guide:b16,"
"leftx:a0,lefty:a1,rightx:a2,righty:a3,"
"platform:Emscripten",
guid_str,
name);
SDL_AddGamepadMapping(mapping);
}
#else
void installWebStandardMapping(SDL_JoystickID /*jid*/) {}
#endif
} // namespace
auto GamepadManager::get() -> GamepadManager& {
static GamepadManager instance_;
return instance_;
}
auto GamepadManager::prettyName(const char* raw) -> std::string {
std::string name = (raw != nullptr && *raw != '\0') ? std::string(raw) : std::string("Gamepad");
const auto POS = name.find_first_of("([");
if (POS != std::string::npos) { name.erase(POS); }
while (!name.empty() && name.back() == ' ') { name.pop_back(); }
if (name.empty()) { name = "Gamepad"; }
return name;
}
auto GamepadManager::uniqueName(const std::string& base) const -> std::string {
const bool COLLISION = std::ranges::any_of(
entries_,
[&](const Entry& e) { return e.pretty_name == base; });
if (!COLLISION) { return base; }
// Format " #N" començant a #2 — la primera ocurrència manté el nom net.
for (int i = 2; i < 100; ++i) {
std::string candidate = base + " #" + std::to_string(i);
const bool MATCH = std::ranges::any_of(
entries_,
[&](const Entry& e) { return e.pretty_name == candidate; });
if (!MATCH) { return candidate; }
}
return base; // cas ultra-improbable: 99 mandos idèntics connectats
}
void GamepadManager::addGamepad(SDL_JoystickID jid, bool notify) {
// Dedup per `instance_id`: SDL encua un SDL_EVENT_GAMEPAD_ADDED per
// cada mando ja connectat quan s'inicialitza el subsistema, a més
// dels que ja hem obert a `init()`. Sense aquesta guarda, el mateix
// mando s'afegiria dues vegades (silenciós + notificat amb sufix
// "#2") i qualsevol referència via `getPrimary`/`entries_` seria
// ambigua.
const bool ALREADY_KNOWN = std::ranges::any_of(
entries_,
[jid](const Entry& e) { return e.instance_id == jid; });
if (ALREADY_KNOWN) { return; }
installWebStandardMapping(jid);
if (!SDL_IsGamepad(jid)) { return; }
SDL_Gamepad* pad = SDL_OpenGamepad(jid);
if (pad == nullptr) {
std::cout << "GamepadManager: SDL_OpenGamepad(" << jid
<< ") ha fallat: " << SDL_GetError() << '\n';
return;
}
std::string name = uniqueName(prettyName(SDL_GetGamepadName(pad)));
// `SDL_GetGamepadPath` pot tornar null a backends que no exposen ruta
// física (Emscripten, alguns drivers). Buit com a fallback; la
// reconnexió es resoldrà per nom.
const char* raw_path = SDL_GetGamepadPath(pad);
std::string path = (raw_path != nullptr) ? raw_path : "";
std::cout << "GamepadManager: connectat " << name
<< (path.empty() ? "" : " [" + path + "]") << '\n';
entries_.push_back(Entry{.pad = pad, .instance_id = jid, .pretty_name = name, .path = path});
// Hot-plug: si alguna assignació pendent encaixa amb aquest mando,
// la vinculem ara. Primer intentem per path exacte, després per nom.
for (auto& a : assignments_) {
if (a.pad != nullptr) { continue; }
if (a.name.empty() && a.path.empty()) { continue; }
rebindAssignment(a);
}
if (notify) {
Notification::notify(name + ' ' + Locale::get().text("notification.gamepad_connected"));
}
}
void GamepadManager::removeGamepad(SDL_JoystickID jid) {
auto it = std::ranges::find_if(
entries_,
[jid](const Entry& e) { return e.instance_id == jid; });
if (it == entries_.end()) { return; }
std::string name = std::move(it->pretty_name);
SDL_Gamepad* pad = it->pad;
if (pad != nullptr) { SDL_CloseGamepad(pad); }
std::cout << "GamepadManager: desconnectat " << name << '\n';
entries_.erase(it);
// Qualsevol assignació que apuntava a aquest mando queda amb `pad`
// a null però conservem nom/ruta: si el mando es torna a connectar,
// el hot-plug el re-vincularà automàticament.
for (auto& a : assignments_) {
if (a.pad == pad) { a.pad = nullptr; }
}
Notification::notify(name + ' ' + Locale::get().text("notification.gamepad_disconnected"));
}
void GamepadManager::init() {
if (initialized_) { return; }
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
std::cout << "GamepadManager: SDL_INIT_GAMEPAD ha fallat: "
<< SDL_GetError() << '\n';
return;
}
// `gamecontrollerdb.txt` és opcional. Si no existeix, SDL retorna -1 i
// continuem amb les definicions internes del sistema.
const int ADDED = SDL_AddGamepadMappingsFromFile("data/input/gamecontrollerdb.txt");
if (ADDED > 0) {
std::cout << "GamepadManager: " << ADDED << " mappings de gamecontrollerdb.txt\n";
}
int count = 0;
SDL_JoystickID* ids = SDL_GetJoysticks(&count);
if (ids != nullptr) {
for (int i = 0; i < count; ++i) {
addGamepad(ids[i], /*notify=*/false);
}
SDL_free(ids);
}
initialized_ = true;
}
void GamepadManager::shutdown() {
for (auto& entry : entries_) {
if (entry.pad != nullptr) { SDL_CloseGamepad(entry.pad); }
}
entries_.clear();
if (initialized_) {
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
initialized_ = false;
}
}
void GamepadManager::handleEvent(const SDL_Event& ev) {
if (ev.type == SDL_EVENT_GAMEPAD_ADDED) {
addGamepad(ev.gdevice.which, /*notify=*/true);
} else if (ev.type == SDL_EVENT_GAMEPAD_REMOVED) {
removeGamepad(ev.gdevice.which);
}
}
auto GamepadManager::getPrimary() const -> SDL_Gamepad* {
return entries_.empty() ? nullptr : entries_.front().pad;
}
auto GamepadManager::getCount() const -> std::size_t {
return entries_.size();
}
auto GamepadManager::getByPlayerIndex(std::size_t idx) const -> SDL_Gamepad* {
return idx < entries_.size() ? entries_[idx].pad : nullptr;
}
auto GamepadManager::getAll() const -> std::vector<Info> {
std::vector<Info> result;
result.reserve(entries_.size());
std::ranges::transform(entries_, std::back_inserter(result), [](const Entry& e) {
return Info{.pad = e.pad, .name = e.pretty_name, .path = e.path};
});
return result;
}
auto GamepadManager::getForPlayer(int player_id) const -> SDL_Gamepad* {
if (player_id < 0 || player_id >= MAX_PLAYERS) { return nullptr; }
return assignments_[static_cast<std::size_t>(player_id)].pad;
}
void GamepadManager::rebindAssignment(Assignment& a) {
// Primera passada: coincidència exacta per path. És el cas estable
// (mateix mando, mateix port) i no canvia res de l'assignació.
if (!a.path.empty()) {
auto it = std::ranges::find_if(
entries_,
[&](const Entry& e) { return !e.path.empty() && e.path == a.path; });
if (it != entries_.end()) {
a.pad = it->pad;
return;
}
}
// Segona passada: coincidència per nom (mando canviat de port USB).
// Acceptem la primera entrada que no estigui ja vinculada a una altra
// assignació — així dos mandos amb el mateix nom no es pisen.
if (!a.name.empty()) {
for (const auto& e : entries_) {
if (e.pretty_name != a.name) { continue; }
const bool TAKEN = std::ranges::any_of(
assignments_,
[&](const Assignment& other) { return &other != &a && other.pad == e.pad; });
if (TAKEN) { continue; }
a.pad = e.pad;
a.path = e.path; // actualitzem ruta persistida
return;
}
}
a.pad = nullptr;
}
void GamepadManager::assignToPlayer(int player_id, const std::string& name, const std::string& path) {
if (player_id < 0 || player_id >= MAX_PLAYERS) { return; }
auto& a = assignments_[static_cast<std::size_t>(player_id)];
a.name = name;
a.path = path;
a.pad = nullptr;
if (name.empty() && path.empty()) { return; }
rebindAssignment(a);
// Last-wins: si l'altre jugador tenia el mateix mando vinculat, el
// desvinculem (conservant el seu nom/path perquè el menú pugui
// mostrar l'estat "pendent" i oferir re-selecció).
if (a.pad != nullptr) {
for (auto& other : assignments_) {
if (&other != &a && other.pad == a.pad) {
std::cout << "GamepadManager: mando " << a.name
<< " reassignat (last-wins)\n";
other.pad = nullptr;
}
}
}
}
void GamepadManager::unassignFromPlayer(int player_id) {
if (player_id < 0 || player_id >= MAX_PLAYERS) { return; }
assignments_[static_cast<std::size_t>(player_id)] = Assignment{};
}
auto GamepadManager::autoAssignFreeSlots() -> std::vector<AutoAssigned> {
std::vector<AutoAssigned> assigned;
for (int pid = 0; pid < MAX_PLAYERS; ++pid) {
auto& slot = assignments_[static_cast<std::size_t>(pid)];
if (slot.pad != nullptr) { continue; }
// Primer mando que cap altre slot no haja reclamat. Així respectem
// qualsevol assignació persistida que ja haja vinculat un pad
// concret i no el reassignem a un jugador diferent.
for (const auto& e : entries_) {
const bool TAKEN = std::ranges::any_of(
assignments_,
[&](const Assignment& other) { return other.pad == e.pad; });
if (TAKEN) { continue; }
slot.pad = e.pad;
slot.name = e.pretty_name;
slot.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
<< " → P" << (pid + 1) << '\n';
break;
}
}
return assigned;
}
+141
View File
@@ -0,0 +1,141 @@
#pragma once
#include <SDL3/SDL.h>
#include <array>
#include <cstddef>
#include <string>
#include <vector>
// Gestor global de gamepads. Descobreix els mandos presents a `init()`
// sense emetre notificacions (el jugador ja els té posats al llançar el
// joc). Durant el runtime, `handleEvent()` processa els events de hot-plug
// i emet notificacions visibles via `Notification::notify`.
//
// El polling real de botons i eixos NO es fa a través d'aquesta classe:
// els consumidors obtenen el `SDL_Gamepad*` primari (o l'enèsim per a
// multi-jugador futur) i el passen a `InputState::getGamepadButton/Axis`,
// que ho filtra pel flag de bloqueig de la UI (mateixa estratègia que
// amb el teclat).
class GamepadManager {
public:
static auto get() -> GamepadManager&;
GamepadManager(const GamepadManager&) = delete;
GamepadManager(GamepadManager&&) = delete;
auto operator=(const GamepadManager&) -> GamepadManager& = delete;
auto operator=(GamepadManager&&) -> GamepadManager& = delete;
// Inicialitza el subsistema SDL de gamepad, intenta carregar un
// `gamecontrollerdb.txt` opcional i obri silenciosament tots els
// mandos ja connectats. Cridat una sola vegada des de Director.
void init();
// Tanca tots els gamepads oberts i treu el subsistema SDL. Cridat
// des de `~Director` abans de `SDL_Quit` per evitar handles orfes.
void shutdown();
// Processa SDL_EVENT_GAMEPAD_ADDED i SDL_EVENT_GAMEPAD_REMOVED
// emesos durant el runtime. Emet notificacions "<nom> connectat" o
// "<nom> desconnectat". El Director decideix quins events li arriben
// via `processGamepadEvents()`.
void handleEvent(const SDL_Event& ev);
// Primary = primer gamepad al vector. `nullptr` si no n'hi ha cap.
// Ús típic: navegació del ServiceMenu. Per al gameplay els jugadors
// usen `getForPlayer(id)`.
[[nodiscard]] auto getPrimary() const -> SDL_Gamepad*;
[[nodiscard]] auto getCount() const -> std::size_t;
// Retorna el mand del jugador `idx`-èsim (0-indexed) en l'ordre de
// connexió. Per al gameplay, `getForPlayer` fa servir l'assignació
// persistida, no l'ordre de connexió.
[[nodiscard]] auto getByPlayerIndex(std::size_t idx) const -> SDL_Gamepad*;
// Informació exposada al menú de servei (llista de mandos
// disponibles). `path` pot estar buit si SDL no l'exposa (p. ex. a
// alguns backends web).
struct Info {
SDL_Gamepad* pad{nullptr};
std::string name;
std::string path;
};
[[nodiscard]] auto getAll() const -> std::vector<Info>;
// Retorna el mand assignat al jugador `player_id` (0 = P1, 1 = P2)
// o `nullptr` si no n'hi ha cap. L'assignació la fixa
// `assignToPlayer` (cridat per Options en carregar `config.yaml` o
// des del ServiceMenu quan l'usuari tria un mando).
[[nodiscard]] auto getForPlayer(int player_id) const -> SDL_Gamepad*;
// Assigna un mando per nom + ruta física. Si ja hi ha un mando
// connectat que coincideix per ruta (o per nom com a fallback), el
// vincula immediatament; si no, l'assignació queda pendent i
// s'aplicarà si el mando es connecta més tard (hot-plug).
// Política last-wins: si l'altre jugador tenia el mateix mando
// vinculat, se'l desvincula silenciosament.
void assignToPlayer(int player_id, const std::string& name, const std::string& path);
// Esborra l'assignació (per si l'usuari trie "Cap" al menú).
void unassignFromPlayer(int player_id);
// Per a cada slot buit, hi vincula el primer mando connectat que no
// estigui reclamat per un altre slot. Equivalent al patró de
// `coffee_crisis_arcade_edition`: les assignacions persistides tenen
// prioritat; els slots que queden buits després s'emplenen per
// ordre. Retorna la llista de parells (player_id, name, path) que
// s'han vinculat, per a que Options pugui persistir-los.
struct AutoAssigned {
int player_id;
std::string name;
std::string path;
};
auto autoAssignFreeSlots() -> std::vector<AutoAssigned>;
private:
struct Entry {
SDL_Gamepad* pad{nullptr};
SDL_JoystickID instance_id{0};
std::string pretty_name;
std::string path;
};
// Assignació persistent per jugador. `name` i `path` venen d'Options
// (o del menú de servei) i poden referenciar un mando encara no
// connectat; en aquest cas `pad` és `nullptr` i el hot-plug el
// vincularà quan aparegui.
struct Assignment {
std::string name;
std::string path;
SDL_Gamepad* pad{nullptr};
};
static constexpr int MAX_PLAYERS = 2;
std::array<Assignment, MAX_PLAYERS> assignments_{};
// Busca a `entries_` el millor match per a una assignació (primer
// per path, després per nom). Retorna l'iterador o `entries_.end()`.
// També accepta assignar `path` nou si ha coincidit per nom
// (cas mando reenchufat a un altre port USB).
void rebindAssignment(Assignment& a);
GamepadManager() = default;
~GamepadManager() = default;
// Retalla `raw` al primer '(' o '[' i elimina espais finals per a
// llevar metadata verbosa ("Retroid Controller (vendor: 1001) ...").
// Fallback "Gamepad" si queda buit. No limita l'amplada final — el
// Notifier multilinia ja gestiona noms llargs.
[[nodiscard]] static auto prettyName(const char* raw) -> std::string;
// Si `base` ja existeix al vector, afegeix " #2", " #3", ... fins que
// trobi un nom lliure. Així dos mandos idèntics es distingeixen a les
// notificacions.
[[nodiscard]] auto uniqueName(const std::string& base) const -> std::string;
void addGamepad(SDL_JoystickID jid, bool notify);
void removeGamepad(SDL_JoystickID jid);
std::vector<Entry> entries_;
bool initialized_{false};
};
+74 -69
View File
@@ -11,8 +11,11 @@
#include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/screenshot.hpp"
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "game/options.hpp"
#include "game/options_apply.hpp"
#include "utils/defaults.hpp"
namespace GlobalInputs {
@@ -23,102 +26,110 @@ namespace GlobalInputs {
// La lliguem a la durada de la notificació EXIT perquè el missatge i
// 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).
constexpr float EXIT_WINDOW_S = static_cast<float>(Notification::EXIT.time_ms) / 1000.0F;
// El valor concret viu a data/ui.yaml (timings.exit_ms) via UiConfig.
constexpr int MIN_ZOOM = 1;
constexpr int MAX_ZOOM = 6;
float exit_window_s = 0.0F;
bool quit_requested = false;
// Canvia uniforms i shader actiu sense recrear la finestra.
void reapplyShader() {
Screen::get().setActiveShader(Options::buildActiveShader());
Screen::get().applyPostFXParams(Options::buildPostFXParams());
Screen::get().applyCrtPiParams(Options::buildCrtPiParams());
}
void notifyLocale(const std::string& key) {
Notification::notify(Locale::get().text(key));
}
void onDecZoom() {
if (Options::window.fullscreen) { return; }
if (Options::window.zoom <= MIN_ZOOM) { return; }
Options::window.zoom--;
Screen::get().setWindowZoom(Options::window.zoom);
Options::saveToFile();
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(Options::window.zoom));
if (Options::window().fullscreen) { return; }
if (Options::window().zoom <= MIN_ZOOM) { return; }
const int NEW_ZOOM = Options::window().zoom - 1;
Options::setWindowZoom(NEW_ZOOM);
Screen::get().setWindowZoom(NEW_ZOOM);
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(NEW_ZOOM));
}
void onIncZoom() {
if (Options::window.fullscreen) { return; }
if (Options::window.zoom >= MAX_ZOOM) { return; }
Options::window.zoom++;
Screen::get().setWindowZoom(Options::window.zoom);
Options::saveToFile();
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(Options::window.zoom));
if (Options::window().fullscreen) { return; }
if (Options::window().zoom >= MAX_ZOOM) { return; }
const int NEW_ZOOM = Options::window().zoom + 1;
Options::setWindowZoom(NEW_ZOOM);
Screen::get().setWindowZoom(NEW_ZOOM);
Notification::notify(Locale::get().text("input.zoom") + " x" + std::to_string(NEW_ZOOM));
}
void onFullscreen() {
Options::window.fullscreen = !Options::window.fullscreen;
Screen::get().setFullscreen(Options::window.fullscreen);
Options::saveToFile();
notifyLocale(Options::window.fullscreen ? "input.fullscreen_on" : "input.fullscreen_off");
const bool NEW_FS = !Options::window().fullscreen;
Options::setWindowFullscreen(NEW_FS);
Screen::get().setFullscreen(NEW_FS);
notifyLocale(NEW_FS ? "input.fullscreen_on" : "input.fullscreen_off");
}
void onToggleVSync() {
Options::video.vsync = !Options::video.vsync;
Screen::get().setVSync(Options::video.vsync);
Options::saveToFile();
notifyLocale(Options::video.vsync ? "input.vsync_on" : "input.vsync_off");
const bool NEW_VSYNC = !Options::video().vsync;
Options::setVideoVSync(NEW_VSYNC);
Screen::get().setVSync(NEW_VSYNC);
notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off");
}
// Volca el canvas ARGB actual a PNG dins de `screenshots/`. Si ha
// fallat l'escriptura, el handler no mostra notificació (retorn buit).
void onScreenshot() {
const auto* buffer = Screen::get().getCanvasArgb();
const int W = Screen::get().getCanvasWidth();
const int H = Screen::get().getCanvasHeight();
const std::string FILENAME = Screenshot::save(buffer, W, H);
if (!FILENAME.empty()) {
Notification::notify(Locale::get().text("input.screenshot") + ' ' + FILENAME);
}
}
void onToggleShader() {
Options::video.shader.enabled = !Options::video.shader.enabled;
reapplyShader();
Options::saveToFile();
notifyLocale(Options::video.shader.enabled ? "input.shader_on" : "input.shader_off");
const bool NEW_ENABLED = !Options::video().shader.enabled;
Options::setShaderEnabled(NEW_ENABLED);
OptionsApply::reapplyShader();
notifyLocale(NEW_ENABLED ? "input.shader_on" : "input.shader_off");
}
void onNextShader() {
Options::video.shader.current_shader = (Options::video.shader.current_shader == "crtpi") ? "postfx" : "crtpi";
reapplyShader();
Options::saveToFile();
Notification::notify(Locale::get().text("input.shader") + ": " + Options::video.shader.current_shader);
const std::string NEW_NAME = (Options::video().shader.current_shader == "crtpi") ? "postfx" : "crtpi";
Options::setActiveShaderName(NEW_NAME);
OptionsApply::reapplyShader();
Notification::notify(Locale::get().text("input.shader") + ' ' + NEW_NAME);
}
// Busca l'entrada actual al vector de presets i passa a la següent
// (wrap-around). Si no en troba cap s'evita el canvi silenciosament.
void onNextPreset() {
const bool IS_CRTPI = Options::video.shader.current_shader == "crtpi";
const bool IS_CRTPI = Options::video().shader.current_shader == "crtpi";
std::string new_preset;
if (IS_CRTPI) {
if (Options::crtpi_presets.empty()) { return; }
auto it = std::ranges::find_if(Options::crtpi_presets,
[](const auto& preset) { return preset.name == Options::video.shader.current_crtpi_preset; });
std::size_t idx = (it == Options::crtpi_presets.end()) ? 0 : std::distance(Options::crtpi_presets.begin(), it);
idx = (idx + 1) % Options::crtpi_presets.size();
Options::video.shader.current_crtpi_preset = Options::crtpi_presets[idx].name;
const auto& presets = Options::crtpiPresets();
if (presets.empty()) { return; }
auto it = std::ranges::find_if(presets,
[](const auto& preset) { return preset.name == Options::video().shader.current_crtpi_preset; });
std::size_t idx = (it == presets.end()) ? 0 : std::distance(presets.begin(), it);
idx = (idx + 1) % presets.size();
new_preset = presets[idx].name;
Options::setCrtPiPreset(new_preset);
} else {
if (Options::postfx_presets.empty()) { return; }
auto it = std::ranges::find_if(Options::postfx_presets,
[](const auto& preset) { return preset.name == Options::video.shader.current_postfx_preset; });
std::size_t idx = (it == Options::postfx_presets.end()) ? 0 : std::distance(Options::postfx_presets.begin(), it);
idx = (idx + 1) % Options::postfx_presets.size();
Options::video.shader.current_postfx_preset = Options::postfx_presets[idx].name;
const auto& presets = Options::postfxPresets();
if (presets.empty()) { return; }
auto it = std::ranges::find_if(presets,
[](const auto& preset) { return preset.name == Options::video().shader.current_postfx_preset; });
std::size_t idx = (it == presets.end()) ? 0 : std::distance(presets.begin(), it);
idx = (idx + 1) % presets.size();
new_preset = presets[idx].name;
Options::setPostFXPreset(new_preset);
}
reapplyShader();
Options::saveToFile();
const std::string& name = IS_CRTPI ? Options::video.shader.current_crtpi_preset : Options::video.shader.current_postfx_preset;
Notification::notify(Locale::get().text("input.preset") + ": " + name);
OptionsApply::reapplyShader();
Notification::notify(Locale::get().text("input.preset") + ' ' + new_preset);
}
void onNextScalingMode() {
using Mode = Defaults::ScalingMode;
Options::video.scaling_mode = static_cast<Mode>((static_cast<int>(Options::video.scaling_mode) + 1) % 5);
Screen::get().setScalingMode(Options::video.scaling_mode);
Options::saveToFile();
const auto NEW_MODE = static_cast<Mode>((static_cast<int>(Options::video().scaling_mode) + 1) % 5);
Options::setVideoScalingMode(NEW_MODE);
Screen::get().setScalingMode(NEW_MODE);
const char* mode_key = nullptr;
switch (Options::video.scaling_mode) {
switch (NEW_MODE) {
case Mode::DISABLED:
mode_key = "input.scaling_disabled";
break;
@@ -139,14 +150,8 @@ namespace GlobalInputs {
}
void onCycleRenderInfo() {
const int NEXT = (static_cast<int>(Options::video.render_info_position) + 1) % Defaults::RENDER_INFO_POSITION_COUNT;
Options::video.render_info_position = static_cast<Defaults::RenderInfoPosition>(NEXT);
Options::saveToFile();
}
void onToggleRenderTime() {
Options::video.render_info_show_time = !Options::video.render_info_show_time;
Options::saveToFile();
const int NEXT = (static_cast<int>(Options::video().render_info_position) + 1) % Defaults::RENDER_INFO_POSITION_COUNT;
Options::setVideoRenderInfoPosition(static_cast<Defaults::RenderInfoPosition>(NEXT));
}
void onExit() {
@@ -154,8 +159,8 @@ namespace GlobalInputs {
quit_requested = true;
return;
}
exit_window_s = EXIT_WINDOW_S;
Notification::notify(Locale::get().text("input.press_again_exit"), Notification::EXIT);
exit_window_s = static_cast<float>(UiConfig::get().exitMs()) / 1000.0F;
Notification::notify(Locale::get().text("input.press_again_exit"), Notification::exitPreset());
}
} // namespace
@@ -176,6 +181,8 @@ namespace GlobalInputs {
onToggleShader();
} else if (SC == KeyConfig::get().scancode("toggle_vsync")) {
onToggleVSync();
} else if (SC == KeyConfig::get().scancode("screenshot")) {
onScreenshot();
} else if (SC == KeyConfig::get().scancode("next_shader")) {
onNextShader();
} else if (SC == KeyConfig::get().scancode("next_preset")) {
@@ -184,8 +191,6 @@ namespace GlobalInputs {
onNextScalingMode();
} else if (SC == KeyConfig::get().scancode("toggle_render_info")) {
onCycleRenderInfo();
} else if (SC == KeyConfig::get().scancode("toggle_render_time")) {
onToggleRenderTime();
} else if (SC == KeyConfig::get().scancode("exit")) {
onExit();
}
+39
View File
@@ -0,0 +1,39 @@
#include "core/input/input_state.hpp"
#include <array>
namespace InputState {
namespace {
bool blocked = false;
// Buffer retornat quan el bloqueig és actiu. `SDL_GetKeyboardState`
// retorna un pointer d'una mida igual a `SDL_SCANCODE_COUNT`; el
// nostre buffer ha de ser com a mínim igual de gran perquè els
// consumidors puguin indexar-lo amb qualsevol scancode sense
// llegir fora de rang.
auto zeroKeyboard() -> const bool* {
static const std::array<bool, SDL_SCANCODE_COUNT> ZEROS{};
return ZEROS.data();
}
} // namespace
void setBlocked(bool value) { blocked = value; }
auto isBlocked() -> bool { return blocked; }
auto getKeyboardState() -> const bool* {
return blocked ? zeroKeyboard() : SDL_GetKeyboardState(nullptr);
}
auto getGamepadButton(SDL_Gamepad* gp, SDL_GamepadButton b) -> bool {
return (blocked || gp == nullptr) ? false : SDL_GetGamepadButton(gp, b);
}
auto getGamepadAxis(SDL_Gamepad* gp, SDL_GamepadAxis ax) -> Sint16 {
return (blocked || gp == nullptr) ? static_cast<Sint16>(0) : SDL_GetGamepadAxis(gp, ax);
}
} // namespace InputState
+46
View File
@@ -0,0 +1,46 @@
#pragma once
// Wrapper de l'estat d'input (polling) amb gating de bloqueig.
//
// El service menu (i qualsevol UI modal futura) ha de poder "amagar" l'estat
// d'entrada als consumidors de polling — el jugador, entitats controlades
// per l'usuari, etc. — mentre la UI captura l'atenció, sense interrompre la
// resta del joc. Per això tots els consumidors de polling han de llegir a
// través d'aquest mòdul i no cridar `SDL_GetKeyboardState` directament. Si
// `setBlocked(true)` està actiu, el wrapper retorna un buffer tot-a-zero;
// altrament, la crida és transparent (mateix pointer que SDL).
//
// El menú NO passa per aquí: consumeix events (`SDL_EVENT_KEY_DOWN`) abans
// que l'escena els vegi, així que la seva navegació segueix funcionant per
// molt que el flag estigui posat.
//
// El mateix patró aplica al gamepad: `getGamepadButton`/`getGamepadAxis`
// retornen zero/false quan el bloqueig és actiu. Els mandos concrets els
// obtén el caller via `GamepadManager::get().getPrimary()` o similars.
#include <SDL3/SDL.h>
namespace InputState {
// Activa/desactiva el bloqueig global de polling. El ServiceMenu el
// crida a `toggle()`/`close()`. No es toca des de cap altre lloc.
void setBlocked(bool value);
[[nodiscard]] auto isBlocked() -> bool;
// Equivalent a `SDL_GetKeyboardState(nullptr)`: retorna un buffer de
// `SDL_SCANCODE_COUNT` bools. Si l'input està bloquejat, tots zero —
// el consumidor veu com si cap tecla estigués premuda, sense branques
// explícites. El buffer és estable mentre dura la sessió; no cal
// guardar-lo frame a frame.
[[nodiscard]] auto getKeyboardState() -> const bool*;
// Versió filtrada de `SDL_GetGamepadButton`. Retorna false si el bloqueig
// és actiu o si `gp` és nullptr (cas habitual: no hi ha cap mand).
[[nodiscard]] auto getGamepadButton(SDL_Gamepad* gp, SDL_GamepadButton b) -> bool;
// Versió filtrada de `SDL_GetGamepadAxis`. Retorna 0 si el bloqueig
// és actiu o si `gp` és nullptr.
[[nodiscard]] auto getGamepadAxis(SDL_Gamepad* gp, SDL_GamepadAxis ax) -> Sint16;
} // namespace InputState
+6 -3
View File
@@ -4,6 +4,7 @@
#include <iostream>
#include <string>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
auto KeyConfig::get() -> KeyConfig& {
@@ -12,14 +13,16 @@ auto KeyConfig::get() -> KeyConfig& {
}
void KeyConfig::load(const std::string& file_path) {
std::ifstream file(file_path);
if (!file.is_open()) {
auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) {
std::cerr << "KeyConfig: no s'ha pogut obrir " << file_path << '\n';
return;
}
try {
auto yaml = fkyaml::node::deserialize(file);
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
if (!yaml.contains("keys") || !yaml["keys"].is_sequence()) {
std::cerr << "KeyConfig: falta la secció 'keys' a " << file_path << '\n';
return;
+1
View File
@@ -22,6 +22,7 @@ class KeyConfig {
void load(const std::string& file_path);
[[nodiscard]] auto scancode(const std::string& id) const -> SDL_Scancode;
[[nodiscard]] auto bindings() const -> const std::unordered_map<std::string, SDL_Scancode>& { return bindings_; }
private:
KeyConfig() = default;
+32
View File
@@ -0,0 +1,32 @@
#include "core/input/mouse.hpp"
namespace Mouse {
namespace {
// Temps (ms) sense moviment de ratolí abans d'amagar el cursor.
constexpr Uint32 HIDE_DELAY_MS = 3000;
Uint32 last_move_time = 0;
bool cursor_visible = true;
} // namespace
void handleEvent(const SDL_Event& ev) {
if (ev.type == SDL_EVENT_MOUSE_MOTION) {
last_move_time = SDL_GetTicks();
if (!cursor_visible) {
SDL_ShowCursor();
cursor_visible = true;
}
}
}
void updateCursorVisibility() {
if (cursor_visible && (SDL_GetTicks() - last_move_time > HIDE_DELAY_MS)) {
SDL_HideCursor();
cursor_visible = false;
}
}
} // namespace Mouse
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <SDL3/SDL.h>
// Auto-ocultació del cursor del ratolí. El joc no usa el ratolí com a
// entrada, així que el cursor del sistema només molesta sobre el canvas.
// El patró és senzill: si l'usuari mou el ratolí, es mostra; si passa
// més de `HIDE_DELAY_MS` sense moviment, s'amaga.
//
// El Director crida `handleEvent()` per cada event i `updateCursorVisibility()`
// una vegada per frame.
namespace Mouse {
void handleEvent(const SDL_Event& ev);
void updateCursorVisibility();
} // namespace Mouse
+6 -3
View File
@@ -4,6 +4,7 @@
#include <iostream>
#include <string>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
namespace {
@@ -33,14 +34,16 @@ auto Locale::get() -> Locale& {
}
void Locale::load(const std::string& file_path) {
std::ifstream file(file_path);
if (!file.is_open()) {
auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) {
std::cerr << "Locale: no s'ha pogut obrir " << file_path << '\n';
return;
}
try {
auto yaml = fkyaml::node::deserialize(file);
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
strings_.clear();
flatten(yaml, "", strings_);
std::cout << "Locale: " << strings_.size() << " traduccions des de " << file_path << '\n';
+43
View File
@@ -1,9 +1,24 @@
#include "core/rendering/canvas.hpp"
#include <algorithm>
#include <vector>
#include "core/rendering/screen.hpp"
namespace Rendering {
namespace {
// Pila de clips en un namespace anònim perquè no siga accessible des
// de fora. L'app és single-threaded; si algun dia hi ha threads de
// render, s'ha de fer thread_local o passar-la explícitament.
auto clipStack() -> std::vector<Clip>& {
static std::vector<Clip> stack_;
return stack_;
}
} // namespace
auto activeCanvas() -> Canvas {
auto& screen = Screen::get();
return {
@@ -13,4 +28,32 @@ namespace Rendering {
};
}
auto activeClip() -> Clip {
const auto& stack = clipStack();
if (!stack.empty()) { return stack.back(); }
const auto CANVAS = activeCanvas();
return {.x = 0, .y = 0, .w = CANVAS.width, .h = CANVAS.height};
}
void pushClip(Clip rect) {
// Retall contra el canvas per garantir que els blitters no s'han
// de preocupar de coordenades negatives o més grans que el canvas.
const auto CANVAS = activeCanvas();
const int X0 = std::max(rect.x, 0);
const int Y0 = std::max(rect.y, 0);
const int X1 = std::min(rect.x + rect.w, CANVAS.width);
const int Y1 = std::min(rect.y + rect.h, CANVAS.height);
clipStack().push_back({
.x = X0,
.y = Y0,
.w = std::max(0, X1 - X0),
.h = std::max(0, Y1 - Y0),
});
}
void popClip() {
auto& stack = clipStack();
if (!stack.empty()) { stack.pop_back(); }
}
} // namespace Rendering
+24
View File
@@ -20,9 +20,33 @@ namespace Rendering {
int height{0};
};
// Rectangle de clip (en píxels de canvas). Els blitters han de comprovar
// `clip` abans d'escriure cada píxel; fora del rectangle no es dibuixa.
// `push/pop` permet niar clips (tot i que de moment només en tenim un
// nivell d'ús, el menú). Si la pila és buida, `activeClip` retorna el
// canvas complet — comportament "sense clip".
struct Clip {
int x{0};
int y{0};
int w{0};
int h{0};
};
// Retorna el canvas actiu del joc. En aquesta fase del projecte n'hi ha
// sempre un i només un (el de Screen); en el futur, si cal renderitzar
// off-screen (miniatures, efectes), es podrà pushejar/popar per ací.
[[nodiscard]] auto activeCanvas() -> Canvas;
// Clip actiu. Si ningú ha fet push, és tot el canvas.
[[nodiscard]] auto activeClip() -> Clip;
// Restringeix el dibuix al rectangle donat fins al proper popClip().
// El rectangle es retalla contra el canvas (cap clip pot excedir els
// seus límits, així els blitters poden assumir-los sans).
void pushClip(Clip rect);
// Desapila el clip actiu i restaura l'anterior. Si no hi havia cap
// push previ, l'operació és idempotent i queda a "canvas complet".
void popClip();
} // namespace Rendering
+25 -2
View File
@@ -198,10 +198,33 @@ namespace GIF {
readBytes(buffer, &lzw_code_size, sizeof(uint8_t));
std::vector<uint8_t> compressed_data = readSubBlocks(buffer);
int uncompressed_data_length = image_descriptor.image_width * image_descriptor.image_height;
std::vector<uint8_t> uncompressed_data(uncompressed_data_length);
const int IMG_W = image_descriptor.image_width;
const int IMG_H = image_descriptor.image_height;
std::vector<uint8_t> uncompressed_data(static_cast<std::size_t>(IMG_W) * IMG_H);
decompress(lzw_code_size, compressed_data.data(), static_cast<int>(compressed_data.size()), uncompressed_data.data());
// Interlaced GIF (flag 0x40 al byte de fields): les files no arriben
// en ordre lineal, sinó en 4 passades (8:0, 8:4, 4:2, 2:1). Reordena
// al buffer lineal que espera Surface. Sense aquest pas, les fuentes
// i qualsevol GIF entrellaçat es veien completament trinxats.
constexpr uint8_t INTERLACE_FLAG = 0x40;
if ((image_descriptor.fields & INTERLACE_FLAG) != 0) {
constexpr int PASS_START[4] = {0, 4, 2, 1};
constexpr int PASS_STEP[4] = {8, 8, 4, 2};
std::vector<uint8_t> deinterlaced(static_cast<std::size_t>(IMG_W) * IMG_H);
int src_row = 0;
for (int pass = 0; pass < 4; ++pass) {
for (int dst_row = PASS_START[pass]; dst_row < IMG_H; dst_row += PASS_STEP[pass]) {
std::memcpy(deinterlaced.data() + (static_cast<std::size_t>(dst_row) * IMG_W),
uncompressed_data.data() + (static_cast<std::size_t>(src_row) * IMG_W),
IMG_W);
++src_row;
}
}
return deinterlaced;
}
return uncompressed_data;
}
+29 -19
View File
@@ -5,12 +5,13 @@
#include <string>
#include "core/rendering/notifier.hpp"
#include "utils/defaults.hpp"
#include "core/ui/ui_config.hpp"
// 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
// en mil·lisegons. Per a llançar una notificació amb un preset concret,
// cridar Notification::notify(text, preset); si s'omet, s'usa DEFAULT.
// en mil·lisegons. Els valors concrets viuen a data/ui.yaml (carregat per
// UiConfig); per llançar una notificació amb un preset concret, cridar
// Notification::notify(text, preset). Sense preset s'usa el DEFAULT.
namespace Notification {
struct Preset {
@@ -19,27 +20,36 @@ namespace Notification {
int time_ms;
};
// Tema per defecte: text clar sobre fons fosc. Els valors concrets
// viuen a `Defaults::UI` — tocar l'aparença global es fa allà.
constexpr Preset DEFAULT{
.text_color = Defaults::UI::NOTIFICATION_TEXT,
.bg_color = Defaults::UI::NOTIFICATION_BG,
.time_ms = Defaults::UI::NOTIFICATION_TIME_MS,
};
// 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
// data/ui.yaml i es carreguen a l'arrencada.
[[nodiscard]] inline auto defaultPreset() -> Preset {
return {
.text_color = UiConfig::get().notificationText(),
.bg_color = UiConfig::get().notificationBg(),
.time_ms = UiConfig::get().notificationMs(),
};
}
// Preset per al missatge de confirmació d'eixida (ESC doble). Color
// d'accent per a distingir-lo d'una notificació informativa; la durada
// d'accent per distingir-lo d'una notificació informativa; la durada
// coincideix amb la finestra durant la qual la segona pulsació d'ESC
// confirma el tancament — veure GlobalInputs::onExit.
constexpr Preset EXIT{
.text_color = Defaults::UI::EXIT_TEXT,
.bg_color = Defaults::UI::EXIT_BG,
.time_ms = Defaults::UI::EXIT_TIME_MS,
};
[[nodiscard]] inline auto exitPreset() -> Preset {
return {
.text_color = UiConfig::get().exitText(),
.bg_color = UiConfig::get().exitBg(),
.time_ms = UiConfig::get().exitMs(),
};
}
// Helper de conveniència: aplica un preset (per defecte DEFAULT) cridant
// la API crua de Notifier. La direcció game → core és la correcta.
inline void notify(const std::string& text, const Preset& preset = DEFAULT) {
// Helper de conveniència: aplica un preset (per defecte el DEFAULT)
// cridant la API crua de Notifier. La direcció game → core és correcta.
inline void notify(const std::string& text) {
const auto P = defaultPreset();
Notifier::get().notify(text, P.text_color, P.bg_color, P.time_ms);
}
inline void notify(const std::string& text, const Preset& preset) {
Notifier::get().notify(text, preset.text_color, preset.bg_color, preset.time_ms);
}
+76 -14
View File
@@ -1,11 +1,59 @@
#include "core/rendering/notifier.hpp"
#include <algorithm>
#include <numeric>
#include <utility>
#include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp"
#include "utils/defines.hpp"
namespace {
// Parteix `text` en una llista de línies que caben cadascuna dins
// `max_width`. Algoritme de projecte_2026: tall per espais, la paraula
// que no cap passa a la línia següent. Les paraules aïllades més
// llargues que `max_width` es queden a la seva pròpia línia sense
// trencar-les (cas improbable amb els pretty_names de gamepad retallats
// a ~25 caràcters).
auto wrapToWidth(const std::string& text, int max_width, const Text& font, int kerning) -> std::vector<std::string> {
std::vector<std::string> lines;
if (text.empty()) {
lines.emplace_back();
return lines;
}
std::string current;
std::size_t i = 0;
while (i < text.size()) {
const std::size_t SPACE_POS = text.find(' ', i);
const bool LAST = (SPACE_POS == std::string::npos);
const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i);
if (current.empty()) {
current = WORD;
} else {
std::string candidate = current;
candidate += ' ';
candidate += WORD;
if (font.length(candidate, kerning) <= max_width) {
current = std::move(candidate);
} else {
lines.push_back(current);
current = WORD;
}
}
if (LAST) { break; }
i = SPACE_POS + 1;
}
lines.push_back(current);
return lines;
}
} // namespace
auto Notifier::get() -> Notifier& {
static Notifier instance_;
return instance_;
@@ -34,8 +82,8 @@ void Notifier::update(int delta_ms) {
break;
case Status::VANISHING:
y_ -= SLIDE_SPEED * DT;
if (y_ <= static_cast<float>(-HEIGHT)) {
y_ = static_cast<float>(-HEIGHT - MARGIN_TOP);
if (y_ <= static_cast<float>(-current_box_h_)) {
y_ = static_cast<float>(-current_box_h_ - MARGIN_TOP);
status_ = Status::HIDDEN;
}
break;
@@ -45,31 +93,45 @@ void Notifier::update(int delta_ms) {
}
void Notifier::render() {
if (status_ == Status::HIDDEN || text_ == nullptr || current_text_.empty()) { return; }
if (status_ == Status::HIDDEN || text_ == nullptr || current_lines_.empty()) { return; }
const int TEXT_W = text_->length(current_text_, 1);
const int BOX_W = TEXT_W + (2 * PADDING_H);
const int BOX_X = (GameCanvas::WIDTH - BOX_W) / 2;
const int BOX_X = (GameCanvas::WIDTH - current_box_w_) / 2;
const int BOX_Y = static_cast<int>(y_);
// Fons sòlid amb la paleta UI que ens ha injectat el caller a init()
// (ARGB directe al canvas, sense tocar cap surface paletada).
Screen::get().fillRect(BOX_X, BOX_Y, BOX_W, HEIGHT, ui_palette_[current_bg_color_]);
Screen::get().fillRect(BOX_X, BOX_Y, current_box_w_, current_box_h_, ui_palette_[current_bg_color_]);
text_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
BOX_Y + PADDING_V,
current_text_,
1,
current_text_color_);
const int LINE_STEP = Rendering::Hud::FONT_SIZE_PX + LINE_GAP;
int line_y = BOX_Y + PADDING_V;
for (const auto& line : current_lines_) {
text_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
line_y,
line,
1,
current_text_color_);
line_y += LINE_STEP;
}
}
void Notifier::notify(const std::string& text, Uint8 text_color, Uint8 bg_color, int time_ms) {
// Política simple: la nova notificació reemplaça la que hi havia. Per
// gestionar-ne una cua, canviar ací per una FIFO quan calga.
current_text_ = text;
if (text_ == nullptr) { return; }
current_lines_ = wrapToWidth(text, MAX_TEXT_WIDTH, *text_, 1);
// Amplada = max de les línies + padding. Alçada = N*glif + (N-1)*gap + padding.
const int MAX_LINE_W = std::accumulate(current_lines_.begin(), current_lines_.end(), 0, [this](int acc, const std::string& s) { return std::max(acc, text_->length(s, 1)); });
current_box_w_ = MAX_LINE_W + (2 * PADDING_H);
const int N = static_cast<int>(current_lines_.size());
current_box_h_ = (N * Rendering::Hud::FONT_SIZE_PX) + ((N - 1) * LINE_GAP) + (2 * PADDING_V);
current_text_color_ = text_color;
current_bg_color_ = bg_color;
remaining_ms_ = time_ms;
y_ = static_cast<float>(-current_box_h_ - MARGIN_TOP);
status_ = Status::RISING;
}
+15 -3
View File
@@ -5,9 +5,11 @@
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
#include "core/rendering/hud_constants.hpp"
#include "core/rendering/surface.hpp"
#include "utils/defines.hpp"
class Text;
@@ -19,6 +21,10 @@ class Text;
// API crua: Notifier::get().notify(text, text_color, bg_color, time_ms).
// Per a treballar amb presets pre-definits, vegeu Notification::notify() a
// core/rendering/notification_presets.hpp.
//
// Multilinia: si el text passa de l'amplada màxima (`MAX_TEXT_WIDTH`), es
// parteix per espais i la caixa creix en alçada automàticament. Els textos
// curts (cas habitual) ocupen una sola línia i no canvien d'aspecte.
class Notifier {
public:
static auto get() -> Notifier&;
@@ -39,9 +45,13 @@ class Notifier {
static constexpr int PADDING_H = 6;
static constexpr int PADDING_V = 3;
static constexpr int HEIGHT = Rendering::Hud::FONT_SIZE_PX + (2 * PADDING_V);
static constexpr int LINE_GAP = 1; // Separació vertical entre línies multilinia.
static constexpr int MARGIN_TOP = 2; // Separació amb la vora superior del canvas.
static constexpr float SLIDE_SPEED = 120.0F;
// Amplada màxima (sense comptar padding) que pot ocupar el text abans que
// `wrapToWidth` el parteixi en línies. Deixem marge respecte a la vora del
// canvas perquè la caixa no toqui els laterals.
static constexpr int MAX_TEXT_WIDTH = GameCanvas::WIDTH - 20 - (2 * PADDING_H);
private:
enum class Status : std::uint8_t { HIDDEN,
@@ -55,8 +65,10 @@ class Notifier {
std::shared_ptr<Text> text_;
Palette ui_palette_{};
Status status_{Status::HIDDEN};
float y_{static_cast<float>(-HEIGHT - MARGIN_TOP)};
std::string current_text_;
float y_{0.0F};
std::vector<std::string> current_lines_;
int current_box_w_{0};
int current_box_h_{0};
Uint8 current_text_color_{0};
Uint8 current_bg_color_{0};
int remaining_ms_{0};
+77 -4
View File
@@ -11,6 +11,7 @@
#include <ranges>
#include "core/rendering/canvas.hpp"
#include "core/rendering/sdl3gpu/viewport.hpp"
#include "utils/defines.hpp"
namespace {
@@ -220,17 +221,89 @@ void Screen::clear(std::uint8_t r, std::uint8_t g, std::uint8_t b) {
}
void Screen::fillRect(int x, int y, int w, int h, std::uint32_t argb) {
const int X0 = std::max(x, 0);
const int Y0 = std::max(y, 0);
const int X1 = std::min(x + w, canvas_width_);
const int Y1 = std::min(y + h, canvas_height_);
const auto CLIP = Rendering::activeClip();
const int X0 = std::max(x, CLIP.x);
const int Y0 = std::max(y, CLIP.y);
const int X1 = std::min(x + w, CLIP.x + CLIP.w);
const int Y1 = std::min(y + h, CLIP.y + CLIP.h);
for (int iy = Y0; iy < Y1; ++iy) {
std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_);
std::fill(row + X0, row + X1, argb);
}
}
void Screen::blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha) {
if (alpha == 0) { return; }
if (alpha == 0xFF) {
fillRect(x, y, w, h, argb);
return;
}
// Mescla linealment src (argb, alpha) sobre dst (el canvas). El canvas
// és ARGB amb R/G/B als bytes baixos i A fix a 0xFF; fem la mescla per
// canal i deixem A intacte perquè el backend espera opacitat total.
const std::uint8_t SA = alpha;
const std::uint8_t INV = 255 - alpha;
const std::uint8_t SR = (argb >> 16) & 0xFF;
const std::uint8_t SG = (argb >> 8) & 0xFF;
const std::uint8_t SB = argb & 0xFF;
const auto CLIP = Rendering::activeClip();
const int X0 = std::max(x, CLIP.x);
const int Y0 = std::max(y, CLIP.y);
const int X1 = std::min(x + w, CLIP.x + CLIP.w);
const int Y1 = std::min(y + h, CLIP.y + CLIP.h);
for (int iy = Y0; iy < Y1; ++iy) {
std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_);
for (int ix = X0; ix < X1; ++ix) {
const std::uint32_t DST = row[ix];
const std::uint8_t DR = (DST >> 16) & 0xFF;
const std::uint8_t DG = (DST >> 8) & 0xFF;
const std::uint8_t DB = DST & 0xFF;
const std::uint8_t R = ((SR * SA) + (DR * INV)) / 255;
const std::uint8_t G = ((SG * SA) + (DG * INV)) / 255;
const std::uint8_t B = ((SB * SA) + (DB * INV)) / 255;
row[ix] = 0xFF000000U | (static_cast<std::uint32_t>(R) << 16) | (static_cast<std::uint32_t>(G) << 8) | static_cast<std::uint32_t>(B);
}
}
}
auto Screen::windowToCanvas(float window_x, float window_y) const -> SDL_FPoint {
// Path fallback: SDL ja porta un helper que desfà el
// logical_presentation automàticament. Evita que haguem de replicar
// les regles de letterbox/integer/etc.
if (renderer_ != nullptr) {
float cx = 0.0F;
float cy = 0.0F;
SDL_RenderCoordinatesFromWindow(renderer_, window_x, window_y, &cx, &cy);
return {cx, cy};
}
// Path GPU: el canvas es pinta al viewport que calcula
// `computeViewport` a partir del mode d'escalat i la mida del
// swapchain. Fem el mateix càlcul aquí per trobar la inversa.
int win_w = 0;
int win_h = 0;
SDL_GetWindowSize(window_, &win_w, &win_h);
if (win_w <= 0 || win_h <= 0) { return {-1.0F, -1.0F}; }
const auto VP = Rendering::computeViewport(config_.scaling_mode,
static_cast<std::uint32_t>(win_w),
static_cast<std::uint32_t>(win_h),
canvas_width_,
canvas_height_);
if (VP.w <= 0.0F || VP.h <= 0.0F) { return {-1.0F, -1.0F}; }
const float NX = (window_x - VP.x) / VP.w;
const float NY = (window_y - VP.y) / VP.h;
return {NX * static_cast<float>(canvas_width_), NY * static_cast<float>(canvas_height_)};
}
void Screen::setOverlayRenderer(std::function<void()> callback) {
overlay_renderer_ = std::move(callback);
}
void Screen::present() {
// Overlays (menú de servei, etc.) s'han de pintar al canvas ABANS que
// shader_backend llegisca els píxels per pujar-los a GPU; si no, queden
// per sota del postprocés o no apareixen al frame presentat.
if (overlay_renderer_) { overlay_renderer_(); }
fps_.increment();
fps_.calculate(SDL_GetTicks());
+27
View File
@@ -3,6 +3,7 @@
#include <SDL3/SDL.h>
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <vector>
@@ -68,6 +69,16 @@ class Screen {
// Pinta un rectangle opac al canvas ARGB del joc. Pensat per a HUDs
// simples (caixes de notificacions, barres d'estat); no gasta paleta.
void fillRect(int x, int y, int w, int h, std::uint32_t argb);
// Alpha-blend sobre el canvas: `alpha=0` no fa res, `alpha=255` equival
// a fillRect. El color entra com a ARGB little-endian; `alpha` és el
// factor de mescla amb el que ja hi ha al canvas. Pensat per a overlays
// (menú, pauses) que volen veure's a través el joc atenuat.
void blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint8_t alpha);
// Hook invocat al principi de `present()`, abans de pujar els píxels al
// backend. Serveix per a pintar overlays que han de quedar per damunt
// de qualsevol escena (p.ex. el menú de servei). El Director el registra
// un sol cop; Screen no coneix el tipus concret del callback.
void setOverlayRenderer(std::function<void()> callback);
void present();
[[nodiscard]] auto getRenderer() const -> SDL_Renderer* { return renderer_; }
@@ -75,6 +86,16 @@ class Screen {
[[nodiscard]] auto getCanvasWidth() const -> int { return canvas_width_; }
[[nodiscard]] auto getCanvasHeight() const -> int { return canvas_height_; }
// Transforma una coordenada en espai finestra (tal com venen als
// esdeveniments SDL de ratolí) a espai canvas (0..canvas_w, 0..canvas_h).
// Reverteix el zoom de la finestra i el mode d'escalat actiu: al path
// fallback SDL_Renderer es delega a `SDL_RenderCoordinatesFromWindow`;
// al path GPU es recalcula a partir del viewport actual. Si el punt
// cau fora de la zona del canvas (letterbox/integer), els valors
// retornats queden fora del rang `[0, canvas_w/h)` — el caller ha de
// fer el bounds-check i ignorar el clic en eixe cas.
[[nodiscard]] auto windowToCanvas(float window_x, float window_y) const -> SDL_FPoint;
// Mètriques per a overlays de debug (RenderInfo).
[[nodiscard]] auto getLastFPS() const -> int { return fps_.last_value; }
[[nodiscard]] auto getGPUDriver() const -> const std::string& { return gpu_driver_; }
@@ -123,4 +144,10 @@ class Screen {
FPS fps_{};
std::string gpu_driver_;
// Callback cridat al principi de present() per pintar overlays per
// damunt de l'escena. Viu en Screen perquè la seqüència correcta
// (dibuix al canvas → overlay → upload al backend → present) queda
// centralitzada en un sol lloc, siga quina siga l'escena activa.
std::function<void()> overlay_renderer_;
};
+60
View File
@@ -0,0 +1,60 @@
#include "core/rendering/screenshot.hpp"
#include <chrono>
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <string>
#include <vector>
#include "external/stb_image_write.h"
namespace Screenshot {
namespace {
// Crea la carpeta `screenshots/` si no existeix i torna la ruta
// completa al fitxer. El paràmetre `filename` rep el nom nu (sense
// directori) per poder mostrar-lo a la notificació.
auto generateFilePath(std::string& filename) -> std::string {
std::filesystem::create_directories("screenshots");
const auto NOW = std::chrono::system_clock::now();
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW);
const std::tm* tm = std::localtime(&TIME);
char timestamp[20];
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm);
filename = std::string("scr_") + timestamp + ".png";
return "screenshots/" + filename;
}
// Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write
// espera RGBA en ordre byte (little-endian: R al byte baix, A al byte alt).
auto writePng(std::uint32_t* buffer, int width, int height, const std::string& filepath) -> bool {
const int TOTAL = width * height;
for (int i = 0; i < TOTAL; ++i) {
const std::uint32_t P = buffer[i];
const std::uint32_t A = (P >> 24) & 0xFF;
const std::uint32_t R = (P >> 16) & 0xFF;
const std::uint32_t G = (P >> 8) & 0xFF;
const std::uint32_t B = P & 0xFF;
buffer[i] = R | (G << 8) | (B << 16) | (A << 24);
}
return stbi_write_png(filepath.c_str(), width, height, 4, buffer, width * 4) != 0;
}
} // namespace
auto save(const std::uint32_t* buffer, int width, int height) -> std::string {
// Còpia local per no tocar el canvas original durant la conversió.
std::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height));
std::string filename;
const std::string FILEPATH = generateFilePath(filename);
return writePng(copy.data(), width, height, FILEPATH) ? filename : "";
}
} // namespace Screenshot
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <cstdint>
#include <string>
// Volca un buffer ARGB8888 a un fitxer PNG dins de la carpeta `screenshots/`
// (relativa al CWD). El nom es genera amb un timestamp i la funció retorna
// el nom del fitxer creat, o una cadena buida si l'escriptura ha fallat.
namespace Screenshot {
auto save(const std::uint32_t* buffer, int width, int height) -> std::string;
} // namespace Screenshot
@@ -72,3 +72,12 @@ void MovingSprite::setPosY(float value) {
y_ = value;
pos_.y = static_cast<int>(y_);
}
void MovingSprite::tryMove(float dx, float dy, const CollisionFn& collides) {
const float OLD_X = getPosX();
setPosX(OLD_X + dx);
if (collides(collisionBox())) { setPosX(OLD_X); }
const float OLD_Y = getPosY();
setPosY(OLD_Y + dy);
if (collides(collisionBox())) { setPosY(OLD_Y); }
}
@@ -2,6 +2,7 @@
#include <SDL3/SDL.h>
#include <functional>
#include <memory>
#include "core/rendering/sprite/sprite.hpp"
@@ -40,6 +41,15 @@ class MovingSprite : public Sprite {
auto getFlip() -> SDL_FlipMode { return flip_; }
void flip() { flip_ = (flip_ == SDL_FLIP_HORIZONTAL) ? SDL_FLIP_NONE : SDL_FLIP_HORIZONTAL; }
// Moviment eix-separat amb col·lisió: aplica `dx` (i si la nova
// `collisionBox()` col·lisiona, el reverteix); després aplica `dy`
// (idem). El caller calcula els deltes (típicament `vx_*dt`,
// `vy_*dt`) i passa la funció de detecció. Aquesta separació deixa
// que el sprite "llisqui" contra obstacles en compte de quedar-se
// bloquejat en sec quan només un eix col·lisiona.
using CollisionFn = std::function<bool(const SDL_FRect&)>;
void tryMove(float dx, float dy, const CollisionFn& collides);
protected:
void move(float delta_time);
+9
View File
@@ -19,6 +19,15 @@ class Sprite {
// Hook d'actualització per frame. Base buida (no hi ha Sprite pur a
// actualitzar); les subclasses mòbils/animades fan override.
virtual void update(float /*delta_time*/) {}
// Caixa que `MovingSprite::tryMove` envia al callback de col·lisió per
// decidir si el moviment és vàlid. Per defecte coincideix amb `pos_`
// (el sprite sencer); les subclasses que usen falsa perspectiva (p.ex.
// `Player`) la sobreescriuen amb una sub-caixa més petita (només els
// peus) per a que el cap puga sobresortir sense bloquejar el
// moviment.
[[nodiscard]] virtual auto collisionBox() const -> SDL_FRect { return pos_; }
virtual void render();
// Renderitza substituint l'índex source_color per target_color (ús típic:
// la classe Text, que recolora glifs monocroms per paràmetre).
+6 -2
View File
@@ -90,9 +90,13 @@ void Surface::render(int x, int y, const SDL_FRect* src_rect, SDL_FlipMode flip)
void Surface::renderWithColorReplace(int x, int y, Uint8 source_color, Uint8 target_color, const SDL_FRect* src_rect, SDL_FlipMode flip) const {
const auto CANVAS = Rendering::activeCanvas();
const auto CLIP = Rendering::activeClip();
Uint32* dst = CANVAS.pixels;
const int DST_W = CANVAS.width;
const int DST_H = CANVAS.height;
const int CLIP_X0 = CLIP.x;
const int CLIP_Y0 = CLIP.y;
const int CLIP_X1 = CLIP.x + CLIP.w;
const int CLIP_Y1 = CLIP.y + CLIP.h;
const int SX = (src_rect != nullptr) ? static_cast<int>(src_rect->x) : 0;
const int SY = (src_rect != nullptr) ? static_cast<int>(src_rect->y) : 0;
@@ -109,7 +113,7 @@ void Surface::renderWithColorReplace(int x, int y, Uint8 source_color, Uint8 tar
const int SRC_Y = (flip == SDL_FLIP_VERTICAL) ? (SY + sh - 1 - iy) : (SY + iy);
const int DST_X = x + ix;
const int DST_Y = y + iy;
if (DST_X < 0 || DST_X >= DST_W || DST_Y < 0 || DST_Y >= DST_H) { continue; }
if (DST_X < CLIP_X0 || DST_X >= CLIP_X1 || DST_Y < CLIP_Y0 || DST_Y >= CLIP_Y1) { continue; }
const Uint8 COLOR = src[SRC_X + (SRC_Y * surface_data_->width)];
if (COLOR == transparent_color_) { continue; }
+11
View File
@@ -150,6 +150,7 @@ void Text::writeDX(Uint8 flags, int x, int y, const std::string& text, int kerni
const bool SHADOWED = ((flags & SHADOW_FLAG) == SHADOW_FLAG);
const bool COLORED = ((flags & COLOR_FLAG) == COLOR_FLAG);
const bool STROKED = ((flags & STROKE_FLAG) == STROKE_FLAG);
const bool STROKED_4 = ((flags & STROKE4_FLAG) == STROKE4_FLAG);
if (CENTERED) { x -= (Text::length(text, kerning) / 2); }
@@ -157,6 +158,16 @@ void Text::writeDX(Uint8 flags, int x, int y, const std::string& text, int kerni
writeColored(x + shadow_distance, y + shadow_distance, text, shadow_color, kerning, length);
}
if (STROKED_4) {
const int MAX_DIST = static_cast<int>(shadow_distance);
for (int dist = 1; dist <= MAX_DIST; ++dist) {
writeColored(x + dist, y, text, shadow_color, kerning, length);
writeColored(x - dist, y, text, shadow_color, kerning, length);
writeColored(x, y + dist, text, shadow_color, kerning, length);
writeColored(x, y - dist, text, shadow_color, kerning, length);
}
}
if (STROKED) {
const int MAX_DIST = static_cast<int>(shadow_distance);
for (int dist = 1; dist <= MAX_DIST; ++dist) {
+17
View File
@@ -31,10 +31,17 @@ class Text {
};
// Flags per a writeDX (composables amb |).
// `STROKE_FLAG` dibuixa un contorn complet de 8 veïns (N/S/E/O + 4
// diagonals) al voltant del glif; `STROKE4_FLAG` només els 4 cardinals
// (N/S/E/O), amb una aparença més prima per a glifs petits on la versió
// diagonal engreixa massa. Els dos flags són mútuament excloents: si
// s'activen a la vegada, ambdós bucles s'executen i l'stroke diagonal
// queda per damunt del cardinal (no té gaire sentit combinar-los).
static constexpr Uint8 COLOR_FLAG = 1;
static constexpr Uint8 SHADOW_FLAG = 2;
static constexpr Uint8 CENTER_FLAG = 4;
static constexpr Uint8 STROKE_FLAG = 8;
static constexpr Uint8 STROKE4_FLAG = 16;
// Índex del píxel "tinta" al PNG de la font. Els glifs són monocroms
// sobre transparent i tots els píxels opacs usen aquest índex; la Surface
@@ -47,6 +54,14 @@ class Text {
Text(const std::shared_ptr<Surface>& surface, const std::shared_ptr<File>& text_file);
~Text() = default;
// Kerning per defecte de la font (separació entre glifs en píxels, sumat
// a l'amplada natural). Els callers poden passar un kerning explícit a
// write/length, o llegir aquest valor i reenviar-lo; així cada font pot
// tenir un kerning pròpi (p. ex. negatiu per a displays amb sidebearings
// amples) sense obligar els callers a saber-ho.
void setDefaultKerning(int kerning) { default_kerning_ = kerning; }
[[nodiscard]] auto getDefaultKerning() const -> int { return default_kerning_; }
Text(const Text&) = delete;
Text(Text&&) = delete;
auto operator=(const Text&) -> Text& = delete;
@@ -64,6 +79,7 @@ class Text {
[[nodiscard]] auto length(const std::string& text, int kerning = 1) const -> int;
[[nodiscard]] auto lengthMonoDigits(const std::string& text, int digit_cell_w, int kerning = 1) const -> int;
[[nodiscard]] auto getCharacterSize() const -> int { return box_width_; }
[[nodiscard]] auto getLineHeight() const -> int { return box_height_; }
static auto loadTextFile(const std::string& file_path) -> std::shared_ptr<File>;
static auto nextCodepoint(const std::string& s, std::size_t& pos) -> std::uint32_t;
@@ -76,5 +92,6 @@ class Text {
std::unique_ptr<Sprite> sprite_;
int box_width_{0};
int box_height_{0};
int default_kerning_{1};
std::unordered_map<std::uint32_t, Offset> offset_;
};
+80 -12
View File
@@ -2,23 +2,91 @@
#include <fstream>
#include <iostream>
#include <string>
#include <string_view>
#include "core/resources/resource_pack.hpp"
namespace Resource::Helper {
namespace {
std::string g_data_root;
Pack g_pack;
bool g_pack_loaded = false;
bool g_fallback_enabled = true;
auto readFromDisk(const std::string& filepath) -> std::vector<std::uint8_t> {
const auto FULL = resolve(filepath);
std::ifstream file(FULL, std::ios::binary | std::ios::ate);
if (!file) {
std::cerr << "Resource::Helper: cannot open " << FULL << '\n';
return {};
}
std::streamsize size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<std::uint8_t> data(static_cast<std::size_t>(size));
if (!file.read(reinterpret_cast<char*>(data.data()), size)) {
std::cerr << "Resource::Helper: failed to read " << FULL << '\n';
return {};
}
return data;
}
} // namespace
auto initializeResourceSystem(const std::string& pack_file, bool enable_fallback) -> bool {
g_fallback_enabled = enable_fallback;
g_pack_loaded = g_pack.loadPack(pack_file);
if (g_pack_loaded) {
std::cout << "Resource::Helper: pack loaded (" << g_pack.getResourceCount()
<< " entries) from " << pack_file << '\n';
} else if (enable_fallback) {
std::cout << "Resource::Helper: no pack at " << pack_file
<< " — using filesystem fallback\n";
} else {
std::cerr << "Resource::Helper: FATAL — no pack at " << pack_file
<< " and fallback disabled\n";
return false;
}
return true;
}
void shutdownResourceSystem() {
g_pack.clear();
g_pack_loaded = false;
}
auto hasPack() -> bool {
return g_pack_loaded;
}
void setDataRoot(const std::string& absolute_path) {
g_data_root = absolute_path;
if (!g_data_root.empty() && g_data_root.back() != '/') {
g_data_root += '/';
}
}
auto resolve(const std::string& filepath) -> std::string {
if (filepath.empty() || filepath.front() == '/') { return filepath; }
if (g_data_root.empty()) { return filepath; }
return g_data_root + filepath;
}
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t> {
std::ifstream file(filepath, std::ios::binary | std::ios::ate);
if (!file) {
std::cerr << "Resource::Helper: cannot open " << filepath << '\n';
return {};
if (g_pack_loaded) {
// El pack està indexat relatiu a `data/` (la carpeta que
// s'empaqueta), però els callers passen "data/foo.yaml" amb
// prefix. Fem el strip ací perquè no hagin de tenir-ho en compte.
constexpr std::string_view PREFIX = "data/";
std::string key = filepath;
if (key.starts_with(PREFIX)) { key.erase(0, PREFIX.size()); }
if (g_pack.hasResource(key)) { return g_pack.getResource(key); }
}
std::streamsize size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<std::uint8_t> data(static_cast<std::size_t>(size));
if (!file.read(reinterpret_cast<char*>(data.data()), size)) {
std::cerr << "Resource::Helper: failed to read " << filepath << '\n';
return {};
}
return data;
if (g_fallback_enabled) { return readFromDisk(filepath); }
return {};
}
} // namespace Resource::Helper
+27 -1
View File
@@ -6,7 +6,33 @@
namespace Resource::Helper {
// Carga un fitxer del filesystem (a aquesta fase encara no hi ha pack).
// Inicialitza el sistema de recursos. Carrega el pack des de `pack_file`
// i decideix si `loadFile` pot caure a disc quan un recurs no és al pack.
// En Release el pack és obligatori: passar `enable_fallback=false` fa
// que `initializeResourceSystem` retorne false si no s'ha pogut carregar.
auto initializeResourceSystem(const std::string& pack_file, bool enable_fallback) -> bool;
// Allibera el pack carregat a memòria.
void shutdownResourceSystem();
// True si el sistema es va inicialitzar amb un pack vàlid.
[[nodiscard]] auto hasPack() -> bool;
// Estableix el prefix des d'on es resolen les rutes quan el fallback
// llig de disc. En Debug apunta al source tree (`${CMAKE_SOURCE_DIR}/`)
// perquè editar YAML/GIF dins `data/` es vegi reflectit sense re-packejar.
// En Release el fallback està desactivat i aquest camí no es fa servir.
void setDataRoot(const std::string& absolute_path);
// Resol una ruta relativa contra el data root. Si ja és absoluta
// (comença per '/') la retorna sense modificar. S'usa per a rutes que
// NO passen pel pack (p.ex. fitxers de config dins system_folder_).
[[nodiscard]] auto resolve(const std::string& filepath) -> std::string;
// Carrega un recurs pel seu camí relatiu (p.ex. "fonts/hud.gif",
// "input/keys.yaml"). Prova primer el pack; si no hi és i el fallback
// està actiu, cau a disc (data root + ruta). Retorna vector buit si
// no el troba.
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t>;
} // namespace Resource::Helper
+209
View File
@@ -0,0 +1,209 @@
#include "core/resources/resource_pack.hpp"
#include <algorithm>
#include <array>
#include <filesystem>
#include <fstream>
#include <iostream>
namespace Resource {
const std::string Pack::DEFAULT_ENCRYPT_KEY = "AEE_2026_PACK_KEY";
namespace {
constexpr const char* MAGIC = "AE26";
constexpr std::uint32_t VERSION = 1;
} // namespace
auto Pack::calculateChecksum(const std::vector<std::uint8_t>& data) -> std::uint32_t {
// djb2: seed 0x12345678. Compatible amb aee/projecte_2026 per si mai
// reutilitzem eines de diagnòstic entre projectes.
std::uint32_t checksum = 0x12345678;
for (unsigned char b : data) {
// cppcheck-suppress useStlAlgorithm
checksum = ((checksum << 5) + checksum) + b;
}
return checksum;
}
void Pack::xorInPlace(std::vector<std::uint8_t>& data, const std::string& key) {
if (key.empty()) { return; }
for (std::size_t i = 0; i < data.size(); ++i) {
data[i] ^= static_cast<std::uint8_t>(key[i % key.length()]);
}
}
auto Pack::loadPack(const std::string& pack_file) -> bool {
std::ifstream file(pack_file, std::ios::binary);
if (!file) { return false; }
std::array<char, 4> header{};
file.read(header.data(), 4);
if (std::string(header.data(), 4) != MAGIC) {
std::cerr << "Resource::Pack: invalid pack file format (bad magic): " << pack_file << '\n';
return false;
}
std::uint32_t version = 0;
file.read(reinterpret_cast<char*>(&version), sizeof(version));
if (version != VERSION) {
std::cerr << "Resource::Pack: unsupported pack version: " << version << '\n';
return false;
}
std::uint32_t resource_count = 0;
file.read(reinterpret_cast<char*>(&resource_count), sizeof(resource_count));
resources_.clear();
resources_.reserve(resource_count);
for (std::uint32_t i = 0; i < resource_count; ++i) {
std::uint32_t filename_length = 0;
file.read(reinterpret_cast<char*>(&filename_length), sizeof(filename_length));
std::string filename(filename_length, '\0');
file.read(filename.data(), filename_length);
PackEntry entry;
entry.filename = filename;
file.read(reinterpret_cast<char*>(&entry.offset), sizeof(entry.offset));
file.read(reinterpret_cast<char*>(&entry.size), sizeof(entry.size));
file.read(reinterpret_cast<char*>(&entry.checksum), sizeof(entry.checksum));
resources_[filename] = entry;
}
std::uint64_t data_size = 0;
file.read(reinterpret_cast<char*>(&data_size), sizeof(data_size));
data_.resize(data_size);
file.read(reinterpret_cast<char*>(data_.data()), static_cast<std::streamsize>(data_size));
xorInPlace(data_, DEFAULT_ENCRYPT_KEY);
return true;
}
auto Pack::savePack(const std::string& pack_file) const -> bool {
std::ofstream file(pack_file, std::ios::binary);
if (!file) {
std::cerr << "Resource::Pack: could not create pack file: " << pack_file << '\n';
return false;
}
file.write(MAGIC, 4);
std::uint32_t version = VERSION;
file.write(reinterpret_cast<const char*>(&version), sizeof(version));
auto resource_count = static_cast<std::uint32_t>(resources_.size());
file.write(reinterpret_cast<const char*>(&resource_count), sizeof(resource_count));
for (const auto& [filename, entry] : resources_) {
auto filename_length = static_cast<std::uint32_t>(filename.length());
file.write(reinterpret_cast<const char*>(&filename_length), sizeof(filename_length));
file.write(filename.c_str(), filename_length);
file.write(reinterpret_cast<const char*>(&entry.offset), sizeof(entry.offset));
file.write(reinterpret_cast<const char*>(&entry.size), sizeof(entry.size));
file.write(reinterpret_cast<const char*>(&entry.checksum), sizeof(entry.checksum));
}
std::vector<std::uint8_t> encrypted = data_;
xorInPlace(encrypted, DEFAULT_ENCRYPT_KEY);
std::uint64_t data_size = encrypted.size();
file.write(reinterpret_cast<const char*>(&data_size), sizeof(data_size));
file.write(reinterpret_cast<const char*>(encrypted.data()), static_cast<std::streamsize>(data_size));
return true;
}
auto Pack::addFile(const std::string& filename, const std::string& filepath) -> bool {
std::ifstream file(filepath, std::ios::binary | std::ios::ate);
if (!file) {
std::cerr << "Resource::Pack: could not open file: " << filepath << '\n';
return false;
}
std::streamsize file_size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<std::uint8_t> file_data(file_size);
if (!file.read(reinterpret_cast<char*>(file_data.data()), file_size)) {
std::cerr << "Resource::Pack: could not read file: " << filepath << '\n';
return false;
}
PackEntry entry;
entry.filename = filename;
entry.offset = data_.size();
entry.size = file_data.size();
entry.checksum = calculateChecksum(file_data);
data_.insert(data_.end(), file_data.begin(), file_data.end());
resources_[filename] = entry;
return true;
}
auto Pack::addDirectory(const std::string& directory) -> bool {
if (!std::filesystem::exists(directory)) {
std::cerr << "Resource::Pack: directory does not exist: " << directory << '\n';
return false;
}
// cppcheck-suppress useStlAlgorithm
for (const auto& entry : std::filesystem::recursive_directory_iterator(directory)) {
if (!entry.is_regular_file()) { continue; }
std::string filepath = entry.path().string();
std::string filename = std::filesystem::relative(entry.path(), directory).string();
std::ranges::replace(filename, '\\', '/');
if (!addFile(filename, filepath)) { return false; }
}
return true;
}
auto Pack::getResource(const std::string& filename) const -> std::vector<std::uint8_t> {
auto it = resources_.find(filename);
if (it == resources_.end()) { return {}; }
const PackEntry& entry = it->second;
if (entry.offset + entry.size > data_.size()) {
std::cerr << "Resource::Pack: invalid resource data: " << filename << '\n';
return {};
}
std::vector<std::uint8_t> result(
data_.begin() + static_cast<std::ptrdiff_t>(entry.offset),
data_.begin() + static_cast<std::ptrdiff_t>(entry.offset + entry.size));
std::uint32_t checksum = calculateChecksum(result);
if (checksum != entry.checksum) {
std::cerr << "Resource::Pack: checksum mismatch for: " << filename << '\n';
}
return result;
}
auto Pack::hasResource(const std::string& filename) const -> bool {
return resources_.contains(filename);
}
void Pack::clear() {
resources_.clear();
data_.clear();
}
auto Pack::getResourceCount() const -> std::size_t {
return resources_.size();
}
auto Pack::getResourceList() const -> std::vector<std::string> {
std::vector<std::string> result;
result.reserve(resources_.size());
for (const auto& [filename, entry] : resources_) {
result.push_back(filename);
}
return result;
}
} // namespace Resource
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
namespace Resource {
// Entrada d'un recurs dins el pack.
struct PackEntry {
std::string filename;
std::uint64_t offset{0};
std::uint64_t size{0};
std::uint32_t checksum{0};
};
// Pack binari de recursos carregat a memòria. Format:
// Header: "AE26" (4 bytes) + version uint32 + resource_count uint32
// Index: per cada recurs -> filename_len uint32 + filename
// + offset uint64 + size uint64 + checksum uint32
// Payload: data_size uint64 + bytes xifrats amb XOR (DEFAULT_ENCRYPT_KEY)
// La classe té dos rols: (a) loader en temps d'execució — carregar un
// .pack del disc i obtindre els bytes d'un recurs per nom; (b) builder
// usat per l'eina `pack_resources` per a empaquetar `data/`.
class Pack {
public:
Pack() = default;
~Pack() = default;
Pack(const Pack&) = delete;
auto operator=(const Pack&) -> Pack& = delete;
Pack(Pack&&) = default;
auto operator=(Pack&&) -> Pack& = default;
// I/O del fitxer. `loadPack` retorna false silenciosament si el
// fitxer no existeix (el caller decideix si cal fallback).
auto loadPack(const std::string& pack_file) -> bool;
auto savePack(const std::string& pack_file) const -> bool;
// Builders usats per l'eina `pack_resources`.
auto addFile(const std::string& filename, const std::string& filepath) -> bool;
auto addDirectory(const std::string& directory) -> bool;
[[nodiscard]] auto getResource(const std::string& filename) const -> std::vector<std::uint8_t>;
[[nodiscard]] auto hasResource(const std::string& filename) const -> bool;
void clear();
[[nodiscard]] auto getResourceCount() const -> std::size_t;
[[nodiscard]] auto getResourceList() const -> std::vector<std::string>;
static const std::string DEFAULT_ENCRYPT_KEY;
private:
std::unordered_map<std::string, PackEntry> resources_;
std::vector<std::uint8_t> data_;
static auto calculateChecksum(const std::vector<std::uint8_t>& data) -> std::uint32_t;
static void xorInPlace(std::vector<std::uint8_t>& data, const std::string& key);
};
} // namespace Resource
+191 -11
View File
@@ -8,10 +8,13 @@
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include "core/audio/audio.hpp"
#include "core/input/gamepad_manager.hpp"
#include "core/input/key_config.hpp"
#include "core/input/mouse.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp"
@@ -19,11 +22,17 @@
#include "core/rendering/surface.hpp"
#include "core/rendering/text.hpp"
#include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "core/scene/scene.hpp"
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "game/debug_config.hpp"
#include "game/options.hpp"
#include "game/scenes/game_scene.hpp"
#include "game/ui/ui_palette.hpp"
#include "game/scenes/intro_scene.hpp"
#include "game/scenes/logo_scene.hpp"
#include "game/scenes/title_scene.hpp"
#include "game/ui/service_menu.hpp"
#include "utils/defaults.hpp"
Director::Director() {
@@ -31,6 +40,28 @@ Director::Director() {
std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
}
// Sistema de recursos: el pack viu al costat del binari (build/), el
// regenera CMake a cada build. En Debug habilitem el fallback a disc
// apuntant al source tree perquè editar YAML/GIF dins `data/` es vegi
// sense re-packejar; en Release el pack és obligatori.
std::string pack_file = "resources.pack";
if (const char* base = SDL_GetBasePath()) {
pack_file = std::string(base) + "resources.pack";
#ifndef _DEBUG
Resource::Helper::setDataRoot(base);
#endif
}
#ifdef _DEBUG
Resource::Helper::setDataRoot(AEE_SOURCE_DIR);
constexpr bool ENABLE_FALLBACK = true;
#else
constexpr bool ENABLE_FALLBACK = false;
#endif
if (!Resource::Helper::initializeResourceSystem(pack_file, ENABLE_FALLBACK)) {
std::fprintf(stderr, "Director: sistema de recursos no disponible; avortem\n");
std::exit(1);
}
// Prepara la carpeta de sistema (~/.config/jailgames/aee_2026/) i carrega
// les opcions; si no existeix config.yaml el crea amb els defaults.
createSystemFolder();
@@ -61,16 +92,25 @@ Director::Director() {
// Traduccions per a tots els textos visibles. Si el fitxer no existeix
// o hi ha un error de parse, Locale::text() caurà a retornar la clau.
Locale::get().load(std::string(Defaults::I18n::LOCALE_DIR) + Options::locale + ".yaml");
Locale::get().load(std::string(Defaults::I18n::LOCALE_DIR) + Options::locale() + ".yaml");
// Hotkeys globals. GlobalInputs::update() les consumirà a cada frame.
KeyConfig::get().load("data/input/keys.yaml");
// Recursos compartits d'UI (paleta + font). Es carreguen abans que els
// overlays perquè tots els reaprofiten sense duplicar la lògica.
Resource::Cache::get().loadPalette("ui", "data/palettes/sega-cube.pal");
Resource::Cache::get().loadSurface("8bithud.gif", "data/font/8bithud.gif");
Resource::Cache::get().getSurface("8bithud.gif")->setPalette(Resource::Cache::get().getPalette("ui"));
// Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a
// data/ui.yaml cau al valor compilat a Defaults::UI.
UiConfig::get().load("data/ui.yaml");
// Recursos compartits d'UI (paleta + fonts). Es carreguen abans que els
// overlays perquè tots els reaprofiten sense duplicar la lògica. Els
// noms de font viuen al fitxer data/ui.yaml via UiConfig.
Resource::Cache::get().loadPalette("ui", "data/palettes/uzebox.pal");
const std::string HUD_FONT_GIF_KEY = UiConfig::get().hudFont().name + ".gif";
Resource::Cache::get().loadSurface(HUD_FONT_GIF_KEY, UiConfig::get().hudFont().gif);
Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
const std::string PLACEHOLDER_FONT_GIF_KEY = UiConfig::get().placeholderFont().name + ".gif";
Resource::Cache::get().loadSurface(PLACEHOLDER_FONT_GIF_KEY, UiConfig::get().placeholderFont().gif);
Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
// Música de la piràmide 1 (primer i únic track de moment). Carreguem ací
// perquè forma part del preload d'assets, com la font i la paleta.
@@ -81,17 +121,95 @@ Director::Director() {
// JA_Music_t que continga, així que no queden streams orfes.
Audio::init(Options::buildAudioConfig());
// Gamepad: descobreix mandos presents silenciosament. A partir d'ara
// els events SDL_EVENT_GAMEPAD_ADDED/REMOVED arribaran via
// `processGamepadEvents()` i dispararan notificacions.
GamepadManager::get().init();
// Font compartida pels overlays: construïm una sola instància de Text
// i la injectem als consumidors (Notifier, RenderInfo) per a que cap
// d'ells hagi de conèixer els noms dels recursos concrets.
auto hud_font_surface = Resource::Cache::get().getSurface("8bithud.gif");
auto hud_text = std::make_shared<Text>(hud_font_surface, "data/font/8bithud.fnt");
auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY);
auto hud_text = std::make_shared<Text>(hud_font_surface, UiConfig::get().hudFont().fnt);
hud_text->setDefaultKerning(UiConfig::get().hudFont().kerning);
const auto& ui_palette = Resource::Cache::get().getPalette("ui");
RenderInfo::get().init(Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW), hud_text);
RenderInfo::get().init(Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()), hud_text);
Notifier::get().init(hud_text, ui_palette);
current_scene_ = std::make_unique<GameScene>();
// Font dels placeholders (LOGO / TITLE / INTRO). És una font distinta a
// la de HUD perquè els placeholders desapareixeran amb les seues escenes
// finals; mentrestant tenen estètica pròpia.
auto placeholder_font_surface = Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY);
auto placeholder_text = std::make_shared<Text>(placeholder_font_surface, UiConfig::get().placeholderFont().fnt);
placeholder_text->setDefaultKerning(UiConfig::get().placeholderFont().kerning);
// Font del menú de servei (F9). El títol va amb la variant `display`
// i els items amb `condensed-display` (més compacte perquè caben més
// files). Els dos noms i kernings viuen a data/ui.yaml via UiConfig.
auto load_menu_font = [&](const UiConfig::Font& font) {
const std::string KEY = font.name + ".gif";
Resource::Cache::get().loadSurface(KEY, font.gif);
Resource::Cache::get().getSurface(KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
auto text = std::make_shared<Text>(Resource::Cache::get().getSurface(KEY), font.fnt);
text->setDefaultKerning(font.kerning);
return text;
};
auto menu_title_text = load_menu_font(UiConfig::get().menuTitleFont());
auto menu_items_text = load_menu_font(UiConfig::get().menuItemsFont());
ServiceMenu::get().init(menu_title_text, menu_items_text, ui_palette);
// Overlay hook: Screen::present() cridarà aquesta lambda al principi,
// ABANS de pujar els píxels al backend GPU. Així el menú queda per
// damunt de qualsevol escena sense que Screen hagi de conèixer game/ui.
Screen::get().setOverlayRenderer([]() { ServiceMenu::get().render(); });
// Flux d'escenes LOGO → TITLE → INTRO → GAME. Cada placeholder rep una
// factory que construeix la següent, construïda bottom-up des de GAME.
//
// Release: sempre s'arranca a LOGO (el flux complet, el que veu el
// jugador final). Debug: s'obri `debug.yaml`; si no existeix, el creem
// amb `start_scene: GAME` perquè el dia a dia de gameplay no es mengi
// els 15 s de placeholders. L'usuari pot editar-lo a mà per escollir
// un punt d'entrada intermedi.
#ifdef _DEBUG
const auto DEBUG_YAML = system_folder_ + "debug.yaml";
DebugConfig::createDefaultIfMissing(DEBUG_YAML);
const auto START = DebugConfig::resolveStartScene(DEBUG_YAML);
const auto STARTING_PLAYERS = DebugConfig::resolveStartingPlayers(DEBUG_YAML);
#else
const auto START = DebugConfig::StartScene::LOGO;
const auto STARTING_PLAYERS = DebugConfig::StartingPlayers::BOTH;
#endif
std::cout << "Director: start_scene=" << DebugConfig::toString(START)
<< " starting_players=" << DebugConfig::toString(STARTING_PLAYERS) << '\n';
auto make_game = [STARTING_PLAYERS]() -> std::unique_ptr<Scene> {
auto cfg = GameScene::defaultConfig();
cfg.starting_players = STARTING_PLAYERS;
return std::make_unique<GameScene>(std::move(cfg));
};
auto make_intro = [placeholder_text, make_game]() -> std::unique_ptr<Scene> {
return std::make_unique<IntroScene>(placeholder_text, make_game);
};
auto make_title = [placeholder_text, make_intro]() -> std::unique_ptr<Scene> {
return std::make_unique<TitleScene>(placeholder_text, make_intro);
};
switch (START) {
case DebugConfig::StartScene::LOGO:
current_scene_ = std::make_unique<LogoScene>(placeholder_text, make_title);
break;
case DebugConfig::StartScene::TITLE:
current_scene_ = make_title();
break;
case DebugConfig::StartScene::INTRO:
current_scene_ = make_intro();
break;
case DebugConfig::StartScene::GAME:
current_scene_ = make_game();
break;
}
last_ticks_ms_ = SDL_GetTicks();
}
@@ -105,6 +223,8 @@ Director::~Director() {
// desfà les llibreries abans que corren els destructors estàtics.
current_scene_.reset();
Resource::Cache::get().clearAudio();
Resource::Helper::shutdownResourceSystem();
GamepadManager::get().shutdown();
Audio::destroy();
Screen::get().shutdown();
SDL_Quit();
@@ -140,14 +260,72 @@ void Director::processEvents() {
if (QUIT_REQUESTED) { current_scene_.reset(); }
}
void Director::processGamepadEvents() {
// Dues responsabilitats: (1) passar ADDED/REMOVED al GamepadManager
// perquè obri/tanqui mandos i emeti les notificacions hot-plug; (2)
// tractar el botó BACK com a toggle del menú quan està tancat (el
// paral·lel de F9 per al teclat). Quan el menú està obert, el BACK
// passa pel pipeline del `ServiceMenu::handleEvent()` via
// `processMenuInput()` i allà es converteix en `close()`.
for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) {
bool consumed = false;
if (it->type == SDL_EVENT_GAMEPAD_ADDED || it->type == SDL_EVENT_GAMEPAD_REMOVED) {
GamepadManager::get().handleEvent(*it);
consumed = true;
} else if (it->type == SDL_EVENT_GAMEPAD_BUTTON_DOWN &&
it->gbutton.button == SDL_GAMEPAD_BUTTON_BACK &&
!ServiceMenu::get().isOpen()) {
ServiceMenu::get().toggle();
consumed = true;
}
if (consumed) {
it = EventBuffer::events.erase(it);
} else {
++it;
}
}
}
void Director::processMenuInput() {
// Intercepta input abans que l'escena vegi els events. Si el menú està
// obert, consumeix TOTS els KEY_DOWN perquè ni GlobalInputs (F1–F8,
// F11, F12) ni el jugador els veuen. F9 obre el menú quan està tancat.
// Erase invalida iteradors, per això actualitzem `it` a cada branca.
for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) {
if (ServiceMenu::get().isOpen() && ServiceMenu::get().handleEvent(*it)) {
it = EventBuffer::events.erase(it);
} else if (it->type == SDL_EVENT_KEY_DOWN &&
it->key.scancode == KeyConfig::get().scancode("menu_toggle")) {
ServiceMenu::get().toggle();
it = EventBuffer::events.erase(it);
} else {
++it;
}
}
}
void Director::switchSceneIfRequested() {
if (!current_scene_) { return; }
if (auto next = current_scene_->nextScene()) {
// TODO: migrar a logger unificat (INC-03 a docs/audit-core-rendering.md).
std::cout << "Director: scene transition\n";
current_scene_ = std::move(next);
}
}
auto Director::iterate() -> SDL_AppResult {
// Ocultació automàtica del cursor: observem els events abans que els
// altres steps els consumeixin; Mouse no els modifica, només reacciona
// a SDL_EVENT_MOUSE_MOTION per reaparèixer el cursor.
for (const auto& ev : EventBuffer::events) { Mouse::handleEvent(ev); }
// Ordre important: primer els events de gamepad (hot-plug + BACK que
// obre el menú quan està tancat), després el menú (pot consumir tecles
// i INJECTAR un SDL_EVENT_QUIT des de l'acció "Eixir del joc"), i
// finalment processEvents perquè vegi el QUIT al mateix frame — altrament
// el QUIT injectat es perdria quan es netegi EventBuffer al final.
processGamepadEvents();
processMenuInput();
processEvents();
if (!current_scene_) {
@@ -161,6 +339,8 @@ auto Director::iterate() -> SDL_AppResult {
last_ticks_ms_ = NOW_MS;
Audio::update();
Mouse::updateCursorVisibility();
ServiceMenu::get().update(delta_ms);
current_scene_->update(delta_ms);
current_scene_->render();
+2
View File
@@ -22,5 +22,7 @@ class Director {
void createSystemFolder();
void processEvents();
static void processGamepadEvents();
static void processMenuInput();
void switchSceneIfRequested();
};
+186
View File
@@ -0,0 +1,186 @@
#include "core/ui/ui_config.hpp"
#include <iostream>
#include <string>
#include <string_view>
#include <unordered_map>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
#include "utils/easing.hpp"
namespace {
// Lector de camp amb fallback: si la clau existeix al node, escriu a
// `dst` el valor convertit; si no, deixa `dst` com estava (que al
// constructor ja hem inicialitzat amb Defaults::UI).
template <typename T>
void readField(const fkyaml::node& node, const char* key, T& dst) {
if (node.contains(key)) { dst = node[key].get_value<T>(); }
}
// Variant per a Uint8 — fkyaml no serialitza a Uint8 directament, cal
// passar per int i encaixar al rang 0..255 (valors fora de rang es
// trunquen i s'avisen).
void readColorIndex(const fkyaml::node& node, const char* key, Uint8& dst) {
if (!node.contains(key)) { return; }
const int VALUE = node[key].get_value<int>();
if (VALUE < 0 || VALUE > 255) {
std::cerr << "UiConfig: índex '" << key << "'=" << VALUE << " fora de rang 0..255 — ignorat\n";
return;
}
dst = static_cast<Uint8>(VALUE);
}
// Registre de corbes disponibles. Afegir-ne una nova és només una
// entrada ací: la llegeix `UiConfig::curveByName` i queda exposada al
// YAML pel seu nom. No hi ha fallback mut: si el nom no hi és, l'api
// retorna linear i avisa per stderr perquè l'error no passi en silenci.
auto curveRegistry() -> const std::unordered_map<std::string_view, UiConfig::CurveFn>& {
static const std::unordered_map<std::string_view, UiConfig::CurveFn> REGISTRY = {
{"linear", &Easing::linear},
{"outQuad", &Easing::outQuad},
{"inQuad", &Easing::inQuad},
{"inOutQuad", &Easing::inOutQuad},
{"outCubic", &Easing::outCubic},
{"inCubic", &Easing::inCubic},
};
return REGISTRY;
}
void readCurve(const fkyaml::node& node, const char* key, UiConfig::CurveFn& dst) {
if (!node.contains(key)) { return; }
const auto NAME = node[key].get_value<std::string>();
dst = UiConfig::curveByName(NAME);
}
void readFont(const fkyaml::node& node, const char* key, UiConfig::Font& dst) {
if (!node.contains(key)) { return; }
const auto& f = node[key];
readField(f, "name", dst.name);
readField(f, "gif", dst.gif);
readField(f, "fnt", dst.fnt);
readField(f, "kerning", dst.kerning);
}
} // namespace
UiConfig::UiConfig()
: render_info_text_(Defaults::UI::RENDER_INFO_TEXT),
render_info_shadow_(Defaults::UI::RENDER_INFO_SHADOW),
canvas_bg_(Defaults::UI::CANVAS_BG),
notification_text_(Defaults::UI::NOTIFICATION_TEXT),
notification_bg_(Defaults::UI::NOTIFICATION_BG),
exit_text_(Defaults::UI::EXIT_TEXT),
exit_bg_(Defaults::UI::EXIT_BG),
menu_bg_(Defaults::UI::MENU_BG),
menu_border_(Defaults::UI::MENU_BORDER),
menu_title_(Defaults::UI::MENU_TITLE),
menu_label_(Defaults::UI::MENU_LABEL),
menu_value_(Defaults::UI::MENU_VALUE),
menu_cursor_(Defaults::UI::MENU_CURSOR),
menu_disabled_(Defaults::UI::MENU_DISABLED),
hud_bg_(Defaults::UI::HUD_BG),
hud_label_(Defaults::UI::HUD_LABEL),
hud_value_(Defaults::UI::HUD_VALUE),
hud_flash_(Defaults::UI::HUD_FLASH),
notification_ms_(Defaults::UI::NOTIFICATION_TIME_MS),
exit_ms_(Defaults::UI::EXIT_TIME_MS),
hud_entry_ms_(Defaults::UI::HUD_ENTRY_MS),
hud_flash_ms_(Defaults::UI::HUD_FLASH_MS),
hud_shake_ms_(Defaults::UI::HUD_SHAKE_MS),
pause_enter_ms_(Defaults::UI::PAUSE_ENTER_MS),
pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS),
pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)),
pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)),
hud_font_{.name = Defaults::UI::Font::HUD_NAME, .gif = Defaults::UI::Font::HUD_GIF, .fnt = Defaults::UI::Font::HUD_FNT, .kerning = Defaults::UI::Font::HUD_KERNING},
placeholder_font_{.name = Defaults::UI::Font::PLACEHOLDER_NAME, .gif = Defaults::UI::Font::PLACEHOLDER_GIF, .fnt = Defaults::UI::Font::PLACEHOLDER_FNT, .kerning = Defaults::UI::Font::PLACEHOLDER_KERNING},
menu_title_font_{.name = Defaults::UI::Font::MENU_TITLE_NAME, .gif = Defaults::UI::Font::MENU_TITLE_GIF, .fnt = Defaults::UI::Font::MENU_TITLE_FNT, .kerning = Defaults::UI::Font::MENU_TITLE_KERNING},
menu_items_font_{.name = Defaults::UI::Font::MENU_ITEMS_NAME, .gif = Defaults::UI::Font::MENU_ITEMS_GIF, .fnt = Defaults::UI::Font::MENU_ITEMS_FNT, .kerning = Defaults::UI::Font::MENU_ITEMS_KERNING} {
}
auto UiConfig::get() -> UiConfig& {
static UiConfig instance_;
return instance_;
}
auto UiConfig::curveByName(const std::string& name) -> CurveFn {
const auto& reg = curveRegistry();
const auto IT = reg.find(name);
if (IT == reg.end()) {
std::cerr << "UiConfig: corba '" << name << "' desconeguda — s'usa linear\n";
return &Easing::linear;
}
return IT->second;
}
void UiConfig::load(const std::string& file_path) {
auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) {
std::cerr << "UiConfig: no s'ha pogut obrir " << file_path << " — s'usaran els defaults compilats\n";
return;
}
try {
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
if (yaml.contains("colors")) {
const auto& c = yaml["colors"];
readColorIndex(c, "render_info_text", render_info_text_);
readColorIndex(c, "render_info_shadow", render_info_shadow_);
readColorIndex(c, "canvas_bg", canvas_bg_);
readColorIndex(c, "notification_text", notification_text_);
readColorIndex(c, "notification_bg", notification_bg_);
readColorIndex(c, "exit_text", exit_text_);
readColorIndex(c, "exit_bg", exit_bg_);
readColorIndex(c, "menu_bg", menu_bg_);
readColorIndex(c, "menu_border", menu_border_);
readColorIndex(c, "menu_title", menu_title_);
readColorIndex(c, "menu_label", menu_label_);
readColorIndex(c, "menu_value", menu_value_);
readColorIndex(c, "menu_cursor", menu_cursor_);
readColorIndex(c, "menu_disabled", menu_disabled_);
readColorIndex(c, "hud_bg", hud_bg_);
readColorIndex(c, "hud_label", hud_label_);
readColorIndex(c, "hud_value", hud_value_);
readColorIndex(c, "hud_flash", hud_flash_);
}
if (yaml.contains("timings")) {
const auto& t = yaml["timings"];
readField(t, "notification_ms", notification_ms_);
readField(t, "exit_ms", exit_ms_);
}
if (yaml.contains("fonts")) {
const auto& f = yaml["fonts"];
readFont(f, "hud", hud_font_);
readFont(f, "placeholder", placeholder_font_);
readFont(f, "menu_title", menu_title_font_);
readFont(f, "menu_items", menu_items_font_);
}
if (yaml.contains("hud")) {
const auto& h = yaml["hud"];
readField(h, "bg_enabled", hud_bg_enabled_);
readField(h, "entry_ms", hud_entry_ms_);
readField(h, "flash_ms", hud_flash_ms_);
readField(h, "shake_ms", hud_shake_ms_);
}
if (yaml.contains("pause")) {
const auto& p = yaml["pause"];
readField(p, "enter_ms", pause_enter_ms_);
readField(p, "exit_ms", pause_exit_ms_);
readCurve(p, "enter_curve", pause_enter_curve_);
readCurve(p, "exit_curve", pause_exit_curve_);
}
std::cout << "UiConfig: carregat des de " << file_path << '\n';
} catch (const std::exception& e) {
std::cerr << "UiConfig: error parsejant " << file_path << ": " << e.what() << '\n';
}
}
+125
View File
@@ -0,0 +1,125 @@
#pragma once
#include <SDL3/SDL.h>
#include <string>
// Configuració del tema UI (índexs de paleta, timings, fonts i flags)
// carregada des de data/ui.yaml. Cada camp s'inicialitza amb el valor
// de Defaults::UI com a fallback: si el fitxer falta o li manca una
// clau, el joc s'executa amb el tema compilat.
//
// Patró idèntic a Locale i KeyConfig: Meyers singleton + load(path).
// No hi ha hot-reload — els canvis al YAML requereixen reiniciar.
class UiConfig {
public:
struct Font {
std::string name;
std::string gif;
std::string fnt;
int kerning;
};
static auto get() -> UiConfig&;
UiConfig(const UiConfig&) = delete;
UiConfig(UiConfig&&) = delete;
auto operator=(const UiConfig&) -> UiConfig& = delete;
auto operator=(UiConfig&&) -> UiConfig& = delete;
void load(const std::string& file_path);
// Colors — índexs sobre la paleta "ui" (Resource::Cache).
[[nodiscard]] auto renderInfoText() const -> Uint8 { return render_info_text_; }
[[nodiscard]] auto renderInfoShadow() const -> Uint8 { return render_info_shadow_; }
[[nodiscard]] auto canvasBg() const -> Uint8 { return canvas_bg_; }
[[nodiscard]] auto notificationText() const -> Uint8 { return notification_text_; }
[[nodiscard]] auto notificationBg() const -> Uint8 { return notification_bg_; }
[[nodiscard]] auto exitText() const -> Uint8 { return exit_text_; }
[[nodiscard]] auto exitBg() const -> Uint8 { return exit_bg_; }
[[nodiscard]] auto menuBg() const -> Uint8 { return menu_bg_; }
[[nodiscard]] auto menuBorder() const -> Uint8 { return menu_border_; }
[[nodiscard]] auto menuTitle() const -> Uint8 { return menu_title_; }
[[nodiscard]] auto menuLabel() const -> Uint8 { return menu_label_; }
[[nodiscard]] auto menuValue() const -> Uint8 { return menu_value_; }
[[nodiscard]] auto menuCursor() const -> Uint8 { return menu_cursor_; }
[[nodiscard]] auto menuDisabled() const -> Uint8 { return menu_disabled_; }
[[nodiscard]] auto hudBg() const -> Uint8 { return hud_bg_; }
[[nodiscard]] auto hudLabel() const -> Uint8 { return hud_label_; }
[[nodiscard]] auto hudValue() const -> Uint8 { return hud_value_; }
[[nodiscard]] auto hudFlash() const -> Uint8 { return hud_flash_; }
// Signatura d'una funció d'easing: t ∈ [0, 1] → valor ∈ [0, 1].
// Permet triar corbes des de data/ui.yaml per nom (cadena) i guardar
// el punter a la corresponent funció de `utils/easing.hpp`.
using CurveFn = auto (*)(float) -> float;
// Timings (ms).
[[nodiscard]] auto notificationMs() const -> int { return notification_ms_; }
[[nodiscard]] auto exitMs() const -> int { return exit_ms_; }
[[nodiscard]] auto hudEntryMs() const -> int { return hud_entry_ms_; }
[[nodiscard]] auto hudFlashMs() const -> int { return hud_flash_ms_; }
[[nodiscard]] auto hudShakeMs() const -> int { return hud_shake_ms_; }
[[nodiscard]] auto pauseEnterMs() const -> int { return pause_enter_ms_; }
[[nodiscard]] auto pauseExitMs() const -> int { return pause_exit_ms_; }
// Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa
// Easing::linear com a fallback.
[[nodiscard]] auto pauseEnterCurve() const -> CurveFn { return pause_enter_curve_; }
[[nodiscard]] auto pauseExitCurve() const -> CurveFn { return pause_exit_curve_; }
// Resol un nom de corba a la seua funció. Pública per permetre a
// futurs camps d'UI triar corbes pel mateix mecanisme (ex.: HUD
// entry, notifications slide...). Si el nom no està registrat,
// retorna Easing::linear i deixa un warning al log.
[[nodiscard]] static auto curveByName(const std::string& name) -> CurveFn;
// Fonts de UI.
[[nodiscard]] auto hudFont() const -> const Font& { return hud_font_; }
[[nodiscard]] auto placeholderFont() const -> const Font& { return placeholder_font_; }
[[nodiscard]] auto menuTitleFont() const -> const Font& { return menu_title_font_; }
[[nodiscard]] auto menuItemsFont() const -> const Font& { return menu_items_font_; }
// Flags visuals.
[[nodiscard]] auto hudBgEnabled() const -> bool { return hud_bg_enabled_; }
private:
UiConfig();
~UiConfig() = default;
Uint8 render_info_text_;
Uint8 render_info_shadow_;
Uint8 canvas_bg_;
Uint8 notification_text_;
Uint8 notification_bg_;
Uint8 exit_text_;
Uint8 exit_bg_;
Uint8 menu_bg_;
Uint8 menu_border_;
Uint8 menu_title_;
Uint8 menu_label_;
Uint8 menu_value_;
Uint8 menu_cursor_;
Uint8 menu_disabled_;
Uint8 hud_bg_;
Uint8 hud_label_;
Uint8 hud_value_;
Uint8 hud_flash_;
int notification_ms_;
int exit_ms_;
int hud_entry_ms_;
int hud_flash_ms_;
int hud_shake_ms_;
int pause_enter_ms_;
int pause_exit_ms_;
CurveFn pause_enter_curve_;
CurveFn pause_exit_curve_;
Font hud_font_;
Font placeholder_font_;
Font menu_title_font_;
Font menu_items_font_;
bool hud_bg_enabled_{false};
};
+1724
View File
File diff suppressed because it is too large. Load diff
+13
View File
@@ -0,0 +1,13 @@
// Unitat de compilació aïllada per a la implementació de stb_image_write.
// Viu dins de source/external/ perquè el `.clang-tidy` d'aquesta carpeta
// desactiva tots els checks (com fa per stb_vorbis.c) i el pre-commit hook
// ja filtra aquesta ruta de clang-format / clang-tidy. Així els fals
// positius de clang-analyzer-* dins de codi de tercers no afecten el
// nostre codi, que continua tenint tots els checks actius.
//
// La resta del codi inclou només el header (sense la macro d'implementació),
// que queda només amb declaracions — clang-analyzer no pot trobar cap bug
// dins d'una declaració, així que l'inclusió és innòcua.
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "external/stb_image_write.h"
+13
View File
@@ -0,0 +1,13 @@
// Unitat de compilació aïllada per a la implementació de stb_vorbis.
// Viu dins de source/external/ perquè el `.clang-tidy` d'aquesta carpeta
// desactiva tots els checks (com fa per stb_image_write_impl.cpp) i el
// pre-commit hook ja filtra aquesta ruta de clang-format / clang-tidy.
// Així els fals positius de clang-analyzer-* dins de codi C de tercers
// no afecten el nostre codi, que continua tenint tots els checks actius.
//
// jail_audio.cpp defineix STB_VORBIS_HEADER_ONLY abans d'incloure el .c,
// així només en veu les declaracions; les definicions les aporta aquest
// TU i l'enllaçador les resol.
// NOLINTNEXTLINE(bugprone-suspicious-include)
#include "external/stb_vorbis.c"
+148
View File
@@ -0,0 +1,148 @@
#include "game/debug_config.hpp"
#include <exception>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include "external/fkyaml_node.hpp"
namespace DebugConfig {
namespace {
auto readFile(const std::string& path) -> std::string {
std::ifstream file(path);
if (!file) { return {}; }
std::stringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}
auto fromName(const std::string& name) -> StartScene {
if (name == "LOGO") { return StartScene::LOGO; }
if (name == "TITLE") { return StartScene::TITLE; }
if (name == "INTRO") { return StartScene::INTRO; }
if (name == "GAME") { return StartScene::GAME; }
return StartScene::GAME;
}
auto isKnown(const std::string& name) -> bool {
return name == "LOGO" || name == "TITLE" || name == "INTRO" || name == "GAME";
}
auto playersFromName(const std::string& name) -> StartingPlayers {
if (name == "P1") { return StartingPlayers::P1; }
if (name == "P2") { return StartingPlayers::P2; }
if (name == "BOTH") { return StartingPlayers::BOTH; }
return StartingPlayers::BOTH;
}
auto isKnownPlayers(const std::string& name) -> bool {
return name == "P1" || name == "P2" || name == "BOTH";
}
} // namespace
auto parseStartScene(const std::string& yaml_content) -> StartScene {
if (yaml_content.empty()) { return StartScene::GAME; }
try {
auto yaml = fkyaml::node::deserialize(yaml_content);
if (!yaml.contains("start_scene")) { return StartScene::GAME; }
const auto RAW = yaml["start_scene"].get_value<std::string>();
if (!isKnown(RAW)) {
std::cerr << "debug.yaml: start_scene \"" << RAW << "\" invàlid, arrancant a GAME\n";
return StartScene::GAME;
}
return fromName(RAW);
} catch (const std::exception& e) {
std::cerr << "debug.yaml: error parsejant: " << e.what() << " — arrancant a GAME\n";
return StartScene::GAME;
}
}
auto resolveStartScene(const std::string& file_path) -> StartScene {
std::error_code error;
if (!std::filesystem::exists(file_path, error)) {
// Silenciós: la no-existència és el cas per defecte en una màquina
// neta, no un error. L'usuari crea el fitxer quan el necessita.
return StartScene::GAME;
}
const auto CONTENT = readFile(file_path);
if (CONTENT.empty()) {
std::cerr << "debug.yaml: fitxer buit a " << file_path << ", arrancant a GAME\n";
return StartScene::GAME;
}
return parseStartScene(CONTENT);
}
auto parseStartingPlayers(const std::string& yaml_content) -> StartingPlayers {
if (yaml_content.empty()) { return StartingPlayers::BOTH; }
try {
auto yaml = fkyaml::node::deserialize(yaml_content);
if (!yaml.contains("starting_players")) { return StartingPlayers::BOTH; }
const auto RAW = yaml["starting_players"].get_value<std::string>();
if (!isKnownPlayers(RAW)) {
std::cerr << "debug.yaml: starting_players \"" << RAW << "\" invàlid, arrancant amb BOTH\n";
return StartingPlayers::BOTH;
}
return playersFromName(RAW);
} catch (const std::exception& e) {
std::cerr << "debug.yaml: error parsejant starting_players: " << e.what() << " — arrancant amb BOTH\n";
return StartingPlayers::BOTH;
}
}
auto resolveStartingPlayers(const std::string& file_path) -> StartingPlayers {
std::error_code error;
if (!std::filesystem::exists(file_path, error)) { return StartingPlayers::BOTH; }
const auto CONTENT = readFile(file_path);
if (CONTENT.empty()) { return StartingPlayers::BOTH; }
return parseStartingPlayers(CONTENT);
}
auto toString(StartScene scene) -> const char* {
switch (scene) {
case StartScene::LOGO:
return "LOGO";
case StartScene::TITLE:
return "TITLE";
case StartScene::INTRO:
return "INTRO";
case StartScene::GAME:
return "GAME";
}
return "GAME";
}
auto toString(StartingPlayers players) -> const char* {
switch (players) {
case StartingPlayers::P1:
return "P1";
case StartingPlayers::P2:
return "P2";
case StartingPlayers::BOTH:
return "BOTH";
}
return "BOTH";
}
void createDefaultIfMissing(const std::string& file_path) {
std::error_code error;
if (std::filesystem::exists(file_path, error)) { return; }
std::ofstream file(file_path);
if (!file) {
std::cerr << "debug.yaml: no s'ha pogut crear " << file_path << '\n';
return;
}
file << "# debug.yaml — ajust personal de dev (no versionat).\n";
file << "# start_scene: LOGO | TITLE | INTRO | GAME\n";
file << "# starting_players: P1 | P2 | BOTH\n";
file << "start_scene: GAME\n";
file << "starting_players: BOTH\n";
std::cout << "debug.yaml: creat a " << file_path << " amb start_scene=GAME starting_players=BOTH\n";
}
} // namespace DebugConfig
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <cstdint>
#include <string>
// Configuració personal de desenvolupament, carregada de `debug.yaml` dins la
// carpeta de sistema (`$XDG_CONFIG_HOME/jailgames/aee_2026/debug.yaml`). No es
// versiona: cada dev el crea a la seua màquina. Si no existeix, s'aplica el
// valor per defecte (GAME) sense warning de nivell error.
namespace DebugConfig {
enum class StartScene : std::uint8_t { LOGO,
TITLE,
INTRO,
GAME };
// Quins jugadors arrenquen a la GameScene. BOTH és el cas de joc real;
// P1 i P2 són només punts d'entrada de dev per provar cada personatge
// aïllat. Llegit de `debug.yaml` (clau `starting_players`).
enum class StartingPlayers : std::uint8_t { P1,
P2,
BOTH };
// Parseja un contingut YAML en cru. Si no conté `start_scene`, el valor és
// desconegut o el YAML és invàlid, retorna GAME. Pura: no fa I/O.
[[nodiscard]] auto parseStartScene(const std::string& yaml_content) -> StartScene;
// Llig el fitxer i delega a parseStartScene. Si el fitxer no existeix
// retorna GAME sense warning (és el cas per defecte en una màquina neta);
// si existeix però el valor és invàlid, imprimeix un warning i retorna
// GAME.
[[nodiscard]] auto resolveStartScene(const std::string& file_path) -> StartScene;
// Paral·lel a parseStartScene / resolveStartScene per al camp
// `starting_players`. El valor per defecte (tant si el fitxer no
// existeix com si li manca la clau) és BOTH, que és el cas de joc real.
[[nodiscard]] auto parseStartingPlayers(const std::string& yaml_content) -> StartingPlayers;
[[nodiscard]] auto resolveStartingPlayers(const std::string& file_path) -> StartingPlayers;
// Nom canònic de l'enum (útil per a logs): "LOGO" / "TITLE" / "INTRO" / "GAME".
[[nodiscard]] auto toString(StartScene scene) -> const char*;
[[nodiscard]] auto toString(StartingPlayers players) -> const char*;
// Si el fitxer no existeix, el crea amb `start_scene: GAME` (el valor
// per defecte pensat per al dia a dia de dev). Si ja existeix, no el
// toca — preservem qualsevol edició manual de l'usuari. Usat només al
// path de debug del Director (el binari release ignora `debug.yaml`).
void createDefaultIfMissing(const std::string& file_path);
} // namespace DebugConfig
+245
View File
@@ -0,0 +1,245 @@
#include "game/editor/editor.hpp"
#ifdef _DEBUG
#include <SDL3/SDL.h>
#include <algorithm>
#include "core/input/key_config.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/notification_presets.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "core/system/event_buffer.hpp"
#include "game/map/tilemap.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
namespace {
// Geometria del TilePicker en espai canvas. `cols × rows_visible` tiles
// encapsulats dins d'un rectangle amb `PICKER_MARGIN` per vora. Com que
// el tileset real (p.ex. 32×16 tiles de 16 px = 512×256) no cabria a
// 1× dins del canvas 336×240, linealitzem els índexs i els repartim
// en una graella que SÍ cap, amb scroll vertical per veure la resta.
struct PickerLayout {
int x{0};
int y{0};
int w{0};
int h{0};
int cols{0};
int rows_visible{0};
int tile_size{0};
int total_tiles{0};
int max_scroll{0};
};
auto layoutPicker(const Tilemap& tm) -> PickerLayout {
PickerLayout lay{};
lay.tile_size = tm.getTileSize();
const int M = Defaults::Editor::PICKER_MARGIN;
const int INNER_W = GameCanvas::WIDTH - (2 * M);
const int INNER_H = GameCanvas::HEIGHT - (2 * M);
if (lay.tile_size <= 0) { return lay; }
lay.cols = INNER_W / lay.tile_size;
lay.rows_visible = INNER_H / lay.tile_size;
lay.x = M;
lay.y = M;
lay.w = lay.cols * lay.tile_size;
lay.h = lay.rows_visible * lay.tile_size;
const auto TILESET = Resource::Cache::get().getSurface(tm.getTilesetPath());
if (TILESET != nullptr) {
const int TCOLS = TILESET->getWidth() / lay.tile_size;
const int TROWS = TILESET->getHeight() / lay.tile_size;
lay.total_tiles = TCOLS * TROWS;
}
const int TOTAL_ROWS = (lay.cols > 0) ? ((lay.total_tiles + lay.cols - 1) / lay.cols) : 0;
lay.max_scroll = std::max(0, TOTAL_ROWS - lay.rows_visible);
return lay;
}
} // namespace
void Editor::enter() {
if (active_) { return; }
active_ = true;
Notification::notify(Locale::get().text("input.editor_on"));
}
// cppcheck-suppress constParameterReference
// Tilemap& mutable per simetria amb `toggle()` i per futures
// invalidacions de caches d'edició, encara que `save()` sigui const.
void Editor::exit(Tilemap& tilemap) {
if (!active_) { return; }
active_ = false;
picker_open_ = false;
// Resolem la ruta via Resource::Helper perquè en Debug el root de
// dades és AEE_SOURCE_DIR (ruta absoluta al repo), no el CWD. Sense
// això, el writer obriria `data/maps/pyramid.yaml` relatiu al CWD
// i fallaria sempre excepte si el binari es llança des del repo root.
const auto ABS_PATH = Resource::Helper::resolve(Defaults::Editor::SAVE_PATH);
const bool SAVED = tilemap.save(ABS_PATH);
Notification::notify(Locale::get().text(SAVED ? "input.editor_off" : "input.editor_save_error"));
}
void Editor::toggle(Tilemap& tilemap) {
if (active_) {
exit(tilemap);
} else {
enter();
}
}
void Editor::update(int /*delta_ms*/, Tilemap& tilemap) {
if (!active_) { return; }
// Posició del ratolí en coordenades del canvas (independent del
// zoom de finestra i del mode d'escalat).
float mouse_x = 0.0F;
float mouse_y = 0.0F;
SDL_GetMouseState(&mouse_x, &mouse_y);
const auto CP = Screen::get().windowToCanvas(mouse_x, mouse_y);
const int CX = static_cast<int>(CP.x);
const int CY = static_cast<int>(CP.y);
// Consumim tecles pròpies del picker abans que cap altre handler
// (GlobalInputs) les vegi. ESC només es consumeix si el picker està
// obert — fora d'ell segueix arribant a GlobalInputs per la seva
// acció de confirmar-eixida.
const SDL_Scancode PICKER_SC = KeyConfig::get().scancode("editor_picker");
for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) {
bool consumed = false;
if (it->type == SDL_EVENT_KEY_DOWN) {
if (PICKER_SC != SDL_SCANCODE_UNKNOWN && it->key.scancode == PICKER_SC) {
picker_open_ = !picker_open_;
if (picker_open_) { picker_scroll_ = 0; }
consumed = true;
} else if (picker_open_ && it->key.scancode == SDL_SCANCODE_ESCAPE) {
picker_open_ = false;
consumed = true;
}
}
if (consumed) {
it = EventBuffer::events.erase(it);
} else {
++it;
}
}
if (picker_open_) {
updatePicker(tilemap, CX, CY);
} else {
updateMap(tilemap, CX, CY);
}
}
void Editor::updateMap(Tilemap& tilemap, int canvas_x, int canvas_y) {
// Cursor → cel·la del tilemap. Fora del mapa deixem -1/-1 perquè
// `render()` sàpiga no dibuixar preview i `setTileAt` ignori el clic.
const int TS = tilemap.getTileSize();
const int MX = canvas_x - tilemap.getOriginX();
const int MY = canvas_y - tilemap.getOriginY();
cursor_col_ = -1;
cursor_row_ = -1;
if (TS > 0 && MX >= 0 && MY >= 0) {
const int C = MX / TS;
const int R = MY / TS;
if (C < tilemap.getCols() && R < tilemap.getRows()) {
cursor_col_ = C;
cursor_row_ = R;
}
}
for (const auto& ev : EventBuffer::events) {
if (ev.type != SDL_EVENT_MOUSE_BUTTON_DOWN) { continue; }
if (ev.button.button == SDL_BUTTON_LEFT) {
if (brush_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
tilemap.setTileAt(cursor_col_, cursor_row_, brush_);
}
} else if (ev.button.button == SDL_BUTTON_RIGHT) {
brush_ = -1;
}
}
}
void Editor::updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
const auto LAY = layoutPicker(tilemap);
picker_hover_ = -1;
if (canvas_x >= LAY.x && canvas_x < LAY.x + LAY.w && canvas_y >= LAY.y && canvas_y < LAY.y + LAY.h && LAY.tile_size > 0) {
const int VC = (canvas_x - LAY.x) / LAY.tile_size;
const int VR = (canvas_y - LAY.y) / LAY.tile_size;
const int IDX = ((picker_scroll_ + VR) * LAY.cols) + VC;
if (IDX < LAY.total_tiles) { picker_hover_ = IDX; }
}
for (const auto& ev : EventBuffer::events) {
if (ev.type == SDL_EVENT_MOUSE_WHEEL) {
picker_scroll_ -= static_cast<int>(ev.wheel.y);
picker_scroll_ = std::clamp(picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (picker_hover_ >= 0) {
brush_ = picker_hover_;
picker_open_ = false;
}
}
}
}
void Editor::render(const Tilemap& tilemap) const {
if (!active_) { return; }
auto& screen = Screen::get();
constexpr int W = GameCanvas::WIDTH;
constexpr int H = GameCanvas::HEIGHT;
if (picker_open_) {
renderPicker(tilemap);
} else if (brush_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
// Preview del brush sota el cursor (només quan el picker és tancat).
const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize());
const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize());
tilemap.renderTilePreview(PX, PY, brush_);
}
constexpr auto BORDER = Defaults::Editor::BORDER_ARGB;
screen.fillRect(0, 0, W, 1, BORDER);
screen.fillRect(0, H - 1, W, 1, BORDER);
screen.fillRect(0, 0, 1, H, BORDER);
screen.fillRect(W - 1, 0, 1, H, BORDER);
}
void Editor::renderPicker(const Tilemap& tilemap) const {
const auto LAY = layoutPicker(tilemap);
auto& screen = Screen::get();
screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, Defaults::Editor::PICKER_BG_ARGB);
for (int vr = 0; vr < LAY.rows_visible; ++vr) {
const int LOGICAL_ROW = picker_scroll_ + vr;
for (int vc = 0; vc < LAY.cols; ++vc) {
const int IDX = (LOGICAL_ROW * LAY.cols) + vc;
if (IDX >= LAY.total_tiles) { break; }
const int PX = LAY.x + (vc * LAY.tile_size);
const int PY = LAY.y + (vr * LAY.tile_size);
tilemap.renderTilePreview(PX, PY, IDX);
}
}
if (picker_hover_ >= 0 && LAY.cols > 0) {
const int VR = (picker_hover_ / LAY.cols) - picker_scroll_;
const int VC = picker_hover_ % LAY.cols;
if (VR >= 0 && VR < LAY.rows_visible) {
const int PX = LAY.x + (VC * LAY.tile_size);
const int PY = LAY.y + (VR * LAY.tile_size);
const int TS = LAY.tile_size;
constexpr auto C = Defaults::Editor::PICKER_HOVER_ARGB;
screen.fillRect(PX, PY, TS, 1, C);
screen.fillRect(PX, PY + TS - 1, TS, 1, C);
screen.fillRect(PX, PY, 1, TS, C);
screen.fillRect(PX + TS - 1, PY, 1, TS, C);
}
}
}
#endif // _DEBUG
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#ifdef _DEBUG
class Tilemap;
// Mode editor modal del GameScene. Només disponible en Debug — en Release
// aquesta classe no existeix (el .cpp es compila a un object buit i cap
// codi del joc final en fa referència).
//
// L'editor NO gestiona la tecla de toggle pròpia (`editor_toggle`): això
// ho fa GameScene per poder decidir la coexistència amb la pausa F11
// (mútuament excloents). L'editor s'encarrega de:
// - Estat propi (actiu/no actiu, brush, picker obert).
// - Input del ratolí i tecles internes (`T`, `ESC`).
// - Render del border blau + brush preview + picker.
// - Persistència del tilemap al YAML del repo en sortir.
//
// GameScene, quan `active()` és true, salta el tick de jugadors/entitats
// i no els pinta; el tilemap sí es pinta perquè és el que s'està editant.
class Editor {
public:
// Entra al mode editor: notifica i marca `active=true`. La congelació
// d'entitats / jugador la gestiona GameScene consultant `active()`.
void enter();
// Surt del mode editor: persisteix el tilemap al YAML (writer surgical)
// i notifica. Si la persistència falla, emet la notificació d'error.
void exit(Tilemap& tilemap);
// Helper: enter() si no actiu, exit() si actiu. Útil des de GameScene
// quan detecta la tecla de toggle.
void toggle(Tilemap& tilemap);
[[nodiscard]] auto active() const -> bool { return active_; }
// Processa input propi (brush, picker). No-op si no actiu.
void update(int delta_ms, Tilemap& tilemap);
// Pinta el border blau i, més endavant, brush preview + picker.
// No-op si no actiu.
void render(const Tilemap& tilemap) const;
private:
// Les branques d'update / render segons el mode (picker obert o
// pintura al mapa) queden separades per claredat; no formen part
// de l'API pública perquè només té sentit cridar-les des d'`update`.
void updateMap(Tilemap& tilemap, int canvas_x, int canvas_y);
void updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y);
void renderPicker(const Tilemap& tilemap) const;
bool active_{false};
// Estat de l'edició (brush + cursor + picker). Es poblen al pas
// següent; els declarem ara perquè `update()` pugui mutar-los i les
// signatures queden estables entre hitos.
int brush_{-1}; // Índex de tile al tileset; -1 = sense brush.
int cursor_col_{-1}; // Cel·la del mapa sota el cursor (-1 = fora).
int cursor_row_{-1};
bool picker_open_{false};
int picker_scroll_{0}; // Fila superior visible del picker (linealitzat).
int picker_hover_{-1}; // Índex del tile sota el cursor dins del picker.
};
#endif // _DEBUG
+9 -2
View File
@@ -5,6 +5,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
namespace {
@@ -63,9 +64,15 @@ void Door::update(float delta_time) {
}
}
void Door::onPlayerContact(Player* /*player*/) {
void Door::onPlayerContact(Player* player) {
if (state_ != State::OPEN || level_complete_emitted_) { return; }
if (bus_ != nullptr) { bus_->publish("level_complete"); }
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "level_complete",
.payload = std::monostate{},
.player_id = (player != nullptr) ? player->getId() : -1,
});
}
level_complete_emitted_ = true;
}
+9 -2
View File
@@ -4,6 +4,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
FloorTrap::FloorTrap(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("floor_trap");
@@ -21,9 +22,15 @@ FloorTrap::FloorTrap(const fkyaml::node& yaml) {
}
}
void FloorTrap::onPlayerContact(Player* /*player*/) {
void FloorTrap::onPlayerContact(Player* player) {
if (!isActive()) { return; }
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_hit",
.payload = getDamage(),
.player_id = (player != nullptr) ? player->getId() : -1,
});
}
}
REGISTER_ENTITY("floor_trap", FloorTrap)
+210 -48
View File
@@ -1,12 +1,15 @@
#include "game/entities/concrete/mummy.hpp"
#include <algorithm>
#include <array>
#include <optional>
#include <random>
#include <string>
#include <utility>
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
#include "game/map/map.hpp"
namespace {
@@ -20,9 +23,59 @@ namespace {
return k_rng_;
}
// Llegeix un bloc d'animació del YAML amb fallbacks tolerants (mateix
// patró que Door/Sarcophagus). Si la clau no apareix, retorna una
// animació d'1 frame loopant, neutra — la momia es podrà moure però
// visualment es quedarà estàtica.
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 8.0F;
bool loop = true;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
// Busca si el segment [prev, prev+delta] travessa una lane del patró
// `base + k·pitch` (k ∈ [0..max_idx]). Retorna el delta que cal aplicar
// per arribar exactament a la primera línia travessada. Iterem en el
// sentit de la marxa per encertar la MÉS propera quan el frame creua
// més d'una lane (cas marginal a velocitats típiques, però pot passar
// amb dt grans). Comparació estricta a un costat: no dispara quan la
// caixa ja es troba alineada a la lane (hi està després del snap).
auto findLaneCross(float prev, float delta, float base, float pitch, int max_idx) -> std::optional<float> {
const float NEXT = prev + delta;
if (delta > 0.0F) {
for (int k = 0; k <= max_idx; ++k) {
const float TARGET = base + (static_cast<float>(k) * pitch);
if (prev < TARGET && NEXT >= TARGET) { return TARGET - prev; }
}
} else if (delta < 0.0F) {
for (int k = max_idx; k >= 0; --k) {
const float TARGET = base + (static_cast<float>(k) * pitch);
if (prev > TARGET && NEXT <= TARGET) { return TARGET - prev; }
}
}
return std::nullopt;
}
} // namespace
Mummy::Mummy(const fkyaml::node& yaml) {
Mummy::Mummy(const fkyaml::node& yaml)
: anim_walk_down_(buildAnimation(yaml, "walk_down")),
anim_walk_up_(buildAnimation(yaml, "walk_up")),
anim_walk_lateral_(buildAnimation(yaml, "walk")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy");
collides_ = true;
interactable_ = false;
@@ -43,16 +96,36 @@ Mummy::Mummy(const fkyaml::node& yaml) {
initial = yaml["ai"]["initial_direction"].get_value<std::string>();
}
if (initial == "right") {
setDirection(1.0F, 0.0F);
changeDirection(1.0F, 0.0F);
} else if (initial == "left") {
setDirection(-1.0F, 0.0F);
changeDirection(-1.0F, 0.0F);
} else if (initial == "up") {
setDirection(0.0F, -1.0F);
changeDirection(0.0F, -1.0F);
} else if (initial == "down") {
setDirection(0.0F, 1.0F);
changeDirection(0.0F, 1.0F);
}
}
void Mummy::changeDirection(float dx, float dy) {
setDirection(dx, dy);
// Prevalença lateral: si hi ha component horitzontal s'usa l'anim
// lateral + flip segons el signe. Només direccions purament verticals
// disparen walk_up/walk_down. Quan la direcció queda (0,0) (cul-de-sac
// amb NONE) no toquem l'animació: la momia es quedarà al frame actual
// fins que step() li torni a assignar direcció.
if (dx != 0.0F) {
anim_ = anim_walk_lateral_;
flip_ = (dx < 0.0F) ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE;
} else if (dy < 0.0F) {
anim_ = anim_walk_up_;
flip_ = SDL_FLIP_NONE;
} else if (dy > 0.0F) {
anim_ = anim_walk_down_;
flip_ = SDL_FLIP_NONE;
}
if (anim_.has_value()) { anim_->reset(); }
}
auto Mummy::chooseDirection(const std::vector<Direction>& allowed) -> Direction {
if (allowed.empty()) { return Direction::NONE; }
std::uniform_int_distribution<std::size_t> dist(0, allowed.size() - 1);
@@ -66,6 +139,31 @@ auto Mummy::candidateBox(float dx, float dy) const -> SDL_FRect {
return box;
}
void Mummy::snapToPlayfieldEdge(float dx, float dy) {
// La caja 16×16 té exactament l'amplada del corredor; si ens aturàvem
// a una posició subpíxel dins de l'última cel·la, qualsevol gir
// perpendicular col·lidiria amb el sarc adjacent al limit. Per això
// reduïm el dx/dy fins a la vora exacta del playfield en l'eix actiu
// (l'altre sempre és 0 perquè la momia només es mou en 1D per frame).
const auto BOX = getCollisionRect();
float snap_dx = 0.0F;
float snap_dy = 0.0F;
if (dx > 0.0F) {
snap_dx = std::max(0.0F, static_cast<float>(Map::playfield_x + Map::playfield_w) - BOX.x - BOX.w);
} else if (dx < 0.0F) {
snap_dx = std::min(0.0F, static_cast<float>(Map::playfield_x) - BOX.x);
}
if (dy > 0.0F) {
snap_dy = std::max(0.0F, static_cast<float>(Map::playfield_y + Map::playfield_h) - BOX.y - BOX.h);
} else if (dy < 0.0F) {
snap_dy = std::min(0.0F, static_cast<float>(Map::playfield_y) - BOX.y);
}
if (!Map::collides(candidateBox(snap_dx, snap_dy))) {
pixel_rect_.x += snap_dx;
pixel_rect_.y += snap_dy;
}
}
void Mummy::step(float delta_time) {
// Desplaçament nominal en aquest frame segons la direcció actual.
const float DX = dir_x_ * speed_ * delta_time;
@@ -75,7 +173,23 @@ void Mummy::step(float delta_time) {
// no-op fins que algú la injecte amb `setDirection`.
if (DX == 0.0F && DY == 0.0F) { return; }
// Prova d'avançar. Si la caixa candidata és vàlida (dins del playfield
// Branca creuament: si aquest frame travessa la línia central d'un
// corredor perpendicular, amarra la caixa al centre del creuament i
// hi decideix. La mòmia consumeix només la porció de moviment fins al
// centre; el remanent queda per al pròxim frame, ja amb la (potser
// nova) direcció aplicada. Així la caixa 16×16 queda sempre alineada
// a la graella de corredors i els girs no xoquen per drift sub-píxel.
if (const auto SNAP = detectLaneCrossing(DX, DY); SNAP.has_value()) {
if (DX != 0.0F) {
pixel_rect_.x += *SNAP;
} else {
pixel_rect_.y += *SNAP;
}
decideAtIntersection(speed_ * delta_time);
return;
}
// Avenç normal: si la caixa candidata és vàlida (dins del playfield
// i sense trepitjar cap sarc), s'aplica el moviment i s'acaba el frame.
const auto CANDIDATE = candidateBox(DX, DY);
if (Map::insidePlayfield(CANDIDATE) && !Map::collides(CANDIDATE)) {
@@ -84,64 +198,112 @@ void Mummy::step(float delta_time) {
return;
}
// Bloqueig: cal girar. Recollim les direccions perpendiculars a l'actual
// que tinguin un pròxim tram lliure. Evitem U-turn llevat que no hi hagi
// cap alternativa (comportament típic d'Oh Mummy!: no es recula sense
// motiu). Si la direcció perpendicular també està bloquejada, no s'hi
// inclou; si totes les perpendiculars estan bloquejades, afegim l'opcio
// oposada com a últim recurs (cul-de-sac).
const float STEP = speed_ * delta_time;
// Bloqueig per la vora del playfield: amarrem la caja a la vora exacta.
if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); }
// Cal girar. Preferim perpendiculars; només fem U-turn si no hi ha cap
// altra opció (comportament típic d'Oh Mummy!: no es recula sense motiu).
const auto POOLS = collectDirectionPools(speed_ * delta_time);
const auto& pool = !POOLS.perp.empty() ? POOLS.perp : POOLS.uturn;
applyChoice(chooseDirection(pool));
// No avancem aquest frame: la direcció nova s'aplicarà al pròxim step.
}
auto Mummy::detectLaneCrossing(float dx, float dy) const -> std::optional<float> {
const auto BOX = getCollisionRect();
if (dx != 0.0F) {
return findLaneCross(BOX.x, dx, static_cast<float>(Map::playfield_x), static_cast<float>(Map::pitch_x), Map::grid_cols);
}
return findLaneCross(BOX.y, dy, static_cast<float>(Map::playfield_y), static_cast<float>(Map::pitch_y), Map::grid_rows);
}
auto Mummy::currentDirection() const -> Direction {
if (dir_x_ > 0.0F) { return Direction::RIGHT; }
if (dir_x_ < 0.0F) { return Direction::LEFT; }
if (dir_y_ > 0.0F) { return Direction::DOWN; }
if (dir_y_ < 0.0F) { return Direction::UP; }
return Direction::NONE;
}
void Mummy::decideAtIntersection(float step_px) {
// Pool base: perpendiculars lliures (collectDirectionPools ja exclou la
// direcció actual i la mitja volta va al seu propi calaix, que ací
// ignorem — mai fem U-turn a un creuament).
auto pools = collectDirectionPools(step_px);
std::vector<Direction> pool = std::move(pools.perp);
// Afegim la recta si segueix essent lliure. A un creuament "net" ho és
// sempre (la mòmia hi ha arribat per aquest corredor), però als creua-
// ments de la vora del playfield la recta pot estar al límit i no
// permetre un pas més — en aquest cas la descartem.
const auto STRAIGHT_BOX = candidateBox(dir_x_ * step_px, dir_y_ * step_px);
if (Map::insidePlayfield(STRAIGHT_BOX) && !Map::collides(STRAIGHT_BOX)) {
pool.push_back(currentDirection());
}
if (pool.empty()) { return; }
applyChoice(chooseDirection(pool));
}
auto Mummy::collectDirectionPools(float step) const -> DirectionPools {
struct Candidate {
float dx;
float dy;
Direction dir;
};
const std::array<Candidate, 4> ALL{{
{.dx = STEP, .dy = 0.0F, .dir = Direction::RIGHT},
{.dx = -STEP, .dy = 0.0F, .dir = Direction::LEFT},
{.dx = 0.0F, .dy = -STEP, .dir = Direction::UP},
{.dx = 0.0F, .dy = STEP, .dir = Direction::DOWN},
{.dx = step, .dy = 0.0F, .dir = Direction::RIGHT},
{.dx = -step, .dy = 0.0F, .dir = Direction::LEFT},
{.dx = 0.0F, .dy = -step, .dir = Direction::UP},
{.dx = 0.0F, .dy = step, .dir = Direction::DOWN},
}};
std::vector<Direction> perp_allowed;
std::vector<Direction> uturn_allowed;
DirectionPools pools;
for (const auto& c : ALL) {
const bool IS_CURRENT = (c.dx > 0.0F && dir_x_ > 0.0F) || (c.dx < 0.0F && dir_x_ < 0.0F) || (c.dy > 0.0F && dir_y_ > 0.0F) || (c.dy < 0.0F && dir_y_ < 0.0F);
const bool IS_UTURN = (c.dx > 0.0F && dir_x_ < 0.0F) || (c.dx < 0.0F && dir_x_ > 0.0F) || (c.dy > 0.0F && dir_y_ < 0.0F) || (c.dy < 0.0F && dir_y_ > 0.0F);
if (IS_CURRENT) { continue; } // ja hem vist que està bloquejada
const bool IS_CURRENT = (c.dx * dir_x_ > 0.0F) || (c.dy * dir_y_ > 0.0F);
// La direcció actual no forma part del pool: al bloqueig sabem que
// està bloquejada; al creuament, qui decideix l'afegeix a banda.
if (IS_CURRENT) { continue; }
const auto TEST = candidateBox(c.dx, c.dy);
if (!Map::insidePlayfield(TEST) || Map::collides(TEST)) { continue; }
const bool IS_UTURN = (c.dx * dir_x_ < 0.0F) || (c.dy * dir_y_ < 0.0F);
if (IS_UTURN) {
uturn_allowed.push_back(c.dir);
pools.uturn.push_back(c.dir);
} else {
perp_allowed.push_back(c.dir);
pools.perp.push_back(c.dir);
}
}
const auto& pool = !perp_allowed.empty() ? perp_allowed : uturn_allowed;
const auto CHOICE = chooseDirection(pool);
switch (CHOICE) {
case Direction::UP:
setDirection(0.0F, -1.0F);
break;
case Direction::DOWN:
setDirection(0.0F, 1.0F);
break;
case Direction::LEFT:
setDirection(-1.0F, 0.0F);
break;
case Direction::RIGHT:
setDirection(1.0F, 0.0F);
break;
case Direction::NONE:
setDirection(0.0F, 0.0F);
break;
}
// No avancem aquest frame: la direcció nova s'aplicarà al pròxim step.
return pools;
}
void Mummy::onPlayerContact(Player* /*player*/) {
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
void Mummy::applyChoice(Direction dir) {
switch (dir) {
case Direction::UP:
changeDirection(0.0F, -1.0F);
break;
case Direction::DOWN:
changeDirection(0.0F, 1.0F);
break;
case Direction::LEFT:
changeDirection(-1.0F, 0.0F);
break;
case Direction::RIGHT:
changeDirection(1.0F, 0.0F);
break;
case Direction::NONE:
changeDirection(0.0F, 0.0F);
break;
}
}
void Mummy::onPlayerContact(Player* player) {
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_hit",
.payload = getDamage(),
.player_id = (player != nullptr) ? player->getId() : -1,
});
}
}
REGISTER_ENTITY("mummy", Mummy)
REGISTER_ENTITY_AS("momia", Mummy, Momia)
+61 -10
View File
@@ -1,9 +1,11 @@
#pragma once
#include <cstdint>
#include <optional>
#include <vector>
#include "external/fkyaml_node.hpp"
#include "game/entities/animation_component.hpp"
#include "game/entities/hazard.hpp"
// Mòmia: perill dinàmic que deambula pels passadís. A cada intersecció
@@ -11,11 +13,6 @@
// (`chooseDirection`) que la base implementa com a aleatori uniforme
// sobre les direccions admissibles. Futurs derivats poden sesgar cap al
// jugador sense tocar la lògica de moviment.
//
// Andamiatge: el concepte d'"intersecció" i la validació de direcció
// requereixen conèixer el mapa. De moment l'exposem com un conjunt de
// direccions admissibles que l'EntityManager injecta; la implementació
// real arribarà quan cablegem l'entitat al game loop.
class Mummy : public DynamicHazard {
public:
enum class Direction : std::uint8_t { NONE,
@@ -31,11 +28,15 @@ class Mummy : public DynamicHazard {
// aleatoria; si `allowed` està buit, retorna `NONE`.
virtual auto chooseDirection(const std::vector<Direction>& allowed) -> Direction;
// Política de moviment "corridor_wanderer": camina recte fins que el
// pròxim tram la bloquejaria (mur del playfield o sarc). Quan troba un
// obstacle, recull les direccions perpendiculars vàlides (evitant
// U-turn llevat que no hi hagi alternativa) i crida `chooseDirection`
// per decidir-ne una. No consumeix moviment el frame de gir.
// Política "corridor_wanderer":
// (a) A cada creuament de corredor (edge-detect de travessar la línia
// central d'un corredor perpendicular) amarra la caixa al centre
// exacte del creuament i decideix: seguir recta o girar a una
// perpendicular lliure. Mai mitja volta.
// (b) Si el pròxim tram la bloqueja (mur del playfield o sarc), gira
// cap a una perpendicular vàlida i, només si no en queda cap, fa
// mitja volta.
// En ambdós casos el frame de gir no consumeix avenç extra.
void step(float delta_time) override;
// Contacte amb el jugador: publica `player_hit` amb el dany.
@@ -44,4 +45,54 @@ class Mummy : public DynamicHazard {
private:
// Caixa de col·lisió candidata si l'entitat es desplaçara (dx, dy).
[[nodiscard]] auto candidateBox(float dx, float dy) const -> SDL_FRect;
// Amarra la caja a la vora exacta del playfield quan el candidat hi
// sortiria — sense això, amb la caja 16×16 iguala el corredor, la
// posició subpíxel dins de l'última cel·la impediria girar a cap
// perpendicular (col·lisionaria amb el sarc adjacent). Es crida abans
// de la lògica de gir.
void snapToPlayfieldEdge(float dx, float dy);
// Recull les direccions alternatives viables des de la posició actual:
// `perpendiculars` (girs) i `uturn` (inversa). La direcció actual s'exclou
// perquè ja sabem que està bloquejada. Cada candidata es valida amb
// `Map::insidePlayfield` + `Map::collides` sobre la caja resultant.
struct DirectionPools {
std::vector<Direction> perp;
std::vector<Direction> uturn;
};
[[nodiscard]] auto collectDirectionPools(float step) const -> DirectionPools;
// Canvi de direcció + aplicació de l'animació i flip corresponents.
// Tots els camins que trien nova direcció (ctor amb `initial_direction`,
// gir després d'un bloqueig) passen per ací per mantenir anim_ i
// flip_ en sincronia amb (dir_x_, dir_y_).
void changeDirection(float dx, float dy);
// Aplica la direcció triada (wrapper del switch en una sola crida).
void applyChoice(Direction dir);
// Si aquest frame travessa la línia central d'un corredor perpendicular,
// retorna el delta (sobre l'eix actiu) per amarrar la caixa a aquesta
// línia. `nullopt` si no en creua cap. Els corredors perpendiculars
// estan a `base + k·pitch` (k ∈ [0..max_idx]); la comparació és
// estricta a un costat per no re-disparar quan la caixa ja hi està.
[[nodiscard]] auto detectLaneCrossing(float dx, float dy) const -> std::optional<float>;
// Decisió a un creuament: pool = perpendiculars lliures + direcció
// actual si continua essent lliure. Mai mitja volta. Si el pool sortís
// buit (cas degenerat, a la pràctica no s'hi arriba), manté la
// direcció actual; el frame següent, si està bloquejada, la branca de
// bloqueig ja la reorientarà.
void decideAtIntersection(float step_px);
// Direcció actual com a enum, reconstruïda a partir de `dir_x_/dir_y_`.
// S'usa al pool de creuament per afegir la recta com a candidata.
[[nodiscard]] auto currentDirection() const -> Direction;
// Animacions carregades del YAML, una per cada facing. Per a horitzontal
// només en cal una (`walk`) i el flip ja diferencia esquerra/dreta.
AnimationComponent anim_walk_down_;
AnimationComponent anim_walk_up_;
AnimationComponent anim_walk_lateral_;
};
@@ -4,6 +4,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
#include "game/map/map.hpp"
namespace {
@@ -60,8 +61,14 @@ void RollingRock::step(float delta_time) {
setDirection(0.0F, 0.0F);
}
void RollingRock::onPlayerContact(Player* /*player*/) {
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
void RollingRock::onPlayerContact(Player* player) {
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_hit",
.payload = getDamage(),
.player_id = (player != nullptr) ? player->getId() : -1,
});
}
}
REGISTER_ENTITY("rolling_rock", RollingRock)
@@ -112,7 +112,7 @@ void Sarcophagus::update(float delta_time) {
}
}
void Sarcophagus::notifyTileEntered(int col, int row) {
void Sarcophagus::notifyTilePainted(int col, int row) {
if (state_ != State::CLOSED) { return; }
const TileKey KEY{.col = col, .row = row};
if (perimeter_tiles_.find(KEY) == perimeter_tiles_.end()) { return; }
@@ -42,7 +42,7 @@ class Sarcophagus : public Interactable {
void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked);
void update(float delta_time) override;
void notifyTileEntered(int col, int row) override;
void notifyTilePainted(int col, int row) override;
[[nodiscard]] auto getState() const -> State { return state_; }
[[nodiscard]] auto getPerimeterSize() const -> std::size_t { return perimeter_tiles_.size(); }
+9 -2
View File
@@ -4,6 +4,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
WallTrap::WallTrap(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("wall_trap");
@@ -21,9 +22,15 @@ WallTrap::WallTrap(const fkyaml::node& yaml) {
}
}
void WallTrap::onPlayerContact(Player* /*player*/) {
void WallTrap::onPlayerContact(Player* player) {
if (!isActive()) { return; }
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_hit",
.payload = getDamage(),
.player_id = (player != nullptr) ? player->getId() : -1,
});
}
}
REGISTER_ENTITY("wall_trap", WallTrap)
+1 -1
View File
@@ -23,7 +23,7 @@ void Entity::onPlayerContact(Player* /*player*/) {
// No-op per defecte.
}
void Entity::notifyTileEntered(int /*col*/, int /*row*/) {
void Entity::notifyTilePainted(int /*col*/, int /*row*/) {
// No-op per defecte; només `Sarcophagus` l'usa.
}
+17 -3
View File
@@ -77,9 +77,10 @@ class Entity {
virtual void onPlayerContact(Player* player);
// Hook que el `EntityManager` invoca per a cada entitat cada vegada
// que el jugador entra en un tile nou. Només `Sarcophagus` ho consumeix
// (per marcar perímetre visitat); per defecte no-op.
virtual void notifyTileEntered(int col, int row);
// que un tile es pinta (via PaintLayer, com a conseqüència d'un
// jugador trepitjant un sòl pintable). Només `Sarcophagus` ho consumeix
// (per marcar perímetre); per defecte no-op.
virtual void notifyTilePainted(int col, int row);
[[nodiscard]] auto getName() const -> const std::string& { return name_; }
[[nodiscard]] auto getTilePos() const -> TilePos { return tile_; }
@@ -128,6 +129,18 @@ class Entity {
// col·lisió coincideix amb el sprite.
void setCollisionBox(CollisionBox box) { collision_box_ = box; }
// Accés a la caixa crua (offsets + mida) — el loader la consulta per
// a col·locar el sprite quan `anchor: feet` ha d'alinear la caja (no
// el sprite) amb el tile. Nullopt si l'entitat no en té.
[[nodiscard]] auto getCollisionBox() const -> const std::optional<CollisionBox>& { return collision_box_; }
// Flip del sprite. Les entitats estàtiques mai no el toquen (queda a
// SDL_FLIP_NONE); les dinàmiques que miren cap als costats (p. ex.
// Mummy en direcció horitzontal) el canvien quan gira. El manager
// el passa a Surface::render.
[[nodiscard]] auto getFlip() const -> SDL_FlipMode { return flip_; }
void setFlip(SDL_FlipMode flip) { flip_ = flip; }
protected:
std::string name_;
TilePos tile_{};
@@ -137,5 +150,6 @@ class Entity {
std::shared_ptr<Surface> surface_{nullptr};
bool collides_{false};
bool interactable_{false};
SDL_FlipMode flip_{SDL_FLIP_NONE};
EventBus* bus_{nullptr};
};
+20 -15
View File
@@ -43,23 +43,28 @@ class EntityFactory {
};
// La macro genera un objecte anònim dins un namespace anònim: el seu
// constructor s'executa abans de `main` i registra la factory. El nom del
// símbol és únic per `CLASS` perquè el preprocessor el concatena; així
// dues entitats diferents no xoquen.
// constructor s'executa abans de `main` i registra la factory. El nom
// del símbol es compon amb `TAG`, perquè així es poden registrar àlies
// per una mateixa classe (p.ex. "mummy" i "momia" → Mummy) sense que
// els símbols xoquen. La forma curta `REGISTER_ENTITY(NAME, CLASS)`
// reusa `CLASS` com a tag; per àlies cal usar `REGISTER_ENTITY_AS` i
// passar un identificador únic.
//
// IMPORTANT: perquè el registrador es cridi, el `.cpp` que el conté ha
// d'estar enllaçat directament a l'executable (no dins una STATIC lib
// sense `--whole-archive`). Tots els `concrete/*.cpp` entren a APP_SOURCES.
#define REGISTER_ENTITY(NAME, CLASS) \
namespace { \
struct CLASS##_Registrar { \
CLASS##_Registrar() { \
EntityFactory::instance().registerCreator( \
NAME, \
[](const fkyaml::node& yaml) -> std::unique_ptr<Entity> { \
return std::make_unique<CLASS>(yaml); \
}); \
} \
}; \
const CLASS##_Registrar g_##CLASS##_registrar; /* NOLINT(cert-err58-cpp) */ \
#define REGISTER_ENTITY_AS(NAME, CLASS, TAG) \
namespace { \
struct TAG##_Registrar { \
TAG##_Registrar() { \
EntityFactory::instance().registerCreator( \
NAME, \
[](const fkyaml::node& yaml) -> std::unique_ptr<Entity> { \
return std::make_unique<CLASS>(yaml); \
}); \
} \
}; \
const TAG##_Registrar g_##TAG##_registrar; /* NOLINT(cert-err58-cpp) */ \
}
#define REGISTER_ENTITY(NAME, CLASS) REGISTER_ENTITY_AS(NAME, CLASS, CLASS)
+12 -8
View File
@@ -1,9 +1,9 @@
#include "game/entities/entity_loader.hpp"
#include <fstream>
#include <stdexcept>
#include <unordered_map>
#include "core/resources/resource_helper.hpp"
#include "game/entities/entity.hpp"
#include "game/entities/entity_factory.hpp"
@@ -30,22 +30,26 @@ namespace EntityLoader {
auto it = cache.find(name);
if (it != cache.end()) { return it->second; }
const std::string PATH = templatePath(name);
std::ifstream file(PATH);
if (!file.is_open()) {
const auto PATH = templatePath(name);
auto bytes = Resource::Helper::loadFile(PATH);
if (bytes.empty()) {
throw std::runtime_error("EntityLoader: no s'ha pogut obrir " + PATH);
}
fkyaml::node node = fkyaml::node::deserialize(file);
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
fkyaml::node node = fkyaml::node::deserialize(begin, end);
cache.emplace(name, node);
return node;
}
auto readSpawns(const std::string& level_yaml_path) -> std::vector<fkyaml::node> {
std::ifstream file(level_yaml_path);
if (!file.is_open()) {
auto bytes = Resource::Helper::loadFile(level_yaml_path);
if (bytes.empty()) {
throw std::runtime_error("EntityLoader: no s'ha pogut obrir " + level_yaml_path);
}
fkyaml::node root = fkyaml::node::deserialize(file);
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
fkyaml::node root = fkyaml::node::deserialize(begin, end);
std::vector<fkyaml::node> spawns;
if (!root.contains("entities")) { return spawns; }
const auto& list = root["entities"];
+8 -7
View File
@@ -24,12 +24,13 @@ EntityManager::EntityManager() {
}
});
// Subscripció al moviment del jugador: quan publica `on_tile_entered`
// amb payload `TilePos`, fem fan-out a totes les entitats perquè els
// sarcòfags puguin actualitzar el seu `visited_`.
tile_sub_id_ = bus_.subscribe("on_tile_entered", [this](const Event& ev) {
// Subscripció al pintat: el PaintLayer publica `tile_painted` amb
// payload `TilePos` un cop ha filtrat pintabilitat i aplicat la regla
// "primer que pinta guanya". Fem fan-out a totes les entitats perquè
// els sarcòfags puguin actualitzar el seu `visited_`.
tile_sub_id_ = bus_.subscribe("tile_painted", [this](const Event& ev) {
const auto* tile = std::get_if<TilePos>(&ev.payload);
if (tile != nullptr) { notifyPlayerEnteredTile(tile->col, tile->row); }
if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row); }
});
}
@@ -66,8 +67,8 @@ void EntityManager::update(float delta_time) {
for (auto& e : entities_) { e->update(delta_time); }
}
void EntityManager::notifyPlayerEnteredTile(int col, int row) {
for (auto& e : entities_) { e->notifyTileEntered(col, row); }
void EntityManager::notifyPlayerTilePainted(int col, int row) {
for (auto& e : entities_) { e->notifyTilePainted(col, row); }
}
auto EntityManager::getEntities() const -> std::vector<Entity*> {
+7 -6
View File
@@ -17,9 +17,9 @@ class Player;
// 2. `update(dt)` propaga el tick a totes.
// 3. Comptador de sarcòfags oberts — quan iguala el total registrat,
// crida `door_->open()` automàticament.
// 4. `notifyPlayerEnteredTile(col, row)` — fan-out del hook per a tots
// 4. `notifyPlayerTilePainted(col, row)` — fan-out del hook per a tots
// els sarcòfags (i qualsevol entitat futura que s'hi subscrigui via
// `notifyTileEntered`).
// `notifyTilePainted`).
//
// NO és un singleton: es crea una instància per nivell. De moment no
// s'usa encara al GameScene (andamiatge pur).
@@ -74,10 +74,11 @@ class EntityManager {
// reflecteixi l'estat del frame actual.
void checkPlayerCollisions(const std::vector<Player*>& players);
// Fan-out del hook de moviment de jugador: per a cada sarcòfag,
// cridar notifyTileEntered. Les entitats no-sarcòfag ho ignoren per
// defecte (veure Entity::notifyTileEntered).
void notifyPlayerEnteredTile(int col, int row);
// Fan-out del hook de pintat: per a cada sarcòfag, cridar
// notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte
// (veure Entity::notifyTilePainted). Invocat com a reacció a
// `tile_painted` al bus (re-publicat pel PaintLayer un cop filtrat).
void notifyPlayerTilePainted(int col, int row);
// Carrega els spawns d'un YAML de nivell i els afegeix al manager:
// 1. Llegeix el bloc `entities:` via EntityLoader.
@@ -51,7 +51,7 @@ void EntityManager::renderEntity(const Entity& e) {
.w = RECT.w,
.h = RECT.h,
};
surface->render(static_cast<int>(RECT.x), static_cast<int>(RECT.y), &SRC);
surface->render(static_cast<int>(RECT.x), static_cast<int>(RECT.y), &SRC, e.getFlip());
}
void EntityManager::renderAll() {
@@ -65,7 +65,11 @@ void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
const auto BOX = entity->getCollisionRect();
for (auto* p : players) {
if (p == nullptr) { continue; }
const auto PBOX = p->getBaseBox();
// Contacte actor-actor amb hit-box petita: la caixa base
// 16×16 servia per moure (xoc amb murs), però per a l'event
// "toca una entitat" volem precisió — que la momia mate quan
// els píxels centrals es solapen, no quan els caps es freguen.
const auto PBOX = p->getHitBox();
const bool OVERLAP = PBOX.x + PBOX.w > BOX.x && BOX.x + BOX.w > PBOX.x && PBOX.y + PBOX.h > BOX.y && BOX.y + BOX.h > PBOX.y;
if (OVERLAP) { entity->onPlayerContact(p); }
}
@@ -105,9 +109,16 @@ namespace {
return info;
}
// Bloc `collision: { size: [w, h], offset: [x, y] }` de la plantilla
// d'entitat. `offset` és opcional: si no apareix, s'entén "la caixa
// queda bottom-center del sprite" (convenció de falsa perspectiva).
// Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }`
// de la plantilla d'entitat.
// - Sense `base_size` ni `offset`: la caja queda alineada a la vora
// inferior del sprite i centrada horitzontalment (convenció antiga).
// - Amb `base_size: [bw, bh]`: la caja queda concèntrica amb el
// rectangle inferior bw×bh del sprite (els "peus" visuals), igual
// que fa el Player. Pot sobresortir per sobre/sota/costats del
// rectangle-base simètricament — així la caja representa el volum
// sòlid al voltant dels peus, no la silueta de tot el sprite.
// - `offset: [x, y]`: si apareix, sobreescriu el càlcul anterior.
// Retorna nullopt si no hi ha bloc, i l'entitat es queda sense caixa
// explícita (getCollisionRect torna tot el sprite).
auto readCollisionBox(const fkyaml::node& tmpl, float sprite_w, float sprite_h) -> std::optional<Entity::CollisionBox> {
@@ -120,6 +131,16 @@ namespace {
const int H = sz[1].get_value<int>();
int off_x = static_cast<int>((sprite_w - static_cast<float>(W)) / 2.0F);
int off_y = static_cast<int>(sprite_h - static_cast<float>(H));
if (c.contains("base_size")) {
const auto& bs = c["base_size"];
if (bs.is_sequence() && bs.size() == 2) {
const int BH = bs[1].get_value<int>();
// Centre vertical del rectangle-base (alineat a la vora
// inferior del sprite): sprite_h − bh/2. Caja centrada hi:
// off_y = sprite_h − bh/2 − H/2 = sprite_h − (bh + H)/2.
off_y = static_cast<int>(sprite_h) - ((BH + H) / 2);
}
}
if (c.contains("offset")) {
const auto& o = c["offset"];
if (o.is_sequence() && o.size() == 2) {
@@ -258,16 +279,26 @@ namespace {
const auto& p = spawn["position"];
const auto PX = tileToPixel(p[0].get_value<int>(), p[1].get_value<int>());
// Anchor "feet": el tile [col,row] és el tile dels peus. El sprite
// es centra horitzontalment sobre el tile i la seua vora inferior
// s'alinea amb la vora inferior del tile. Els píxels que sobresurten
// (alçada del sprite per damunt del tile dels peus) queden "per
// sobre" sense afectar cap col·lisió — falsa perspectiva.
// Anchor "feet": el tile [col,row] és el tile dels peus de l'actor.
// Si l'entitat té caixa de col·lisió explícita, la caixa s'alinea
// exactament amb el tile (el sprite queda col·locat per als offsets
// que la caja defineix — típicament sobresurt cap amunt per falsa
// perspectiva). Això és coherent amb el jugador, que també centra
// la seua 16×16 sobre el rectangle-base; la caja del tile i la del
// mapeig són la mateixa. Sense caixa, fallback històric: sprite
// centrat horitzontalment i amb la vora inferior a la vora inferior
// del tile.
float rect_x = PX.x;
float rect_y = PX.y;
if (anchor == "feet") {
rect_x = PX.x + (static_cast<float>(Map::tile_size) - sprite.width) / 2.0F;
rect_y = PX.y + static_cast<float>(Map::tile_size) - sprite.height;
const auto& box_opt = entity->getCollisionBox();
if (box_opt.has_value()) {
rect_x = PX.x - static_cast<float>(box_opt->offset_x);
rect_y = PX.y - static_cast<float>(box_opt->offset_y);
} else {
rect_x = PX.x + (static_cast<float>(Map::tile_size) - sprite.width) / 2.0F;
rect_y = PX.y + static_cast<float>(Map::tile_size) - sprite.height;
}
}
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
+22 -3
View File
@@ -23,17 +23,33 @@ class Entity;
// consumir events tipats amb `std::get_if`. Si apareixen més tipus de
// payload, s'amplia aquí sense tocar cridants existents.
// Tile (col, row) — payload de `on_tile_entered`. No és un `pair<int,int>`
// per evitar alias ambigus al variant (dos ints sense nom).
// Tile (col, row) — payload de `tile_painted` (i altres events de tile sense
// metadada extra). No és un `pair<int,int>` per evitar alias ambigus al
// variant (dos ints sense nom).
struct TilePos {
int col{0};
int row{0};
};
// Tile pintat pel jugador — payload de `on_tile_entered`. Porta l'índex
// de tile amb què el jugador pinta (cada Player pot pintar amb un tile
// diferent, llegit del YAML del mapa). El PaintLayer filtra pintabilitat,
// marca i re-publica `tile_painted` amb només la posició.
struct PaintedTile {
int col{0};
int row{0};
int tile_index{-1};
};
struct Event {
std::string name;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos>;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile>;
Payload payload;
// Jugador que ha originat (o provocat) l'event. -1 = sense jugador
// associat (events del món, com `sarcophagus_opened` des de la pròpia
// entitat). Usat per al HUD (dispatch a P1/P2) i per a regles futures
// de puntuació que atribueixin accions al jugador concret.
int player_id{-1};
};
class EventBus {
@@ -71,6 +87,9 @@ class EventBus {
void publish(const std::string& name, TilePos tile) {
publish(Event{.name = name, .payload = tile});
}
void publish(const std::string& name, PaintedTile painted) {
publish(Event{.name = name, .payload = painted});
}
private:
struct Subscription {
+328 -56
View File
@@ -4,14 +4,15 @@
#include <algorithm>
#include <cmath>
#include <fstream>
#include <stdexcept>
#include <utility>
#include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.hpp"
#include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "game/entities/event_bus.hpp"
#include "game/map/map.hpp"
#include "utils/defaults.hpp"
namespace {
@@ -19,14 +20,44 @@ namespace {
// Alias local per reduir soroll; la constant viu a Defaults::Player.
constexpr float SPEED_PX_PER_S = Defaults::Player::SPEED_PX_PER_S;
// Dead-zone del stick analògic esquerre (rang Sint16: -32768..32767).
// Converteix eix analògic en direcció digital; per sota, es considera
// repòs i no contribueix a la velocitat.
constexpr Sint16 STICK_DEADZONE = 12000;
// Acumula la contribució del mando assignat al jugador (cruceta +
// stick esquerre) sobre els acumuladors de velocitat. Extracció
// d'`update()` per a mantenir la complexitat cognitiva sota llindar
// (threshold 25). L'assignació mando↔jugador viu al `GamepadManager`
// i la persisteix Options; si aquest jugador no en té cap assignat,
// la funció és un no-op (sols es mou amb el teclat).
void accumulateGamepadMovement(int player_id, float& vx, float& vy) {
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
if (pad == nullptr) { return; }
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_LEFT)) { vx -= SPEED_PX_PER_S; }
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_RIGHT)) { vx += SPEED_PX_PER_S; }
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_UP)) { vy -= SPEED_PX_PER_S; }
if (InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_DOWN)) { vy += SPEED_PX_PER_S; }
const Sint16 LX = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX);
const Sint16 LY = InputState::getGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTY);
if (LX < -STICK_DEADZONE) { vx -= SPEED_PX_PER_S; }
if (LX > STICK_DEADZONE) { vx += SPEED_PX_PER_S; }
if (LY < -STICK_DEADZONE) { vy -= SPEED_PX_PER_S; }
if (LY > STICK_DEADZONE) { vy += SPEED_PX_PER_S; }
}
} // namespace
Player::TurnConfig Player::turn_config;
void Player::loadFrom(const std::string& yaml_path) {
std::ifstream file(yaml_path);
if (!file.is_open()) {
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
throw std::runtime_error("Player: no s'ha pogut obrir " + yaml_path);
}
auto yaml = fkyaml::node::deserialize(file);
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
sprite_w = yaml["frameWidth"].get_value<int>();
sprite_h = yaml["frameHeight"].get_value<int>();
@@ -45,17 +76,210 @@ void Player::loadFrom(const std::string& yaml_path) {
collision_padding = (collision_size - base_size) / 2;
collision_x_offset = -collision_padding;
collision_y_offset = (sprite_h - base_size) - collision_padding;
// Hit-box opcional (actor-actor). Si no hi és, s'iguala a la base
// per preservar compatibilitat amb YAMLs antics. Concèntrica amb la
// caixa de col·lisió: offset interior = (collision_size - hit_size) / 2.
hit_size = c.contains("hit_size") ? c["hit_size"].get_value<int>() : collision_size;
const int HIT_INSET = (collision_size - hit_size) / 2;
hit_x_offset = collision_x_offset + HIT_INSET;
hit_y_offset = collision_y_offset + HIT_INSET;
// Bloc opcional `turn:` — si manca, `duration_ms` queda a 0 i la
// mecànica del gir de 180° no s'activa (canvis de direcció instantanis).
turn_config = TurnConfig{};
if (yaml.contains("turn")) {
const auto& t = yaml["turn"];
if (t.contains("duration_ms")) { turn_config.duration_ms = t["duration_ms"].get_value<int>(); }
if (t.contains("transitions")) {
const auto& tr = t["transitions"];
if (tr.contains("west_to_east")) { turn_config.w_to_e = cardinalFromString(tr["west_to_east"].get_value<std::string>()); }
if (tr.contains("east_to_west")) { turn_config.e_to_w = cardinalFromString(tr["east_to_west"].get_value<std::string>()); }
if (tr.contains("north_to_south")) { turn_config.n_to_s = cardinalFromString(tr["north_to_south"].get_value<std::string>()); }
if (tr.contains("south_to_north")) { turn_config.s_to_n = cardinalFromString(tr["south_to_north"].get_value<std::string>()); }
}
}
}
Player::Player(const std::string& anim_key, float x, float y, const Controls& controls)
void Player::applyCardinal(Cardinal c) {
switch (c) {
case Cardinal::NORTH:
facing_ = Facing::UP;
setFlip(SDL_FLIP_NONE);
break;
case Cardinal::SOUTH:
facing_ = Facing::DOWN;
setFlip(SDL_FLIP_NONE);
break;
case Cardinal::EAST:
facing_ = Facing::SIDE;
setFlip(SDL_FLIP_NONE);
break;
case Cardinal::WEST:
facing_ = Facing::SIDE;
setFlip(SDL_FLIP_HORIZONTAL);
break;
case Cardinal::NONE:
break;
}
}
auto Player::cardinalFromInput(float vx, float vy) -> Cardinal {
// Mateixa precedència que la lògica original: l'horitzontal preval
// sobre el vertical (els diagonals s'interpreten com a laterals).
if (vx > 0.0F) { return Cardinal::EAST; }
if (vx < 0.0F) { return Cardinal::WEST; }
if (vy < 0.0F) { return Cardinal::NORTH; }
if (vy > 0.0F) { return Cardinal::SOUTH; }
return Cardinal::NONE;
}
auto Player::cardinalFromString(const std::string& s) -> Cardinal {
if (s == "north" || s == "up") { return Cardinal::NORTH; }
if (s == "south" || s == "down") { return Cardinal::SOUTH; }
if (s == "east" || s == "right") { return Cardinal::EAST; }
if (s == "west" || s == "left") { return Cardinal::WEST; }
throw std::runtime_error("Player: cardinal desconegut '" + s + "' al bloc turn.transitions");
}
auto Player::opposite(Cardinal c) -> Cardinal {
switch (c) {
case Cardinal::NORTH:
return Cardinal::SOUTH;
case Cardinal::SOUTH:
return Cardinal::NORTH;
case Cardinal::EAST:
return Cardinal::WEST;
case Cardinal::WEST:
return Cardinal::EAST;
case Cardinal::NONE:
return Cardinal::NONE;
}
return Cardinal::NONE;
}
auto Player::intermediateFor(Cardinal target) -> Cardinal {
switch (target) {
case Cardinal::EAST:
return turn_config.w_to_e;
case Cardinal::WEST:
return turn_config.e_to_w;
case Cardinal::SOUTH:
return turn_config.n_to_s;
case Cardinal::NORTH:
return turn_config.s_to_n;
case Cardinal::NONE:
return Cardinal::NONE;
}
return Cardinal::NONE;
}
void Player::updateFacingAndTurn(float delta_time, float vx, float vy) {
// Cardinal d'INPUT cru. Important: la detecció del gir de 180° s'ha
// de basar en l'input (intent del jugador), no en la velocitat
// resultant. Mentre no hi haja inèrcia, vx/vy són directament l'input
// i serveixen com a tal; quan s'introduïsca, cal preservar aquesta
// separació (calcular el cardinal a partir de les tecles/gamepad
// crus, no de la velocitat suavitzada).
const Cardinal INPUT_CARD = cardinalFromInput(vx, vy);
// Si hi ha un gir en curs, descomptem el timer i mantenim el facing
// forçat al cardinal intermedi. Quan s'esgota, apliquem el cardinal
// pendent. El moviment físic continua sense alterar-se (vx/vy ja
// s'han enviat a setVel al caller).
if (turning_remaining_ms_ > 0.0F) {
turning_remaining_ms_ -= delta_time * 1000.0F;
if (turning_remaining_ms_ <= 0.0F) {
turning_remaining_ms_ = 0.0F;
applyCardinal(pending_cardinal_);
pending_cardinal_ = Cardinal::NONE;
intermediate_cardinal_ = Cardinal::NONE;
} else {
applyCardinal(intermediate_cardinal_);
}
} else {
// Detecció del gir: `last_input_cardinal_` és l'últim cardinal
// d'input no nul (es preserva entre releases). Així, el primer
// input des de l'estat idle no dispara cap gir — només dispara
// quan el jugador "canvia d'opinió" prement la direcció oposada
// a la prèvia. Requereix també que el bloc `turn:` del YAML
// estiga activat (`duration_ms > 0`).
const bool TRIGGER_TURN =
(turn_config.duration_ms > 0) && INPUT_CARD != Cardinal::NONE && last_input_cardinal_ != Cardinal::NONE && INPUT_CARD == opposite(last_input_cardinal_);
if (TRIGGER_TURN) {
pending_cardinal_ = INPUT_CARD;
intermediate_cardinal_ = intermediateFor(INPUT_CARD);
turning_remaining_ms_ = static_cast<float>(turn_config.duration_ms);
applyCardinal(intermediate_cardinal_);
} else if (vx != 0.0F) {
// Tria de facing original: prevalença lateral, flip segons el
// signe de vx; verticals purs van a UP/DOWN amb flip NONE; en
// aturar-se conservem `facing_` per a l'animació stand.
facing_ = Facing::SIDE;
setFlip(vx < 0.0F ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE);
} else if (vy < 0.0F) {
facing_ = Facing::UP;
setFlip(SDL_FLIP_NONE);
} else if (vy > 0.0F) {
facing_ = Facing::DOWN;
setFlip(SDL_FLIP_NONE);
}
}
// Memoritzem l'últim cardinal d'input no nul per a la propera detecció
// de gir (ignorant els zeros: l'estat idle no reinicia la memòria).
if (INPUT_CARD != Cardinal::NONE) {
last_input_cardinal_ = INPUT_CARD;
}
}
Player::Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index)
: AnimatedSprite(Resource::Cache::get().getAnimationData(anim_key)),
controls_(controls) {
id_(id),
controls_(controls),
map_(map_info),
paint_tile_index_(paint_tile_index) {
setPos(x, y);
setCurrentAnimation("stand");
// Postura inicial mirant cap al jugador (cara avall), no de costat:
// forcem facing_ a DOWN i l'animació `stand_down` abans del primer
// frame d'input — el primer `update` la preservarà si l'usuari no
// prem cap tecla.
facing_ = Facing::DOWN;
setCurrentAnimation("stand_down");
}
void Player::update(float delta_time) {
const bool* keys = SDL_GetKeyboardState(nullptr);
tick(delta_time);
applyMovement(delta_time);
}
auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
if (map_.collides(box)) { return true; }
if (all_players_ == nullptr) { return false; }
// Per al contacte actor-actor usem la hit-box petita a banda i banda:
// projectem la hit-box des de la posició base proposada i la comparem
// amb la hit-box actual de l'altre jugador. Així la caixa de moviment
// (16×16) segueix topant amb murs/sarcs però els jugadors poden
// acostar-se fins al contacte real.
const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box);
return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT](const Player* p) {
if (p == nullptr || p == this) { return false; }
const auto OTHER = p->getHitBox();
return SDL_HasRectIntersectionFloat(&PROPOSED_HIT, &OTHER);
});
}
void Player::tick(float delta_time) {
// Reset per frame: el resolver del GameScene el tornarà a posar a
// true si aquest frame hi ha push actiu. Així quan el push acaba
// (un deixa d'empentar, l'altre comença a caminar), l'animació torna
// a governar-se per la velocitat real sense estat romanent.
being_pushed_ = false;
// Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el
// service menu (o qualsevol UI modal) està obert, el wrapper retorna
// un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari
// navega el menú. Polling-blocked; veure `core/input/input_state.hpp`.
const bool* keys = InputState::getKeyboardState();
float vx = 0.0F;
float vy = 0.0F;
if (keys[controls_.left]) { vx -= SPEED_PX_PER_S; }
@@ -63,90 +287,117 @@ void Player::update(float delta_time) {
if (keys[controls_.up]) { vy -= SPEED_PX_PER_S; }
if (keys[controls_.down]) { vy += SPEED_PX_PER_S; }
// Cruceta i stick esquerre del mando assignat (si n'hi ha). També
// respecten el bloqueig d'`InputState`, així que amb el menú obert
// els botons tornen false/0 i no contribueixen a la velocitat.
accumulateGamepadMovement(id_, vx, vy);
intended_vx_ = vx;
intended_vy_ = vy;
setVelX(vx);
setVelY(vy);
// El sprite original mira a la dreta; flipem només si es mou a l'esquerra
// i restaurem si torna a la dreta. Moviments purament verticals conserven
// l'últim flip perquè el jugador no "gire" sense motiu.
if (vx < 0.0F) {
setFlip(SDL_FLIP_HORIZONTAL);
} else if (vx > 0.0F) {
setFlip(SDL_FLIP_NONE);
}
updateFacingAndTurn(delta_time, vx, vy);
}
const bool MOVING = vx != 0.0F || vy != 0.0F;
setCurrentAnimation(MOVING ? "walk" : "stand");
void Player::applyMovement(float delta_time) {
// El resolver del GameScene pot haver escalat `intended_vx_/vy_`
// (cas push: velocitat * push_factor) o haver-les anul·lat (cas
// head-on). Consumim-les aquí com a velocitat definitiva del frame.
const float VX = intended_vx_;
const float VY = intended_vy_;
// Snap al passadís: si només hi ha moviment horitzontal (o vertical) i
// la caixa està prou prop d'una lane perpendicular (≤ mitja caixa), la
// Y (o X) es corregeix gradualment per enfilar el passadís. Amb
// moviment diagonal no es fa snap — l'usuari pilota manualment.
const bool ONLY_H = (vx != 0.0F) && (vy == 0.0F);
const bool ONLY_V = (vy != 0.0F) && (vx == 0.0F);
// Y (o X) es corregeix gradualment per enfilar el passadís.
const bool ONLY_H = (VX != 0.0F) && (VY == 0.0F);
const bool ONLY_V = (VY != 0.0F) && (VX == 0.0F);
const float FRAME_START_X = getPosX();
const float FRAME_START_Y = getPosY();
if (ONLY_H) {
snapToHorizontalLane(delta_time);
} else if (ONLY_V) {
snapToVerticalLane(delta_time);
}
// Guardem la posició prèvia i deixem que AnimatedSprite::update aplique
// la velocitat com sempre; així no dupliquem la integració temporal ni
// l'animació. Després, axis-separated collision: revertim i re-apliquem
// cada eix comprovant el mapa de colisions, de manera que el jugador
// llisca contra els sarcòfags en compte de quedar-se bloquejat en sec.
const float OLD_X = getPosX();
const float OLD_Y = getPosY();
AnimatedSprite::update(delta_time);
const float NEW_X = getPosX();
const float NEW_Y = getPosY();
setPos(OLD_X, OLD_Y);
setPosX(NEW_X);
if (Map::collides(baseBox())) { setPosX(OLD_X); }
setPosY(NEW_Y);
if (Map::collides(baseBox())) { setPosY(OLD_Y); }
// Animació del frame + moviment col·lisió-aware. `tryMove` aplica els
// deltes per eix i reverteix l'eix que col·lisiona; així el jugador
// llisca contra sarcòfags, murs i altres jugadors en compte de quedar
// bloquejat en sec. La callback tracta els altres jugadors com a
// sòlids per evitar overlaps (el resolver del GameScene deixa les
// velocitats "segures", i aquesta comprovació és la xarxa de
// seguretat davant de casos d'ordre d'aplicació).
animate(delta_time);
tryMove(VX * delta_time, VY * delta_time, [this](const SDL_FRect& box) {
return collidesWithWorld(box);
});
clampToPlayfield();
const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y);
// `being_pushed_` força `stand_*`: el jugador es desplaça sense input,
// animar-lo com a caminant faria creure que està caminant quan de
// fet és arrossegat per l'altre.
const bool ANIM_AS_MOVING = MOVED && (turning_remaining_ms_ <= 0.0F) && !being_pushed_;
setCurrentAnimation(animationName(ANIM_AS_MOVING));
// Detecció de canvi de tile. Es calcula *després* del clamp i de les
// col·lisions perquè la posició aquí ja és la definitiva del frame;
// així els sarcòfags no reben falsos positius mentre el Player rellisca
// així el PaintLayer no rep falsos positius mentre el Player rellisca
// contra una paret. Si el tile ha canviat, publiquem `on_tile_entered`
// al bus; l'EntityManager farà fan-out a notifyTileEntered.
// al bus amb l'índex de pintat d'aquest jugador; el PaintLayer filtra
// pintabilitat i re-publica `tile_painted` als sarcòfags.
if (bus_ != nullptr) {
const auto [COL, ROW] = getCurrentTile();
if (COL != last_tile_col_ || ROW != last_tile_row_) {
last_tile_col_ = COL;
last_tile_row_ = ROW;
bus_->publish("on_tile_entered", TilePos{.col = COL, .row = ROW});
bus_->publish(Event{
.name = "on_tile_entered",
.payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_},
.player_id = id_,
});
}
}
}
auto Player::animationName(bool moving) const -> const char* {
switch (facing_) {
case Facing::UP:
return moving ? "walk_up" : "stand_up";
case Facing::DOWN:
return moving ? "walk_down" : "stand_down";
case Facing::SIDE:
break;
}
return moving ? "walk" : "stand";
}
auto Player::getCurrentTile() const -> std::pair<int, int> {
// El grid del tilemap és de `Map::tile_size` píxels i està 16-alineat
// El grid del tilemap és de `tile_size` píxels i està 16-alineat
// respecte a l'origen del tilemap (pot ser negatiu per overscan). El
// centre de la caixa de col·lisió ens dóna el tile on "està" el Player.
const auto BOX = baseBox();
const float CX = BOX.x + (BOX.w * 0.5F);
const float CY = BOX.y + (BOX.h * 0.5F);
const int COL = static_cast<int>(std::floor(
(CX - static_cast<float>(Map::tilemap_origin_x)) / static_cast<float>(Map::tile_size)));
(CX - static_cast<float>(map_.tilemap_origin_x)) / static_cast<float>(map_.tile_size)));
const int ROW = static_cast<int>(std::floor(
(CY - static_cast<float>(Map::tilemap_origin_y)) / static_cast<float>(Map::tile_size)));
(CY - static_cast<float>(map_.tilemap_origin_y)) / static_cast<float>(map_.tile_size)));
return {COL, ROW};
}
void Player::clampToPlayfield() {
// Límits de la zona jugable: la caixa de col·lisió no pot eixir del
// playfield (muros). El cap del sprite sí pot sobresortir per damunt.
const auto MIN_X = static_cast<float>(Map::playfield_x - collision_x_offset);
const auto MIN_X = static_cast<float>(map_.playfield_x - collision_x_offset);
const auto MAX_X = static_cast<float>(
Map::playfield_x + Map::playfield_w - collision_x_offset - collision_size);
const auto MIN_Y = static_cast<float>(Map::playfield_y - collision_y_offset);
map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size);
const auto MIN_Y = static_cast<float>(map_.playfield_y - collision_y_offset);
const auto MAX_Y = static_cast<float>(
Map::playfield_y + Map::playfield_h - collision_y_offset - collision_size);
map_.playfield_y + map_.playfield_h - collision_y_offset - collision_size);
setPosX(std::clamp(getPosX(), MIN_X, MAX_X));
setPosY(std::clamp(getPosY(), MIN_Y, MAX_Y));
}
@@ -160,6 +411,27 @@ auto Player::getBaseBox() const -> SDL_FRect {
};
}
auto Player::getHitBox() const -> SDL_FRect {
return {
.x = getPosX() + static_cast<float>(hit_x_offset),
.y = getPosY() + static_cast<float>(hit_y_offset),
.w = static_cast<float>(hit_size),
.h = static_cast<float>(hit_size),
};
}
auto Player::hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect {
// Desplaçament intern respecte al cantó de la caixa base: mateixos
// offsets que `getHitBox()` però partint d'una caixa arbitrària.
const int INSET = (collision_size - hit_size) / 2;
return {
.x = base.x + static_cast<float>(INSET),
.y = base.y + static_cast<float>(INSET),
.w = static_cast<float>(hit_size),
.h = static_cast<float>(hit_size),
};
}
void Player::snapToLane(
float delta_time,
float base,
@@ -182,24 +454,24 @@ void Player::snapToLane(
const float STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time);
const float SAVED = (this->*get_pos)();
(this->*set_pos)(SAVED + std::copysign(STEP, DELTA));
if (Map::collides(baseBox())) { (this->*set_pos)(SAVED); }
if (map_.collides(baseBox())) { (this->*set_pos)(SAVED); }
}
}
void Player::snapToHorizontalLane(float delta_time) {
snapToLane(delta_time,
static_cast<float>(Map::playfield_y - collision_y_offset),
static_cast<float>(Map::pitch_y),
Map::grid_rows,
static_cast<float>(map_.playfield_y - collision_y_offset),
static_cast<float>(map_.pitch_y),
map_.grid_rows,
&Player::getPosY,
&Player::setPosY);
}
void Player::snapToVerticalLane(float delta_time) {
snapToLane(delta_time,
static_cast<float>(Map::playfield_x - collision_x_offset),
static_cast<float>(Map::pitch_x),
Map::grid_cols,
static_cast<float>(map_.playfield_x - collision_x_offset),
static_cast<float>(map_.pitch_x),
map_.grid_cols,
&Player::getPosX,
&Player::setPosX);
}
+180 -5
View File
@@ -2,10 +2,13 @@
#include <SDL3/SDL.h>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#include "core/rendering/sprite/animated_sprite.hpp"
#include "game/map/map.hpp"
class EventBus;
@@ -21,7 +24,8 @@ class Player : public AnimatedSprite {
SDL_Scancode right;
};
// Geometria carregada des de `data/player/player.yaml`. `sprite_*` són
// Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`).
// `sprite_*` són
// la mida del frame (sprite 12×24 amb perspectiva falsa: visualment, els
// 12 píxels superiors són el cap i els 12 inferiors la base).
// La caixa de col·lisió és un quadrat `collision_size` concèntric amb el
@@ -36,13 +40,72 @@ class Player : public AnimatedSprite {
inline static int collision_x_offset = 0;
inline static int collision_y_offset = 0;
// Caixa de "contacte" més petita que la caixa de col·lisió: s'usa per
// a les interaccions actor-actor (player↔player, player↔momia/trampa),
// on volem que el contacte sigui precís i no dispari a "distància"
// de la vora del sprite. La caixa de col·lisió gran continua usant-se
// per xocar amb murs i sarcòfags (per mantenir el carril del
// passadís). Sense `hit_size` al YAML, s'iguala a `collision_size` i
// el comportament és idèntic a l'anterior.
inline static int hit_size = 0;
inline static int hit_x_offset = 0;
inline static int hit_y_offset = 0;
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
static void loadFrom(const std::string& yaml_path);
Player(const std::string& anim_key, float x, float y, const Controls& controls);
Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1);
// Update en una sola crida: manté l'API anterior per als tests i
// escenaris amb un sol jugador. Internament és equivalent a cridar
// `tick(dt)` seguit d'`applyMovement(dt)`.
void update(float delta_time) override;
// Fase 1: llig l'input d'aquest frame i emmagatzema la velocitat
// intencionada a `intended_vx_` / `intended_vy_`. No mou el jugador.
// Separada d'`applyMovement` perquè el GameScene puga resoldre
// interaccions entre jugadors (empentes, neutralitzacions head-on)
// abans que ningú consume la posició.
void tick(float delta_time);
// Fase 2: consumeix `intended_vx_`/`intended_vy_` (possiblement
// modificats pel resolver), crida `tryMove` contra el mapa i els
// altres jugadors, fa snap al passadís, clamp al playfield, avança
// l'animació i publica `on_tile_entered` si ha canviat de tile.
void applyMovement(float delta_time);
// Identificador local (0 = P1, 1 = P2…). S'usa als events del bus
// perquè els consumidors (HUD, futures regles de puntuació) sàpiguen
// quin jugador ha originat l'event. No té cap vinculació amb la
// xarxa — és únic per dins del procés.
[[nodiscard]] auto getId() const -> int { return id_; }
// Accés i mutació de la velocitat intencionada d'aquest frame. El
// GameScene els toca entre `tick` i `applyMovement` per implementar
// col·lisions jugador↔jugador (escalar a push_factor, anul·lar en
// head-on). Expressades en píxels/segon, no en píxels/frame.
[[nodiscard]] auto getIntendedVX() const -> float { return intended_vx_; }
[[nodiscard]] auto getIntendedVY() const -> float { return intended_vy_; }
void setIntendedVelocity(float vx, float vy) {
intended_vx_ = vx;
intended_vy_ = vy;
}
// Llista de tots els jugadors del nivell (incloent-se ell mateix —
// la callback ignora `this`). La caixa del jugador es considera un
// sòlid a la callback de col·lisió: així `tryMove` no permet que un
// jugador travesse l'altre, i els desplaçaments parcials (push
// contra mur) s'emparen automàticament.
void setOtherPlayers(const std::vector<Player*>* all) { all_players_ = all; }
// Marcat pel resolver jugador↔jugador quan aquest frame el jugador
// és arrossegat per una empenta (no té input propi; es desplaça
// perquè l'altre l'empeny). Determina que l'animació del frame siga
// `stand_*` encara que la posició canvie. El flag es reseteja a
// l'inici de cada `tick`, així que el resolver l'ha de tornar a
// posar cada frame que el push s'haja de mantenir.
void setBeingPushed(bool pushed) { being_pushed_ = pushed; }
// Y de la base del jugador (cara inferior de la caixa de colisió). Usada
// per al Z-order contra els sarcs: peus per sobre d'un sarc → darrere;
// per sota → davant.
@@ -52,7 +115,9 @@ class Player : public AnimatedSprite {
// Injecta el bus del nivell. El Player publica `on_tile_entered` cada
// vegada que el centre de la seua caixa de col·lisió entra en una
// cel·la nova del tilemap. Si el bus no s'injecta, no emet res.
// cel·la nova del tilemap, amb payload `PaintedTile{col,row,paint_tile_index_}`.
// El PaintLayer filtra pintabilitat i re-publica `tile_painted` a qui
// correspongui (sarcòfags). Si el bus no s'injecta, no emet res.
void setEventBus(EventBus* bus) { bus_ = bus; }
// Tile actual del jugador (centre de `baseBox()` convertit a
@@ -61,15 +126,37 @@ class Player : public AnimatedSprite {
[[nodiscard]] auto getCurrentTile() const -> std::pair<int, int>;
// Rectangle de col·lisió del jugador, en coordenades del canvas. Usat
// pel mapa de col·lisions i per les entitats que comproven contacte
// amb el jugador (momies, trampes, porta).
// pel mapa de col·lisions (tryMove contra murs/sarcòfags).
[[nodiscard]] auto getBaseBox() const -> SDL_FRect;
// Rectangle de contacte actor-actor (hit-box), més petit que la caixa
// base i concèntric amb ella. L'usen `checkPlayerCollisions`
// (momies, trampes, porta) i el resolver jugador↔jugador per
// decidir quan es toquen.
[[nodiscard]] auto getHitBox() const -> SDL_FRect;
// Versió estàtica: deriva la hit-box a partir d'una caixa base
// arbitrària (p. ex. la posició proposada per `tryMove` abans
// d'aplicar-la). Evita recalcular `hit_x_offset`/`hit_y_offset` fora
// de Player.
[[nodiscard]] static auto hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect;
// Override del hook de Sprite: `MovingSprite::tryMove` envia aquesta
// caixa al callback de col·lisió. La base del Player és més petita
// que el sprite (el cap pot sobresortir per murs/sarcòfags sense
// bloquejar el moviment).
[[nodiscard]] auto collisionBox() const -> SDL_FRect override { return getBaseBox(); }
private:
// Alias privat històric. Mantingut perquè viu dins de l'implementació
// (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`.
[[nodiscard]] auto baseBox() const -> SDL_FRect { return getBaseBox(); }
// Callback de col·lisió per a `tryMove`: true si la caixa proposada
// xoca amb el mapa o amb un altre jugador. Membre per mantenir la
// complexitat cognitiva d'`applyMovement` sota el llindar.
[[nodiscard]] auto collidesWithWorld(const SDL_FRect& box) const -> bool;
// Clamp de la caixa de col·lisió al playfield (no al sprite sencer:
// el cap pot sobresortir pel mur superior sense afectar el joc).
void clampToPlayfield();
@@ -81,6 +168,10 @@ class Player : public AnimatedSprite {
// wrappers deleguen a `snapToLane` només canviant l'eix actiu.
void snapToHorizontalLane(float delta_time);
void snapToVerticalLane(float delta_time);
// Nom d'animació per al `facing_` actual. Si `moving` és cert s'usa
// la variant `walk*`, altrament la variant `stand*`.
[[nodiscard]] auto animationName(bool moving) const -> const char*;
void snapToLane(
float delta_time,
float base,
@@ -89,8 +180,86 @@ class Player : public AnimatedSprite {
float (Player::*get_pos)() const,
void (Player::*set_pos)(float));
int id_{-1};
Controls controls_;
// Velocitat intencionada calculada a `tick` i aplicada a
// `applyMovement`. El GameScene pot sobreescriure-la entremig per
// resoldre interaccions jugador↔jugador (empentes, head-on).
float intended_vx_{0.0F};
float intended_vy_{0.0F};
// Punter a la llista de tots els jugadors del nivell (propietat del
// GameScene). `nullptr` = jugador aïllat, sense col·lisió inter-P.
const std::vector<Player*>* all_players_{nullptr};
// Frame actual el jugador està sent empentat (no té input propi).
// Serveix només per a l'elecció d'animació — la posició ja la
// gestiona `intended_vx_/vy_` escalats pel resolver.
bool being_pushed_{false};
// Còpia plana de la geometria del mapa + punters a `collides`/
// `inside_playfield`. POD trivial; copiada al ctor i no muta durant
// la vida del Player. Vol dir que aquest Player només és vàlid per
// a un mapa concret — si el mapa canvia, la Scene ha de
// reinstanciar-lo.
Map::Info map_;
// Direcció "cap on mira" el jugador. Governa quin conjunt d'animacions
// s'aplica (lateral, amunt, avall) i si el sprite es flipeja o no. Els
// moviments purament horitzontals o diagonals prenen `SIDE` (prevalença
// lateral), els purament verticals fan `UP`/`DOWN`. En aturar-se es
// preserva l'última orientació perquè la versió stand coincideixi.
enum class Facing : std::uint8_t { SIDE,
UP,
DOWN };
Facing facing_{Facing::SIDE};
// Cardinal complet (N/S/E/W) usat per a la detecció de girs de 180°.
// El parell (Facing, flip) només té 3+1 estats; aquesta enum n'afegeix
// la 4a sense reorganitzar el sistema d'animacions.
enum class Cardinal : std::uint8_t { NONE,
NORTH,
SOUTH,
EAST,
WEST };
// Configuració del gir de 180°, llegida del bloc `turn:` del YAML del
// player. Si `duration_ms` és 0 (o el bloc no existeix) la mecànica
// queda desactivada i els canvis de direcció són instantanis.
struct TurnConfig {
int duration_ms{0};
Cardinal w_to_e{Cardinal::SOUTH};
Cardinal e_to_w{Cardinal::NORTH};
Cardinal n_to_s{Cardinal::EAST};
Cardinal s_to_n{Cardinal::WEST};
};
// Definida out-of-class al .cpp: dins de la classe, els initializers
// per defecte de TurnConfig encara no són visibles per al compilador.
static TurnConfig turn_config;
// Estat del gir en curs. Mentre `turning_remaining_ms_ > 0`, el facing/
// flip es manté forçat a `intermediate_cardinal_` i l'animació es força
// a `stand` encara que el moviment continuï. En esgotar-se el timer,
// s'aplica `pending_cardinal_`. `last_input_cardinal_` recorda l'últim
// cardinal d'input no nul: serveix per disparar el gir només quan
// l'usuari canvia d'opinió, no la primera vegada que prem una tecla
// des de l'estat idle.
float turning_remaining_ms_{0.0F};
Cardinal pending_cardinal_{Cardinal::NONE};
Cardinal intermediate_cardinal_{Cardinal::NONE};
Cardinal last_input_cardinal_{Cardinal::NONE};
// Aplica un cardinal a (`facing_`, flip). NONE no fa res — preserva el
// facing actual (cas idle: l'última orientació es manté).
void applyCardinal(Cardinal c);
// Actualitza facing_/flip i l'estat del gir a partir de l'input del frame.
// Extret de `update` per acotar la complexitat cognitiva (clang-tidy).
void updateFacingAndTurn(float delta_time, float vx, float vy);
static auto cardinalFromInput(float vx, float vy) -> Cardinal;
static auto cardinalFromString(const std::string& s) -> Cardinal;
static auto opposite(Cardinal c) -> Cardinal;
static auto intermediateFor(Cardinal target) -> Cardinal;
// Bus del nivell (opcional). Propietat externa (EntityManager); el
// Player només en guarda un punter per publicar events.
EventBus* bus_{nullptr};
@@ -99,4 +268,10 @@ class Player : public AnimatedSprite {
// el primer frame en què el bus estigui connectat ja emeti un event.
int last_tile_col_{-9999};
int last_tile_row_{-9999};
// Índex de tile del tileset amb què aquest jugador pinta. Llegit del
// YAML del mapa (`player1_paint_tile` / `player2_paint_tile`) i passat
// al ctor per GameScene. -1 desactiva el pintat (p.ex. al test
// d'arrencada o en un escenari que no vulgui feedback visual).
int paint_tile_index_{-1};
};
+41 -4
View File
@@ -1,9 +1,9 @@
#include "game/map/map.hpp"
#include <fstream>
#include <iostream>
#include <stdexcept>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
namespace {
@@ -21,11 +21,13 @@ namespace {
namespace Map {
void loadFrom(const std::string& yaml_path) {
std::ifstream file(yaml_path);
if (!file.is_open()) {
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
throw std::runtime_error("Map: no s'ha pogut obrir " + yaml_path);
}
auto yaml = fkyaml::node::deserialize(file);
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
if (!yaml.contains("geometry")) {
throw std::runtime_error("Map: manca bloc `geometry:` a " + yaml_path);
}
@@ -104,4 +106,39 @@ namespace Map {
return box.x >= static_cast<float>(playfield_x) && box.y >= static_cast<float>(playfield_y) && box.x + box.w <= static_cast<float>(playfield_x + playfield_w) && box.y + box.h <= static_cast<float>(playfield_y + playfield_h);
}
auto isPaintable(int col, int row) -> bool {
// Construïm la caixa píxel del tile a partir de l'origen del tilemap.
// El grid és 16-alineat respecte a `tilemap_origin_*`; per a un tile
// (col,row) la cantonada superior-esquerra cau a:
// x = tilemap_origin_x + col * tile_size
// y = tilemap_origin_y + row * tile_size
const SDL_FRect BOX{
.x = static_cast<float>(tilemap_origin_x + (col * tile_size)),
.y = static_cast<float>(tilemap_origin_y + (row * tile_size)),
.w = static_cast<float>(tile_size),
.h = static_cast<float>(tile_size),
};
if (!insidePlayfield(BOX)) { return false; }
if (collides(BOX)) { return false; }
return true;
}
auto makeInfo() -> Info {
return {
.playfield_x = playfield_x,
.playfield_y = playfield_y,
.playfield_w = playfield_w,
.playfield_h = playfield_h,
.pitch_x = pitch_x,
.pitch_y = pitch_y,
.grid_cols = grid_cols,
.grid_rows = grid_rows,
.tile_size = tile_size,
.tilemap_origin_x = tilemap_origin_x,
.tilemap_origin_y = tilemap_origin_y,
.collides = &collides,
.inside_playfield = &insidePlayfield,
};
}
} // namespace Map
+37
View File
@@ -14,6 +14,14 @@
// els membres viuen com a globals del namespace i els callers hi accedeixen
// amb `Map::grid_cols`, `Map::collides(...)`, etc. El canvi respecte a
// l'anterior `class Map { static ... };` és purament sintàctic.
//
// `MapInfo` (POD trivialment copiable) empaqueta la geometria + dos
// punters a funció de col·lisió per injectar-la a entitats que han
// d'evitar conèixer el namespace `Map::` directament. Construïda via
// `Map::makeInfo()` després de `loadFrom`. Avui només l'usa el Player
// (COUP-02 a docs/audit-game.md); momies, roques i `loadFromLevel`
// continuen llegint `Map::*` directament — la nota original justifica
// que mentre hi hagi un Map global actiu, l'acoblament és acceptable.
namespace Map {
// Geometria de la graella (carregada de pyramid.yaml > geometry).
@@ -73,4 +81,33 @@ namespace Map {
// entitats dinàmiques per validar posicions candidates del seu step.
[[nodiscard]] auto insidePlayfield(const SDL_FRect& box) -> bool;
// true si el tile (col, row) — coordenades del tilemap, amb origen
// `tilemap_origin_*` — és "sòl transitable" i per tant pintable: cau
// completament dins del playfield i no s'encavalca cap sarcòfag. Ho
// usa el `PaintLayer` per filtrar quines cel·les accepten pintat.
[[nodiscard]] auto isPaintable(int col, int row) -> bool;
// Snapshot plà de la geometria del mapa + punters a `collides` /
// `insidePlayfield`. Pensat per ser passat per valor (POD petit,
// trivialment copiable). Un cop construïda, llegeix els valors
// congelats — si `Map::loadFrom` es torna a cridar amb un mapa
// diferent, cal demanar un MapInfo nou.
struct Info {
int playfield_x{0};
int playfield_y{0};
int playfield_w{0};
int playfield_h{0};
int pitch_x{0};
int pitch_y{0};
int grid_cols{0};
int grid_rows{0};
int tile_size{0};
int tilemap_origin_x{0};
int tilemap_origin_y{0};
bool (*collides)(const SDL_FRect&){nullptr};
bool (*inside_playfield)(const SDL_FRect&){nullptr};
};
[[nodiscard]] auto makeInfo() -> Info;
} // namespace Map
+141
View File
@@ -0,0 +1,141 @@
#include "game/map/paint_layer.hpp"
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <variant>
#include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp"
#include "game/map/map.hpp"
#include "game/map/tilemap.hpp"
namespace {
// Blit d'un tile del tileset sobre un buffer ARGB de destí, respectant
// l'índex transparent del GIF. Mateixa convenció que la versió a
// `tilemap.cpp`: el tileset guarda índexs + paleta, i la destinació
// és un buffer ARGB32 lineal. Duplicat intencionat per no acoblar
// Tilemap i PaintLayer; si en el futur un tercer consumidor el vol
// reutilitzar, es promou a un helper compartit.
void blitTile(const Surface& tileset, int tile_size, int src_col, int src_row, std::vector<std::uint32_t>& dest, int dest_w, int dest_x, int dest_y) {
const int SRC_X0 = src_col * tile_size;
const int SRC_Y0 = src_row * tile_size;
const auto TRANSPARENT = tileset.getTransparentColor();
for (int y = 0; y < tile_size; ++y) {
for (int x = 0; x < tile_size; ++x) {
const auto IDX = tileset.getPixel(SRC_X0 + x, SRC_Y0 + y);
if (IDX == TRANSPARENT) { continue; }
const auto ARGB = tileset.getPaletteColor(IDX);
const auto DST_OFFSET =
(static_cast<std::size_t>(dest_y + y) * static_cast<std::size_t>(dest_w)) + static_cast<std::size_t>(dest_x + x);
dest[DST_OFFSET] = ARGB;
}
}
}
} // namespace
PaintLayer::PaintLayer(const Tilemap& tilemap, EventBus* bus)
: bus_(bus),
cols_(tilemap.getCols()),
rows_(tilemap.getRows()),
tile_size_(tilemap.getTileSize()),
origin_x_(tilemap.getOriginX()),
origin_y_(tilemap.getOriginY()),
width_px_(tilemap.getWidth()),
height_px_(tilemap.getHeight()),
tileset_cols_(tilemap.getTilesetCols()),
tileset_path_(tilemap.getTilesetPath()) {
painted_.assign(static_cast<std::size_t>(rows_), std::vector<int>(static_cast<std::size_t>(cols_), -1));
pixels_.assign(
static_cast<std::size_t>(width_px_) * static_cast<std::size_t>(height_px_),
0U);
if (bus_ != nullptr) {
// Subscripció a `on_tile_entered`: el Player publica aquest event
// amb `PaintedTile{col, row, tile_index}`. Filtrem pintabilitat
// (sòl transitable) via `Map::isPaintable` i, si aplica, marquem
// i re-publiquem `tile_painted` amb només la posició.
on_tile_sub_ = bus_->subscribe("on_tile_entered", [this](const Event& ev) {
// Re-propaguem el `player_id` de qui ha trepitjat la cel·la, perquè
// `tile_painted` (que consumeixen els sarcòfags i el HUD futur)
// sàpiga quin jugador ha pintat.
const auto* paint = std::get_if<PaintedTile>(&ev.payload);
if (paint != nullptr) { tryPaint(paint->col, paint->row, paint->tile_index, ev.player_id); }
});
}
std::cout << "PaintLayer: " << cols_ << "x" << rows_ << " tiles ("
<< width_px_ << "x" << height_px_ << " px), tileset "
<< tileset_path_ << '\n';
}
PaintLayer::~PaintLayer() {
if (bus_ != nullptr && on_tile_sub_ != 0) { bus_->unsubscribe(on_tile_sub_); }
}
auto PaintLayer::isPainted(int col, int row) const -> bool {
if (col < 0 || col >= cols_ || row < 0 || row >= rows_) { return false; }
return painted_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] >= 0;
}
auto PaintLayer::tryPaint(int col, int row, int tile_index, int player_id) -> bool {
if (col < 0 || col >= cols_ || row < 0 || row >= rows_) { return false; }
if (tile_index < 0) { return false; }
if (painted_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] >= 0) {
// Primer que pinta guanya: no sobreescrivim.
return false;
}
if (!Map::isPaintable(col, row)) { return false; }
painted_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] = tile_index;
blitPaintTile(col, row, tile_index);
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "tile_painted",
.payload = TilePos{.col = col, .row = row},
.player_id = player_id,
});
}
return true;
}
void PaintLayer::blitPaintTile(int col, int row, int tile_index) {
const auto TILESET = Resource::Cache::get().getSurface(tileset_path_);
if (TILESET == nullptr) { return; }
const int DX = col * tile_size_;
const int DY = row * tile_size_;
const int SRC_COL = tile_index % tileset_cols_;
const int SRC_ROW = tile_index / tileset_cols_;
blitTile(*TILESET, tile_size_, SRC_COL, SRC_ROW, pixels_, width_px_, DX, DY);
}
void PaintLayer::render() const {
// Còpia ARGB al canvas, anàloga a `Tilemap::render`. Píxels amb
// alpha=0 (tiles no pintats) es salten per deixar visible el tilemap
// base per sota.
auto& screen = Screen::get();
auto* canvas = screen.getCanvasArgb();
const int CANVAS_W = screen.getCanvasWidth();
const int CANVAS_H = screen.getCanvasHeight();
const int Y_START = std::max(0, -origin_y_);
const int Y_END = std::min(height_px_, CANVAS_H - origin_y_);
const int X_START = std::max(0, -origin_x_);
const int X_END = std::min(width_px_, CANVAS_W - origin_x_);
constexpr std::uint32_t ALPHA_MASK = 0xFF000000U;
for (int y = Y_START; y < Y_END; ++y) {
const auto* src_row = &pixels_[static_cast<std::size_t>(y) * static_cast<std::size_t>(width_px_)];
auto* dst_row =
&canvas[(static_cast<std::size_t>(origin_y_ + y) * static_cast<std::size_t>(CANVAS_W)) + static_cast<std::size_t>(origin_x_)];
for (int x = X_START; x < X_END; ++x) {
const auto PIXEL = src_row[x];
if ((PIXEL & ALPHA_MASK) != 0U) { dst_row[x] = PIXEL; }
}
}
}
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "game/entities/event_bus.hpp"
class Tilemap;
// Capa visual de "pintat" sobre el tilemap base. Quan el jugador (o
// qualsevol altre actor futur) publica `on_tile_entered` amb payload
// `PaintedTile{col, row, tile_index}`, el PaintLayer filtra per
// `Map::isPaintable`, marca el tile i re-publica `tile_painted` amb
// només `TilePos` — abstracció del "el tile s'ha pintat" independent de
// qui ho ha fet. Els sarcòfags escolten aquest segon event per comptar
// el seu perímetre.
//
// Font única de veritat del pintat: evita duplicar l'estat dins de cada
// sarcòfag i permet que el render mostri l'efecte al instant.
//
// Geometria: hereta `tile_size`, origen i dimensions del Tilemap
// (mateixa graella). Reutilitza el tileset del Tilemap per blitejar els
// tiles pintats a un buffer ARGB propi, anàleg a `Tilemap::pixels_`.
//
// Política de repintat: el primer que pinta un tile guanya. Intents
// posteriors (amb el mateix índex o un altre) no tenen efecte —
// garanteix coherència visual i evita notificar dos cops al mateix sarc.
class PaintLayer {
public:
PaintLayer(const Tilemap& tilemap, EventBus* bus);
~PaintLayer();
PaintLayer(const PaintLayer&) = delete;
PaintLayer(PaintLayer&&) = delete;
auto operator=(const PaintLayer&) -> PaintLayer& = delete;
auto operator=(PaintLayer&&) -> PaintLayer& = delete;
// Còpia del buffer ARGB sobre el canvas del Screen, respectant alpha
// (píxels no pintats queden al buffer amb alpha=0 i no s'escriuen).
// Pensat per cridar entre `Tilemap::render()` i el bloc d'actors.
void render() const;
// Intent de pintat directe (exposat per a tests i actors que pinten
// sense passar pel bus). Retorna true si el tile és pintable i no
// estava ja pintat; en aquest cas, publica `tile_painted`.
auto tryPaint(int col, int row, int tile_index, int player_id = -1) -> bool;
[[nodiscard]] auto isPainted(int col, int row) const -> bool;
private:
void blitPaintTile(int col, int row, int tile_index);
EventBus* bus_{nullptr};
EventBus::SubId on_tile_sub_{0};
int cols_{0};
int rows_{0};
int tile_size_{0};
int origin_x_{0};
int origin_y_{0};
int width_px_{0};
int height_px_{0};
int tileset_cols_{0};
std::string tileset_path_;
// `-1` = no pintat. Altres valors = índex de tile del tileset usat
// per pintar. No cal per al render (el buffer `pixels_` ja el conté),
// però facilita consultes ràpides d'estat i futures extensions.
std::vector<std::vector<int>> painted_;
std::vector<std::uint32_t> pixels_;
};
+98 -59
View File
@@ -2,49 +2,23 @@
#include <algorithm>
#include <cstddef>
#include <fstream>
#include <cstdint>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <unordered_map>
#include <utility>
#include <vector>
#include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#ifdef _DEBUG
#include "game/map/tilemap_writer.hpp"
#endif
namespace {
// Cada símbol del grid es mapeja a (col, fila) dins del tileset.
using TileCoord = std::pair<int, int>;
using Legend = std::unordered_map<char, TileCoord>;
auto splitLines(const std::string& text) -> std::vector<std::string> {
std::vector<std::string> lines;
std::istringstream iss(text);
std::string line;
while (std::getline(iss, line)) {
if (!line.empty()) { lines.push_back(line); }
}
return lines;
}
// La llegenda usa índexs lineals del tileset (esq→dre, dalt→baix).
// Els convertim a (col, fila) amb el nombre de tiles per fila del tileset.
auto parseLegend(const fkyaml::node& node, int tileset_cols) -> Legend {
Legend legend;
for (auto it = node.begin(); it != node.end(); ++it) {
const auto KEY_STR = it.key().get_value<std::string>();
if (KEY_STR.empty()) { continue; }
const auto INDEX = it.value().get_value<int>();
const int COL = INDEX % tileset_cols;
const int ROW = INDEX / tileset_cols;
legend[KEY_STR.front()] = {COL, ROW};
}
return legend;
}
// Copia un tile (col, fila) del tileset al buffer ARGB a la posició de
// destí (dest_x, dest_y). Els píxels amb l'índex de transparència del
// tileset es deixen com estan al buffer (alpha=0 per defecte).
@@ -67,15 +41,16 @@ namespace {
} // namespace
Tilemap::Tilemap(const std::string& yaml_path) {
std::ifstream file(yaml_path);
if (!file.is_open()) {
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
throw std::runtime_error("Tilemap: no s'ha pogut obrir " + yaml_path);
}
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
auto yaml = fkyaml::node::deserialize(file);
const auto TILESET_PATH = yaml["tileset"].get_value<std::string>();
const auto TILE_SIZE = yaml["tile_size"].get_value<int>();
tileset_path_ = yaml["tileset"].get_value<std::string>();
tile_size_ = yaml["tile_size"].get_value<int>();
if (yaml.contains("origin")) {
origin_x_ = yaml["origin"][0].get_value<int>();
origin_y_ = yaml["origin"][1].get_value<int>();
@@ -83,37 +58,101 @@ Tilemap::Tilemap(const std::string& yaml_path) {
// Carreguem el tileset al cache (idempotent) i el pillem per accedir
// als seus píxels i paleta. La paleta del tileset ve del GIF mateix.
// Ens cal abans de la llegenda per saber quants tiles per fila té.
const auto& cache_name = TILESET_PATH;
Resource::Cache::get().loadSurface(cache_name, TILESET_PATH);
const auto TILESET = Resource::Cache::get().getSurface(cache_name);
const int TILESET_COLS = TILESET->getWidth() / TILE_SIZE;
// Ens cal abans de llegir `data` per saber quants tiles per fila té.
Resource::Cache::get().loadSurface(tileset_path_, tileset_path_);
const auto TILESET = Resource::Cache::get().getSurface(tileset_path_);
tileset_cols_ = TILESET->getWidth() / tile_size_;
const auto LEGEND = parseLegend(yaml["legend"], TILESET_COLS);
const auto LINES = splitLines(yaml["data"].get_value<std::string>());
const int ROWS = static_cast<int>(LINES.size());
const int COLS = ROWS > 0 ? static_cast<int>(LINES.front().size()) : 0;
width_px_ = COLS * TILE_SIZE;
height_px_ = ROWS * TILE_SIZE;
// `data` és una seqüència de seqüències d'enters: cada fila és una
// llista d'índexs lineals al tileset (esq→dre, dalt→baix). Un valor
// negatiu (per convenció -1) deixa la cel·la buida (alpha=0).
const auto& data = yaml["data"];
if (!data.is_sequence() || data.empty()) {
throw std::runtime_error("Tilemap: `data` buit o amb format incorrecte a " + yaml_path);
}
rows_ = static_cast<int>(data.size());
cols_ = static_cast<int>(data[0].size());
width_px_ = cols_ * tile_size_;
height_px_ = rows_ * tile_size_;
pixels_.assign(
static_cast<std::size_t>(width_px_) * static_cast<std::size_t>(height_px_),
0U);
for (int row = 0; row < ROWS; ++row) {
const auto& line = LINES[row];
for (int col = 0; col < COLS && col < static_cast<int>(line.size()); ++col) {
const char SYMBOL = line[col];
const auto IT = LEGEND.find(SYMBOL);
if (IT == LEGEND.end()) { continue; }
blitTile(*TILESET, TILE_SIZE, IT->second.first, IT->second.second, pixels_, width_px_, col * TILE_SIZE, row * TILE_SIZE);
#ifdef _DEBUG
data_.assign(static_cast<std::size_t>(rows_), std::vector<int>(static_cast<std::size_t>(cols_), -1));
#endif
for (int row = 0; row < rows_; ++row) {
const auto& line = data[row];
const int LINE_COLS = static_cast<int>(line.size());
for (int col = 0; col < cols_ && col < LINE_COLS; ++col) {
const int INDEX = line[col].get_value<int>();
#ifdef _DEBUG
data_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] = INDEX;
#endif
if (INDEX < 0) { continue; }
const int SRC_COL = INDEX % tileset_cols_;
const int SRC_ROW = INDEX / tileset_cols_;
blitTile(*TILESET, tile_size_, SRC_COL, SRC_ROW, pixels_, width_px_, col * tile_size_, row * tile_size_);
}
}
std::cout << "Tilemap: " << COLS << "x" << ROWS << " tiles ("
std::cout << "Tilemap: " << cols_ << "x" << rows_ << " tiles ("
<< width_px_ << "x" << height_px_ << " px) des de " << yaml_path << '\n';
}
#ifdef _DEBUG
void Tilemap::setTileAt(int col, int row, int index) {
if (col < 0 || col >= cols_ || row < 0 || row >= rows_) { return; }
if (index < 0) { return; } // El mapa no admet cel·les buides (política de projecte).
// No validem l'índex contra el total de tiles del tileset: la
// invariant la garanteix el TilePicker, que només ofereix índexs en
// rang.
data_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] = index;
// Netegem el rectangle del tile al buffer de píxels i re-blitegem.
const int DX = col * tile_size_;
const int DY = row * tile_size_;
for (int y = 0; y < tile_size_; ++y) {
auto* dst = &pixels_[(static_cast<std::size_t>(DY + y) * static_cast<std::size_t>(width_px_)) + static_cast<std::size_t>(DX)];
for (int x = 0; x < tile_size_; ++x) { dst[x] = 0U; }
}
const auto TILESET = Resource::Cache::get().getSurface(tileset_path_);
const int SRC_COL = index % tileset_cols_;
const int SRC_ROW = index / tileset_cols_;
blitTile(*TILESET, tile_size_, SRC_COL, SRC_ROW, pixels_, width_px_, DX, DY);
}
auto Tilemap::save(const std::string& yaml_path) const -> bool {
return TilemapWriter::saveDataBlock(yaml_path, data_);
}
void Tilemap::renderTilePreview(int canvas_x, int canvas_y, int tile_index) const {
if (tile_index < 0) { return; }
const auto TILESET = Resource::Cache::get().getSurface(tileset_path_);
if (TILESET == nullptr) { return; }
auto& screen = Screen::get();
auto* buf = screen.getCanvasArgb();
const int CW = screen.getCanvasWidth();
const int CH = screen.getCanvasHeight();
const auto TRANSPARENT = TILESET->getTransparentColor();
const int SX0 = (tile_index % tileset_cols_) * tile_size_;
const int SY0 = (tile_index / tileset_cols_) * tile_size_;
for (int y = 0; y < tile_size_; ++y) {
const int DY = canvas_y + y;
if (DY < 0 || DY >= CH) { continue; }
for (int x = 0; x < tile_size_; ++x) {
const int DX = canvas_x + x;
if (DX < 0 || DX >= CW) { continue; }
const auto IDX = TILESET->getPixel(SX0 + x, SY0 + y);
if (IDX == TRANSPARENT) { continue; }
buf[(static_cast<std::size_t>(DY) * static_cast<std::size_t>(CW)) + static_cast<std::size_t>(DX)] = TILESET->getPaletteColor(IDX);
}
}
}
#endif
void Tilemap::render() const {
auto& screen = Screen::get();
auto* canvas = screen.getCanvasArgb();
+45
View File
@@ -27,10 +27,55 @@ class Tilemap {
[[nodiscard]] auto getOriginX() const -> int { return origin_x_; }
[[nodiscard]] auto getOriginY() const -> int { return origin_y_; }
// Metadades per a consumidors que necessiten conèixer la geometria
// lògica del tilemap (p.ex. l'editor per a la conversió px→cel·la).
// Sempre disponibles (en Debug i Release) perquè són barates i no
// lliguen amb cap codi específic de l'editor.
[[nodiscard]] auto getTileSize() const -> int { return tile_size_; }
[[nodiscard]] auto getCols() const -> int { return cols_; }
[[nodiscard]] auto getRows() const -> int { return rows_; }
[[nodiscard]] auto getTilesetPath() const -> const std::string& { return tileset_path_; }
[[nodiscard]] auto getTilesetCols() const -> int { return tileset_cols_; }
#ifdef _DEBUG
// API d'edició (només Debug). L'editor és absent en Release, així que
// aquestes operacions no formen part de l'API pública del joc final.
//
// `setTileAt` valida els límits i reemplaça el tile de (col,row) per
// `index`. Internament neteja el rectangle `tile_size×tile_size` del
// buffer de píxels i re-blita el tile nou; el render al pròxim frame
// ja reflectirà el canvi.
//
// `save` persisteix `data_` al YAML indicat via `TilemapWriter`, que
// només toca el bloc `data:` i preserva la resta byte a byte.
void setTileAt(int col, int row, int index);
[[nodiscard]] auto save(const std::string& yaml_path) const -> bool;
// Pinta un únic tile del tileset sobre el canvas del Screen a la
// posició (canvas_x, canvas_y). Els píxels transparents del tileset
// es respecten (deixen visible el que hi ha sota). Usat per l'editor
// com a preview del brush sota el cursor, sense tocar `pixels_` ni
// `data_` fins que l'usuari fa click.
void renderTilePreview(int canvas_x, int canvas_y, int tile_index) const;
#endif
private:
int width_px_{0};
int height_px_{0};
int origin_x_{0};
int origin_y_{0};
int tile_size_{0};
int cols_{0};
int rows_{0};
int tileset_cols_{0};
std::string tileset_path_;
std::vector<std::uint32_t> pixels_;
#ifdef _DEBUG
// Graella en memòria només necessària per a l'editor: guardar-la en
// Release gastaria ~1 KB sense retorn, perquè el `pixels_` ja té tot
// el que cal per renderitzar. En Debug la usem com a font de veritat
// de l'edició i com a payload de `save()`.
std::vector<std::vector<int>> data_;
#endif
};
+118
View File
@@ -0,0 +1,118 @@
#include "game/map/tilemap_writer.hpp"
#include <algorithm>
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
namespace TilemapWriter {
namespace {
// Retorna la posició de l'inici de la línia que conté "data:" a
// columna 0. La detecció requereix que els 5 caràcters siguin al
// principi d'una línia i que el següent siga un terminador típic
// (fi de línia, espai, tabulador o comentari). Així evitem trigger
// amb claus com "data_version:" o valors escalars que comencin
// amb "data:".
auto findDataLine(const std::string& content) -> std::size_t {
std::size_t pos = 0;
while (pos < content.size()) {
const bool AT_LINE_START = (pos == 0 || content[pos - 1] == '\n');
if (AT_LINE_START && content.compare(pos, 5, "data:") == 0) {
const std::size_t AFTER = pos + 5;
if (AFTER >= content.size() || content[AFTER] == '\n' || content[AFTER] == '\r' || content[AFTER] == ' ' || content[AFTER] == '\t' || content[AFTER] == '#') {
return pos;
}
}
const auto NL = content.find('\n', pos);
if (NL == std::string::npos) { break; }
pos = NL + 1;
}
return std::string::npos;
}
// Avança mentre trobe línies indentades (espai/tab). S'atura al
// primer caràcter a columna 0 (nova clau top-level) o davant una
// línia buida (separadors de seccions). Així els comentaris i el
// blanc que precedeixen a la pròxima secció queden fora del bloc.
auto advancePastIndented(const std::string& content, std::size_t pos) -> std::size_t {
while (pos < content.size()) {
const char C = content[pos];
if (C != ' ' && C != '\t') { break; }
const auto NL = content.find('\n', pos);
pos = (NL == std::string::npos) ? content.size() : NL + 1;
}
return pos;
}
auto buildBlock(const std::vector<std::vector<int>>& data) -> std::string {
int max_width = 1;
for (const auto& row : data) {
for (const int V : row) {
const int W = static_cast<int>(std::to_string(V).size());
max_width = std::max(max_width, W);
}
}
std::string out;
for (const auto& row : data) {
out += " - [";
for (std::size_t i = 0; i < row.size(); ++i) {
if (i > 0) { out += ","; }
const auto S = std::to_string(row[i]);
const int PAD = max_width - static_cast<int>(S.size());
out.append(static_cast<std::size_t>(PAD), ' ');
out += S;
}
out += "]\n";
}
return out;
}
} // namespace
auto saveDataBlock(const std::string& path, const std::vector<std::vector<int>>& data) -> bool {
std::ifstream in(path, std::ios::binary);
if (!in) { return false; }
std::ostringstream ss;
ss << in.rdbuf();
const std::string CONTENT = ss.str();
in.close();
const auto DATA_POS = findDataLine(CONTENT);
if (DATA_POS == std::string::npos) { return false; }
const auto LINE_END = CONTENT.find('\n', DATA_POS);
const auto AFTER_DATA = (LINE_END == std::string::npos) ? CONTENT.size() : LINE_END + 1;
const auto BLOCK_END = advancePastIndented(CONTENT, AFTER_DATA);
const std::string NEW_CONTENT =
CONTENT.substr(0, AFTER_DATA) + buildBlock(data) + CONTENT.substr(BLOCK_END);
const auto TMP_PATH = path + ".tmp";
{
std::ofstream out(TMP_PATH, std::ios::binary | std::ios::trunc);
if (!out) { return false; }
out.write(NEW_CONTENT.data(), static_cast<std::streamsize>(NEW_CONTENT.size()));
out.close();
if (!out) {
std::error_code ec;
std::filesystem::remove(TMP_PATH, ec);
return false;
}
}
std::error_code ec;
std::filesystem::rename(TMP_PATH, path, ec);
if (ec) {
std::filesystem::remove(TMP_PATH, ec);
return false;
}
return true;
}
} // namespace TilemapWriter
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more